mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: oauth re-link via admin-provided token
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
|
||||
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { parse as parseCookie } from 'cookie';
|
||||
import { Request, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
OAuthBackchannelLogoutDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
OAuthReLinkStartDto,
|
||||
} from 'src/dtos/auth.dto';
|
||||
import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
||||
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
|
||||
@@ -73,6 +75,8 @@ export class OAuthController {
|
||||
@Body() dto: OAuthCallbackDto,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<LoginResponseDto> {
|
||||
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
|
||||
let freshLinkCookieIssued = false;
|
||||
try {
|
||||
const body = await this.service.callback(dto, request.headers, loginDetails);
|
||||
return respondWithCookie(res, body, {
|
||||
@@ -89,14 +93,38 @@ export class OAuthController {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.OAuthLinkToken, value: error.oauthLinkToken }],
|
||||
});
|
||||
freshLinkCookieIssued = true;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
res.clearCookie(ImmichCookie.OAuthState);
|
||||
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
|
||||
if (hadLinkCookie && !freshLinkCookieIssued) {
|
||||
res.clearCookie(ImmichCookie.OAuthLinkToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Post('relink-start')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Start OAuth re-link',
|
||||
description:
|
||||
'Redeem an admin-issued OAuth re-link token, setting a short-lived cookie that gets consumed by the subsequent OAuth callback.',
|
||||
history: new HistoryBuilder().added('v2'),
|
||||
})
|
||||
async startOAuthReLink(
|
||||
@Body() dto: OAuthReLinkStartDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<void> {
|
||||
await this.service.validateOAuthReLinkToken(dto.token);
|
||||
respondWithCookie(res, null, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.OAuthLinkToken, value: dto.token }],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('unlink')
|
||||
@Authenticated()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
OAuthReLinkTokenResponseDto,
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
@@ -137,6 +138,21 @@ export class UserAdminController {
|
||||
return this.service.updatePreferences(auth, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/oauth-relink-token')
|
||||
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
|
||||
@Endpoint({
|
||||
summary: 'Issue an OAuth re-link token',
|
||||
description:
|
||||
'Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.',
|
||||
history: new HistoryBuilder().added('v2'),
|
||||
})
|
||||
createOAuthReLinkTokenAdmin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<OAuthReLinkTokenResponseDto> {
|
||||
return this.service.createOAuthReLinkToken(auth, id);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -121,6 +121,15 @@ const UserAdminDeleteSchema = z
|
||||
|
||||
export class UserAdminDeleteDto extends createZodDto(UserAdminDeleteSchema) {}
|
||||
|
||||
const OAuthReLinkTokenResponseSchema = z
|
||||
.object({
|
||||
token: z.string().describe('Single-use token; deliver to the user via /auth/link?token=<token>'),
|
||||
expiresAt: isoDatetimeToDate.describe('Token expiration'),
|
||||
})
|
||||
.meta({ id: 'OAuthReLinkTokenResponseDto' });
|
||||
|
||||
export class OAuthReLinkTokenResponseDto extends createZodDto(OAuthReLinkTokenResponseSchema) {}
|
||||
|
||||
const UserAdminResponseSchema = UserResponseSchema.extend({
|
||||
storageLabel: z.string().nullable().describe('Storage label'),
|
||||
shouldChangePassword: z.boolean().describe('Require password change on next login'),
|
||||
|
||||
@@ -13,15 +13,28 @@ export class OAuthLinkTokenRepository {
|
||||
return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
consumeToken(token: Buffer) {
|
||||
getByToken(token: Buffer) {
|
||||
return this.db
|
||||
.deleteFrom('oauth_link_token')
|
||||
.selectFrom('oauth_link_token')
|
||||
.selectAll()
|
||||
.where('token', '=', token)
|
||||
.where('expiresAt', '>', DateTime.now().toJSDate())
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
consumeToken(token: Buffer, kind: 'callback' | 'admin' | 'any' = 'any') {
|
||||
let query = this.db
|
||||
.deleteFrom('oauth_link_token')
|
||||
.where('token', '=', token)
|
||||
.where('expiresAt', '>', DateTime.now().toJSDate());
|
||||
if (kind === 'callback') {
|
||||
query = query.where('oauthSub', 'is not', null);
|
||||
} else if (kind === 'admin') {
|
||||
query = query.where('oauthSub', 'is', null);
|
||||
}
|
||||
return query.returningAll().executeTakeFirst();
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
const result = await this.db
|
||||
.deleteFrom('oauth_link_token')
|
||||
|
||||
@@ -5,10 +5,10 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
CREATE TABLE "oauth_link_token" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"token" bytea NOT NULL,
|
||||
"oauthSub" varchar NOT NULL,
|
||||
"oauthSub" varchar,
|
||||
"oauthSid" varchar,
|
||||
"email" varchar NOT NULL,
|
||||
"profile" jsonb NOT NULL,
|
||||
"profile" jsonb,
|
||||
"expiresAt" timestamp with time zone NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
@@ -16,8 +16,8 @@ export class OAuthLinkTokenTable {
|
||||
@Column({ type: 'bytea', index: true })
|
||||
token!: Buffer;
|
||||
|
||||
@Column()
|
||||
oauthSub!: string;
|
||||
@Column({ nullable: true })
|
||||
oauthSub!: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
oauthSid!: string | null;
|
||||
@@ -25,8 +25,8 @@ export class OAuthLinkTokenTable {
|
||||
@Column()
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
profile!: OAuthLinkTokenProfile;
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
profile!: OAuthLinkTokenProfile | null;
|
||||
|
||||
@Column({ type: 'timestamp with time zone' })
|
||||
expiresAt!: Timestamp;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -91,13 +91,13 @@ export class AuthService extends BaseService {
|
||||
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
|
||||
if (linkTokenCookie) {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
||||
if (record) {
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
|
||||
if (record && record.oauthSub !== null && record.profile !== null) {
|
||||
const duplicate = await this.userRepository.getByOAuthId(record.oauthSub);
|
||||
if (duplicate && duplicate.id !== user.id) {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
user = await this.applyOAuthProfileToUser(user, record);
|
||||
user = await this.applyOAuthProfileToUser(user, { oauthSub: record.oauthSub, profile: record.profile });
|
||||
linkedOAuthSid = record.oauthSid ?? undefined;
|
||||
}
|
||||
}
|
||||
@@ -116,19 +116,25 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('Missing OAuth link token');
|
||||
}
|
||||
|
||||
const record = await this.consumeOAuthLinkToken(linkTokenCookie);
|
||||
const existing = await this.userRepository.getByOAuthId(record.oauthSub);
|
||||
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken, 'callback');
|
||||
if (!record || record.oauthSub === null || record.profile === null) {
|
||||
throw new BadRequestException('Invalid OAuth link token for registration');
|
||||
}
|
||||
const { oauthSub, profile } = record;
|
||||
|
||||
const existing = await this.userRepository.getByOAuthId(oauthSub);
|
||||
if (existing) {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user from OAuth: ${record.oauthSub}/${record.email}`);
|
||||
this.logger.log(`Registering new user from OAuth: ${oauthSub}/${record.email}`);
|
||||
const newUser = await this.createUser({
|
||||
email: record.email,
|
||||
name: record.profile.name,
|
||||
isAdmin: record.profile.isAdmin,
|
||||
name: profile.name,
|
||||
isAdmin: profile.isAdmin,
|
||||
});
|
||||
const user = await this.applyOAuthProfileToUser(newUser, record);
|
||||
const user = await this.applyOAuthProfileToUser(newUser, { oauthSub, profile });
|
||||
return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
|
||||
}
|
||||
|
||||
@@ -331,6 +337,19 @@ export class AuthService extends BaseService {
|
||||
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
|
||||
}
|
||||
|
||||
async validateOAuthReLinkToken(plainToken: string) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const hashed = this.cryptoRepository.hashSha256(plainToken);
|
||||
const record = await this.oauthLinkTokenRepository.getByToken(hashed);
|
||||
if (!record || record.oauthSub !== null) {
|
||||
throw new BadRequestException('Invalid or expired re-link token');
|
||||
}
|
||||
}
|
||||
|
||||
async authorize(dto: OAuthConfigDto) {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
|
||||
@@ -380,6 +399,15 @@ export class AuthService extends BaseService {
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid);
|
||||
}
|
||||
|
||||
const reLinkTokenCookie = this.getCookieOAuthLinkToken(headers);
|
||||
if (reLinkTokenCookie) {
|
||||
const hashedCookie = this.cryptoRepository.hashSha256(reLinkTokenCookie);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedCookie, 'admin');
|
||||
if (record) {
|
||||
return this.completeAdminIssuedReLink(record, profile.sub, oauthSid, loginDetails);
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalizedEmail) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
@@ -398,6 +426,27 @@ export class AuthService extends BaseService {
|
||||
throw new OAuthLinkRequiredException(normalizedEmail, plainToken);
|
||||
}
|
||||
|
||||
private async completeAdminIssuedReLink(
|
||||
record: { email: string },
|
||||
newOAuthSub: string,
|
||||
oauthSid: string | undefined,
|
||||
loginDetails: LoginDetails,
|
||||
) {
|
||||
const targetUser = await this.userRepository.getByEmail(record.email);
|
||||
if (!targetUser) {
|
||||
throw new BadRequestException('The user for this re-link token no longer exists');
|
||||
}
|
||||
|
||||
const duplicate = await this.userRepository.getByOAuthId(newOAuthSub);
|
||||
if (duplicate && duplicate.id !== targetUser.id) {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
this.logger.log(`Completing admin-issued OAuth re-link for user ${targetUser.id}`);
|
||||
const updated = await this.userRepository.update(targetUser.id, { oauthId: newOAuthSub });
|
||||
return this.createLoginResponse(updated, loginDetails, oauthSid);
|
||||
}
|
||||
|
||||
private resolveOAuthProfile(
|
||||
profile: OAuthProfile,
|
||||
normalizedEmail: string,
|
||||
@@ -433,15 +482,6 @@ export class AuthService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async consumeOAuthLinkToken(plainToken: string) {
|
||||
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||
const record = await this.oauthLinkTokenRepository.consumeToken(hashedToken);
|
||||
if (!record) {
|
||||
throw new BadRequestException('Invalid or expired link token');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
|
||||
const { profile } = record;
|
||||
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
@@ -165,6 +166,42 @@ describe(UserAdminService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOAuthReLinkToken', () => {
|
||||
it('should throw when OAuth is not enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
await expect(sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when the target user is missing', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.get.mockResolvedValueOnce(void 0);
|
||||
await expect(sut.createOAuthReLinkToken(authStub.admin, 'missing')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a token with null oauthSub and the target email', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||
|
||||
const result = await sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id);
|
||||
|
||||
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
oauthSub: null,
|
||||
oauthSid: null,
|
||||
profile: null,
|
||||
email: userStub.user1.email,
|
||||
}),
|
||||
);
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user