mirror of
https://github.com/immich-app/immich.git
synced 2026-04-24 01:59:42 -04: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
@ -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}''",
|
||||
|
||||
@ -21,6 +21,7 @@ class SystemConfigOAuthDto {
|
||||
required this.clientSecret,
|
||||
required this.defaultStorageQuota,
|
||||
required this.enabled,
|
||||
required this.endSessionEndpoint,
|
||||
required this.issuerUrl,
|
||||
required this.mobileOverrideEnabled,
|
||||
required this.mobileRedirectUri,
|
||||
@ -61,6 +62,9 @@ class SystemConfigOAuthDto {
|
||||
/// Enabled
|
||||
bool enabled;
|
||||
|
||||
/// End session endpoint
|
||||
String endSessionEndpoint;
|
||||
|
||||
/// Issuer URL
|
||||
String issuerUrl;
|
||||
|
||||
@ -109,6 +113,7 @@ class SystemConfigOAuthDto {
|
||||
other.clientSecret == clientSecret &&
|
||||
other.defaultStorageQuota == defaultStorageQuota &&
|
||||
other.enabled == enabled &&
|
||||
other.endSessionEndpoint == endSessionEndpoint &&
|
||||
other.issuerUrl == issuerUrl &&
|
||||
other.mobileOverrideEnabled == mobileOverrideEnabled &&
|
||||
other.mobileRedirectUri == mobileRedirectUri &&
|
||||
@ -133,6 +138,7 @@ class SystemConfigOAuthDto {
|
||||
(clientSecret.hashCode) +
|
||||
(defaultStorageQuota == null ? 0 : defaultStorageQuota!.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(endSessionEndpoint.hashCode) +
|
||||
(issuerUrl.hashCode) +
|
||||
(mobileOverrideEnabled.hashCode) +
|
||||
(mobileRedirectUri.hashCode) +
|
||||
@ -147,7 +153,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, prompt=$prompt, 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, endSessionEndpoint=$endSessionEndpoint, 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>{};
|
||||
@ -163,6 +169,7 @@ class SystemConfigOAuthDto {
|
||||
// json[r'defaultStorageQuota'] = null;
|
||||
}
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'endSessionEndpoint'] = this.endSessionEndpoint;
|
||||
json[r'issuerUrl'] = this.issuerUrl;
|
||||
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
|
||||
json[r'mobileRedirectUri'] = this.mobileRedirectUri;
|
||||
@ -197,6 +204,7 @@ class SystemConfigOAuthDto {
|
||||
? null
|
||||
: num.parse('${json[r'defaultStorageQuota']}'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
|
||||
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
|
||||
mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
|
||||
mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
|
||||
@ -264,6 +272,7 @@ class SystemConfigOAuthDto {
|
||||
'clientSecret',
|
||||
'defaultStorageQuota',
|
||||
'enabled',
|
||||
'endSessionEndpoint',
|
||||
'issuerUrl',
|
||||
'mobileOverrideEnabled',
|
||||
'mobileRedirectUri',
|
||||
|
||||
@ -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…
x
Reference in New Issue
Block a user