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
+4
View File
@@ -1126,6 +1126,7 @@
"unable_to_hide_person": "Unable to hide person", "unable_to_hide_person": "Unable to hide person",
"unable_to_link_motion_video": "Unable to link motion video", "unable_to_link_motion_video": "Unable to link motion video",
"unable_to_link_oauth_account": "Unable to link OAuth account", "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_all_devices": "Unable to log out all devices",
"unable_to_log_out_device": "Unable to log out device", "unable_to_log_out_device": "Unable to log out device",
"unable_to_login_with_oauth": "Unable to login with OAuth", "unable_to_login_with_oauth": "Unable to login with OAuth",
@@ -1643,7 +1644,10 @@
"notifications": "Notifications", "notifications": "Notifications",
"notifications_setting_description": "Manage notifications", "notifications_setting_description": "Manage notifications",
"oauth": "OAuth", "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_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", "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": "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", "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",
+117
View File
@@ -1285,6 +1285,59 @@
"x-immich-state": "Stable" "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": { "/admin/users/{id}/preferences": {
"get": { "get": {
"description": "Retrieve the preferences of a specific user.", "description": "Retrieve the preferences of a specific user.",
@@ -7499,6 +7552,38 @@
"x-immich-state": "Stable" "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": { "/oauth/unlink": {
"post": { "post": {
"description": "Unlink the OAuth account from the authenticated user.", "description": "Unlink the OAuth account from the authenticated user.",
@@ -19086,6 +19171,38 @@
], ],
"type": "object" "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=<token>",
"type": "string"
}
},
"required": [
"expiresAt",
"token"
],
"type": "object"
},
"OAuthTokenEndpointAuthMethod": { "OAuthTokenEndpointAuthMethod": {
"description": "OAuth token endpoint auth method", "description": "OAuth token endpoint auth method",
"enum": [ "enum": [
@@ -262,6 +262,12 @@ export type UserAdminUpdateDto = {
/** Storage label */ /** Storage label */
storageLabel?: string | null; storageLabel?: string | null;
}; };
export type OAuthReLinkTokenResponseDto = {
/** Token expiration */
expiresAt: string;
/** Single-use token; deliver to the user via /auth/link?token=<token> */
token: string;
};
export type AlbumsResponse = { export type AlbumsResponse = {
defaultAssetOrder: AssetOrder; defaultAssetOrder: AssetOrder;
}; };
@@ -1421,6 +1427,10 @@ export type OAuthCallbackDto = {
/** OAuth callback URL */ /** OAuth callback URL */
url: string; url: string;
}; };
export type OAuthReLinkStartDto = {
/** Plaintext OAuth re-link token issued by an administrator */
token: string;
};
export type PartnerResponseDto = { export type PartnerResponseDto = {
avatarColor: UserAvatarColor; avatarColor: UserAvatarColor;
/** User email */ /** User email */
@@ -3527,6 +3537,20 @@ export function updateUserAdmin({ id, userAdminUpdateDto }: {
body: 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 * Retrieve user preferences
*/ */
@@ -4966,6 +4990,18 @@ export function redirectOAuthToMobile(opts?: Oazapfts.RequestOpts) {
...opts ...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 * Unlink OAuth account
*/ */
@@ -1,5 +1,6 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { import {
@@ -9,6 +10,7 @@ import {
OAuthBackchannelLogoutDto, OAuthBackchannelLogoutDto,
OAuthCallbackDto, OAuthCallbackDto,
OAuthConfigDto, OAuthConfigDto,
OAuthReLinkStartDto,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum'; import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
@@ -73,6 +75,8 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto, @Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
let freshLinkCookieIssued = false;
try { try {
const body = await this.service.callback(dto, request.headers, loginDetails); const body = await this.service.callback(dto, request.headers, loginDetails);
return respondWithCookie(res, body, { return respondWithCookie(res, body, {
@@ -89,13 +93,37 @@ export class OAuthController {
isSecure: loginDetails.isSecure, isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }], values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }],
}); });
freshLinkCookieIssued = true;
} }
throw error; throw error;
} finally { } finally {
res.clearCookie(ImmichCookie.OAuthState); res.clearCookie(ImmichCookie.OAuthState);
res.clearCookie(ImmichCookie.OAuthCodeVerifier); 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') @Post('unlink')
@Authenticated() @Authenticated()
@@ -6,6 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto'; import { SessionResponseDto } from 'src/dtos/session.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { import {
OAuthReLinkTokenResponseDto,
UserAdminCreateDto, UserAdminCreateDto,
UserAdminDeleteDto, UserAdminDeleteDto,
UserAdminResponseDto, UserAdminResponseDto,
@@ -137,6 +138,21 @@ export class UserAdminController {
return this.service.updatePreferences(auth, id, dto); 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') @Post(':id/restore')
@Authenticated({ permission: Permission.AdminUserDelete, admin: true }) @Authenticated({ permission: Permission.AdminUserDelete, admin: true })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
+7
View File
@@ -128,6 +128,12 @@ const OAuthBackchannelLogoutSchema = z
.object({ logout_token: z.string().describe('OAuth logout token') }) .object({ logout_token: z.string().describe('OAuth logout token') })
.meta({ id: 'OAuthBackchannelLogoutDto' }); .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 const AuthStatusResponseSchema = z
.object({ .object({
pinCode: z.boolean().describe('Has PIN code set'), 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 OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {} export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {} export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
export class OAuthReLinkStartDto extends createZodDto(OAuthReLinkStartSchema) {}
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {} export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
+9
View File
@@ -121,6 +121,15 @@ const UserAdminDeleteSchema = z
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {} 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({ const UserAdminResponseSchema = UserResponseSchema.extend({
storageLabel: z.string().nullable().describe('Storage label'), storageLabel: z.string().nullable().describe('Storage label'),
shouldChangePassword: z.boolean().describe('Require password change on next login'), 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(); return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
} }
consumeToken(token: Buffer) { getByToken(token: Buffer) {
return this.db return this.db
.deleteFrom('oauth_link_token') .selectFrom('oauth_link_token')
.selectAll()
.where('token', '=', token) .where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate()) .where('expiresAt', '>', DateTime.now().toJSDate())
.returningAll()
.executeTakeFirst(); .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() { async cleanup() {
const result = await this.db const result = await this.db
.deleteFrom('oauth_link_token') .deleteFrom('oauth_link_token')
@@ -5,10 +5,10 @@ export async function up(db: Kysely<any>): Promise<void> {
CREATE TABLE "oauth_link_token" ( CREATE TABLE "oauth_link_token" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"token" bytea NOT NULL, "token" bytea NOT NULL,
"oauthSub" varchar NOT NULL, "oauthSub" varchar,
"oauthSid" varchar, "oauthSid" varchar,
"email" varchar NOT NULL, "email" varchar NOT NULL,
"profile" jsonb NOT NULL, "profile" jsonb,
"expiresAt" timestamp with time zone NOT NULL, "expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now() "createdAt" timestamp with time zone NOT NULL DEFAULT now()
); );
@@ -16,8 +16,8 @@ export class OAuthLinkTokenTable {
@Column({ type: 'bytea', index: true }) @Column({ type: 'bytea', index: true })
token!: Buffer; token!: Buffer;
@Column() @Column({ nullable: true })
oauthSub!: string; oauthSub!: string | null;
@Column({ nullable: true }) @Column({ nullable: true })
oauthSid!: string | null; oauthSid!: string | null;
@@ -25,8 +25,8 @@ export class OAuthLinkTokenTable {
@Column() @Column()
email!: string; email!: string;
@Column({ type: 'jsonb' }) @Column({ type: 'jsonb', nullable: true })
profile!: OAuthLinkTokenProfile; profile!: OAuthLinkTokenProfile | null;
@Column({ type: 'timestamp with time zone' }) @Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp; expiresAt!: Timestamp;
+139
View File
@@ -1355,6 +1355,145 @@ describe(AuthService.name, () => {
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }), 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', () => { describe('unlink', () => {
+58 -18
View File
@@ -91,13 +91,13 @@ export class AuthService extends BaseService {
const linkTokenCookie = this.getCookieOAuthLinkToken(headers); const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (linkTokenCookie) { if (linkTokenCookie) {
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie); const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken); const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
if (record) { if (record && record.oauthSub !== null && record.profile !== null) {
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub); const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
if (duplicate && duplicate.id !== user.id) { if (duplicate && duplicate.id !== user.id) {
throw new BadRequestException('This OAuth account has already been linked to another user.'); 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; linkedOAuthSid = record.oauthSid ?? undefined;
} }
} }
@@ -116,19 +116,25 @@ export class AuthService extends BaseService {
throw new BadRequestException('Missing OAuth link token'); throw new BadRequestException('Missing OAuth link token');
} }
const record = await this.consumeOAuthLinkToken(linkTokenCookie); const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
const existing = await this.userRepository.getByOAuthId(record.oauthSub); 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) { if (existing) {
throw new BadRequestException('This OAuth account has already been linked to another user.'); 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({ const newUser = await this.createUser({
email: record.email, email: record.email,
name: record.profile.name, name: profile.name,
isAdmin: record.profile.isAdmin, 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); return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
} }
@@ -331,6 +337,19 @@ export class AuthService extends BaseService {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; 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) { async authorize(dto: OAuthConfigDto) {
const { oauth } = await this.getConfig({ withCache: false }); const { oauth } = await this.getConfig({ withCache: false });
@@ -380,6 +399,15 @@ export class AuthService extends BaseService {
return this.createLoginResponse(user, loginDetails, oauthSid); 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) { if (!normalizedEmail) {
throw new BadRequestException('OAuth profile does not have an email address'); 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); 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( private resolveOAuthProfile(
profile: OAuthProfile, profile: OAuthProfile,
normalizedEmail: string, 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 }) { private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
const { profile } = record; const { profile } = record;
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null; 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 { AuthFactory } from 'test/factories/auth.factory';
import { UserFactory } from 'test/factories/user.factory'; import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
import { describe } from 'vitest'; 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', () => { describe('restore', () => {
it('should throw error if user could not be found', async () => { it('should throw error if user could not be found', async () => {
mocks.user.get.mockResolvedValue(void 0); mocks.user.get.mockResolvedValue(void 0);
+25
View File
@@ -1,10 +1,12 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto'; import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { import {
OAuthReLinkTokenResponseDto,
UserAdminCreateDto, UserAdminCreateDto,
UserAdminDeleteDto, UserAdminDeleteDto,
UserAdminResponseDto, UserAdminResponseDto,
@@ -137,6 +139,29 @@ export class UserAdminService extends BaseService {
return mapPreferences(getPreferences(metadata)); 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) { async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
await this.findOrFail(id, { withDeleted: false }); await this.findOrFail(id, { withDeleted: false });
const metadata = await this.userRepository.getMetadata(id); const metadata = await this.userRepository.getMetadata(id);
@@ -5,7 +5,7 @@
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { oauth } from '$lib/utils'; import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; 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 { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -20,18 +20,28 @@
}; };
</script> </script>
<section class="my-4"> {#if featureFlagsManager.value.oauth}
<section class="my-4">
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end"> <Stack gap={3}>
{#if featureFlagsManager.value.oauth}
{#if authManager.user.oauthId} {#if authManager.user.oauthId}
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button> <Text>{$t('oauth_account_is_linked')}</Text>
{#if featureFlagsManager.value.passwordLogin}
<div class="sm:ms-8 flex justify-end">
<Button shape="round" size="small" color="danger" onclick={() => handleUnlink()}>
{$t('unlink_oauth')}
</Button>
</div>
{/if}
{:else} {:else}
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))} <Text>{$t('oauth_account_not_linked')}</Text>
>{$t('link_to_oauth')}</Button <div class="sm:ms-8 flex justify-end">
> <Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}>
{/if} {$t('link_to_oauth')}
{/if} </Button>
</div> </div>
{/if}
</Stack>
</div> </div>
</section> </section>
{/if}
+48 -1
View File
@@ -5,9 +5,11 @@
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error'; 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 { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
@@ -22,6 +24,41 @@
let errorMessage = $state(''); let errorMessage = $state('');
let loading = $state(false); let loading = $state(false);
let registering = $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) => { const handleSubmit = async (event: Event) => {
event.preventDefault(); event.preventDefault();
@@ -55,6 +92,15 @@
<AuthPageLayout title={data.meta.title}> <AuthPageLayout title={data.meta.title}>
<Stack gap={4}> <Stack gap={4}>
{#if reLinkError}
<Alert color="danger" title={reLinkError} closable />
{/if}
{#if reLinkMode && reLinkLoading}
<Alert color="primary">
{$t('oauth_relink_in_progress')}
</Alert>
{:else}
{#if featureFlagsManager.value.passwordLogin} {#if featureFlagsManager.value.passwordLogin}
<Alert color="primary"> <Alert color="primary">
{$t('oauth_link_existing_account')} {$t('oauth_link_existing_account')}
@@ -106,5 +152,6 @@
{$t('create_new_account')} {$t('create_new_account')}
</Button> </Button>
{/if} {/if}
{/if}
</Stack> </Stack>
</AuthPageLayout> </AuthPageLayout>
+2
View File
@@ -3,6 +3,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => { export const load = (async ({ url }) => {
const email = url.searchParams.get('email') || ''; const email = url.searchParams.get('email') || '';
const reLinkToken = url.searchParams.get('token') || '';
const $t = await getFormatter(); const $t = await getFormatter();
return { return {
@@ -10,5 +11,6 @@ export const load = (async ({ url }) => {
title: $t('link_to_oauth'), title: $t('link_to_oauth'),
}, },
email, email,
reLinkToken,
}; };
}) satisfies PageLoad; }) satisfies PageLoad;