From 55f2b3b6a0d1d774347b7b81381403f9be52bc4c Mon Sep 17 00:00:00 2001 From: sparsh985 <90522171+sparsh985@users.noreply.github.com> Date: Sat, 18 Apr 2026 02:50:07 +0530 Subject: [PATCH] feat(server): add configurable OAuth prompt parameter (#26755) * feat(server): add configurable OAuth prompt parameter Add a `prompt` field to the OAuth system config, allowing admins to configure the OIDC `prompt` parameter (e.g. `select_account`, `login`, `consent`). Defaults to empty string (no prompt sent), preserving backward compatibility. This is useful for providers like Google where users want to be prompted to select an account when multiple accounts are signed in. Discussed in #20762 * chore: regenerate OpenAPI spec and clients for OAuth prompt field * Adding e2e test cases * feat: web setting * feat: docs --------- Co-authored-by: Jason Rasmussen --- docs/docs/administration/oauth.md | 1 + e2e/src/specs/server/api/oauth.e2e-spec.ts | 58 +++++++++++++++---- i18n/en.json | 1 + .../lib/model/system_config_o_auth_dto.dart | 11 +++- open-api/immich-openapi-specs.json | 5 ++ open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/config.ts | 2 + server/src/dtos/system-config.dto.ts | 1 + server/src/repositories/oauth.repository.ts | 5 ++ server/src/services/auth.service.spec.ts | 2 +- .../services/system-config.service.spec.ts | 1 + .../admin-settings/AuthSettings.svelte | 10 ++++ 12 files changed, 87 insertions(+), 12 deletions(-) diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 8d259d8074..95ad8db72d 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -67,6 +67,7 @@ Once you have a new OAuth client application configured, Immich can be configure | `scope` | string | openid email profile | Full list of scopes to send with the request (space delimited) | | `id_token_signed_response_alg` | string | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) | | `userinfo_signed_response_alg` | string | none | The algorithm used to sign the userinfo response (examples: RS256, HS256) | +| `prompt` | string | (empty) | Prompt parameter for authorization url (examples: select_account, login, consent) | | Request timeout | string | 30,000 (30 seconds) | Number of milliseconds to wait for http requests to complete before giving up | | Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** | | Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** | diff --git a/e2e/src/specs/server/api/oauth.e2e-spec.ts b/e2e/src/specs/server/api/oauth.e2e-spec.ts index a3bc0d8770..9dcb431a4b 100644 --- a/e2e/src/specs/server/api/oauth.e2e-spec.ts +++ b/e2e/src/specs/server/api/oauth.e2e-spec.ts @@ -89,17 +89,19 @@ describe(`/oauth`, () => { beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - - await setupOAuth(admin.accessToken, { - enabled: true, - clientId: OAuthClient.DEFAULT, - clientSecret: OAuthClient.DEFAULT, - buttonText: 'Login with Immich', - storageLabelClaim: 'immich_username', - }); }); describe('POST /oauth/authorize', () => { + beforeAll(async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + buttonText: 'Login with Immich', + storageLabelClaim: 'immich_username', + }); + }); + it(`should throw an error if a redirect uri is not provided`, async () => { const { status, body } = await request(app).post('/oauth/authorize').send({}); expect(status).toBe(400); @@ -119,9 +121,46 @@ describe(`/oauth`, () => { expect(params.get('redirect_uri')).toBe('http://127.0.0.1:2285/auth/login'); expect(params.get('state')).toBeDefined(); }); + + it('should not include the prompt parameter when not configured', async () => { + const { status, body } = await request(app) + .post('/oauth/authorize') + .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); + expect(status).toBe(201); + + const params = new URL(body.url).searchParams; + expect(params.get('prompt')).toBeNull(); + }); + + it('should include the prompt parameter when configured', async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + prompt: 'select_account', + }); + + const { status, body } = await request(app) + .post('/oauth/authorize') + .send({ redirectUri: 'http://127.0.0.1:2285/auth/login' }); + expect(status).toBe(201); + + const params = new URL(body.url).searchParams; + expect(params.get('prompt')).toBe('select_account'); + }); }); describe('POST /oauth/callback', () => { + beforeAll(async () => { + await setupOAuth(admin.accessToken, { + enabled: true, + clientId: OAuthClient.DEFAULT, + clientSecret: OAuthClient.DEFAULT, + buttonText: 'Login with Immich', + storageLabelClaim: 'immich_username', + }); + }); + it(`should throw an error if a url is not provided`, async () => { const { status, body } = await request(app).post('/oauth/callback').send({}); expect(status).toBe(400); @@ -160,10 +199,9 @@ describe(`/oauth`, () => { it(`should throw an error if the codeVerifier doesn't match the challenge`, async () => { const callbackParams = await loginWithOAuth('oauth-auto-register'); const { codeVerifier } = await loginWithOAuth('oauth-auto-register'); - const { status, body } = await request(app) + const { status } = await request(app) .post('/oauth/callback') .send({ ...callbackParams, codeVerifier }); - console.log(body); expect(status).toBeGreaterThanOrEqual(400); }); diff --git a/i18n/en.json b/i18n/en.json index 4f608f890d..d5a176b117 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -279,6 +279,7 @@ "oauth_mobile_redirect_uri": "Mobile redirect URI", "oauth_mobile_redirect_uri_override": "Mobile redirect URI override", "oauth_mobile_redirect_uri_override_description": "Enable when OAuth provider does not allow a mobile URI, like ''{callback}''", + "oauth_prompt_description": "Prompt parameter (e.g. select_account, login, consent)", "oauth_role_claim": "Role Claim", "oauth_role_claim_description": "Automatically grant admin access based on the presence of this claim. The claim may have either 'user' or 'admin'.", "oauth_settings": "OAuth", diff --git a/mobile/openapi/lib/model/system_config_o_auth_dto.dart b/mobile/openapi/lib/model/system_config_o_auth_dto.dart index cc8c38e503..e481fe2cdf 100644 --- a/mobile/openapi/lib/model/system_config_o_auth_dto.dart +++ b/mobile/openapi/lib/model/system_config_o_auth_dto.dart @@ -25,6 +25,7 @@ class SystemConfigOAuthDto { required this.mobileOverrideEnabled, required this.mobileRedirectUri, required this.profileSigningAlgorithm, + required this.prompt, required this.roleClaim, required this.scope, required this.signingAlgorithm, @@ -72,6 +73,9 @@ class SystemConfigOAuthDto { /// Profile signing algorithm String profileSigningAlgorithm; + /// OAuth prompt parameter (e.g. select_account, login, consent) + String prompt; + /// Role claim String roleClaim; @@ -109,6 +113,7 @@ class SystemConfigOAuthDto { other.mobileOverrideEnabled == mobileOverrideEnabled && other.mobileRedirectUri == mobileRedirectUri && other.profileSigningAlgorithm == profileSigningAlgorithm && + other.prompt == prompt && other.roleClaim == roleClaim && other.scope == scope && other.signingAlgorithm == signingAlgorithm && @@ -132,6 +137,7 @@ class SystemConfigOAuthDto { (mobileOverrideEnabled.hashCode) + (mobileRedirectUri.hashCode) + (profileSigningAlgorithm.hashCode) + + (prompt.hashCode) + (roleClaim.hashCode) + (scope.hashCode) + (signingAlgorithm.hashCode) + @@ -141,7 +147,7 @@ class SystemConfigOAuthDto { (tokenEndpointAuthMethod.hashCode); @override - String toString() => 'SystemConfigOAuthDto[allowInsecureRequests=$allowInsecureRequests, autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, roleClaim=$roleClaim, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]'; + String toString() => 'SystemConfigOAuthDto[allowInsecureRequests=$allowInsecureRequests, autoLaunch=$autoLaunch, autoRegister=$autoRegister, buttonText=$buttonText, clientId=$clientId, clientSecret=$clientSecret, defaultStorageQuota=$defaultStorageQuota, enabled=$enabled, issuerUrl=$issuerUrl, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri, profileSigningAlgorithm=$profileSigningAlgorithm, prompt=$prompt, roleClaim=$roleClaim, scope=$scope, signingAlgorithm=$signingAlgorithm, storageLabelClaim=$storageLabelClaim, storageQuotaClaim=$storageQuotaClaim, timeout=$timeout, tokenEndpointAuthMethod=$tokenEndpointAuthMethod]'; Map toJson() { final json = {}; @@ -161,6 +167,7 @@ class SystemConfigOAuthDto { json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled; json[r'mobileRedirectUri'] = this.mobileRedirectUri; json[r'profileSigningAlgorithm'] = this.profileSigningAlgorithm; + json[r'prompt'] = this.prompt; json[r'roleClaim'] = this.roleClaim; json[r'scope'] = this.scope; json[r'signingAlgorithm'] = this.signingAlgorithm; @@ -194,6 +201,7 @@ class SystemConfigOAuthDto { mobileOverrideEnabled: mapValueOfType(json, r'mobileOverrideEnabled')!, mobileRedirectUri: mapValueOfType(json, r'mobileRedirectUri')!, profileSigningAlgorithm: mapValueOfType(json, r'profileSigningAlgorithm')!, + prompt: mapValueOfType(json, r'prompt')!, roleClaim: mapValueOfType(json, r'roleClaim')!, scope: mapValueOfType(json, r'scope')!, signingAlgorithm: mapValueOfType(json, r'signingAlgorithm')!, @@ -260,6 +268,7 @@ class SystemConfigOAuthDto { 'mobileOverrideEnabled', 'mobileRedirectUri', 'profileSigningAlgorithm', + 'prompt', 'roleClaim', 'scope', 'signingAlgorithm', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 5853fa6b0d..be32f5452a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -24347,6 +24347,10 @@ "description": "Profile signing algorithm", "type": "string" }, + "prompt": { + "description": "OAuth prompt parameter (e.g. select_account, login, consent)", + "type": "string" + }, "roleClaim": { "description": "Role claim", "type": "string" @@ -24390,6 +24394,7 @@ "mobileOverrideEnabled", "mobileRedirectUri", "profileSigningAlgorithm", + "prompt", "roleClaim", "scope", "signingAlgorithm", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f86ede7592..ae1d2bc271 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2526,6 +2526,8 @@ export type SystemConfigOAuthDto = { mobileRedirectUri: string; /** Profile signing algorithm */ profileSigningAlgorithm: string; + /** OAuth prompt parameter (e.g. select_account, login, consent) */ + prompt: string; /** Role claim */ roleClaim: string; /** Scope */ diff --git a/server/src/config.ts b/server/src/config.ts index bf408fcca7..ae057e9476 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -106,6 +106,7 @@ export type SystemConfig = { issuerUrl: string; mobileOverrideEnabled: boolean; mobileRedirectUri: string; + prompt: string; scope: string; signingAlgorithm: string; profileSigningAlgorithm: string; @@ -298,6 +299,7 @@ export const defaults = Object.freeze({ issuerUrl: '', mobileOverrideEnabled: false, mobileRedirectUri: '', + prompt: '', scope: 'openid email profile', signingAlgorithm: 'RS256', profileSigningAlgorithm: 'none', diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 4e5bef2627..ebe5d46724 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -189,6 +189,7 @@ const SystemConfigOAuthSchema = z }) .describe('Issuer URL'), scope: z.string().describe('Scope'), + prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), signingAlgorithm: z.string().describe('Signing algorithm'), profileSigningAlgorithm: z.string().describe('Profile signing algorithm'), storageLabelClaim: z.string().describe('Storage label claim'), diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 012648b58d..ea9e69e146 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -25,6 +25,7 @@ export type OAuthConfig = { mobileOverrideEnabled: boolean; mobileRedirectUri: string; profileSigningAlgorithm: string; + prompt: string; scope: string; signingAlgorithm: string; tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod; @@ -57,6 +58,10 @@ export class OAuthRepository { state, }; + if (config.prompt) { + params.prompt = config.prompt; + } + if (client.serverMetadata().supportsPKCE()) { params.code_challenge = codeChallenge; params.code_challenge_method = 'S256'; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 3d79a88126..61a5bfbd11 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -964,7 +964,7 @@ describe(AuthService.name, () => { const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled); - mocks.oauth.getProfile.mockResolvedValue(profile); + mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile }); mocks.user.getByOAuthId.mockResolvedValue(user); mocks.oauth.getProfilePicture.mockResolvedValue({ contentType: 'text/html', diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 992dc4a177..b527ba41cd 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -140,6 +140,7 @@ const updatedConfig = Object.freeze({ issuerUrl: '', mobileOverrideEnabled: false, mobileRedirectUri: '', + prompt: '', scope: 'openid email profile', signingAlgorithm: 'RS256', profileSigningAlgorithm: 'none', diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index 1dd8aff25b..e6fd72724c 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -164,6 +164,16 @@ isEdited={!(configToEdit.oauth.profileSigningAlgorithm === config.oauth.profileSigningAlgorithm)} /> + +