Compare commits

...

14 Commits

Author SHA1 Message Date
bo0tzz
00f83e7c66 feat: oauth re-link via admin-provided token 2026-04-23 13:11:34 +02:00
bo0tzz
2da2bef777 fix: review notes, new register endpoint 2026-04-23 12:22:27 +02:00
bo0tzz
fd52481582 fix: review notes 2026-04-20 14:31:04 +02:00
bo0tzz
e583e3c55a chore: remove userEmail to email 2026-04-18 17:28:21 +02:00
bo0tzz
12e36ad082 chore: remove deleteByEmail 2026-04-18 17:25:15 +02:00
bo0tzz
f4e016edb5 chore: move clearCookie to finally 2026-04-18 15:25:22 +02:00
bo0tzz
d50ea005a1 feat: manage link token via cookie instead 2026-04-18 13:46:30 +02:00
bo0tzz
b8c373f0f1 chore: rename linkToken to oauthLinkToken 2026-04-18 13:46:30 +02:00
bo0tzz
b3e5ec48e6 fix: oauthlink cleanup mock 2026-04-18 13:46:29 +02:00
bo0tzz
058bd40708 fix: await goto 2026-04-18 13:46:29 +02:00
bo0tzz
81a885c31d fix: migration 2026-04-18 13:46:29 +02:00
bo0tzz
9b7f75a407 fix: email normalization test 2026-04-18 13:46:29 +02:00
bo0tzz
b42fdcfca9 fix: review notes 2026-04-18 13:46:29 +02:00
bo0tzz
5731c261eb fix: require users to authenticate existing Immich account before OAuth linking 2026-04-18 13:46:29 +02:00
38 changed files with 1616 additions and 578 deletions

View File

@@ -205,30 +205,39 @@ describe(`/oauth`, () => {
expect(status).toBeGreaterThanOrEqual(400);
});
it('should auto register the user by default', async () => {
it('should return a link token for a new OAuth user', async () => {
const callbackParams = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
isAdmin: false,
name: 'OAuth User',
userEmail: 'oauth-auto-register@immich.app',
userId: expect.any(String),
});
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
const setCookie = response.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((cookie) => cookie.startsWith('immich_oauth_link_token='))).toBe(true);
});
it('should allow passing state and codeVerifier via cookies', async () => {
const { url, state, codeVerifier } = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app)
const response = await request(app)
.post('/oauth/callback')
.set('Cookie', [`immich_oauth_state=${state}`, `immich_oauth_code_verifier=${codeVerifier}`])
.send({ url });
expect(status).toBe(201);
expect(body).toMatchObject({
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
it('should register a new user via POST /auth/register using the link token cookie', async () => {
const callbackParams = await loginWithOAuth('oauth-register-flow');
const callbackResponse = await request(app).post('/oauth/callback').send(callbackParams);
expect(callbackResponse.status).toBe(403);
const setCookie = callbackResponse.headers['set-cookie'] as unknown as string[];
const linkCookie = setCookie.find((cookie) => cookie.startsWith('immich_oauth_link_token='));
expect(linkCookie).toBeDefined();
const registerResponse = await request(app).post('/auth/register').set('Cookie', linkCookie!);
expect(registerResponse.status).toBe(201);
expect(registerResponse.body).toMatchObject({
accessToken: expect.any(String),
userEmail: 'oauth-register-flow@immich.app',
userId: expect.any(String),
userEmail: 'oauth-auto-register@immich.app',
});
});
@@ -349,26 +358,27 @@ describe(`/oauth`, () => {
});
});
it('should not auto register the user', async () => {
it('should still create a link token when auto register is disabled', async () => {
const callbackParams = await loginWithOAuth('oauth-no-auto-register');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('User does not exist and auto registering is disabled.'));
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
it('should link to an existing user by email', async () => {
const { userId } = await utils.userSetup(admin.accessToken, {
it('should not auto-link to an existing user by email', async () => {
await utils.userSetup(admin.accessToken, {
name: 'OAuth User 3',
email: 'oauth-user3@immich.app',
password: 'password',
});
const callbackParams = await loginWithOAuth('oauth-user3');
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
userId,
userEmail: 'oauth-user3@immich.app',
});
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
expect(response.body.userEmail).toBe('oauth-user3@immich.app');
expect(response.body.oauthLinkToken).toBeUndefined();
const setCookie = response.headers['set-cookie'] as unknown as string[];
expect(setCookie.some((cookie) => cookie.startsWith('immich_oauth_link_token='))).toBe(true);
});
});
});
@@ -444,24 +454,18 @@ describe(`/oauth`, () => {
expect(params.get('state')).toBeDefined();
});
it('should auto register the user by default', async () => {
it('should return a link token for a new OAuth user via mobile redirect', async () => {
const callbackParams = await loginWithOAuth('oauth-mobile-override', 'app.immich:///oauth-callback');
expect(callbackParams.url).toEqual(expect.stringContaining(mobileOverrideRedirectUri));
// simulate redirecting back to mobile app
const url = callbackParams.url.replace(mobileOverrideRedirectUri, 'app.immich:///oauth-callback');
const { status, body } = await request(app)
const response = await request(app)
.post('/oauth/callback')
.send({ ...callbackParams, url });
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
isAdmin: false,
name: 'OAuth User',
userEmail: 'oauth-mobile-override@immich.app',
userId: expect.any(String),
});
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
});
@@ -473,14 +477,9 @@ describe(`/oauth`, () => {
clientSecret: OAuthClient.DEFAULT,
});
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
expect(status).toBe(201);
expect(body).toMatchObject({
accessToken: expect.any(String),
name: 'ID Token User',
userEmail: 'oauth-id-token-claims@immich.app',
userId: expect.any(String),
});
const response = await request(app).post('/oauth/callback').send(callbackParams);
expect(response.status).toBe(403);
expect(response.body.message).toBe('oauth_account_link_required');
});
});

