chore(core): Support state and nonce parameter for OIDC (#19098)

This commit is contained in:
Andreas Fitzek
2025-09-03 12:33:56 +02:00
committed by GitHub
parent 52747b1625
commit 06578f287c
5 changed files with 297 additions and 41 deletions

View File

@@ -22,6 +22,7 @@ import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/sso.ee/oidc/constants';
import { OidcService } from '@/sso.ee/oidc/oidc.service.ee';
import { createUser } from '@test-integration/db/users';
import { UserError } from 'n8n-workflow';
import { JwtService } from '@/services/jwt.service';
beforeAll(async () => {
await testDb.init();
@@ -211,8 +212,8 @@ describe('OIDC service', () => {
// Generate login URL again - should use new configuration
const authUrl = await oidcService.generateLoginUrl();
expect(authUrl.pathname).toEqual('/auth');
expect(authUrl.searchParams.get('client_id')).toEqual('new-client-id');
expect(authUrl.url.pathname).toEqual('/auth');
expect(authUrl.url.searchParams.get('client_id')).toEqual('new-client-id');
// Verify discovery was called again due to cache invalidation
expect(discoveryMock).toHaveBeenCalledTimes(4); // Initial config, initial login, new config, new login
@@ -248,19 +249,26 @@ describe('OIDC service', () => {
const authUrl = await oidcService.generateLoginUrl();
expect(authUrl.pathname).toEqual('/auth');
expect(authUrl.searchParams.get('client_id')).toEqual('test-client-id');
expect(authUrl.searchParams.get('redirect_uri')).toEqual(
expect(authUrl.url.pathname).toEqual('/auth');
expect(authUrl.url.searchParams.get('client_id')).toEqual('test-client-id');
expect(authUrl.url.searchParams.get('redirect_uri')).toEqual(
'http://localhost:5678/rest/sso/oidc/callback',
);
expect(authUrl.searchParams.get('response_type')).toEqual('code');
expect(authUrl.searchParams.get('scope')).toEqual('openid email profile');
expect(authUrl.url.searchParams.get('response_type')).toEqual('code');
expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile');
expect(authUrl.url.searchParams.get('state')).toBeDefined();
expect(authUrl.url.searchParams.get('state')?.startsWith('n8n_state:')).toBe(true);
expect(authUrl.state).toBeDefined();
expect(authUrl.nonce).toBeDefined();
});
describe('loginUser', () => {
it('should handle new user login with valid callback URL', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -288,7 +296,7 @@ describe('OIDC service', () => {
email: 'user2@example.com',
});
const user = await oidcService.loginUser(callbackUrl);
const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
expect(user).toBeDefined();
expect(user.email).toEqual('user2@example.com');
@@ -303,8 +311,10 @@ describe('OIDC service', () => {
});
it('should handle existing user login with valid callback URL', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -324,7 +334,7 @@ describe('OIDC service', () => {
expiresIn: () => 3600,
} as mocked_oidc_client.TokenEndpointResponse &
mocked_oidc_client.TokenEndpointResponseHelpers;
state;
authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
fetchUserInfoMock.mockResolvedValueOnce({
@@ -332,15 +342,17 @@ describe('OIDC service', () => {
email: 'user2@example.com',
});
const user = await oidcService.loginUser(callbackUrl);
const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
expect(user).toBeDefined();
expect(user.email).toEqual('user2@example.com');
expect(user.id).toEqual(createdUser.id);
});
it('should sign up the user if user already exists out of OIDC system', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -369,14 +381,16 @@ describe('OIDC service', () => {
email: 'user1@example.com',
});
const user = await oidcService.loginUser(callbackUrl);
const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
expect(user).toBeDefined();
expect(user.email).toEqual('user1@example.com');
});
it('should sign in user if OIDC Idp does not have email verified', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -405,14 +419,16 @@ describe('OIDC service', () => {
email: 'user3@example.com',
});
const user = await oidcService.loginUser(callbackUrl);
const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
expect(user).toBeDefined();
expect(user.email).toEqual('user3@example.com');
});
it('should throw `BadRequestError` if OIDC Idp does not provide an email', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -440,12 +456,16 @@ describe('OIDC service', () => {
email_verified: true,
});
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
await expect(
oidcService.loginUser(callbackUrl, state.signed, nonce.signed),
).rejects.toThrowError(BadRequestError);
});
it('should throw `BadRequestError` if OIDC Idp provides an invalid email format', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -474,7 +494,9 @@ describe('OIDC service', () => {
email: 'invalid-email-format',
});
const error = await oidcService.loginUser(callbackUrl).catch((e) => e);
const error = await oidcService
.loginUser(callbackUrl, state.signed, nonce.signed)
.catch((e) => e);
expect(error.message).toBe('Invalid email format');
});
@@ -485,8 +507,10 @@ describe('OIDC service', () => {
['spaces in@email.com'],
['double@@domain.com'],
])('should throw `BadRequestError` for invalid email <%s>', async (invalidEmail) => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -513,12 +537,16 @@ describe('OIDC service', () => {
email: invalidEmail,
});
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError);
await expect(
oidcService.loginUser(callbackUrl, state.signed, nonce.signed),
).rejects.toThrowError(BadRequestError);
});
it('should throw `ForbiddenError` if OIDC token does not provide claims', async () => {
const state = oidcService.generateState();
const nonce = oidcService.generateNonce();
const callbackUrl = new URL(
'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state',
`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
);
const mockTokens: mocked_oidc_client.TokenEndpointResponse &
@@ -540,7 +568,51 @@ describe('OIDC service', () => {
email_verified: true,
});
await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(ForbiddenError);
await expect(
oidcService.loginUser(callbackUrl, state.signed, nonce.signed),
).rejects.toThrowError(ForbiddenError);
});
});
describe('State and nonce', () => {
it('should generate and verify a valid state', () => {
const state = oidcService.generateState();
const decoded = oidcService.verifyState(state.signed);
expect(decoded).toBe(state.plaintext);
});
it('should generate and verify a valid nonce', () => {
const nonce = oidcService.generateNonce();
const decoded = oidcService.verifyNonce(nonce.signed);
expect(decoded).toBe(nonce.plaintext);
});
it('should throw an error for an invalid state', () => {
expect(() => oidcService.verifyState('invalid_state')).toThrow(BadRequestError);
});
it('should throw an error for an invalid formatted state', () => {
const invalid = Container.get(JwtService).sign({ state: 'invalid_state' });
expect(() => oidcService.verifyState(invalid)).toThrow(BadRequestError);
});
it('should throw an error for an invalid random part of the state', () => {
const invalid = Container.get(JwtService).sign({ state: 'n8n_state:invalid-state' });
expect(() => oidcService.verifyState(invalid)).toThrow(BadRequestError);
});
it('should throw an error for an invalid nonce', () => {
expect(() => oidcService.verifyNonce('invalid_nonce')).toThrow(BadRequestError);
});
it('should throw an error for an invalid formatted nonce', () => {
const invalid = Container.get(JwtService).sign({ nonce: 'invalid_nonce' });
expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError);
});
it('should throw an error for an invalid random part of the nonce', () => {
const invalid = Container.get(JwtService).sign({ nonce: 'n8n_nonce:invalid-nonce' });
expect(() => oidcService.verifyNonce(invalid)).toThrow(BadRequestError);
});
});
});