mirror of
https://github.com/immich-app/immich
synced 2026-04-21 13:37:38 +00:00
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:
parent
384d3a0984
commit
b8591cb591
13 changed files with 62 additions and 1 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ The default configuration looks like this:
|
|||
"defaultStorageQuota": null,
|
||||
"enabled": false,
|
||||
"issuerUrl": "",
|
||||
"endSessionEndpoint": "",
|
||||
"mobileOverrideEnabled": false,
|
||||
"mobileRedirectUri": "",
|
||||
"profileSigningAlgorithm": "none",
|
||||
|
|
|
|||
|
|
@ -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}''",
|
||||
|
|
|
|||
BIN
mobile/openapi/lib/model/system_config_o_auth_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_o_auth_dto.dart
generated
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type OAuthConfig = {
|
|||
clientId: string;
|
||||
clientSecret?: string;
|
||||
issuerUrl: string;
|
||||
endSessionEndpoint: string;
|
||||
mobileOverrideEnabled: boolean;
|
||||
mobileRedirectUri: string;
|
||||
profileSigningAlgorithm: string;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||
defaultStorageQuota: null,
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
endSessionEndpoint: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
prompt: '',
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
Loading…
Reference in a new issue