mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
chore(core): Support state and nonce parameter for OIDC (#19098)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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<URL> {
|
||||
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<User> {
|
||||
async loginUser(callbackUrl: URL, storedState: string, storedNonce: string): Promise<User> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<AuthService>();
|
||||
const oidcService = mock<OidcService>();
|
||||
const urlService = mock<UrlService>();
|
||||
const controller = new OidcController(oidcService, authService, urlService);
|
||||
const globalConfig = mock<GlobalConfig>();
|
||||
const logger = mock<Logger>();
|
||||
const controller = new OidcController(oidcService, authService, urlService, globalConfig, logger);
|
||||
|
||||
const user = mock<User>({
|
||||
id: '456',
|
||||
@@ -37,6 +43,10 @@ describe('OidcController', () => {
|
||||
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>();
|
||||
|
||||
@@ -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<Response>();
|
||||
|
||||
@@ -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<AuthlessRequest>({
|
||||
originalUrl: '/sso/oidc/callback',
|
||||
browserId: undefined,
|
||||
cookies: {
|
||||
[OIDC_STATE_COOKIE_NAME]: 'state_value',
|
||||
[OIDC_NONCE_COOKIE_NAME]: 'nonce_value',
|
||||
},
|
||||
});
|
||||
const res = mock<Response>();
|
||||
|
||||
@@ -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<Request>({
|
||||
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<Response>();
|
||||
|
||||
@@ -119,11 +153,16 @@ describe('OidcController', () => {
|
||||
test('Should redirect to generated authorization URL', async () => {
|
||||
const req = mock<Request>();
|
||||
const res = mock<Response>();
|
||||
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 () => {
|
||||
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user