View File

@@ -853,6 +853,7 @@
"create_link_to_share": "Create link to share",
"create_link_to_share_description": "Let anyone with the link see the selected photo(s)",
"create_new": "CREATE NEW",
"create_new_account": "Create new account",
"create_new_face": "Create new face",
"create_new_person": "Create new person",
"create_new_person_hint": "Assign selected assets to a new person",
@@ -1125,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",
@@ -1642,6 +1644,11 @@
"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",
"ocr": "OCR",

View File

@@ -122,7 +122,6 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status | Retrieve auth status
*AuthenticationApi* | [**linkOAuthAccount**](doc//AuthenticationApi.md#linkoauthaccount) | **POST** /oauth/link | Link OAuth account
*AuthenticationApi* | [**lockAuthSession**](doc//AuthenticationApi.md#lockauthsession) | **POST** /auth/session/lock | Lock auth session
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | Login
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | Logout

View File

@@ -224,62 +224,6 @@ class AuthenticationApi {
return null;
}
/// Link OAuth account
///
/// Link an OAuth account to the authenticated user.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<Response> linkOAuthAccountWithHttpInfo(OAuthCallbackDto oAuthCallbackDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/oauth/link';
// ignore: prefer_final_locals
Object? postBody = oAuthCallbackDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Link OAuth account
///
/// Link an OAuth account to the authenticated user.
///
/// Parameters:
///
/// * [OAuthCallbackDto] oAuthCallbackDto (required):
Future<UserAdminResponseDto?> linkOAuthAccount(OAuthCallbackDto oAuthCallbackDto,) async {
final response = await linkOAuthAccountWithHttpInfo(oAuthCallbackDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserAdminResponseDto',) as UserAdminResponseDto;
}
return null;
}
/// Lock auth session
///
/// Remove elevated access to locked assets from the current session.

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.",
@@ -4660,6 +4713,35 @@
"x-immich-state": "Stable"
}
},
"/auth/register": {
"post": {
"description": "Create a new user from a pending OAuth link token (requires OAuth auto-register to be enabled).",
"operationId": "register",
"parameters": [],
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponseDto"
}
}
},
"description": ""
}
},
"summary": "Register via OAuth",
"tags": [
"Authentication"
],
"x-immich-history": [
{
"version": "v2",
"state": "Added"
}
]
}
},
"/auth/session/lock": {
"post": {
"description": "Remove elevated access to locked assets from the current session.",
@@ -7439,65 +7521,6 @@
"x-immich-state": "Stable"
}
},
"/oauth/link": {
"post": {
"description": "Link an OAuth account to the authenticated user.",
"operationId": "linkOAuthAccount",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthCallbackDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserAdminResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Link OAuth account",
"tags": [
"Authentication"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v1",
"state": "Beta"
},
{
"version": "v2",
"state": "Stable"
}
],
"x-immich-state": "Stable"
}
},
"/oauth/mobile-redirect": {
"get": {
"description": "Requests to this URL are automatically forwarded to the mobile app, and is used in some cases for OAuth redirecting.",
@@ -7529,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.",
@@ -19116,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": [
@@ -21189,6 +21276,10 @@
"description": "Whether OAuth auto-launch is enabled",
"type": "boolean"
},
"oauthAutoRegister": {
"description": "Whether OAuth auto-register is enabled",
"type": "boolean"
},
"ocr": {
"description": "Whether OCR is enabled",
"type": "boolean"
@@ -21227,6 +21318,7 @@
"map",
"oauth",
"oauthAutoLaunch",
"oauthAutoRegister",
"ocr",
"passwordLogin",
"reverseGeocoding",

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 */
@@ -2047,6 +2057,8 @@ export type ServerFeaturesDto = {
oauth: boolean;
/** Whether OAuth auto-launch is enabled */
oauthAutoLaunch: boolean;
/** Whether OAuth auto-register is enabled */
oauthAutoRegister: boolean;
/** Whether OCR is enabled */
ocr: boolean;
/** Whether password login is enabled */
@@ -3525,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
*/
@@ -4304,6 +4330,18 @@ export function changePinCode({ pinCodeChangeDto }: {
body: pinCodeChangeDto
})));
}
/**
* Register via OAuth
*/
export function register(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: LoginResponseDto;
}>("/auth/register", {
...opts,
method: "POST"
}));
}
/**
* Lock auth session
*/
@@ -4944,21 +4982,6 @@ export function finishOAuth({ oAuthCallbackDto }: {
body: oAuthCallbackDto
})));
}
/**
* Link OAuth account
*/
export function linkOAuthAccount({ oAuthCallbackDto }: {
oAuthCallbackDto: OAuthCallbackDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserAdminResponseDto;
}>("/oauth/link", oazapfts.json({
...opts,
method: "POST",
body: oAuthCallbackDto
})));
}
/**
* Redirect OAuth to mobile
*/
@@ -4967,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

