feat(server): add OIDC logout URL override option (#27389)

* feat(server): add OIDC logout URL override option
- Added toggle and field consistent with existing mobile redirect URI override.
- Existing auto-discovery remains default.
- Update tests and docs for new feature.

* fix(server): changes from review for OIDC logout URL override
- Rename 'logoutUri' to 'endSessionEndpoint'
- Remove toggle, just use override if provided
- Moved field in settings UI
This commit is contained in:
LJspice 2026-04-17 21:18:21 -07:00 committed by GitHub
parent 384d3a0984
commit b8591cb591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 62 additions and 1 deletions

View file

@ -68,6 +68,7 @@ Once you have a new OAuth client application configured, Immich can be configure
| `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) |
| `end_session_endpoint` | URL | (empty) | Http(s) alternative end session endpoint (logout URI) |
| 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")**¹** |
@ -186,6 +187,7 @@ Configuration of OAuth in Immich System Settings
| Scope | openid email profile immich_scope |
| ID Token Signed Response Algorithm | RS256 |
| Userinfo Signed Response Algorithm | RS256 |
| End Session Endpoint | https://auth.example.com/logout?rd=https://immich.example.com/ |
| Storage Label Claim | uid |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |

View file

@ -193,6 +193,7 @@ The default configuration looks like this:
"defaultStorageQuota": null,
"enabled": false,
"issuerUrl": "",
"endSessionEndpoint": "",
"mobileOverrideEnabled": false,
"mobileRedirectUri": "",
"profileSigningAlgorithm": "none",

View file

@ -276,6 +276,7 @@
"oauth_button_text": "Button text",
"oauth_client_secret_description": "Required for confidential client, or if PKCE (Proof Key for Code Exchange) is not supported for public client.",
"oauth_enable_description": "Login with OAuth",
"oauth_end_session_url_description": "Redirect the user to this URI when they log out.",
"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}''",

View file

@ -24331,6 +24331,10 @@
"description": "Enabled",
"type": "boolean"
},
"endSessionEndpoint": {
"description": "End session endpoint",
"type": "string"
},
"issuerUrl": {
"description": "Issuer URL",
"type": "string"
@ -24390,6 +24394,7 @@
"clientSecret",
"defaultStorageQuota",
"enabled",
"endSessionEndpoint",
"issuerUrl",
"mobileOverrideEnabled",
"mobileRedirectUri",

View file

@ -2518,6 +2518,8 @@ export type SystemConfigOAuthDto = {
defaultStorageQuota: number | null;
/** Enabled */
enabled: boolean;
/** End session endpoint */
endSessionEndpoint: string;
/** Issuer URL */
issuerUrl: string;
/** Mobile override enabled */

View file

@ -104,6 +104,7 @@ export type SystemConfig = {
defaultStorageQuota: number | null;
enabled: boolean;
issuerUrl: string;
endSessionEndpoint: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
prompt: string;
@ -297,6 +298,7 @@ export const defaults = Object.freeze<SystemConfig>({
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',

View file

@ -190,6 +190,12 @@ const SystemConfigOAuthSchema = z
.describe('Issuer URL'),
scope: z.string().describe('Scope'),
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
endSessionEndpoint: z
.string()
.refine((url) => url.length === 0 || z.url().safeParse(url).success, {
error: 'endSessionEndpoint must be an empty string or a valid URL',
})
.describe('End session endpoint'),
signingAlgorithm: z.string().describe('Signing algorithm'),
profileSigningAlgorithm: z.string().describe('Profile signing algorithm'),
storageLabelClaim: z.string().describe('Storage label claim'),

View file

@ -22,6 +22,7 @@ export type OAuthConfig = {
clientId: string;
clientSecret?: string;
issuerUrl: string;
endSessionEndpoint: string;
mobileOverrideEnabled: boolean;
mobileRedirectUri: string;
profileSigningAlgorithm: string;

View file

@ -164,6 +164,32 @@ describe(AuthService.name, () => {
});
});
it('should return the custom end session endpoint if provided', async () => {
const auth = AuthFactory.create();
mocks.systemMetadata.get.mockResolvedValue({
oauth: { enabled: true, endSessionEndpoint: 'http://custom-logout-url' },
});
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://custom-logout-url',
});
});
it('should return the auto-discovered end session endpoint if custom endpoint is not provided', async () => {
const auth = AuthFactory.create();
mocks.systemMetadata.get.mockResolvedValue({
oauth: { enabled: true, endSessionEndpoint: '' },
});
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
});
});
it('should return the default redirect', async () => {
const auth = AuthFactory.create();

View file

@ -455,6 +455,10 @@ export class AuthService extends BaseService {
return LOGIN_URL;
}
if (config.oauth.endSessionEndpoint) {
return config.oauth.endSessionEndpoint;
}
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
}

View file

@ -138,6 +138,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
defaultStorageQuota: null,
enabled: false,
issuerUrl: '',
endSessionEndpoint: '',
mobileOverrideEnabled: false,
mobileRedirectUri: '',
prompt: '',

View file

@ -1,7 +1,6 @@
<script lang="ts">
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
import SettingSelect from './setting-select.svelte';
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
import { SettingInputFieldType } from '$lib/constants';
@ -15,6 +14,7 @@
import { mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import SettingSelect from './setting-select.svelte';
const disabled = $derived(featureFlagsManager.value.configFile);
const config = $derived(systemConfigManager.value);
@ -174,6 +174,16 @@
isEdited={!(configToEdit.oauth.prompt === config.oauth.prompt)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="end_session_endpoint"
description={$t('admin.oauth_end_session_url_description')}
bind:value={configToEdit.oauth.endSessionEndpoint}
required={false}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={!(configToEdit.oauth.endSessionEndpoint === config.oauth.endSessionEndpoint)}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.oauth_timeout')}