From 06578f287c6e40b1ba6758ba1cc1f14e56260707 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Wed, 3 Sep 2025 12:33:56 +0200 Subject: [PATCH] chore(core): Support state and nonce parameter for OIDC (#19098) --- packages/cli/src/constants.ts | 2 + .../cli/src/sso.ee/oidc/oidc.service.ee.ts | 106 +++++++++++++++- .../__tests__/oidc.controller.ee.test.ts | 67 ++++++++-- .../sso.ee/oidc/routes/oidc.controller.ee.ts | 43 ++++++- .../integration/oidc/oidc.service.ee.test.ts | 120 ++++++++++++++---- 5 files changed, 297 insertions(+), 41 deletions(-) diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 6391f687ac..ba2a4154e6 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -52,6 +52,8 @@ export const RESPONSE_ERROR_MESSAGES = { } as const; export const AUTH_COOKIE_NAME = 'n8n-auth'; +export const OIDC_STATE_COOKIE_NAME = 'n8n-oidc-state'; +export const OIDC_NONCE_COOKIE_NAME = 'n8n-oidc-nonce'; export const NPM_COMMAND_TOKENS = { NPM_PACKAGE_NOT_FOUND_ERROR: '404 Not Found', diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index 8f43015bec..334b207bbf 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -11,6 +11,7 @@ import { UserRepository, } from '@n8n/db'; import { Container, Service } from '@n8n/di'; +import { randomUUID } from 'crypto'; import { Cipher } from 'n8n-core'; import { jsonParse, UserError } from 'n8n-workflow'; import * as client from 'openid-client'; @@ -18,15 +19,16 @@ import * as client from 'openid-client'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { JwtService } from '@/services/jwt.service'; import { UrlService } from '@/services/url.service'; -import { OIDC_CLIENT_SECRET_REDACTED_VALUE, OIDC_PREFERENCES_DB_KEY } from './constants'; import { getCurrentAuthenticationMethod, isEmailCurrentAuthenticationMethod, isOidcCurrentAuthenticationMethod, setCurrentAuthenticationMethod, } from '../sso-helpers'; +import { OIDC_CLIENT_SECRET_REDACTED_VALUE, OIDC_PREFERENCES_DB_KEY } from './constants'; const DEFAULT_OIDC_CONFIG: OidcConfigDto = { clientId: '', @@ -56,6 +58,7 @@ export class OidcService { private readonly userRepository: UserRepository, private readonly cipher: Cipher, private readonly logger: Logger, + private readonly jwtService: JwtService, ) {} async init() { @@ -76,23 +79,116 @@ export class OidcService { }; } - async generateLoginUrl(): Promise { + generateState() { + const state = `n8n_state:${randomUUID()}`; + return { + signed: this.jwtService.sign({ state }, { expiresIn: '15m' }), + plaintext: state, + }; + } + + verifyState(signedState: string) { + let state: string; + try { + const decodedState = this.jwtService.verify(signedState); + state = decodedState?.state; + } catch (error) { + this.logger.error('Failed to verify state', { error }); + throw new BadRequestError('Invalid state'); + } + + if (typeof state !== 'string') { + this.logger.error('Provided state has an invalid format'); + throw new BadRequestError('Invalid state'); + } + + const splitState = state.split(':'); + + if (splitState.length !== 2 || splitState[0] !== 'n8n_state') { + this.logger.error('Provided state is missing the well-known prefix'); + throw new BadRequestError('Invalid state'); + } + + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + splitState[1], + ) + ) { + this.logger.error('Provided state is not formatted correctly'); + throw new BadRequestError('Invalid state'); + } + return state; + } + + generateNonce() { + const nonce = `n8n_nonce:${randomUUID()}`; + return { + signed: this.jwtService.sign({ nonce }, { expiresIn: '15m' }), + plaintext: nonce, + }; + } + + verifyNonce(signedNonce: string) { + let nonce: string; + try { + const decodedNonce = this.jwtService.verify(signedNonce); + nonce = decodedNonce?.nonce; + } catch (error) { + this.logger.error('Failed to verify nonce', { error }); + throw new BadRequestError('Invalid nonce'); + } + + if (typeof nonce !== 'string') { + this.logger.error('Provided nonce has an invalid format'); + throw new BadRequestError('Invalid nonce'); + } + + const splitNonce = nonce.split(':'); + + if (splitNonce.length !== 2 || splitNonce[0] !== 'n8n_nonce') { + this.logger.error('Provided nonce is missing the well-known prefix'); + throw new BadRequestError('Invalid nonce'); + } + + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + splitNonce[1], + ) + ) { + this.logger.error('Provided nonce is not formatted correctly'); + throw new BadRequestError('Invalid nonce'); + } + return nonce; + } + + async generateLoginUrl(): Promise<{ url: URL; state: string; nonce: string }> { const configuration = await this.getOidcConfiguration(); + const state = this.generateState(); + const nonce = this.generateNonce(); + const authorizationURL = client.buildAuthorizationUrl(configuration, { redirect_uri: this.getCallbackUrl(), response_type: 'code', scope: 'openid email profile', prompt: 'select_account', + state: state.plaintext, + nonce: nonce.plaintext, }); - return authorizationURL; + return { url: authorizationURL, state: state.signed, nonce: nonce.signed }; } - async loginUser(callbackUrl: URL): Promise { + async loginUser(callbackUrl: URL, storedState: string, storedNonce: string): Promise { const configuration = await this.getOidcConfiguration(); - const tokens = await client.authorizationCodeGrant(configuration, callbackUrl); + const expectedState = this.verifyState(storedState); + const expectedNonce = this.verifyNonce(storedNonce); + + const tokens = await client.authorizationCodeGrant(configuration, callbackUrl, { + expectedState, + expectedNonce, + }); const claims = tokens.claims(); diff --git a/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts b/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts index cff5369540..5798a3b4e5 100644 --- a/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts +++ b/packages/cli/src/sso.ee/oidc/routes/__tests__/oidc.controller.ee.test.ts @@ -1,18 +1,24 @@ +import type { Logger } from '@n8n/backend-common'; +import type { GlobalConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db'; import { type Request, type Response } from 'express'; import { mock } from 'jest-mock-extended'; -import type { OidcService } from '../../oidc.service.ee'; -import { OidcController } from '../oidc.controller.ee'; - import type { AuthService } from '@/auth/auth.service'; +import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants'; import type { AuthlessRequest } from '@/requests'; import type { UrlService } from '@/services/url.service'; +import type { OidcService } from '../../oidc.service.ee'; +import { OidcController } from '../oidc.controller.ee'; + const authService = mock(); const oidcService = mock(); const urlService = mock(); -const controller = new OidcController(oidcService, authService, urlService); +const globalConfig = mock(); +const logger = mock(); +const controller = new OidcController(oidcService, authService, urlService, globalConfig, logger); const user = mock({ id: '456', @@ -37,6 +43,10 @@ describe('OidcController', () => { const req = mock({ 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(); @@ -50,7 +60,11 @@ describe('OidcController', () => { await controller.callbackHandler(req, res); // Verify that loginUser was called with the correct callback URL - expect(oidcService.loginUser).toHaveBeenCalledWith(expectedCallbackUrl); + expect(oidcService.loginUser).toHaveBeenCalledWith( + expectedCallbackUrl, + 'state_value', + 'nonce_value', + ); // Verify that issueCookie was called with MFA flag set to true expect(authService.issueCookie).toHaveBeenCalledWith(res, user, true, req.browserId); @@ -64,6 +78,10 @@ describe('OidcController', () => { originalUrl: '/sso/oidc/callback?code=different_code&state=different_state&session_state=session123', browserId: 'browser-id-123', + cookies: { + [OIDC_STATE_COOKIE_NAME]: 'state_value', + [OIDC_NONCE_COOKIE_NAME]: 'nonce_value', + }, }); const res = mock(); @@ -75,7 +93,11 @@ describe('OidcController', () => { await controller.callbackHandler(req, res); - expect(oidcService.loginUser).toHaveBeenCalledWith(expectedCallbackUrl); + expect(oidcService.loginUser).toHaveBeenCalledWith( + expectedCallbackUrl, + 'state_value', + 'nonce_value', + ); expect(authService.issueCookie).toHaveBeenCalledWith(res, user, true, req.browserId); expect(res.redirect).toHaveBeenCalledWith('/'); }); @@ -84,6 +106,10 @@ describe('OidcController', () => { const req = mock({ originalUrl: '/sso/oidc/callback', browserId: undefined, + cookies: { + [OIDC_STATE_COOKIE_NAME]: 'state_value', + [OIDC_NONCE_COOKIE_NAME]: 'nonce_value', + }, }); const res = mock(); @@ -93,7 +119,11 @@ describe('OidcController', () => { await controller.callbackHandler(req, res); - expect(oidcService.loginUser).toHaveBeenCalledWith(expectedCallbackUrl); + expect(oidcService.loginUser).toHaveBeenCalledWith( + expectedCallbackUrl, + 'state_value', + 'nonce_value', + ); expect(authService.issueCookie).toHaveBeenCalledWith(res, user, true, undefined); expect(res.redirect).toHaveBeenCalledWith('/'); }); @@ -101,6 +131,10 @@ describe('OidcController', () => { test('Should propagate errors from OIDC service', async () => { const req = mock({ originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value', + cookies: { + [OIDC_STATE_COOKIE_NAME]: 'state_value', + [OIDC_NONCE_COOKIE_NAME]: 'nonce_value', + }, }); const res = mock(); @@ -119,11 +153,16 @@ describe('OidcController', () => { test('Should redirect to generated authorization URL', async () => { const req = mock(); const res = mock(); + globalConfig.auth.cookie = { samesite: 'lax', secure: true }; const mockAuthUrl = new URL( 'https://provider.com/auth?client_id=123&redirect_uri=http://localhost:5678/callback', ); - oidcService.generateLoginUrl.mockResolvedValueOnce(mockAuthUrl); + oidcService.generateLoginUrl.mockResolvedValueOnce({ + url: mockAuthUrl, + state: 'state_value', + nonce: 'nonce_value', + }); await controller.redirectToAuthProvider(req, res); @@ -131,6 +170,18 @@ describe('OidcController', () => { expect(res.redirect).toHaveBeenCalledWith( 'https://provider.com/auth?client_id=123&redirect_uri=http://localhost:5678/callback', ); + expect(res.cookie).toHaveBeenCalledWith(OIDC_STATE_COOKIE_NAME, 'state_value', { + httpOnly: true, + sameSite: 'lax', + secure: true, + maxAge: 15 * Time.minutes.toMilliseconds, + }); + expect(res.cookie).toHaveBeenCalledWith(OIDC_NONCE_COOKIE_NAME, 'nonce_value', { + httpOnly: true, + sameSite: 'lax', + secure: true, + maxAge: 15 * Time.minutes.toMilliseconds, + }); }); test('Should propagate errors from OIDC service during URL generation', async () => { diff --git a/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts b/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts index f9d2fb8a23..e5eff673fa 100644 --- a/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts +++ b/packages/cli/src/sso.ee/oidc/routes/oidc.controller.ee.ts @@ -1,14 +1,19 @@ import { OidcConfigDto } from '@n8n/api-types'; +import { Logger } from '@n8n/backend-common'; +import { GlobalConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; import { Body, Get, GlobalScope, Licensed, Post, RestController } from '@n8n/decorators'; import { Request, Response } from 'express'; import { AuthService } from '@/auth/auth.service'; +import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { AuthlessRequest } from '@/requests'; import { UrlService } from '@/services/url.service'; import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '../constants'; import { OidcService } from '../oidc.service.ee'; -import { AuthlessRequest } from '@/requests'; @RestController('/sso/oidc') export class OidcController { @@ -16,6 +21,8 @@ export class OidcController { private readonly oidcService: OidcService, private readonly authService: AuthService, private readonly urlService: UrlService, + private readonly globalConfig: GlobalConfig, + private readonly logger: Logger, ) {} @Get('/config') @@ -45,9 +52,22 @@ export class OidcController { @Get('/login', { skipAuth: true }) @Licensed('feat:oidc') async redirectToAuthProvider(_req: Request, res: Response) { - const authorizationURL = await this.oidcService.generateLoginUrl(); + const authorization = await this.oidcService.generateLoginUrl(); + const { samesite, secure } = this.globalConfig.auth.cookie; - res.redirect(authorizationURL.toString()); + res.cookie(OIDC_STATE_COOKIE_NAME, authorization.state, { + maxAge: 15 * Time.minutes.toMilliseconds, + httpOnly: true, + sameSite: samesite, + secure, + }); + res.cookie(OIDC_NONCE_COOKIE_NAME, authorization.nonce, { + maxAge: 15 * Time.minutes.toMilliseconds, + httpOnly: true, + sameSite: samesite, + secure, + }); + res.redirect(authorization.url.toString()); } @Get('/callback', { skipAuth: true }) @@ -55,9 +75,24 @@ export class OidcController { async callbackHandler(req: AuthlessRequest, res: Response) { const fullUrl = `${this.urlService.getInstanceBaseUrl()}${req.originalUrl}`; const callbackUrl = new URL(fullUrl); + const state = req.cookies[OIDC_STATE_COOKIE_NAME]; - const user = await this.oidcService.loginUser(callbackUrl); + if (typeof state !== 'string') { + this.logger.error('State is missing'); + throw new BadRequestError('Invalid state'); + } + const nonce = req.cookies[OIDC_NONCE_COOKIE_NAME]; + + if (typeof nonce !== 'string') { + this.logger.error('Nonce is missing'); + throw new BadRequestError('Invalid nonce'); + } + + const user = await this.oidcService.loginUser(callbackUrl, state, nonce); + + res.clearCookie(OIDC_STATE_COOKIE_NAME); + res.clearCookie(OIDC_NONCE_COOKIE_NAME); this.authService.issueCookie(res, user, true, req.browserId); res.redirect('/'); diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index af5731ed04..74138df35d 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -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); }); }); });