@@ -118,6 +118,7 @@ describe(AuthController.name, () => {
expect(service.login).toHaveBeenCalledWith(
expect.objectContaining({ email: 'admin@immich.app' }),
expect.anything(),
expect.anything(),
);
});
@@ -129,7 +130,49 @@ describe(AuthController.name, () => {
.send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201);
expect(service.login).toHaveBeenCalledWith(expect.objectContaining({ email: 'admin@local' }), expect.anything());
expect(service.login).toHaveBeenCalledWith(
expect.objectContaining({ email: 'admin@local' }),
expect.anything(),
expect.anything(),
);
});
it('should clear the link token cookie on successful login when it was present', async () => {
const loginResponse = mediumFactory.loginResponse();
service.login.mockResolvedValue(loginResponse);
const { status, headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({ name: 'admin', email: 'admin@local', password: 'password' });
expect(status).toEqual(201);
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
it('should clear the link token cookie when login fails', async () => {
service.login.mockRejectedValue(new Error('Incorrect email or password'));
const { headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({ name: 'admin', email: 'admin@local', password: 'wrong' });
const cookies = (headers['set-cookie'] as unknown as string[] | undefined)?.join('\n') ?? '';
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
it('should not set a link token cookie header when no link token was present', async () => {
const loginResponse = mediumFactory.loginResponse();
service.login.mockResolvedValue(loginResponse);
const { headers } = await request(ctx.getHttpServer())
.post('/auth/login')
.send({ name: 'admin', email: 'admin@local', password: 'password' });
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
expect(cookies).not.toMatch(/immich_oauth_link_token=/);
});
it('should auth cookies on a secure connection', async () => {
@@ -170,6 +213,33 @@ describe(AuthController.name, () => {
});
});
describe('POST /auth/register', () => {
it('should clear the link token cookie on successful register', async () => {
const loginResponse = mediumFactory.loginResponse();
service.register.mockResolvedValue(loginResponse);
const { headers } = await request(ctx.getHttpServer())
.post('/auth/register')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({});
const cookies = (headers['set-cookie'] as unknown as string[]).join('\n');
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
it('should clear the link token cookie when register fails', async () => {
service.register.mockRejectedValue(new Error('Missing OAuth link token'));
const { headers } = await request(ctx.getHttpServer())
.post('/auth/register')
.set('Cookie', 'immich_oauth_link_token=plain')
.send({});
const cookies = (headers['set-cookie'] as unknown as string[] | undefined)?.join('\n') ?? '';
expect(cookies).toMatch(/immich_oauth_link_token=;/);
});
});
describe('POST /auth/logout', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/auth/logout');

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { parse as parseCookie } from 'cookie';
import { Request, Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
@@ -34,19 +35,56 @@ export class AuthController {
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async login(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Body() loginCredential: LoginCredentialDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.login(loginCredential, loginDetails);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Password },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
try {
const body = await this.service.login(loginCredential, loginDetails, request.headers);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.Password },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
} finally {
if (hadLinkCookie) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
}
}
@Post('register')
@Endpoint({
summary: 'Register via OAuth',
description: 'Create a new user from a pending OAuth link token (requires OAuth auto-register to be enabled).',
history: new HistoryBuilder().added('v2'),
})
async register(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const hadLinkCookie = !!parseCookie(request.headers.cookie || '')[ImmichCookie.OAuthLinkToken];
try {
const body = await this.service.register(loginDetails, request.headers);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
} finally {
if (hadLinkCookie) {
res.clearCookie(ImmichCookie.OAuthLinkToken);
}
}
}
@Post('admin-sign-up')

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,11 +10,12 @@ import {
OAuthBackchannelLogoutDto,
OAuthCallbackDto,
OAuthConfigDto,
OAuthReLinkStartDto,
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto } from 'src/dtos/user.dto';
import { ApiTag, AuthType, ImmichCookie } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { AuthService, LoginDetails, OAuthLinkRequiredException } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Authentication)
@@ -73,33 +75,54 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const body = await this.service.callback(dto, request.headers, loginDetails);
res.clearCookie(ImmichCookie.OAuthState);
res.clearCookie(ImmichCookie.OAuthCodeVerifier);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
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, {
isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.AccessToken, value: body.accessToken },
{ key: ImmichCookie.AuthType, value: AuthType.OAuth },
{ key: ImmichCookie.IsAuthenticated, value: 'true' },
],
});
} catch (error) {
if (error instanceof OAuthLinkRequiredException) {
respondWithCookie(res, null, {
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('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Post('relink-start')
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Link OAuth account',
description: 'Link an OAuth account to the authenticated user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
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'),
})
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@Body() dto: OAuthCallbackDto,
): Promise<UserAdminResponseDto> {
return this.service.link(auth, dto, request.headers);
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')

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

@@ -132,6 +132,7 @@ const ServerFeaturesSchema = z
importFaces: z.boolean().describe('Whether face import is enabled'),
oauth: z.boolean().describe('Whether OAuth is enabled'),
oauthAutoLaunch: z.boolean().describe('Whether OAuth auto-launch is enabled'),
oauthAutoRegister: z.boolean().describe('Whether OAuth auto-register is enabled'),
passwordLogin: z.boolean().describe('Whether password login is enabled'),
sidecar: z.boolean().describe('Whether sidecar files are supported'),
search: z.boolean().describe('Whether search is enabled'),

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

@@ -15,6 +15,7 @@ export enum ImmichCookie {
SharedLinkToken = 'immich_shared_link_token',
OAuthState = 'immich_oauth_state',
OAuthCodeVerifier = 'immich_oauth_code_verifier',
OAuthLinkToken = 'immich_oauth_link_token',
}
export const ImmichCookieSchema = z.enum(ImmichCookie).describe('Immich cookie').meta({ id: 'ImmichCookie' });

View File

@@ -25,6 +25,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -78,6 +79,7 @@ export const repositories = [
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthLinkTokenRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/schema';
import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table';
@Injectable()
export class OAuthLinkTokenRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<OAuthLinkTokenTable>) {
return this.db.insertInto('oauth_link_token').values(dto).returningAll().executeTakeFirstOrThrow();
}
getByToken(token: Buffer) {
return this.db
.selectFrom('oauth_link_token')
.selectAll()
.where('token', '=', token)
.where('expiresAt', '>', DateTime.now().toJSDate())
.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')
.where('expiresAt', '<=', DateTime.now().toJSDate())
.execute();
return Number(result[0]?.numDeletedRows ?? 0);
}
}

View File

@@ -50,6 +50,7 @@ import { MemoryTable } from 'src/schema/tables/memory.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
import { OAuthLinkTokenTable } from 'src/schema/tables/oauth-link-token.table';
import { OcrSearchTable } from 'src/schema/tables/ocr-search.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
@@ -108,6 +109,7 @@ export class ImmichDatabase {
MoveTable,
NaturalEarthCountriesTable,
NotificationTable,
OAuthLinkTokenTable,
OcrSearchTable,
PartnerAuditTable,
PartnerTable,
@@ -210,6 +212,8 @@ export interface DB {
notification: NotificationTable;
oauth_link_token: OAuthLinkTokenTable;
move_history: MoveTable;
naturalearth_countries: NaturalEarthCountriesTable;

View File

@@ -0,0 +1,23 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`
CREATE TABLE "oauth_link_token" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"token" bytea NOT NULL,
"oauthSub" varchar,
"oauthSid" varchar,
"email" varchar NOT NULL,
"profile" jsonb,
"expiresAt" timestamp with time zone NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now()
);
`.execute(db);
await sql`ALTER TABLE "oauth_link_token" ADD CONSTRAINT "oauth_link_token_pkey" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "oauth_link_token_token_idx" ON "oauth_link_token" ("token")`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE IF EXISTS "oauth_link_token";`.execute(db);
}

View File

@@ -0,0 +1,36 @@
import { Column, CreateDateColumn, Generated, PrimaryGeneratedColumn, Table, Timestamp } from '@immich/sql-tools';
export type OAuthLinkTokenProfile = {
name: string;
storageLabel: string | null;
storageQuotaInGiB: number | null;
isAdmin: boolean;
picture: string | null;
};
@Table({ name: 'oauth_link_token' })
export class OAuthLinkTokenTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'bytea', index: true })
token!: Buffer;
@Column({ nullable: true })
oauthSub!: string | null;
@Column({ nullable: true })
oauthSid!: string | null;
@Column()
email!: string;
@Column({ type: 'jsonb', nullable: true })
profile!: OAuthLinkTokenProfile | null;
@Column({ type: 'timestamp with time zone' })
expiresAt!: Timestamp;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}

View File

@@ -4,7 +4,7 @@ import { SALT_ROUNDS } from 'src/constants';
import { UserAdmin } from 'src/database';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service';
import { AuthService, OAuthLinkRequiredException } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory';
@@ -50,13 +50,13 @@ describe(AuthService.name, () => {
it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -64,7 +64,7 @@ describe(AuthService.name, () => {
it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(dto, loginDetails, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -75,7 +75,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
await expect(sut.login(dto, loginDetails, {})).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id,
userEmail: user.email,
@@ -88,6 +88,312 @@ describe(AuthService.name, () => {
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an OAuth account when link token cookie is present', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, expect.objectContaining({ oauthId: 'oauth-sub-123' }));
});
it('should propagate oauthSid from link token to the session', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: 'idp-sid-456',
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid-456' }));
});
it('should silently fall back to normal login when the link token is invalid or expired', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue(null as any);
await expect(
sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=bad-token' }),
).resolves.toMatchObject({ userId: user.id });
expect(mocks.oauthLinkToken.consumeToken).toHaveBeenCalledTimes(1);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: null }));
});
it('should reject when the link token points to a sub already linked to another user', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const otherUser = UserFactory.create({ oauthId: 'oauth-sub-123' });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(otherUser);
await expect(sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' })).rejects.toThrow(
'This OAuth account has already been linked to another user.',
);
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should sanitize the storage label when linking from an OAuth profile', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: user.email,
profile: {
name: 'OAuth User',
storageLabel: '../evil/path',
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.update.mockResolvedValue(user);
await sut.login(dto, loginDetails, { cookie: 'immich_oauth_link_token=plain-token' });
const updateCall = mocks.user.update.mock.calls[0][1];
expect(updateCall.storageLabel).not.toContain('/');
expect(updateCall.storageLabel).not.toContain('.');
});
});
describe('register', () => {
it('should throw if auto-register is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
'OAuth auto-register is disabled',
);
});
it('should throw if link token cookie is missing', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
await expect(sut.register(loginDetails, {})).rejects.toThrow('Missing OAuth link token');
});
it('should throw if the sub is already linked', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'new@immich.cloud',
profile: {
name: 'New User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-sub-123' }));
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
'This OAuth account has already been linked to another user',
);
});
it('should create a user from the link token and apply the profile', async () => {
const newUser = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: 'idp-sid',
email: 'new@immich.cloud',
profile: {
name: 'New User',
storageLabel: 'shiny',
storageQuotaInGiB: 5,
isAdmin: true,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(newUser);
mocks.user.update.mockResolvedValue(newUser);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
expect(mocks.user.create).toHaveBeenCalledWith(
expect.objectContaining({ email: 'new@immich.cloud', name: 'New User', isAdmin: true }),
);
expect(mocks.user.update).toHaveBeenCalledWith(
newUser.id,
expect.objectContaining({
oauthId: 'oauth-sub-123',
storageLabel: 'shiny',
quotaSizeInBytes: 5 * 1024 * 1024 * 1024,
isAdmin: true,
}),
);
expect(mocks.session.create).toHaveBeenCalledWith(expect.objectContaining({ oauthSid: 'idp-sid' }));
});
it('should allow the first OAuth admin to bootstrap the instance', async () => {
const newUser = UserFactory.create({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'first@immich.cloud',
profile: {
name: 'First Admin',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: true,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(newUser);
mocks.user.update.mockResolvedValue(newUser);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
expect(mocks.user.getAdmin).not.toHaveBeenCalled();
});
it('should reject a non-admin OAuth register when no admin exists yet', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'first@immich.cloud',
profile: {
name: 'Regular User',
storageLabel: null,
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(void 0);
await expect(sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' })).rejects.toThrow(
'The first registered account must the administrator.',
);
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should sanitize the storage label on register', async () => {
const newUser = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauthLinkToken.consumeToken.mockResolvedValue({
id: 'token-id',
oauthSub: 'oauth-sub-123',
oauthSid: null,
email: 'new@immich.cloud',
profile: {
name: 'New User',
storageLabel: '../sneaky',
storageQuotaInGiB: null,
isAdmin: false,
picture: null,
},
token: Buffer.from('hashed'),
expiresAt: new Date(Date.now() + 600_000),
createdAt: new Date(),
});
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(newUser);
mocks.user.update.mockResolvedValue(newUser);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.register(loginDetails, { cookie: 'immich_oauth_link_token=plain' });
const updateCall = mocks.user.update.mock.calls[0][1];
expect(updateCall.storageLabel).not.toContain('/');
expect(updateCall.storageLabel).not.toContain('.');
});
});
describe('changePassword', () => {
@@ -686,69 +992,12 @@ describe(AuthService.name, () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
const user = UserFactory.create();
it('should create a link token when the oauth sub is not yet linked', async () => {
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'idp-sid-789' });
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
@@ -756,31 +1005,34 @@ describe(AuthService.name, () => {
{},
loginDetails,
),
).rejects.toThrow(BadRequestException);
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.user.create).not.toHaveBeenCalled();
});
it('should allow auto registering by default', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ oauthSub: profile.sub, oauthSid: 'idp-sid-789', email: profile.email }),
);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(mocks.user.create).toHaveBeenCalledTimes(1);
});
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
it('should normalize the email from the OAuth profile before storing in the link token', async () => {
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'test@immich.cloud' }));
});
it('should throw an error if the OAuth profile does not have an email claim', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
@@ -824,19 +1076,20 @@ describe(AuthService.name, () => {
it('should use the default quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should infer name from given and family names', async () => {
@@ -844,18 +1097,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ name: 'Given Family' }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
});
it('should fallback to email when no username is provided', async () => {
@@ -863,18 +1117,19 @@ describe(AuthService.name, () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ name: profile.email }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
});
it('should ignore an invalid storage quota', async () => {
@@ -882,18 +1137,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should ignore a negative quota', async () => {
@@ -901,53 +1157,55 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
});
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 1 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should set quota for 0 quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 0 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
});
it('should use a valid storage quota', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ storageQuotaInGiB: 5 }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
});
it('should sync the profile picture', async () => {
@@ -1041,19 +1299,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: false }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
});
it('should create an admin user if the role claim is set to admin', async () => {
@@ -1061,18 +1319,19 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
});
it('should accept a custom role claim', async () => {
@@ -1082,77 +1341,158 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
});
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).rejects.toThrow(OAuthLinkRequiredException);
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
expect.objectContaining({ profile: expect.objectContaining({ isAdmin: true }) }),
);
});
expect(mocks.user.create).toHaveBeenCalledWith(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('link', () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
mocks.user.update.mockResolvedValue(user);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
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 link an account and update the session with the oauthSid', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
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');
});
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
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(),
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
await sut.link(
auth,
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
await expect(sut.validateOAuthReLinkToken('plain')).rejects.toThrow('Invalid or expired');
});
it('should not link an already linked oauth.sub', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(
sut.link(auth, { url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' }, {}),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.user.update).not.toHaveBeenCalled();
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();
});
});

View File

@@ -2,6 +2,8 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import {
@@ -23,6 +25,7 @@ import {
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { OAuthLinkTokenProfile } from 'src/schema/tables/oauth-link-token.table';
import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
@@ -42,6 +45,15 @@ interface ClaimOptions<T> {
isValid: (value: unknown) => boolean;
}
export class OAuthLinkRequiredException extends ForbiddenException {
constructor(
public readonly userEmail: string,
public readonly oauthLinkToken: string,
) {
super({ message: 'oauth_account_link_required', userEmail });
}
}
export type ValidateRequest = {
headers: IncomingHttpHeaders;
queryParams: Record<string, string>;
@@ -56,7 +68,7 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
async login(dto: LoginCredentialDto, details: LoginDetails) {
async login(dto: LoginCredentialDto, details: LoginDetails, headers: IncomingHttpHeaders) {
const config = await this.getConfig({ withCache: false });
if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
@@ -75,7 +87,55 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Incorrect email or password');
}
return this.createLoginResponse(user, details);
let linkedOAuthSid: string | undefined;
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (linkTokenCookie) {
const hashedToken = this.cryptoRepository.hashSha256(linkTokenCookie);
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, { oauthSub: record.oauthSub, profile: record.profile });
linkedOAuthSid = record.oauthSid ?? undefined;
}
}
return this.createLoginResponse(user, details, linkedOAuthSid);
}
async register(details: LoginDetails, headers: IncomingHttpHeaders) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled || !oauth.autoRegister) {
throw new BadRequestException('OAuth auto-register is disabled');
}
const linkTokenCookie = this.getCookieOAuthLinkToken(headers);
if (!linkTokenCookie) {
throw new BadRequestException('Missing OAuth link token');
}
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: ${oauthSub}/${record.email}`);
const newUser = await this.createUser({
email: record.email,
name: profile.name,
isAdmin: profile.isAdmin,
});
const user = await this.applyOAuthProfileToUser(newUser, { oauthSub, profile });
return this.createLoginResponse(user, details, record.oauthSid ?? undefined);
}
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
@@ -277,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 });
@@ -316,71 +389,112 @@ export class AuthService extends BaseService {
codeVerifier,
);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
const user = await this.userRepository.getByOAuthId(profile.sub);
// link by email
if (!user && normalizedEmail) {
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
if (emailUser) {
if (emailUser.oauthId) {
throw new BadRequestException('User already exists, but is linked to another account.');
}
user = await this.userRepository.update(emailUser.id, { oauthId: profile.sub });
if (user) {
if (!user.profileImagePath && profile.picture) {
await this.syncProfilePicture(user, profile.picture);
}
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);
}
}
// register new user
if (!user) {
if (!autoRegister) {
this.logger.warn(
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
if (!normalizedEmail) {
throw new BadRequestException('OAuth profile does not have an email address');
}
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: (value: unknown): value is string => typeof value === 'string',
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
const role = this.getClaim<'admin' | 'user'>(profile, {
key: roleClaim,
default: 'user',
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
});
user = await this.createUser({
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
normalizedEmail,
email: normalizedEmail,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,
isAdmin: role === 'admin',
});
if (!normalizedEmail) {
throw new BadRequestException('OAuth profile does not have an email address');
}
if (!user.profileImagePath && profile.picture) {
await this.syncProfilePicture(user, profile.picture);
const resolvedProfile = this.resolveOAuthProfile(profile, normalizedEmail, oauth);
const plainToken = this.cryptoRepository.randomBytesAsText(32);
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
await this.oauthLinkTokenRepository.create({
token: hashedToken,
oauthSub: profile.sub,
oauthSid: oauthSid ?? null,
email: normalizedEmail,
profile: resolvedProfile,
expiresAt: DateTime.now().plus({ minutes: 10 }).toJSDate(),
});
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');
}
return this.createLoginResponse(user, loginDetails, oauthSid);
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,
oauth: SystemConfig['oauth'],
): OAuthLinkTokenProfile {
const { defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
const storageLabel = this.getClaim(profile, {
key: storageLabelClaim,
default: '',
isValid: (value: unknown): value is string => typeof value === 'string',
});
const storageQuota = this.getClaim(profile, {
key: storageQuotaClaim,
default: defaultStorageQuota,
isValid: (value: unknown) => Number(value) >= 0,
});
const role = this.getClaim<'admin' | 'user'>(profile, {
key: roleClaim,
default: 'user',
isValid: (value: unknown) => typeof value === 'string' && ['admin', 'user'].includes(value),
});
return {
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
normalizedEmail,
storageLabel: storageLabel || null,
storageQuotaInGiB: storageQuota,
isAdmin: role === 'admin',
picture: profile.picture ?? null,
};
}
private async applyOAuthProfileToUser(user: UserAdmin, record: { oauthSub: string; profile: OAuthLinkTokenProfile }) {
const { profile } = record;
const storageLabel = profile.storageLabel ? sanitize(profile.storageLabel.replaceAll('.', '')) : null;
const updated = await this.userRepository.update(user.id, {
oauthId: record.oauthSub,
storageLabel,
quotaSizeInBytes: profile.storageQuotaInGiB === null ? null : profile.storageQuotaInGiB * HumanReadableSize.GiB,
isAdmin: profile.isAdmin,
});
if (!updated.profileImagePath && profile.picture) {
await this.syncProfilePicture(updated, profile.picture);
}
return updated;
}
private async syncProfilePicture(user: UserAdmin, url: string) {
@@ -406,36 +520,6 @@ export class AuthService extends BaseService {
}
}
async link(auth: AuthDto, dto: OAuthCallbackDto, headers: IncomingHttpHeaders): Promise<UserAdminResponseDto> {
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
}
const codeVerifier = dto.codeVerifier ?? this.getCookieCodeVerifier(headers);
if (!codeVerifier?.length) {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const {
profile: { sub: oauthId },
sid,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
return mapUserAdmin(user);
}
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
@@ -486,6 +570,11 @@ export class AuthService extends BaseService {
return cookies[ImmichCookie.OAuthCodeVerifier] || null;
}
private getCookieOAuthLinkToken(headers: IncomingHttpHeaders): string | null {
const cookies = parse(headers.cookie || '');
return cookies[ImmichCookie.OAuthLinkToken] || null;
}
async validateSharedLinkKey(key: string | string[]): Promise<AuthDto> {
key = Array.isArray(key) ? key[0] : key;

View File

@@ -32,6 +32,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -88,6 +89,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
MetadataRepository,
MoveRepository,
NotificationRepository,
OAuthLinkTokenRepository,
OAuthRepository,
OcrRepository,
PartnerRepository,
@@ -146,6 +148,7 @@ export class BaseService {
protected metadataRepository: MetadataRepository,
protected moveRepository: MoveRepository,
protected notificationRepository: NotificationRepository,
protected oauthLinkTokenRepository: OAuthLinkTokenRepository,
protected oauthRepository: OAuthRepository,
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,

View File

@@ -141,6 +141,7 @@ describe(ServerService.name, () => {
reverseGeocoding: true,
oauth: false,
oauthAutoLaunch: false,
oauthAutoRegister: true,
ocr: true,
passwordLogin: true,
search: true,

View File

@@ -102,6 +102,7 @@ export class ServerService extends BaseService {
trash: trash.enabled,
oauth: oauth.enabled,
oauthAutoLaunch: oauth.autoLaunch,
oauthAutoRegister: oauth.autoRegister,
ocr: isOcrEnabled(machineLearning),
passwordLogin: passwordLogin.enabled,
configFile: !!configFile,

View File

@@ -20,6 +20,7 @@ describe('SessionService', () => {
describe('handleCleanup', () => {
it('should clean sessions', async () => {
mocks.session.cleanup.mockResolvedValue([]);
mocks.oauthLinkToken.cleanup.mockResolvedValue(0);
await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.Success);
});
});

View File

@@ -24,6 +24,11 @@ export class SessionService extends BaseService {
this.logger.log(`Deleted ${sessions.length} expired session tokens`);
const expiredLinkTokens = await this.oauthLinkTokenRepository.cleanup();
if (expiredLinkTokens > 0) {
this.logger.debug(`Deleted ${expiredLinkTokens} expired OAuth link tokens`);
}
return JobStatus.Success;
}

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

@@ -18,6 +18,7 @@ export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values
[ImmichCookie.MaintenanceToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
[ImmichCookie.OAuthState]: defaults,
[ImmichCookie.OAuthCodeVerifier]: defaults,
[ImmichCookie.OAuthLinkToken]: { ...defaults, maxAge: Duration.fromObject({ minutes: 10 }).toMillis() },
// no httpOnly so that the client can know the auth state
[ImmichCookie.IsAuthenticated]: { ...defaults, httpOnly: false },
[ImmichCookie.SharedLinkToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },

View File

@@ -77,7 +77,7 @@ describe(AuthService.name, () => {
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password: 'wrong-password' };
await expect(sut.login(dto, mediumFactory.loginDetails())).rejects.toThrow('Incorrect email or password');
await expect(sut.login(dto, mediumFactory.loginDetails(), {})).rejects.toThrow('Incorrect email or password');
});
it('should accept a correct password and return a login response', async () => {
@@ -87,7 +87,7 @@ describe(AuthService.name, () => {
const { user } = await ctx.newUser({ password: passwordHashed });
const dto = { email: user.email, password };
await expect(sut.login(dto, mediumFactory.loginDetails())).resolves.toEqual({
await expect(sut.login(dto, mediumFactory.loginDetails(), {})).resolves.toEqual({
accessToken: expect.any(String),
isAdmin: user.isAdmin,
isOnboarded: false,
@@ -147,7 +147,7 @@ describe(AuthService.name, () => {
expect((response as any).password).not.toBeDefined();
await expect(
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails()),
sut.login({ email: user.email, password: dto.newPassword }, mediumFactory.loginDetails(), {}),
).resolves.toBeDefined();
});

View File

@@ -43,6 +43,7 @@ import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthLinkTokenRepository } from 'src/repositories/oauth-link-token.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
@@ -239,6 +240,7 @@ export type ServiceOverrides = {
metadata: MetadataRepository;
move: MoveRepository;
notification: NotificationRepository;
oauthLinkToken: OAuthLinkTokenRepository;
ocr: OcrRepository;
oauth: OAuthRepository;
partner: PartnerRepository;
@@ -321,6 +323,7 @@ export const getMocks = () => {
move: automock(MoveRepository, { strict: false }),
notification: automock(NotificationRepository),
ocr: automock(OcrRepository, { strict: false }),
oauthLinkToken: automock(OAuthLinkTokenRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: automock(PersonRepository, { strict: false }),
@@ -387,6 +390,7 @@ export const newTestService = <T extends BaseService>(
overrides.metadata || (mocks.metadata as As<MetadataRepository>),
overrides.move || (mocks.move as As<MoveRepository>),
overrides.notification || (mocks.notification as As<NotificationRepository>),
overrides.oauthLinkToken || (mocks.oauthLinkToken as As<OAuthLinkTokenRepository>),
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),

View File

@@ -51,6 +51,7 @@ export const Docs = {
export const Route = {
// auth
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
authLink: (params?: { email?: string }) => '/auth/link' + asQueryString(params),
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
register: () => '/auth/register',
changePassword: () => '/auth/change-password',

View File

@@ -15,7 +15,6 @@ import {
getBaseUrl,
getPeopleThumbnailPath,
getUserProfileImagePath,
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type AssetResponseDto,
@@ -293,9 +292,6 @@ export const oauth = {
login: (location: Location) => {
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
},
link: (location: Location) => {
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
},
unlink: () => {
return unlinkOAuthAccount();
},

View File

@@ -2,32 +2,13 @@
import { goto } from '$app/navigation';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { Button, Stack, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let loading = $state(true);
onMount(async () => {
if (oauth.isCallback(globalThis.location)) {
try {
loading = true;
const response = await oauth.link(globalThis.location);
authManager.setUser(response);
toastManager.primary($t('linked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
await goto('?open=oauth');
}
}
loading = false;
});
const handleUnlink = async () => {
try {
const response = await oauth.unlink();
@@ -39,22 +20,28 @@
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end">
{#if loading}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{:else 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={() => oauth.authorize(globalThis.location)}
>{$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

@@ -0,0 +1,157 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
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 { 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';
type Props = {
data: PageData;
};
const { data }: Props = $props();
let email = $state(data.email || authManager.user?.email || '');
let password = $state('');
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();
try {
errorMessage = '';
loading = true;
const user = await login({ loginCredentialDto: { email, password } });
eventManager.emit('AuthLogin', user);
await authManager.refresh();
toastManager.primary($t('linked_oauth_account'));
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
loading = false;
}
};
const handleRegister = async () => {
try {
registering = true;
const user = await register();
eventManager.emit('AuthLogin', user);
await authManager.refresh();
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
registering = false;
}
};
</script>
<AuthPageLayout title={data.meta.title}>
<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}
<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}
{#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

@@ -0,0 +1,16 @@
import { getFormatter } from '$lib/utils/i18n';
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 {
meta: {
title: $t('link_to_oauth'),
},
email,
reLinkToken,
};
}) satisfies PageLoad;

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { OpenQueryParam } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { login, type LoginResponseDto } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
import { isHttpError, login, type LoginResponseDto } 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';
@@ -43,6 +45,20 @@
}
if (oauth.isCallback(globalThis.location)) {
const params = new URLSearchParams(globalThis.location.search);
if (params.has('error')) {
if (authManager.authenticated) {
const message = params.get('error_description') || $t('errors.unable_to_link_oauth_account');
await goto(Route.userSettings({ isOpen: OpenQueryParam.OAUTH }));
toastManager.warning(message);
} else {
oauthError =
params.get('error_description') || params.get('error') || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
}
return;
}
try {
const user = await oauth.login(globalThis.location);
@@ -54,6 +70,11 @@
await onSuccess(user);
return;
} catch (error) {
if (isHttpError(error) && error.data?.message === 'oauth_account_link_required') {
const errorData = error.data as unknown as Record<string, string>;
await goto(Route.authLink({ email: errorData.userEmail }));
return;
}
console.error('Error [login-form] [oauth.callback]', error);
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;

View File

@@ -9,7 +9,9 @@ export const load = (async ({ parent, url }) => {
await parent();
const continueUrl = url.searchParams.get('continue') || Route.photos();
if (authManager.authenticated) {
const isOAuthCallback = url.searchParams.has('code') || url.searchParams.has('error');
const isOAuthAutoLaunch = url.searchParams.has('autoLaunch');
if (authManager.authenticated && !isOAuthCallback && !isOAuthAutoLaunch) {
redirect(307, continueUrl);
}