const discoveryMock = jest.fn(); const authorizationCodeGrantMock = jest.fn(); const fetchUserInfoMock = jest.fn(); jest.mock('openid-client', () => ({ ...jest.requireActual('openid-client'), discovery: discoveryMock, authorizationCodeGrant: authorizationCodeGrantMock, fetchUserInfo: fetchUserInfoMock, })); import type { OidcConfigDto } from '@n8n/api-types'; import { testDb } from '@n8n/backend-test-utils'; import { type User, UserRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type * as mocked_oidc_client from 'openid-client'; const real_odic_client = jest.requireActual('openid-client'); import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; 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'; beforeAll(async () => { await testDb.init(); }); afterAll(async () => { await testDb.terminate(); }); describe('OIDC service', () => { let oidcService: OidcService; let userRepository: UserRepository; let createdUser: User; beforeAll(async () => { oidcService = Container.get(OidcService); userRepository = Container.get(UserRepository); await oidcService.init(); await createUser({ email: 'user1@example.com', }); }); describe('loadConfig', () => { it('should initialize with default config', () => { expect(oidcService.getRedactedConfig()).toEqual({ clientId: '', clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE, discoveryEndpoint: 'http://n8n.io/not-set', loginEnabled: false, }); }); it('should fallback to default configuration', async () => { const config = await oidcService.loadConfig(); expect(config).toEqual({ clientId: '', clientSecret: '', discoveryEndpoint: new URL('http://n8n.io/not-set'), loginEnabled: false, }); }); it('should load and update OIDC configuration', async () => { const newConfig: OidcConfigDto = { clientId: 'test-client-id', clientSecret: 'test-client-secret', discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, }; await oidcService.updateConfig(newConfig); const loadedConfig = await oidcService.loadConfig(); expect(loadedConfig.clientId).toEqual('test-client-id'); // The secret should be encrypted and not match the original value expect(loadedConfig.clientSecret).not.toEqual('test-client-secret'); expect(loadedConfig.discoveryEndpoint.toString()).toEqual( 'https://example.com/.well-known/openid-configuration', ); expect(loadedConfig.loginEnabled).toBe(true); }); it('should load and decrypt OIDC configuration', async () => { const newConfig: OidcConfigDto = { clientId: 'test-client-id', clientSecret: 'test-client-secret', discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, }; await oidcService.updateConfig(newConfig); const loadedConfig = await oidcService.loadConfig(true); expect(loadedConfig.clientId).toEqual('test-client-id'); // The secret should be encrypted and not match the original value expect(loadedConfig.clientSecret).toEqual('test-client-secret'); expect(loadedConfig.discoveryEndpoint.toString()).toEqual( 'https://example.com/.well-known/openid-configuration', ); expect(loadedConfig.loginEnabled).toBe(true); }); it('should throw an error if the discovery endpoint is invalid', async () => { const newConfig: OidcConfigDto = { clientId: 'test-client-id', clientSecret: 'test-client-secret', discoveryEndpoint: 'Not an url', loginEnabled: true, }; await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(UserError); }); it('should keep current secret if redact value is given in update', async () => { const newConfig: OidcConfigDto = { clientId: 'test-client-id', clientSecret: OIDC_CLIENT_SECRET_REDACTED_VALUE, discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, }; await oidcService.updateConfig(newConfig); const loadedConfig = await oidcService.loadConfig(true); expect(loadedConfig.clientId).toEqual('test-client-id'); // The secret should be encrypted and not match the original value expect(loadedConfig.clientSecret).toEqual('test-client-secret'); expect(loadedConfig.discoveryEndpoint.toString()).toEqual( 'https://example.com/.well-known/openid-configuration', ); expect(loadedConfig.loginEnabled).toBe(true); }); it('should throw UserError when OIDC discovery fails during updateConfig', async () => { const newConfig: OidcConfigDto = { clientId: 'test-client-id', clientSecret: 'test-client-secret', discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, }; discoveryMock.mockRejectedValueOnce(new Error('Discovery failed')); await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(UserError); expect(discoveryMock).toHaveBeenCalledWith( expect.any(URL), 'test-client-id', 'test-client-secret', ); }); it('should invalidate cached configuration when updateConfig is called', async () => { // First, set up a working configuration const initialConfig: OidcConfigDto = { clientId: 'initial-client-id', clientSecret: 'initial-client-secret', discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, }; const mockConfiguration = new real_odic_client.Configuration( { issuer: 'https://example.com/auth/realms/n8n', client_id: 'initial-client-id', redirect_uris: ['http://n8n.io/sso/oidc/callback'], response_types: ['code'], scopes: ['openid', 'profile', 'email'], authorization_endpoint: 'https://example.com/auth', }, 'initial-client-id', ); discoveryMock.mockReset(); discoveryMock.mockClear(); discoveryMock.mockResolvedValue(mockConfiguration); await oidcService.updateConfig(initialConfig); // Generate a login URL to populate the cache await oidcService.generateLoginUrl(); expect(discoveryMock).toHaveBeenCalledTimes(2); // Once in updateConfig, once in generateLoginUrl // Update config with new values const newConfig: OidcConfigDto = { clientId: 'new-client-id', clientSecret: 'new-client-secret', discoveryEndpoint: 'https://newprovider.example.com/.well-known/openid-configuration', loginEnabled: true, }; const newMockConfiguration = new real_odic_client.Configuration( { issuer: 'https://newprovider.example.com/auth/realms/n8n', client_id: 'new-client-id', redirect_uris: ['http://n8n.io/sso/oidc/callback'], response_types: ['code'], scopes: ['openid', 'profile', 'email'], authorization_endpoint: 'https://newprovider.example.com/auth', }, 'new-client-id', ); discoveryMock.mockResolvedValue(newMockConfiguration); await oidcService.updateConfig(newConfig); // 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'); // Verify discovery was called again due to cache invalidation expect(discoveryMock).toHaveBeenCalledTimes(4); // Initial config, initial login, new config, new login }); }); it('should generate a valid callback URL', () => { const callbackUrl = oidcService.getCallbackUrl(); expect(callbackUrl).toContain('/sso/oidc/callback'); }); it('should generate a valid authentication URL', async () => { const mockConfiguration = new real_odic_client.Configuration( { issuer: 'https://example.com/auth/realms/n8n', client_id: 'test-client-id', redirect_uris: ['http://n8n.io/sso/oidc/callback'], response_types: ['code'], scopes: ['openid', 'profile', 'email'], authorization_endpoint: 'https://example.com/auth', }, 'test-client-id', ); discoveryMock.mockResolvedValue(mockConfiguration); const initialConfig: OidcConfigDto = { clientId: 'test-client-id', clientSecret: 'test-client-secret', discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, }; await oidcService.updateConfig(initialConfig); 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( 'http://localhost:5678/rest/sso/oidc/callback', ); expect(authUrl.searchParams.get('response_type')).toEqual('code'); expect(authUrl.searchParams.get('scope')).toEqual('openid email profile'); }); describe('loginUser', () => { it('should handle new user login with valid callback URL', async () => { const callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token', id_token: 'mock-id-token', token_type: 'bearer', claims: () => { return { sub: 'mock-subject', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, email: 'user2@example.com', }); const user = await oidcService.loginUser(callbackUrl); expect(user).toBeDefined(); expect(user.email).toEqual('user2@example.com'); createdUser = user; const userFromDB = await userRepository.findOne({ where: { email: 'user2@example.com' }, }); expect(userFromDB).toBeDefined(); expect(userFromDB!.id).toEqual(user.id); }); it('should handle existing user login with valid callback URL', async () => { const callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-1', id_token: 'mock-id-token-1', token_type: 'bearer', claims: () => { return { sub: 'mock-subject', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, email: 'user2@example.com', }); const user = await oidcService.loginUser(callbackUrl); 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 callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-2', id_token: 'mock-id-token-2', token_type: 'bearer', claims: () => { return { sub: 'mock-subject-1', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); // Simulate that the user already exists in the database fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, email: 'user1@example.com', }); const user = await oidcService.loginUser(callbackUrl); 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 callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-2', id_token: 'mock-id-token-2', token_type: 'bearer', claims: () => { return { sub: 'mock-subject-3', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); // Simulate that the user already exists in the database fetchUserInfoMock.mockResolvedValueOnce({ email_verified: false, email: 'user3@example.com', }); const user = await oidcService.loginUser(callbackUrl); expect(user).toBeDefined(); expect(user.email).toEqual('user3@example.com'); }); it('should throw `BadRequestError` if OIDC Idp does not provide an email', async () => { const callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-2', id_token: 'mock-id-token-2', token_type: 'bearer', claims: () => { return { sub: 'mock-subject-3', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); // Simulate that the user already exists in the database fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, }); await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError); }); it('should throw `BadRequestError` if OIDC Idp provides an invalid email format', async () => { const callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-invalid', id_token: 'mock-id-token-invalid', token_type: 'bearer', claims: () => { return { sub: 'mock-subject-invalid', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); // Provide an invalid email format fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, email: 'invalid-email-format', }); const error = await oidcService.loginUser(callbackUrl).catch((e) => e); expect(error.message).toBe('Invalid email format'); }); it.each([ ['not-an-email'], ['@missinglocal.com'], ['missing@.com'], ['spaces in@email.com'], ['double@@domain.com'], ])('should throw `BadRequestError` for invalid email <%s>', async (invalidEmail) => { const callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-multi', id_token: 'mock-id-token-multi', token_type: 'bearer', claims: () => { return { sub: 'mock-subject-multi', iss: 'https://example.com/auth/realms/n8n', aud: 'test-client-id', iat: Math.floor(Date.now() / 1000) - 1000, exp: Math.floor(Date.now() / 1000) + 3600, } as mocked_oidc_client.IDToken; }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, email: invalidEmail, }); await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(BadRequestError); }); it('should throw `ForbiddenError` if OIDC token does not provide claims', async () => { const callbackUrl = new URL( 'http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=valid-state', ); const mockTokens: mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers = { access_token: 'mock-access-token-2', id_token: 'mock-id-token-2', token_type: 'bearer', claims: () => { return undefined; // Simulating no claims }, expiresIn: () => 3600, } as mocked_oidc_client.TokenEndpointResponse & mocked_oidc_client.TokenEndpointResponseHelpers; authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens); // Simulate that the user already exists in the database fetchUserInfoMock.mockResolvedValueOnce({ email_verified: true, }); await expect(oidcService.loginUser(callbackUrl)).rejects.toThrowError(ForbiddenError); }); }); });