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
No known key found for this signature in database
17 changed files with 615 additions and 85 deletions

View File

@ -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",

View File

@ -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=<token>",
"type": "string"
}
},
"required": [
"expiresAt",
"token"
],
"type": "object"
},
"OAuthTokenEndpointAuthMethod": {
"description": "OAuth token endpoint auth method",
"enum": [

View File

@ -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> */
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
*/

View File

@ -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)

View File

@ -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)

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) {}

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'),

View File

@ -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')

View File

@ -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()
);

View File

@ -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;

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', () => {

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;

View File

@ -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);

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);

View File

@ -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 @@
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end">
{#if featureFlagsManager.value.oauth}
{#if featureFlagsManager.value.oauth}
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<Stack gap={3}>
{#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}
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}
>{$t('link_to_oauth')}</Button
>
<Text>{$t('oauth_account_not_linked')}</Text>
<div class="sm:ms-8 flex justify-end">
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}>
{$t('link_to_oauth')}
</Button>
</div>
{/if}
{/if}
</Stack>
</div>
</div>
</section>
</section>
{/if}

View File

@ -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 @@
<AuthPageLayout title={data.meta.title}>
<Stack gap={4}>
{#if featureFlagsManager.value.passwordLogin}
<Alert color="primary">
{$t('oauth_link_existing_account')}
</Alert>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
{$t('to_login')}
</Button>
</form>
{:else}
<Alert color="warning">
{$t('oauth_link_password_login_required')}
</Alert>
{#if reLinkError}
<Alert color="danger" title={reLinkError} closable />
{/if}
{#if featureFlagsManager.value.oauthAutoRegister}
{#if reLinkMode && reLinkLoading}
<Alert color="primary">
{$t('oauth_relink_in_progress')}
</Alert>
{:else}
{#if featureFlagsManager.value.passwordLogin}
<div class="inline-flex w-full items-center justify-center my-4">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
>
{$t('or')}
</span>
</div>
<Alert color="primary">
{$t('oauth_link_existing_account')}
</Alert>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
{$t('to_login')}
</Button>
</form>
{:else}
<Alert color="warning">
{$t('oauth_link_password_login_required')}
</Alert>
{/if}
<Button
shape="round"
size="large"
fullWidth
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
loading={registering}
onclick={handleRegister}
>
{$t('create_new_account')}
</Button>
{#if featureFlagsManager.value.oauthAutoRegister}
{#if featureFlagsManager.value.passwordLogin}
<div class="inline-flex w-full items-center justify-center my-4">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
>
{$t('or')}
</span>
</div>
{/if}
<Button
shape="round"
size="large"
fullWidth
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
loading={registering}
onclick={handleRegister}
>
{$t('create_new_account')}
</Button>
{/if}
{/if}
</Stack>
</AuthPageLayout>

View File

@ -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;