mirror of
https://github.com/immich-app/immich.git
synced 2026-07-15 01:21:29 +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 72 additions and 2 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) |
|
| `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) |
|
| `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) |
|
| `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 |
|
| 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**¹** |
|
| 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")**¹** |
|
| 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 |
|
| Scope | openid email profile immich_scope |
|
||||||
| ID Token Signed Response Algorithm | RS256 |
|
| ID Token Signed Response Algorithm | RS256 |
|
||||||
| Userinfo 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 Label Claim | uid |
|
||||||
| Storage Quota Claim | immich_quota |
|
| Storage Quota Claim | immich_quota |
|
||||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,7 @@ The default configuration looks like this:
|
||||||
"defaultStorageQuota": null,
|
"defaultStorageQuota": null,
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"issuerUrl": "",
|
"issuerUrl": "",
|
||||||
|
"endSessionEndpoint": "",
|
||||||
"mobileOverrideEnabled": false,
|
"mobileOverrideEnabled": false,
|
||||||
"mobileRedirectUri": "",
|
"mobileRedirectUri": "",
|
||||||
"profileSigningAlgorithm": "none",
|
"profileSigningAlgorithm": "none",
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,7 @@
|
||||||
"oauth_button_text": "Button text",
|
"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_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_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": "Mobile redirect URI",
|
||||||
"oauth_mobile_redirect_uri_override": "Mobile redirect URI override",
|
"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_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.clientSecret,
|
||||||
required this.defaultStorageQuota,
|
required this.defaultStorageQuota,
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
|
required this.endSessionEndpoint,
|
||||||
required this.issuerUrl,
|
required this.issuerUrl,
|
||||||
required this.mobileOverrideEnabled,
|
required this.mobileOverrideEnabled,
|
||||||
required this.mobileRedirectUri,
|
required this.mobileRedirectUri,
|
||||||
|
|
@ -61,6 +62,9 @@ class SystemConfigOAuthDto {
|
||||||
/// Enabled
|
/// Enabled
|
||||||
bool enabled;
|
bool enabled;
|
||||||
|
|
||||||
|
/// End session endpoint
|
||||||
|
String endSessionEndpoint;
|
||||||
|
|
||||||
/// Issuer URL
|
/// Issuer URL
|
||||||
String issuerUrl;
|
String issuerUrl;
|
||||||
|
|
||||||
|
|
@ -109,6 +113,7 @@ class SystemConfigOAuthDto {
|
||||||
other.clientSecret == clientSecret &&
|
other.clientSecret == clientSecret &&
|
||||||
other.defaultStorageQuota == defaultStorageQuota &&
|
other.defaultStorageQuota == defaultStorageQuota &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
|
other.endSessionEndpoint == endSessionEndpoint &&
|
||||||
other.issuerUrl == issuerUrl &&
|
other.issuerUrl == issuerUrl &&
|
||||||
other.mobileOverrideEnabled == mobileOverrideEnabled &&
|
other.mobileOverrideEnabled == mobileOverrideEnabled &&
|
||||||
other.mobileRedirectUri == mobileRedirectUri &&
|
other.mobileRedirectUri == mobileRedirectUri &&
|
||||||
|
|
@ -133,6 +138,7 @@ class SystemConfigOAuthDto {
|
||||||
(clientSecret.hashCode) +
|
(clientSecret.hashCode) +
|
||||||
(defaultStorageQuota == null ? 0 : defaultStorageQuota!.hashCode) +
|
(defaultStorageQuota == null ? 0 : defaultStorageQuota!.hashCode) +
|
||||||
(enabled.hashCode) +
|
(enabled.hashCode) +
|
||||||
|
(endSessionEndpoint.hashCode) +
|
||||||
(issuerUrl.hashCode) +
|
(issuerUrl.hashCode) +
|
||||||
(mobileOverrideEnabled.hashCode) +
|
(mobileOverrideEnabled.hashCode) +
|
||||||
(mobileRedirectUri.hashCode) +
|
(mobileRedirectUri.hashCode) +
|
||||||
|
|
@ -147,7 +153,7 @@ class SystemConfigOAuthDto {
|
||||||
(tokenEndpointAuthMethod.hashCode);
|
(tokenEndpointAuthMethod.hashCode);
|
||||||
|
|
||||||
@override
|
@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() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
|
@ -163,6 +169,7 @@ class SystemConfigOAuthDto {
|
||||||
// json[r'defaultStorageQuota'] = null;
|
// json[r'defaultStorageQuota'] = null;
|
||||||
}
|
}
|
||||||
json[r'enabled'] = this.enabled;
|
json[r'enabled'] = this.enabled;
|
||||||
|
json[r'endSessionEndpoint'] = this.endSessionEndpoint;
|
||||||
json[r'issuerUrl'] = this.issuerUrl;
|
json[r'issuerUrl'] = this.issuerUrl;
|
||||||
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
|
json[r'mobileOverrideEnabled'] = this.mobileOverrideEnabled;
|
||||||
json[r'mobileRedirectUri'] = this.mobileRedirectUri;
|
json[r'mobileRedirectUri'] = this.mobileRedirectUri;
|
||||||
|
|
@ -197,6 +204,7 @@ class SystemConfigOAuthDto {
|
||||||
? null
|
? null
|
||||||
: num.parse('${json[r'defaultStorageQuota']}'),
|
: num.parse('${json[r'defaultStorageQuota']}'),
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
|
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
|
||||||
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
|
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
|
||||||
mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
|
mobileOverrideEnabled: mapValueOfType<bool>(json, r'mobileOverrideEnabled')!,
|
||||||
mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
|
mobileRedirectUri: mapValueOfType<String>(json, r'mobileRedirectUri')!,
|
||||||
|
|
@ -264,6 +272,7 @@ class SystemConfigOAuthDto {
|
||||||
'clientSecret',
|
'clientSecret',
|
||||||
'defaultStorageQuota',
|
'defaultStorageQuota',
|
||||||
'enabled',
|
'enabled',
|
||||||
|
'endSessionEndpoint',
|
||||||
'issuerUrl',
|
'issuerUrl',
|
||||||
'mobileOverrideEnabled',
|
'mobileOverrideEnabled',
|
||||||
'mobileRedirectUri',
|
'mobileRedirectUri',
|
||||||
|
|
|
||||||
|
|
@ -24331,6 +24331,10 @@
|
||||||
"description": "Enabled",
|
"description": "Enabled",
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
"endSessionEndpoint": {
|
||||||
|
"description": "End session endpoint",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"issuerUrl": {
|
"issuerUrl": {
|
||||||
"description": "Issuer URL",
|
"description": "Issuer URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|
@ -24390,6 +24394,7 @@
|
||||||
"clientSecret",
|
"clientSecret",
|
||||||
"defaultStorageQuota",
|
"defaultStorageQuota",
|
||||||
"enabled",
|
"enabled",
|
||||||
|
"endSessionEndpoint",
|
||||||
"issuerUrl",
|
"issuerUrl",
|
||||||
"mobileOverrideEnabled",
|
"mobileOverrideEnabled",
|
||||||
"mobileRedirectUri",
|
"mobileRedirectUri",
|
||||||
|
|
|
||||||
|
|
@ -2518,6 +2518,8 @@ export type SystemConfigOAuthDto = {
|
||||||
defaultStorageQuota: number | null;
|
defaultStorageQuota: number | null;
|
||||||
/** Enabled */
|
/** Enabled */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** End session endpoint */
|
||||||
|
endSessionEndpoint: string;
|
||||||
/** Issuer URL */
|
/** Issuer URL */
|
||||||
issuerUrl: string;
|
issuerUrl: string;
|
||||||
/** Mobile override enabled */
|
/** Mobile override enabled */
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ export type SystemConfig = {
|
||||||
defaultStorageQuota: number | null;
|
defaultStorageQuota: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
issuerUrl: string;
|
issuerUrl: string;
|
||||||
|
endSessionEndpoint: string;
|
||||||
mobileOverrideEnabled: boolean;
|
mobileOverrideEnabled: boolean;
|
||||||
mobileRedirectUri: string;
|
mobileRedirectUri: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
|
@ -297,6 +298,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||||
defaultStorageQuota: null,
|
defaultStorageQuota: null,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
issuerUrl: '',
|
issuerUrl: '',
|
||||||
|
endSessionEndpoint: '',
|
||||||
mobileOverrideEnabled: false,
|
mobileOverrideEnabled: false,
|
||||||
mobileRedirectUri: '',
|
mobileRedirectUri: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,12 @@ const SystemConfigOAuthSchema = z
|
||||||
.describe('Issuer URL'),
|
.describe('Issuer URL'),
|
||||||
scope: z.string().describe('Scope'),
|
scope: z.string().describe('Scope'),
|
||||||
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
|
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'),
|
signingAlgorithm: z.string().describe('Signing algorithm'),
|
||||||
profileSigningAlgorithm: z.string().describe('Profile signing algorithm'),
|
profileSigningAlgorithm: z.string().describe('Profile signing algorithm'),
|
||||||
storageLabelClaim: z.string().describe('Storage label claim'),
|
storageLabelClaim: z.string().describe('Storage label claim'),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export type OAuthConfig = {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
issuerUrl: string;
|
issuerUrl: string;
|
||||||
|
endSessionEndpoint: string;
|
||||||
mobileOverrideEnabled: boolean;
|
mobileOverrideEnabled: boolean;
|
||||||
mobileRedirectUri: string;
|
mobileRedirectUri: string;
|
||||||
profileSigningAlgorithm: 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 () => {
|
it('should return the default redirect', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,10 @@ export class AuthService extends BaseService {
|
||||||
return LOGIN_URL;
|
return LOGIN_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.oauth.endSessionEndpoint) {
|
||||||
|
return config.oauth.endSessionEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
|
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||||
defaultStorageQuota: null,
|
defaultStorageQuota: null,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
issuerUrl: '',
|
issuerUrl: '',
|
||||||
|
endSessionEndpoint: '',
|
||||||
mobileOverrideEnabled: false,
|
mobileOverrideEnabled: false,
|
||||||
mobileRedirectUri: '',
|
mobileRedirectUri: '',
|
||||||
prompt: '',
|
prompt: '',
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.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 SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||||
import { SettingInputFieldType } from '$lib/constants';
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
|
|
@ -15,6 +14,7 @@
|
||||||
import { mdiRestart } from '@mdi/js';
|
import { mdiRestart } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import SettingSelect from './setting-select.svelte';
|
||||||
|
|
||||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||||
const config = $derived(systemConfigManager.value);
|
const config = $derived(systemConfigManager.value);
|
||||||
|
|
@ -174,6 +174,16 @@
|
||||||
isEdited={!(configToEdit.oauth.prompt === config.oauth.prompt)}
|
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
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.oauth_timeout')}
|
label={$t('admin.oauth_timeout')}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue