mirror of
https://github.com/immich-app/immich.git
synced 2026-04-30 04:58:48 -07:00
Compare commits
14 Commits
fix/deep-l
...
fix/oauth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f83e7c66 | ||
|
|
2da2bef777 | ||
|
|
fd52481582 | ||
|
|
e583e3c55a | ||
|
|
12e36ad082 | ||
|
|
f4e016edb5 | ||
|
|
d50ea005a1 | ||
|
|
b8c373f0f1 | ||
|
|
b3e5ec48e6 | ||
|
|
058bd40708 | ||
|
|
81a885c31d | ||
|
|
9b7f75a407 | ||
|
|
b42fdcfca9 | ||
|
|
5731c261eb |
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
56
mobile/openapi/lib/api/authentication_api.dart
generated
56
mobile/openapi/lib/api/authentication_api.dart
generated
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
OAuthReLinkTokenResponseDto,
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
@@ -137,6 +138,21 @@ export class UserAdminController {
|
||||
return this.service.updatePreferences(auth, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/oauth-relink-token')
|
||||
@Authenticated({ permission: Permission.AdminUserUpdate, admin: true })
|
||||
@Endpoint({
|
||||
summary: 'Issue an OAuth re-link token',
|
||||
description:
|
||||
'Create a single-use token that lets a user re-link their account to a new OAuth sub (e.g. when migrating IdPs). Deliver the token to the user out-of-band.',
|
||||
history: new HistoryBuilder().added('v2'),
|
||||
})
|
||||
createOAuthReLinkTokenAdmin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
): Promise<OAuthReLinkTokenResponseDto> {
|
||||
return this.service.createOAuthReLinkToken(auth, id);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@Authenticated({ permission: Permission.AdminUserDelete, admin: true })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -128,6 +128,12 @@ const OAuthBackchannelLogoutSchema = z
|
||||
.object({ logout_token: z.string().describe('OAuth logout token') })
|
||||
.meta({ id: 'OAuthBackchannelLogoutDto' });
|
||||
|
||||
const OAuthReLinkStartSchema = z
|
||||
.object({
|
||||
token: z.string().describe('Plaintext OAuth re-link token issued by an administrator'),
|
||||
})
|
||||
.meta({ id: 'OAuthReLinkStartDto' });
|
||||
|
||||
const AuthStatusResponseSchema = z
|
||||
.object({
|
||||
pinCode: z.boolean().describe('Has PIN code set'),
|
||||
@@ -152,4 +158,5 @@ export class OAuthCallbackDto extends createZodDto(OAuthCallbackSchema) {}
|
||||
export class OAuthConfigDto extends createZodDto(OAuthConfigSchema) {}
|
||||
export class OAuthAuthorizeResponseDto extends createZodDto(OAuthAuthorizeResponseSchema) {}
|
||||
export class OAuthBackchannelLogoutDto extends createZodDto(OAuthBackchannelLogoutSchema) {}
|
||||
export class OAuthReLinkStartDto extends createZodDto(OAuthReLinkStartSchema) {}
|
||||
export class AuthStatusResponseDto extends createZodDto(AuthStatusResponseSchema) {}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
server/src/repositories/oauth-link-token.repository.ts
Normal file
45
server/src/repositories/oauth-link-token.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
36
server/src/schema/tables/oauth-link-token.table.ts
Normal file
36
server/src/schema/tables/oauth-link-token.table.ts
Normal 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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -141,6 +141,7 @@ describe(ServerService.name, () => {
|
||||
reverseGeocoding: true,
|
||||
oauth: false,
|
||||
oauthAutoLaunch: false,
|
||||
oauthAutoRegister: true,
|
||||
ocr: true,
|
||||
passwordLogin: true,
|
||||
search: true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
@@ -165,6 +166,42 @@ describe(UserAdminService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOAuthReLinkToken', () => {
|
||||
it('should throw when OAuth is not enabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
|
||||
await expect(sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when the target user is missing', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.get.mockResolvedValueOnce(void 0);
|
||||
await expect(sut.createOAuthReLinkToken(authStub.admin, 'missing')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.oauthLinkToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a token with null oauthSub and the target email', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauthLinkToken.create.mockResolvedValue({} as any);
|
||||
|
||||
const result = await sut.createOAuthReLinkToken(authStub.admin, userStub.user1.id);
|
||||
|
||||
expect(mocks.oauthLinkToken.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
oauthSub: null,
|
||||
oauthSid: null,
|
||||
profile: null,
|
||||
email: userStub.user1.email,
|
||||
}),
|
||||
);
|
||||
expect(result.token).toEqual(expect.any(String));
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should throw error if user could not be found', async () => {
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { AssetStatsDto, AssetStatsResponseDto, mapStats } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import {
|
||||
OAuthReLinkTokenResponseDto,
|
||||
UserAdminCreateDto,
|
||||
UserAdminDeleteDto,
|
||||
UserAdminResponseDto,
|
||||
@@ -137,6 +139,29 @@ export class UserAdminService extends BaseService {
|
||||
return mapPreferences(getPreferences(metadata));
|
||||
}
|
||||
|
||||
async createOAuthReLinkToken(auth: AuthDto, id: string): Promise<OAuthReLinkTokenResponseDto> {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('OAuth is not enabled');
|
||||
}
|
||||
|
||||
const user = await this.findOrFail(id, {});
|
||||
const plainToken = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashedToken = this.cryptoRepository.hashSha256(plainToken);
|
||||
const expiresAt = DateTime.now().plus({ hours: 24 }).toJSDate();
|
||||
await this.oauthLinkTokenRepository.create({
|
||||
token: hashedToken,
|
||||
oauthSub: null,
|
||||
oauthSid: null,
|
||||
email: user.email,
|
||||
profile: null,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.logger.log(`Admin ${auth.user.id} issued an OAuth re-link token for user ${user.id}`);
|
||||
return { token: plainToken, expiresAt };
|
||||
}
|
||||
|
||||
async updatePreferences(auth: AuthDto, id: string, dto: UserPreferencesUpdateDto) {
|
||||
await this.findOrFail(id, { withDeleted: false });
|
||||
const metadata = await this.userRepository.getMetadata(id);
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
157
web/src/routes/auth/link/+page.svelte
Normal file
157
web/src/routes/auth/link/+page.svelte
Normal 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>
|
||||
16
web/src/routes/auth/link/+page.ts
Normal file
16
web/src/routes/auth/link/+page.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user