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 <jason@rasm.me>
This commit is contained in:
sparsh985
2026-04-18 02:50:07 +05:30
committed by GitHub
parent fd5e8d6521
commit 55f2b3b6a0
12 changed files with 87 additions and 12 deletions

View File

@@ -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")**¹** |

View File

@@ -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);
});

View File

@@ -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",

View File

@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -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<bool>(json, r'mobileOverrideEnabled')!,
mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
profileSigningAlgorithm: mapValueOfType<String>(json, r'profileSigningAlgorithm')!,
prompt: mapValueOfType<String>(json, r'prompt')!,
roleClaim: mapValueOfType<String>(json, r'roleClaim')!,
scope: mapValueOfType<String>(json, r'scope')!,
signingAlgorithm: mapValueOfType<String>(json, r'signingAlgorithm')!,
@@ -260,6 +268,7 @@ class SystemConfigOAuthDto {
'mobileOverrideEnabled',
'mobileRedirectUri',
'profileSigningAlgorithm',
'prompt',
'roleClaim',
'scope',
'signingAlgorithm',

View File

@@ -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",

View File

@@ -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 */

View File

@@ -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<SystemConfig>({
issuerUrl: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',

View File

@@ -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'),

View File

@@ -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';

View File

@@ -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',

View File

@@ -140,6 +140,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
issuerUrl: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',
scope: 'openid email profile',
signingAlgorithm: 'RS256',
profileSigningAlgorithm: 'none',

View File

@@ -164,6 +164,16 @@
isEdited={!(configToEdit.oauth.profileSigningAlgorithm === config.oauth.profileSigningAlgorithm)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="prompt"
description={$t('admin.oauth_prompt_description')}
bind:value={configToEdit.oauth.prompt}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.prompt === config.oauth.prompt)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_timeout')}