feat(editor): Make OIDC RP-initiated logout an opt-in setting (#35146)

This commit is contained in:
Andreas Fitzek
2026-07-29 14:42:47 +02:00
committed by GitHub
parent 23622769e6
commit 8fd73e2acd
19 changed files with 302 additions and 14 deletions
@@ -26,7 +26,22 @@ describe('UpdateOidcConfigurationDto', () => {
prompt: 'consent',
authenticationContextClassReference: ['mfa'],
additionalScopes: 'groups',
rpInitiatedLogoutEnabled: true,
});
expect(result.success).toBe(true);
});
});
describe('OidcConfigDto', () => {
it('defaults rpInitiatedLogoutEnabled to false when omitted', () => {
const result = OidcConfigDto.safeParse({
clientId: 'n8n-client',
clientSecret: 'super-secret',
discoveryEndpoint: 'https://accounts.example.com/.well-known/openid-configuration',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.rpInitiatedLogoutEnabled).toBe(false);
}
});
});
@@ -12,6 +12,7 @@ export class OidcConfigDto extends Z.class({
prompt: z.enum(OIDC_PROMPT_VALUES).optional().default('select_account'),
authenticationContextClassReference: z.array(z.string()).default([]),
additionalScopes: z.string().default(''),
rpInitiatedLogoutEnabled: z.boolean().optional().default(false),
}) {}
/**
@@ -24,4 +25,5 @@ export class UpdateOidcConfigurationDto extends OidcConfigDto.extend({
prompt: z.enum(OIDC_PROMPT_VALUES),
authenticationContextClassReference: z.array(z.string()),
additionalScopes: z.string(),
rpInitiatedLogoutEnabled: z.boolean(),
}) {}
@@ -59,6 +59,13 @@ export class InstanceSettingsLoaderConfig {
@Env('N8N_SSO_OIDC_ACR_VALUES')
oidcAcrValues: string = '';
/** Space-separated additional scopes appended to the OIDC authorization request. */
@Env('N8N_SSO_OIDC_ADDITIONAL_SCOPES')
oidcAdditionalScopes: string = '';
@Env('N8N_SSO_OIDC_RP_INITIATED_LOGOUT_ENABLED')
oidcRpInitiatedLogoutEnabled: boolean = false;
/**
* When true, security policy settings are managed via environment variables.
* On every startup the security policy will be overridden by env vars.
+2
View File
@@ -641,6 +641,8 @@ describe('GlobalConfig', () => {
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
oidcAdditionalScopes: '',
oidcRpInitiatedLogoutEnabled: false,
ssoUserRoleProvisioning: 'disabled',
securityPolicyManagedByEnv: false,
mfaEnforcedEnabled: false,
@@ -18,6 +18,8 @@ describe('OidcInstanceSettingsLoader', () => {
oidcLoginEnabled: false,
oidcPrompt: 'select_account',
oidcAcrValues: '',
oidcAdditionalScopes: '',
oidcRpInitiatedLogoutEnabled: false,
};
const validConfig: Partial<InstanceSettingsLoaderConfig> = {
@@ -82,6 +84,20 @@ describe('OidcInstanceSettingsLoader', () => {
const parsed = getUpsertedValue();
expect(parsed.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
});
it('should persist additionalScopes and rpInitiatedLogoutEnabled', async () => {
const loader = createLoader({
...validConfig,
oidcAdditionalScopes: 'offline_access',
oidcRpInitiatedLogoutEnabled: true,
});
await loader.apply();
const parsed = getUpsertedValue();
expect(parsed.additionalScopes).toBe('offline_access');
expect(parsed.rpInitiatedLogoutEnabled).toBe(true);
});
});
describe('when OIDC login is disabled', () => {
@@ -29,6 +29,8 @@ const oidcEnvSchema = z
}),
}),
oidcAcrValues: z.string(),
oidcAdditionalScopes: z.string(),
oidcRpInitiatedLogoutEnabled: z.boolean(),
})
.transform((input) => ({
clientId: input.oidcClientId,
@@ -42,6 +44,8 @@ const oidcEnvSchema = z
.map((v) => v.trim())
.filter(Boolean)
: [],
additionalScopes: input.oidcAdditionalScopes,
rpInitiatedLogoutEnabled: input.oidcRpInitiatedLogoutEnabled,
}));
@Service()
@@ -235,6 +235,36 @@ describe('OidcController', () => {
expect(res.redirect).toHaveBeenCalledWith('/');
});
test('Should still complete login when storing the ID token throws', async () => {
const req = mock<AuthlessRequest>({
originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value',
browserId: 'browser-id-123',
cookies: {
[OIDC_STATE_COOKIE_NAME]: 'state_value',
[OIDC_NONCE_COOKIE_NAME]: 'nonce_value',
},
});
const res = mock<Response>();
oidcService.loginUser.mockResolvedValueOnce({ user, idToken: 'raw-id-token' });
oidcService.encryptIdToken.mockRejectedValueOnce(new Error('encryption failed'));
await controller.callbackHandler(req, res);
// The ID token cookie is skipped, but login still completes.
expect(res.cookie).not.toHaveBeenCalledWith(
OIDC_ID_TOKEN_COOKIE_NAME,
expect.any(String),
expect.any(Object),
);
expect(logger.warn).toHaveBeenCalled();
expect(eventService.emit).toHaveBeenCalledWith('user-logged-in', {
user,
authenticationMethod: 'oidc',
});
expect(res.redirect).toHaveBeenCalledWith('/');
});
test('Should render success page in test mode without creating session', async () => {
const req = mock<AuthlessRequest>({
originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value',
@@ -211,6 +211,7 @@ describe('OidcService', () => {
discoveryEndpoint: expect.any(URL),
authenticationContextClassReference: expect.any(Array),
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
});
@@ -231,6 +232,7 @@ describe('OidcService', () => {
discoveryEndpoint: expect.any(URL),
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
});
@@ -326,6 +328,7 @@ describe('OidcService', () => {
discoveryEndpoint: expect.any(URL),
authenticationContextClassReference: expect.any(Array),
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
expect(logger.warn).not.toHaveBeenCalled();
});
@@ -893,4 +896,62 @@ describe('OidcService', () => {
expect(body).toEqual({ ok: true, path: '/userinfo' });
});
});
describe('generateEndSessionUrl', () => {
const idToken = 'stored-id-token';
const setRpInitiatedLogoutEnabled = (enabled: boolean) => {
// Replace (not mutate) the runtime config so the shared default object
// isn't polluted across tests. updateConfig would require live discovery.
const service = oidcService as unknown as { oidcConfig: Record<string, unknown> };
service.oidcConfig = { ...service.oidcConfig, rpInitiatedLogoutEnabled: enabled };
};
it('returns undefined and does not contact the provider when RP-initiated logout is disabled', async () => {
setRpInitiatedLogoutEnabled(false);
// @ts-expect-error - getOidcConfiguration is private
oidcService.getOidcConfiguration = vi.fn();
const url = await oidcService.generateEndSessionUrl(idToken);
expect(url).toBeUndefined();
// @ts-expect-error - getOidcConfiguration is private
expect(oidcService.getOidcConfiguration).not.toHaveBeenCalled();
});
it('returns undefined when the provider does not advertise an end_session_endpoint', async () => {
setRpInitiatedLogoutEnabled(true);
// @ts-expect-error - getOidcConfiguration is private
oidcService.getOidcConfiguration = vi.fn().mockResolvedValue({
serverMetadata: () => ({}),
} as unknown as client.Configuration);
const url = await oidcService.generateEndSessionUrl(idToken);
expect(url).toBeUndefined();
});
it('builds the RP-initiated logout URL with the id_token_hint when enabled', async () => {
setRpInitiatedLogoutEnabled(true);
// @ts-expect-error - getOidcConfiguration is private
oidcService.getOidcConfiguration = vi.fn().mockResolvedValue({
serverMetadata: () => ({ end_session_endpoint: 'https://example.com/logout' }),
} as unknown as client.Configuration);
const expectedUrl = new URL('https://example.com/logout?id_token_hint=stored-id-token');
const buildEndSessionUrl = vi
.spyOn(client, 'buildEndSessionUrl')
.mockReturnValue(expectedUrl);
const url = await oidcService.generateEndSessionUrl(idToken);
expect(url).toEqual(expectedUrl);
expect(buildEndSessionUrl).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
id_token_hint: idToken,
post_logout_redirect_uri: expect.stringMatching(/\/signin$/),
}),
);
});
});
});
@@ -149,20 +149,30 @@ export class OidcController {
// RP-Initiated Logout with the required `id_token_hint`. The cookie's
// presence also marks this session as OIDC-established, as opposed to
// e.g. an email session of the instance owner.
//
// The user is already authenticated (`issueCookie` above), so any failure
// storing the token must not fail the login: it only degrades sign-out to a
// local (n8n-only) logout, same as the oversized-token branch below.
if (idToken) {
const encryptedIdToken = await this.oidcService.encryptIdToken(idToken);
if (Buffer.byteLength(encryptedIdToken, 'utf8') <= OIDC_ID_TOKEN_COOKIE_MAX_BYTES) {
const { samesite, secure } = this.globalConfig.auth.cookie;
res.cookie(OIDC_ID_TOKEN_COOKIE_NAME, encryptedIdToken, {
maxAge: this.authService.jwtExpiration * Time.seconds.toMilliseconds,
httpOnly: true,
sameSite: samesite,
secure,
try {
const encryptedIdToken = await this.oidcService.encryptIdToken(idToken);
if (Buffer.byteLength(encryptedIdToken, 'utf8') <= OIDC_ID_TOKEN_COOKIE_MAX_BYTES) {
const { samesite, secure } = this.globalConfig.auth.cookie;
res.cookie(OIDC_ID_TOKEN_COOKIE_NAME, encryptedIdToken, {
maxAge: this.authService.jwtExpiration * Time.seconds.toMilliseconds,
httpOnly: true,
sameSite: samesite,
secure,
});
} else {
this.logger.warn(
'The OIDC ID token is too large to be stored in a cookie. Signing out will terminate the n8n session but not the OIDC provider session. Consider reducing the claims included in the ID token.',
);
}
} catch (error) {
this.logger.warn('Failed to store the OIDC ID token; sign-out will be local only', {
error,
});
} else {
this.logger.warn(
'The OIDC ID token is too large to be stored in a cookie. Signing out will terminate the n8n session but not the OIDC provider session. Consider reducing the claims included in the ID token.',
);
}
}
this.eventService.emit('user-logged-in', {
@@ -180,6 +190,12 @@ export class OidcController {
* the client to redirect to. The n8n session is always invalidated first,
* so whatever happens at the provider afterwards cannot leave a valid n8n
* session behind.
*
* Trade-off: the returned URL carries the ID token as a URL-encoded
* `id_token_hint`, so it is handed to page JS (the SPA sets `window.location`
* after running its logout hooks / clearing local storage). This is accepted
* for client-side RP-logout; moving the hint into a server-issued redirect so
* it never reaches a JS-readable body is a possible future hardening.
*/
@Post('/logout')
@Licensed('feat:oidc')
@@ -43,6 +43,7 @@ const DEFAULT_OIDC_CONFIG: OidcConfigDto = {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
type OidcRuntimeConfig = Pick<
@@ -53,6 +54,7 @@ type OidcRuntimeConfig = Pick<
| 'prompt'
| 'authenticationContextClassReference'
| 'additionalScopes'
| 'rpInitiatedLogoutEnabled'
> & {
discoveryEndpoint: URL;
};
@@ -428,6 +430,11 @@ export class OidcService {
* `end_session_endpoint`, in which case sign-out is local to n8n only.
*/
async generateEndSessionUrl(idToken: string): Promise<URL | undefined> {
// RP-Initiated Logout is opt-in: when disabled, sign-out stays local to n8n.
if (!this.oidcConfig.rpInitiatedLogoutEnabled) {
return undefined;
}
await this.loadOpenIdClient();
const configuration = await this.getOidcConfiguration();
@@ -12,6 +12,7 @@ required:
- prompt
- authenticationContextClassReference
- additionalScopes
- rpInitiatedLogoutEnabled
properties:
clientId:
type: string
@@ -53,3 +54,9 @@ properties:
Additional scopes to request, space separated. n8n always requests `openid`, `profile`
and `email`. Use an empty string when unused.
example: groups roles
rpInitiatedLogoutEnabled:
type: boolean
description: >
Whether signing out of n8n also ends the session at the OIDC provider via RP-Initiated
Logout. When disabled, sign-out is local to n8n only.
example: false
@@ -8,6 +8,7 @@ required:
- prompt
- authenticationContextClassReference
- additionalScopes
- rpInitiatedLogoutEnabled
properties:
clientId:
type: string
@@ -47,3 +48,9 @@ properties:
Additional scopes to request, space separated. n8n always requests `openid`, `profile`
and `email`.
example: groups roles
rpInitiatedLogoutEnabled:
type: boolean
description: >
Whether signing out of n8n also ends the session at the OIDC provider via RP-Initiated
Logout. When disabled, sign-out is local to n8n only.
example: false
@@ -48,6 +48,8 @@ describe('SsoInstanceSettingsLoader → OIDC', () => {
oidcLoginEnabled: true,
oidcPrompt: 'consent',
oidcAcrValues: 'mfa, phrh',
oidcAdditionalScopes: 'offline_access',
oidcRpInitiatedLogoutEnabled: true,
ssoUserRoleProvisioning: 'instance_and_project_roles',
});
@@ -65,5 +67,7 @@ describe('SsoInstanceSettingsLoader → OIDC', () => {
expect(config.loginEnabled).toBe(true);
expect(config.prompt).toBe('consent');
expect(config.authenticationContextClassReference).toEqual(['mfa', 'phrh']);
expect(config.additionalScopes).toBe('offline_access');
expect(config.rpInitiatedLogoutEnabled).toBe(true);
});
});
@@ -70,6 +70,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
});
@@ -83,6 +84,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
});
@@ -95,6 +97,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
await oidcService.updateConfig(newConfig);
@@ -119,6 +122,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
await oidcService.updateConfig(newConfig);
@@ -142,6 +146,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(UserError);
@@ -156,6 +161,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
await oidcService.updateConfig(newConfig);
@@ -180,6 +186,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
discoveryMock.mockRejectedValueOnce(new Error('Discovery failed'));
@@ -206,6 +213,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
const mockConfiguration = new real_odic_client.Configuration(
@@ -238,6 +246,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
const newMockConfiguration = new real_odic_client.Configuration(
@@ -291,6 +300,7 @@ describe('OIDC service', () => {
prompt: 'consent',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
await oidcService.updateConfig(initialConfig);
@@ -336,6 +346,7 @@ describe('OIDC service', () => {
prompt: 'consent',
authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
await oidcService.updateConfig(initialConfig);
@@ -437,6 +448,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
let provisioningConfig: GlobalConfig['sso']['provisioning'];
@@ -511,6 +523,7 @@ describe('OIDC service', () => {
await oidcService.updateConfig({
...baseConfig,
additionalScopes: 'groups&redirect_uri=https://evil.com',
rpInitiatedLogoutEnabled: false,
});
const authUrl = await oidcService.generateLoginUrl();
@@ -548,6 +561,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: 'groups',
rpInitiatedLogoutEnabled: false,
});
const authUrl = await oidcService.generateTestLoginUrl();
@@ -577,6 +591,7 @@ describe('OIDC service', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
const authUrl = await oidcService.generateTestLoginUrl();
@@ -1354,4 +1369,18 @@ describe('OIDC service', () => {
expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError);
});
});
describe('ID token encryption', () => {
it('round-trips an ID token through the real cipher', async () => {
const idToken = 'header.payload.signature';
const encrypted = await oidcService.encryptIdToken(idToken);
expect(encrypted).not.toEqual(idToken);
expect(await oidcService.decryptIdToken(encrypted)).toEqual(idToken);
});
it('returns undefined for a tampered ciphertext', async () => {
expect(await oidcService.decryptIdToken('not-a-valid-ciphertext')).toBeUndefined();
});
});
});
@@ -35,6 +35,7 @@ const validConfig = {
prompt: 'consent' as const,
authenticationContextClassReference: ['mfa'],
additionalScopes: 'groups',
rpInitiatedLogoutEnabled: false,
};
describe('OIDC SSO configuration in Public API', () => {
@@ -84,6 +85,7 @@ describe('OIDC SSO configuration in Public API', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
expect(typeof response.body.discoveryEndpoint).toBe('string');
});
@@ -103,6 +105,7 @@ describe('OIDC SSO configuration in Public API', () => {
'discoveryEndpoint',
'loginEnabled',
'prompt',
'rpInitiatedLogoutEnabled',
].sort(),
);
});
@@ -208,6 +211,20 @@ describe('OIDC SSO configuration in Public API', () => {
expect(disabled.body.loginEnabled).toBe(false);
});
it('persists rpInitiatedLogoutEnabled', async () => {
testServer.license.enable('feat:oidc');
const response = await testServer
.publicApiAgentFor(owner)
.put('/settings/sso/oidc')
.send({ ...validConfig, rpInitiatedLogoutEnabled: true });
expect(response.status).toBe(200);
expect(response.body.rpInitiatedLogoutEnabled).toBe(true);
const read = await testServer.publicApiAgentFor(owner).get('/settings/sso/oidc');
expect(read.body.rpInitiatedLogoutEnabled).toBe(true);
});
it('keeps the stored secret when the redacted sentinel is submitted', async () => {
testServer.license.enable('feat:oidc');
await testServer.publicApiAgentFor(owner).put('/settings/sso/oidc').send(validConfig);
@@ -72,6 +72,7 @@ const promptDescriptions: PromptDescription[] = [
const authenticationContextClassReference = ref('');
const additionalScopes = ref('');
const rpInitiatedLogoutEnabled = ref(false);
const isAdditionalScopesInvalid = computed(() =>
[',', ';'].some((c) => additionalScopes.value.includes(c)),
);
@@ -86,6 +87,7 @@ const getOidcConfig = async () => {
authenticationContextClassReference.value =
config.authenticationContextClassReference?.join(',') || '';
additionalScopes.value = config.additionalScopes ?? '';
rpInitiatedLogoutEnabled.value = config.rpInitiatedLogoutEnabled ?? false;
};
const loadOidcConfig = async () => {
@@ -118,6 +120,7 @@ const cannotSaveOidcSettings = computed(() => {
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled &&
ssoStore.oidcConfig?.prompt === prompt.value &&
ssoStore.oidcConfig?.additionalScopes === additionalScopes.value &&
ssoStore.oidcConfig?.rpInitiatedLogoutEnabled === rpInitiatedLogoutEnabled.value &&
!isUserRoleProvisioningChanged.value &&
!isRuleMappingDirty &&
storedAcrString === authenticationContextClassReference.value &&
@@ -186,6 +189,7 @@ async function onOidcSettingsSave(provisioningChangesConfirmed: boolean = false)
loginEnabled: ssoStore.isOidcLoginEnabled,
authenticationContextClassReference: acrArray,
additionalScopes: additionalScopes.value,
rpInitiatedLogoutEnabled: rpInitiatedLogoutEnabled.value,
});
const provisioningResult = await saveProvisioningConfig(isDisablingOidcLogin);
@@ -405,7 +409,7 @@ onMounted(async () => {
</div>
</div>
<div :class="$style.card">
<div :class="[$style.settingsItem, $style.settingsItemNoBorder]">
<div :class="$style.settingsItem">
<div :class="$style.settingsItemLabel">
<label>Single sign-on (SSO)</label>
<small>Allow users to sign in through your identity provider</small>
@@ -421,6 +425,27 @@ onMounted(async () => {
<template #prefix>
<span v-if="ssoStore.isOidcLoginEnabled" :class="$style.greenDot" />
</template>
<N8nOption value="enabled" label="Enabled" data-test-id="sso-oidc-toggle-option" />
<N8nOption value="disabled" label="Disabled" data-test-id="sso-oidc-toggle-option" />
</N8nSelect>
</div>
</div>
<div :class="$style.settingsItem">
<div :class="$style.settingsItemLabel">
<label>Log out from identity provider</label>
<small>Also end your session at the identity provider when signing out of n8n</small>
</div>
<div :class="$style.settingsItemControl">
<N8nSelect
:model-value="rpInitiatedLogoutEnabled ? 'enabled' : 'disabled'"
size="medium"
data-test-id="sso-oidc-logout-toggle"
:disabled="isSsoManagedByEnv"
@update:model-value="rpInitiatedLogoutEnabled = $event === 'enabled'"
>
<template #prefix>
<span v-if="rpInitiatedLogoutEnabled" :class="$style.greenDot" />
</template>
<N8nOption value="enabled" label="Enabled" />
<N8nOption value="disabled" label="Disabled" />
</N8nSelect>
@@ -194,6 +194,7 @@ describe('SSO store', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
};
vi.mocked(ssoApi.getOidcConfig).mockResolvedValue(oidcConfig);
@@ -226,6 +227,7 @@ describe('SSO store', () => {
prompt: 'select_account',
authenticationContextClassReference: [],
additionalScopes: '',
rpInitiatedLogoutEnabled: false,
});
await ssoStore.getOidcConfig();
@@ -533,6 +533,43 @@ describe('SettingsSso View', () => {
);
});
it('includes the RP-initiated logout setting in the saved OIDC config', async () => {
ssoStore.isEnterpriseOidcEnabled = true;
ssoStore.isEnterpriseSamlEnabled = false;
ssoStore.isOidcLoginEnabled = true;
ssoStore.isSamlLoginEnabled = false;
ssoStore.selectedAuthProtocol = SupportedProtocols.OIDC;
ssoStore.oidcConfig = {
...oidcConfig,
discoveryEndpoint: '',
rpInitiatedLogoutEnabled: true,
};
ssoStore.getOidcConfig.mockResolvedValue({
...oidcConfig,
discoveryEndpoint: '',
rpInitiatedLogoutEnabled: true,
});
ssoStore.saveOidcConfig.mockResolvedValue({ ...oidcConfig, rpInitiatedLogoutEnabled: true });
const { getByTestId } = renderView();
const saveButton = await waitFor(() => getByTestId('sso-oidc-save'));
expect(getByTestId('sso-oidc-logout-toggle')).toBeVisible();
// Change another field so the form is dirty and Save is enabled.
await userEvent.type(getByTestId('oidc-discovery-endpoint'), oidcConfig.discoveryEndpoint);
await userEvent.type(getByTestId('oidc-client-id'), 'test-client-id');
await userEvent.type(getByTestId('oidc-client-secret'), 'test-client-secret');
ssoStore.oidcConfig = { ...oidcConfig, rpInitiatedLogoutEnabled: true };
await userEvent.click(saveButton);
expect(ssoStore.saveOidcConfig).toHaveBeenCalledWith(
expect.objectContaining({ rpInitiatedLogoutEnabled: true }),
);
});
it('shows error message to user when OIDC config save fails', async () => {
const error = new Error('Save failed');
ssoStore.saveOidcConfig.mockRejectedValue(error);
@@ -45,7 +45,7 @@ export class SettingsSsoPage extends BasePage {
const isEnabled = await this.isOidcLoginEnabled();
if (!isEnabled) {
await this.getOidcLoginToggle().locator('.el-select').click();
await this.page.locator('.el-select-dropdown__item').filter({ hasText: 'Enabled' }).click();
await this.page.getByTestId('sso-oidc-toggle-option').filter({ hasText: 'Enabled' }).click();
}
}