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:
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user