feat: oauth re-link via admin-provided token

This commit is contained in:
bo0tzz
2026-04-23 13:11:34 +02:00
parent 2da2bef777
commit 00f83e7c66
17 changed files with 615 additions and 85 deletions
@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
@@ -9,6 +10,7 @@ import {
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthReLinkStartDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
@@ -73,6 +75,8 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
let freshLinkCookieIssued = false;
try {
const body = await this.service.callback(dto, request.headers, loginDetails);
return respondWithCookie(res, body, {
@@ -89,14 +93,38 @@ export class OAuthController {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }],
});
freshLinkCookieIssued = true;
}
throw error;
} finally {
res.clearCookie(ImmichCookie.OAuthState);
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
if (hadLinkCookie && !freshLinkCookieIssued) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
}
}
@Post('relink-start')
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Start OAuth re-link',
description:
'Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.',
history: new HistoryBuilder().added('v2'),
})
async startOAuthReLink(
@Body() dto: OAuthReLinkStartDto,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<void> {
await this.service.validateOAuthReLinkToken(dto.token);
respondWithCookie(res, null, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.OAuthLinkToken, value: dto.token }],
});
}
@Post('unlink')
@Authenticated()
@HttpCode(HttpStatus.OK)
@@ -6,6 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import {
OAuthReLinkTokenResponseDto,
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
@@ -137,6 +138,21 @@ export class UserAdminController {
return this.service.updatePreferences(auth, id, dto);
}
@Post(':id/oauth-relink-token')
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
@Endpoint({
summary: 'Issue an OAuth re-link token',
description:
'Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.',
history: new HistoryBuilder().added('v2'),
})
createOAuthReLinkTokenAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
): Promise<OAuthReLinkTokenResponseDto> {
return this.service.createOAuthReLinkToken(auth, id);
}
@Post(':id/restore')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@HttpCode(HttpStatus.OK)
+7
View File
@@ -128,6 +128,12 @@ const OAuthBackchannelLogoutSchema = z
.object({ logout_token: z.string().describe('OAuth logout token') })
.meta({ id: 'OAuthBackchannelLogoutDto' });
const OAuthReLinkStartSchema = z
.object({
token: z.string().describe('Plaintext OAuth re-link token issued by an administrator'),
})
.meta({ id: 'OAuthReLinkStartDto' });
const AuthStatusResponseSchema = z
.object({
pinCode: z.boolean().describe('Has PIN code set'),
@@ -152,4 +158,5 @@ export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
export class OAuthReLinkStartDto extends createZodDto(OAuthReLinkStartSchema) {}
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
+9
View File
@@ -121,6 +121,15 @@ const UserAdminDeleteSchema = z
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
const OAuthReLinkTokenResponseSchema = z
.object({
token: z.string().describe('Single-use token; deliver to the user via /auth/link?token=<token>'),
expiresAt: isoDatetimeToDate.describe('Token expiration'),
})
.meta({ id: 'OAuthReLinkTokenResponseDto' });
export class OAuthReLinkTokenResponseDto extends createZodDto(OAuthReLinkTokenResponseSchema) {}
const UserAdminResponseSchema = UserResponseSchema.extend({
storageLabel: z.string().nullable().describe('Storage label'),
shouldChangePassword: z.boolean().describe('Require password change on next login'),
@@ -13,15 +13,28 @@ export class OAuthLinkTokenRepository {
return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
}
consumeToken(token: Buffer) {
getByToken(token: Buffer) {
return this.db
.deleteFrom('oauth_link_token')
.selectFrom('oauth_link_token')
.selectAll()
.where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate())
.returningAll()
.executeTakeFirst();
}
consumeToken(token: Buffer, kind: 'callback' | 'admin' | 'any' = 'any') {
let query = this.db
.deleteFrom('oauth_link_token')
.where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate());
if (kind === 'callback') {
query = query.where('oauthSub', 'is not', null);
} else if (kind === 'admin') {
query = query.where('oauthSub', 'is', null);
}
return query.returningAll().executeTakeFirst();
}
async cleanup() {
const result = await this.db
.deleteFrom('oauth_link_token')
@@ -5,10 +5,10 @@ export async function up(db: Kysely<any>): Promise<void> {
CREATE TABLE "oauth_link_token" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"token" bytea NOT NULL,
"oauthSub" varchar NOT NULL,
"oauthSub" varchar,
"oauthSid" varchar,
"email" varchar NOT NULL,
"profile" jsonb NOT NULL,
"profile" jsonb,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
);
@@ -16,8 +16,8 @@ export class OAuthLinkTokenTable {
@Column({ type: 'bytea', index: true })
token!: Buffer;
@Column()
oauthSub!: string;
@Column({ nullable: true })
oauthSub!: string | null;
@Column({ nullable: true })
oauthSid!: string | null;
@@ -25,8 +25,8 @@ export class OAuthLinkTokenTable {
@Column()
email!: string;
@Column({ type: 'jsonb' })
profile!: OAuthLinkTokenProfile;
@Column({ type: 'jsonb', nullable: true })
profile!: OAuthLinkTokenProfile | null;
@Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp;
+139
View File
@@ -1355,6 +1355,145 @@ describe(AuthService.name, () => {
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
);
});
describe('admin-issued re-link token', () => {
const reLinkRecord = {
id: 'token-id',
oauthSub: null,
oauthSid: 'idp-sid-new',
email: 'linked@immich.cloud',
profile: null,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
};
it('should relink to the user identified by the token when the new sub is unknown', async () => {
const targetUser = UserFactory.create({ email: 'linked@immich.cloud', oauthId: 'old-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
sid: 'idp-sid-new',
});
mocks.user.getByOAuthId.mockResolvedValueOnce(void 0).mockResolvedValueOnce(void 0);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
mocks.user.getByEmail.mockResolvedValue(targetUser);
mocks.user.update.mockResolvedValue({ ...targetUser, oauthId: 'new-sub' });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
);
expect(mocks.user.update).toHaveBeenCalledWith(targetUser.id, { oauthId: 'new-sub' });
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-new' }));
});
it('should reject when the token email no longer matches a user', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(
sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
),
).rejects.toThrow('no longer exists');
});
it('should reject when the new sub is already linked to a different user', async () => {
const targetUser = UserFactory.create({ email: 'linked@immich.cloud' });
const other = UserFactory.create({ oauthId: 'new-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
});
mocks.user.getByOAuthId.mockResolvedValueOnce(void 0).mockResolvedValueOnce(other);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(reLinkRecord);
mocks.user.getByEmail.mockResolvedValue(targetUser);
await expect(
sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
),
).rejects.toThrow('already been linked to another user');
});
it('should fall through to callback-issued link flow when the cookie is not an admin-issued token', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ sub: 'new-sub' }),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
// Cookie carries a callback-issued token; admin-typed consume returns nothing.
mocks.oauthLinkToken.consumeToken.mockResolvedValue(void 0);
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
{ url: 'http://immich/auth/link?code=abc', state: 'xyz', codeVerifier: 'foo' },
{ cookie: 'immich_oauth_link_token=plain' },
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauthLinkToken.create).toHaveBeenCalled();
});
});
});
describe('validateOAuthReLinkToken', () => {
it('should throw when OAuth is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('OAuth is not enabled');
});
it('should throw when the token does not exist', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.getByToken.mockResolvedValue(void 0);
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
});
it('should throw when the token is a callback-issued one (non-null sub)', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.getByToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'sub',
oauthSid: null,
email: 'e',
profile: null,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
});
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
});
it('should resolve when the token is valid and admin-issued', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.getByToken.mockResolvedValue({
id: 'token-id',
oauthSub: null,
oauthSid: null,
email: 'e',
profile: null,
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 60_000),
createdAt: new Date(),
});
await expect(sut.validateOAuthReLinkToken('plain')).resolves.toBeUndefined();
});
});
describe('unlink', () => {
+58 -18
View File
@@ -91,13 +91,13 @@ export class AuthService extends BaseService {
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (linkTokenCookie) {
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
if (record) {
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
if (record && record.oauthSub !== null && record.profile !== null) {
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
if (duplicate && duplicate.id !== user.id) {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
user = await this.applyOAuthProfileToUser(user, record);
user = await this.applyOAuthProfileToUser(user, { oauthSub: record.oauthSub, profile: record.profile });
linkedOAuthSid = record.oauthSid ?? undefined;
}
}
@@ -116,19 +116,25 @@ export class AuthService extends BaseService {
throw new BadRequestException('Missing OAuth link token');
}
const record = await this.consumeOAuthLinkToken(linkTokenCookie);
const existing = await this.userRepository.getByOAuthId(record.oauthSub);
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
if (!record || record.oauthSub === null || record.profile === null) {
throw new BadRequestException('Invalid OAuth link token for registration');
}
const { oauthSub, profile } = record;
const existing = await this.userRepository.getByOAuthId(oauthSub);
if (existing) {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
this.logger.log(`Registering new user from OAuth: ${record.oauthSub}/${record.email}`);
this.logger.log(`Registering new user from OAuth: ${oauthSub}/${record.email}`);
const newUser = await this.createUser({
email: record.email,
name: record.profile.name,
isAdmin: record.profile.isAdmin,
name: profile.name,
isAdmin: profile.isAdmin,
});
const user = await this.applyOAuthProfileToUser(newUser, record);
const user = await this.applyOAuthProfileToUser(newUser, { oauthSub, profile });
return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
}
@@ -331,6 +337,19 @@ export class AuthService extends BaseService {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
async validateOAuthReLinkToken(plainToken: string) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const hashed = this.cryptoRepository.hashSha256(plainToken);
const record = await this.oauthLinkTokenRepository.getByToken(hashed);
if (!record || record.oauthSub !== null) {
throw new BadRequestException('Invalid or expired re-link token');
}
}
async authorize(dto: OAuthConfigDto) {
const { oauth } = await this.getConfig({ withCache: false });
@@ -380,6 +399,15 @@ export class AuthService extends BaseService {
return this.createLoginResponse(user, loginDetails, oauthSid);
}
const reLinkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (reLinkTokenCookie) {
const hashedCookie = this.cryptoRepository.hashSha256(reLinkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedCookie, 'admin');
if (record) {
return this.completeAdminIssuedReLink(record, profile.sub, oauthSid, loginDetails);
}
}
if (!normalizedEmail) {
throw new BadRequestException('OAuth profile does not have an email address');
}
@@ -398,6 +426,27 @@ export class AuthService extends BaseService {
throw new OAuthLinkRequiredException(normalizedEmail, plainToken);
}
private async completeAdminIssuedReLink(
record: { email: string },
newOAuthSub: string,
oauthSid: string | undefined,
loginDetails: LoginDetails,
) {
const targetUser = await this.userRepository.getByEmail(record.email);
if (!targetUser) {
throw new BadRequestException('The user for this re-link token no longer exists');
}
const duplicate = await this.userRepository.getByOAuthId(newOAuthSub);
if (duplicate && duplicate.id !== targetUser.id) {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
this.logger.log(`Completing admin-issued OAuth re-link for user ${targetUser.id}`);
const updated = await this.userRepository.update(targetUser.id, { oauthId: newOAuthSub });
return this.createLoginResponse(updated, loginDetails, oauthSid);
}
private resolveOAuthProfile(
profile: OAuthProfile,
normalizedEmail: string,
@@ -433,15 +482,6 @@ export class AuthService extends BaseService {
};
}
private async consumeOAuthLinkToken(plainToken: string) {
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
if (!record) {
throw new BadRequestException('Invalid or expired link token');
}
return record;
}
private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
const { profile } = record;
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null;
@@ -5,6 +5,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
import { AuthFactory } from 'test/factories/auth.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe } from 'vitest';
@@ -165,6 +166,42 @@ describe(UserAdminService.name, () => {
});
});
describe('createOAuthReLinkToken', () => {
it('should throw when OAuth is not enabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
});
it('should throw when the target user is missing', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.get.mockResolvedValueOnce(void 0);
await expect(sut.createOAuthReLinkToken(authStub.admin, 'missing')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
});
it('should create a token with null oauthSub and the target email', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
const result = await sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({
oauthSub: null,
oauthSid: null,
profile: null,
email: userStub.user1.email,
}),
);
expect(result.token).toEqual(expect.any(String));
expect(result.expiresAt).toBeInstanceOf(Date);
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
});
});
describe('restore', () => {
it('should throw error if user could not be found', async () => {
mocks.user.get.mockResolvedValue(void 0);
+25
View File
@@ -1,10 +1,12 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import {
OAuthReLinkTokenResponseDto,
UserAdminCreateDto,
UserAdminDeleteDto,
UserAdminResponseDto,
@@ -137,6 +139,29 @@ export class UserAdminService extends BaseService {
return mapPreferences(getPreferences(metadata));
}
async createOAuthReLinkToken(auth: AuthDto, id: string): Promise<OAuthReLinkTokenResponseDto> {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const user = await this.findOrFail(id, {});
const plainToken = this.cryptoRepository.randomBytesAsText(32);
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
const expiresAt = DateTime.now().plus({ hours: 24 }).toJSDate();
await this.oauthLinkTokenRepository.create({
token: hashedToken,
oauthSub: null,
oauthSid: null,
email: user.email,
profile: null,
expiresAt,
});
this.logger.log(`Admin ${auth.user.id} issued an OAuth re-link token for user ${user.id}`);
return { token: plainToken, expiresAt };
}
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
await this.findOrFail(id, { withDeleted: false });
const metadata = await this.userRepository.getMetadata(id);