diff --git a/i18n/en.json b/i18n/en.json index 1fe2f2b746..6074930f5d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1126,6 +1126,7 @@ "unable_to_hide_person": "Unable to hide person", "unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_oauth_account": "Unable to link OAuth account", + "invalid_oauth_relink_token": "This OAuth re-link token is invalid or has expired", "unable_to_log_out_all_devices": "Unable to log out all devices", "unable_to_log_out_device": "Unable to log out device", "unable_to_login_with_oauth": "Unable to login with OAuth", @@ -1643,7 +1644,10 @@ "notifications": "Notifications", "notifications_setting_description": "Manage notifications", "oauth": "OAuth", + "oauth_account_is_linked": "This account is linked to an OAuth identity. Logging in via OAuth will sign you in directly.", + "oauth_account_not_linked": "Link this account to an OAuth identity to sign in via your identity provider.", "oauth_link_existing_account": "Log in with your Immich password to link your OAuth account", + "oauth_relink_in_progress": "Redirecting to your identity provider to complete the re-link...", "oauth_link_password_login_required": "An account with this email already exists but password login is required to link your OAuth account. Please contact your administrator", "obtainium_configurator": "Obtainium Configurator", "obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a5fd08fd8c..106ecacb87 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1285,6 +1285,59 @@ "x-immich-state": "Stable" } }, + "/admin/users/{id}/oauth-relink-token": { + "post": { + "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.", + "operationId": "createOAuthReLinkTokenAdmin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthReLinkTokenResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Issue an OAuth re-link token", + "tags": [ + "Users (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2", + "state": "Added" + } + ], + "x-immich-permission": "adminUser.update" + } + }, "/admin/users/{id}/preferences": { "get": { "description": "Retrieve the preferences of a specific user.", @@ -7499,6 +7552,38 @@ "x-immich-state": "Stable" } }, + "/oauth/relink-start": { + "post": { + "description": "Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.", + "operationId": "startOAuthReLink", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthReLinkStartDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "summary": "Start OAuth re-link", + "tags": [ + "Authentication" + ], + "x-immich-history": [ + { + "version": "v2", + "state": "Added" + } + ] + } + }, "/oauth/unlink": { "post": { "description": "Unlink the OAuth account from the authenticated user.", @@ -19086,6 +19171,38 @@ ], "type": "object" }, + "OAuthReLinkStartDto": { + "properties": { + "token": { + "description": "Plaintext OAuth re-link token issued by an administrator", + "type": "string" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "OAuthReLinkTokenResponseDto": { + "properties": { + "expiresAt": { + "description": "Token expiration", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "type": "string" + }, + "token": { + "description": "Single-use token; deliver to the user via /auth/link?token=", + "type": "string" + } + }, + "required": [ + "expiresAt", + "token" + ], + "type": "object" + }, "OAuthTokenEndpointAuthMethod": { "description": "OAuth token endpoint auth method", "enum": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5828f0d4a6..5fd7ba3116 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -262,6 +262,12 @@ export type UserAdminUpdateDto = { /** Storage label */ storageLabel?: string | null; }; +export type OAuthReLinkTokenResponseDto = { + /** Token expiration */ + expiresAt: string; + /** Single-use token; deliver to the user via /auth/link?token= */ + token: string; +}; export type AlbumsResponse = { defaultAssetOrder: AssetOrder; }; @@ -1421,6 +1427,10 @@ export type OAuthCallbackDto = { /** OAuth callback URL */ url: string; }; +export type OAuthReLinkStartDto = { + /** Plaintext OAuth re-link token issued by an administrator */ + token: string; +}; export type PartnerResponseDto = { avatarColor: UserAvatarColor; /** User email */ @@ -3527,6 +3537,20 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: { body: userAdminUpdateDto }))); } +/** + * Issue an OAuth re-link token + */ +export function createOAuthReLinkTokenAdmin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: OAuthReLinkTokenResponseDto; + }>(`/admin/users/${encodeURIComponent(id)}/oauth-relink-token`, { + ...opts, + method: "POST" + })); +} /** * Retrieve user preferences */ @@ -4966,6 +4990,18 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * Start OAuth re-link + */ +export function startOAuthReLink({ oAuthReLinkStartDto }: { + oAuthReLinkStartDto: OAuthReLinkStartDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/oauth/relink-start", oazapfts.json({ + ...opts, + method: "POST", + body: oAuthReLinkStartDto + }))); +} /** * Unlink OAuth account */ diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index a961e6c02c..42c4bb4d0d 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -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 { + 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 { + 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) diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 6dd919e193..f432e1643b 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -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 { + return this.service.createOAuthReLinkToken(auth, id); + } + @Post(':id/restore') @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) @HttpCode(HttpStatus.OK) diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index 1f75401e33..0ccb275649 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -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) {} diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 75256b9e1a..1b662bb8bc 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -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='), + 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'), diff --git a/server/src/repositories/oauth-link-token.repository.ts b/server/src/repositories/oauth-link-token.repository.ts index 8dea55e7ac..c6d14d7f1e 100644 --- a/server/src/repositories/oauth-link-token.repository.ts +++ b/server/src/repositories/oauth-link-token.repository.ts @@ -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') diff --git a/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts b/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts index c7acf8616c..9b8d74268a 100644 --- a/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts +++ b/server/src/schema/migrations/1776362646907-CreateOAuthLinkTokenTable.ts @@ -5,10 +5,10 @@ export async function up(db: Kysely): Promise { 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() ); diff --git a/server/src/schema/tables/oauth-link-token.table.ts b/server/src/schema/tables/oauth-link-token.table.ts index 6fa41e034a..d359b6eb4a 100644 --- a/server/src/schema/tables/oauth-link-token.table.ts +++ b/server/src/schema/tables/oauth-link-token.table.ts @@ -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; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 83b7d7852c..ef6f028255 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -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', () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index b47f364a09..592e9ccd7c 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -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; diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 49aefaa870..6117f8d1c8 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -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); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 58b4221cc9..8a560f97a3 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -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 { + 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); diff --git a/web/src/routes/(user)/user-settings/oauth-settings.svelte b/web/src/routes/(user)/user-settings/oauth-settings.svelte index 0e02fea638..6868728264 100644 --- a/web/src/routes/(user)/user-settings/oauth-settings.svelte +++ b/web/src/routes/(user)/user-settings/oauth-settings.svelte @@ -5,7 +5,7 @@ import { Route } from '$lib/route'; import { oauth } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { Button, toastManager } from '@immich/ui'; + import { Button, Stack, Text, toastManager } from '@immich/ui'; import { t } from 'svelte-i18n'; import { fade } from 'svelte/transition'; @@ -20,18 +20,28 @@ }; -
-
-
- {#if featureFlagsManager.value.oauth} +{#if featureFlagsManager.value.oauth} +
+
+ {#if authManager.user.oauthId} - + {$t('oauth_account_is_linked')} + {#if featureFlagsManager.value.passwordLogin} +
+ +
+ {/if} {:else} - + {$t('oauth_account_not_linked')} +
+ +
{/if} - {/if} +
-
-
+ +{/if} diff --git a/web/src/routes/auth/link/+page.svelte b/web/src/routes/auth/link/+page.svelte index 4ce5b6de12..0c9b33381a 100644 --- a/web/src/routes/auth/link/+page.svelte +++ b/web/src/routes/auth/link/+page.svelte @@ -5,9 +5,11 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { Route } from '$lib/route'; + import { oauth } from '$lib/utils'; import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; - import { login, register } from '@immich/sdk'; + import { isHttpError, login, register, startOAuthReLink } from '@immich/sdk'; import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui'; + import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -22,6 +24,41 @@ let errorMessage = $state(''); let loading = $state(false); let registering = $state(false); + let reLinkMode = $state(!!data.reLinkToken); + let reLinkLoading = $state(!!data.reLinkToken); + let reLinkError = $state(''); + + onMount(async () => { + if (oauth.isCallback(globalThis.location)) { + reLinkLoading = true; + try { + const user = await oauth.login(globalThis.location); + eventManager.emit('AuthLogin', user); + await authManager.refresh(); + toastManager.primary($t('linked_oauth_account')); + await goto(Route.photos(), { invalidateAll: true }); + } catch (error) { + reLinkLoading = false; + reLinkMode = false; + reLinkError = + getServerErrorMessage(error) || + (isHttpError(error) ? error.message : undefined) || + $t('errors.unable_to_complete_oauth_login'); + } + return; + } + + if (data.reLinkToken) { + try { + await startOAuthReLink({ oAuthReLinkStartDto: { token: data.reLinkToken } }); + await oauth.authorize(globalThis.location); + } catch (error) { + reLinkLoading = false; + reLinkMode = false; + reLinkError = getServerErrorMessage(error) || $t('errors.invalid_oauth_relink_token'); + } + } + }); const handleSubmit = async (event: Event) => { event.preventDefault(); @@ -55,56 +92,66 @@ - {#if featureFlagsManager.value.passwordLogin} - - {$t('oauth_link_existing_account')} - - -
- {#if errorMessage} - - {/if} - - - - - - - - - - - - {:else} - - {$t('oauth_link_password_login_required')} - + {#if reLinkError} + {/if} - {#if featureFlagsManager.value.oauthAutoRegister} + {#if reLinkMode && reLinkLoading} + + {$t('oauth_relink_in_progress')} + + {:else} {#if featureFlagsManager.value.passwordLogin} -
-
- - {$t('or')} - -
+ + {$t('oauth_link_existing_account')} + + +
+ {#if errorMessage} + + {/if} + + + + + + + + + + + + {:else} + + {$t('oauth_link_password_login_required')} + {/if} - + {#if featureFlagsManager.value.oauthAutoRegister} + {#if featureFlagsManager.value.passwordLogin} +
+
+ + {$t('or')} + +
+ {/if} + + + {/if} {/if}
diff --git a/web/src/routes/auth/link/+page.ts b/web/src/routes/auth/link/+page.ts index 425a93f69d..c84780485a 100644 --- a/web/src/routes/auth/link/+page.ts +++ b/web/src/routes/auth/link/+page.ts @@ -3,6 +3,7 @@ import type { PageLoad } from './$types'; export const load = (async ({ url }) => { const email = url.searchParams.get('email') || ''; + const reLinkToken = url.searchParams.get('token') || ''; const $t = await getFormatter(); return { @@ -10,5 +11,6 @@ export const load = (async ({ url }) => { title: $t('link_to_oauth'), }, email, + reLinkToken, }; }) satisfies PageLoad;