mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(editor): Make OIDC RP-initiated logout an opt-in setting (#35146)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -641,6 +641,8 @@ describe('GlobalConfig', () => {
|
||||
oidcLoginEnabled: false,
|
||||
oidcPrompt: 'select_account',
|
||||
oidcAcrValues: '',
|
||||
oidcAdditionalScopes: '',
|
||||
oidcRpInitiatedLogoutEnabled: false,
|
||||
ssoUserRoleProvisioning: 'disabled',
|
||||
securityPolicyManagedByEnv: false,
|
||||
mfaEnforcedEnabled: false,
|
||||
|
||||
+16
@@ -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();
|
||||
|
||||
|
||||
+7
@@ -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);
|
||||
|
||||
+26
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user