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, ProvisioningConfigDto } from '@n8n/api-types';
import { LicenseState } from '@n8n/backend-common';
import { createTeamProject, getProjectRoleForUser, testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { type User, UserRepository, RoleRepository, RoleMappingRuleRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { UserError } from 'n8n-workflow';
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 { License } from '@/license';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import { OIDC_CLIENT_SECRET_REDACTED_VALUE } from '@/modules/sso-oidc/constants';
import { OidcService } from '@/modules/sso-oidc/oidc.service.ee';
import { JwtService } from '@/services/jwt.service';
import { createCustomRoleWithScopes, createScope } from '@test-integration/db/roles';
import { createUser } from '@test-integration/db/users';

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', () => {
		beforeEach(() => {
			discoveryMock.mockResolvedValue({});
		});

		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,
				prompt: 'select_account',
				authenticationContextClassReference: [],
				additionalScopes: '',
			});
		});

		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,
				prompt: 'select_account',
				authenticationContextClassReference: [],
				additionalScopes: '',
			});
		});

		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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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',
				undefined,
				expect.objectContaining({
					[real_odic_client.customFetch]: expect.any(Function),
				}),
			);
		});

		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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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,
				prompt: 'select_account',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			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.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
		});
	});
	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,
			prompt: 'consent',
			authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
			additionalScopes: '',
		};

		await oidcService.updateConfig(initialConfig);

		const authUrl = await oidcService.generateLoginUrl();

		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.url.searchParams.get('response_type')).toEqual('code');
		expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile');
		expect(authUrl.url.searchParams.get('prompt')).toBeDefined();
		expect(authUrl.url.searchParams.get('prompt')).toEqual('consent');
		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('SSO provisioning', () => {
		beforeAll(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,
				prompt: 'consent',
				authenticationContextClassReference: ['mfa', 'phrh', 'pwd'],
				additionalScopes: '',
			};

			await oidcService.updateConfig(initialConfig);
		});

		let provisioningConfig: GlobalConfig['sso']['provisioning'];

		beforeEach(() => {
			// safe original provisioning config, by making a copy
			provisioningConfig = {
				...Container.get(GlobalConfig).sso.provisioning,
			};
		});

		afterEach(() => {
			// restore original provisioning config
			Container.get(GlobalConfig).sso.provisioning = provisioningConfig;
		});

		const validateUrl = (authUrl: Awaited<ReturnType<OidcService['generateLoginUrl']>>) => {
			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.url.searchParams.get('response_type')).toEqual('code');
			expect(authUrl.url.searchParams.get('prompt')).toBeDefined();
			expect(authUrl.url.searchParams.get('prompt')).toEqual('consent');
			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();
		};

		it('should not include the provisioning scope if no provisioning is enabled', async () => {
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope';
			const authUrl = await oidcService.generateLoginUrl();

			validateUrl(authUrl);
			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile');
		});

		it('should include the provisioning scope if project provisioning is enabled', async () => {
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = true;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope';
			const authUrl = await oidcService.generateLoginUrl();

			validateUrl(authUrl);
			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope');
		});

		it('should include the provisioning scope if instance provisioning is enabled', async () => {
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = true;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope';
			const authUrl = await oidcService.generateLoginUrl();

			validateUrl(authUrl);
			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope');
		});

		it('should include the provisioning scope if project and instance provisioning is enabled', async () => {
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = true;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = true;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope';
			const authUrl = await oidcService.generateLoginUrl();

			validateUrl(authUrl);
			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile n8n_test_scope');
		});
	});

	describe('additionalScopes', () => {
		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',
		);

		const baseConfig: OidcConfigDto = {
			clientId: 'test-client-id',
			clientSecret: 'test-client-secret',
			discoveryEndpoint: 'https://example.com/.well-known/openid-configuration',
			loginEnabled: true,
			prompt: 'select_account',
			authenticationContextClassReference: [],
			additionalScopes: '',
		};

		let provisioningConfig: GlobalConfig['sso']['provisioning'];

		beforeEach(() => {
			discoveryMock.mockResolvedValue(mockConfiguration);
			provisioningConfig = { ...Container.get(GlobalConfig).sso.provisioning };
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionProjectRoles = false;
		});

		afterEach(() => {
			Container.get(GlobalConfig).sso.provisioning = provisioningConfig;
		});

		it('should include additional scopes in the authorization URL', async () => {
			await oidcService.updateConfig({ ...baseConfig, additionalScopes: 'groups b2xroles' });

			const authUrl = await oidcService.generateLoginUrl();

			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile groups b2xroles');
		});

		it('should only use default scopes when additionalScopes is empty', async () => {
			await oidcService.updateConfig({ ...baseConfig, additionalScopes: '' });

			const authUrl = await oidcService.generateLoginUrl();

			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile');
		});

		it('should trim whitespace from additionalScopes', async () => {
			await oidcService.updateConfig({ ...baseConfig, additionalScopes: '  groups  ' });

			const authUrl = await oidcService.generateLoginUrl();

			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile groups');
		});

		it('should include additional scopes alongside provisioning scope', async () => {
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = true;
			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesName = 'n8n_test_scope';

			await oidcService.updateConfig({ ...baseConfig, additionalScopes: 'groups' });

			const authUrl = await oidcService.generateLoginUrl();

			expect(authUrl.url.searchParams.get('scope')).toEqual(
				'openid email profile n8n_test_scope groups',
			);

			// @ts-expect-error - provisioningConfig is private and only accessible within the class
			Container.get(ProvisioningService).provisioningConfig.scopesProvisionInstanceRole = false;
		});

		it('should URL-encode special characters in additionalScopes preventing injection', async () => {
			await oidcService.updateConfig({
				...baseConfig,
				additionalScopes: 'groups&redirect_uri=https://evil.com',
			});

			const authUrl = await oidcService.generateLoginUrl();
			const scopeParam = authUrl.url.searchParams.get('scope');

			// The scope value should contain the raw string (URL-encoded by the URL object)
			expect(scopeParam).toContain('groups&redirect_uri=https://evil.com');
			// There must be no extra redirect_uri parameter injected into the URL
			const urlString = authUrl.url.toString();
			const redirectUriMatches = [...urlString.matchAll(/redirect_uri=/g)];
			expect(redirectUriMatches).toHaveLength(1);
		});
	});

	describe('generateTestLoginUrl', () => {
		it('should include additional scopes in the test authorization 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);

			await oidcService.updateConfig({
				clientId: 'test-client-id',
				clientSecret: 'test-client-secret',
				discoveryEndpoint: 'https://example.com/.well-known/openid-configuration',
				loginEnabled: true,
				prompt: 'select_account',
				authenticationContextClassReference: [],
				additionalScopes: 'groups',
			});

			const authUrl = await oidcService.generateTestLoginUrl();

			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile groups');
		});

		it('should only use default scopes when additionalScopes is empty in test 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);

			await oidcService.updateConfig({
				clientId: 'test-client-id',
				clientSecret: 'test-client-secret',
				discoveryEndpoint: 'https://example.com/.well-known/openid-configuration',
				loginEnabled: true,
				prompt: 'select_account',
				authenticationContextClassReference: [],
				additionalScopes: '',
			});

			const authUrl = await oidcService.generateTestLoginUrl();

			expect(authUrl.url.searchParams.get('scope')).toEqual('openid email profile');
		});
	});

	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=${state.plaintext}`,
			);

			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, state.signed, nonce.signed);
			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 state = oidcService.generateState();
			const nonce = oidcService.generateNonce();
			const callbackUrl = new URL(
				`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
			);

			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;
			state;
			authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);

			fetchUserInfoMock.mockResolvedValueOnce({
				email_verified: true,
				email: 'user2@example.com',
			});

			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=${state.plaintext}`,
			);

			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, 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=${state.plaintext}`,
			);

			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, 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=${state.plaintext}`,
			);

			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, 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=${state.plaintext}`,
			);

			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, state.signed, nonce.signed)
				.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 state = oidcService.generateState();
			const nonce = oidcService.generateNonce();
			const callbackUrl = new URL(
				`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
			);

			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, 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=${state.plaintext}`,
			);

			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, state.signed, nonce.signed),
			).rejects.toThrowError(ForbiddenError);
		});

		it('should throw `BadRequestError` with "Invalid authorization code" when authorizationCodeGrant fails', async () => {
			const state = oidcService.generateState();
			const nonce = oidcService.generateNonce();
			const callbackUrl = new URL(
				`http://localhost:5678/rest/sso/oidc/callback?code=invalid-code&state=${state.plaintext}`,
			);

			// Mock authorizationCodeGrant to throw an error
			authorizationCodeGrantMock.mockRejectedValueOnce(
				new Error('Authorization code exchange failed'),
			);

			const error = await oidcService
				.loginUser(callbackUrl, state.signed, nonce.signed)
				.catch((e) => e);

			expect(error).toBeInstanceOf(BadRequestError);
			expect(error.message).toBe('Invalid authorization code');
			expect(authorizationCodeGrantMock).toHaveBeenCalledWith(
				expect.any(Object), // configuration
				callbackUrl,
				{
					expectedState: state.plaintext,
					expectedNonce: nonce.plaintext,
				},
			);
		});

		it('should throw `BadRequestError` with "Invalid token" when tokens.claims() fails', async () => {
			const state = oidcService.generateState();
			const nonce = oidcService.generateNonce();
			const callbackUrl = new URL(
				`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
			);

			const mockTokens: mocked_oidc_client.TokenEndpointResponse &
				mocked_oidc_client.TokenEndpointResponseHelpers = {
				access_token: 'mock-access-token-claims-error',
				id_token: 'mock-id-token-claims-error',
				token_type: 'bearer',
				claims: (() => {
					throw new Error('Failed to extract claims');
				}) as any,
				expiresIn: () => 3600,
			} as mocked_oidc_client.TokenEndpointResponse &
				mocked_oidc_client.TokenEndpointResponseHelpers;

			authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);

			const error = await oidcService
				.loginUser(callbackUrl, state.signed, nonce.signed)
				.catch((e) => e);

			expect(error).toBeInstanceOf(BadRequestError);
			expect(error.message).toBe('Invalid token');
		});

		describe('new user login with SSO provisioning', () => {
			const createProvisioningMockTokens = (
				sub: string,
				extraClaims: Record<string, unknown>,
			): mocked_oidc_client.TokenEndpointResponse &
				mocked_oidc_client.TokenEndpointResponseHelpers =>
				({
					access_token: `mock-access-token-${sub}`,
					id_token: `mock-id-token-${sub}`,
					token_type: 'bearer',
					claims: () => {
						return {
							sub,
							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,
							...extraClaims,
						} as mocked_oidc_client.IDToken;
					},
					expiresIn: () => 3600,
				}) as mocked_oidc_client.TokenEndpointResponse &
					mocked_oidc_client.TokenEndpointResponseHelpers;

			let savedProvisioningConfig: ProvisioningConfigDto;

			beforeAll(async () => {
				await Container.get(ProvisioningService).init();
			});

			beforeEach(() => {
				authorizationCodeGrantMock.mockReset();
				fetchUserInfoMock.mockReset();

				const provisioningService = Container.get(ProvisioningService);
				// @ts-expect-error - provisioningConfig is private
				savedProvisioningConfig = { ...provisioningService.provisioningConfig };
			});

			afterEach(() => {
				const provisioningService = Container.get(ProvisioningService);
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig = { ...savedProvisioningConfig };
			});

			it('should provision instance role for a new user', async () => {
				const provisioningService = Container.get(ProvisioningService);
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProvisionInstanceRole = true;
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProvisionProjectRoles = false;
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesInstanceRoleClaimName = 'n8n_instance_role';

				const state = oidcService.generateState();
				const nonce = oidcService.generateNonce();
				const callbackUrl = new URL(
					`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
				);

				const mockTokens = createProvisioningMockTokens('new-instance-role-sub', {
					n8n_instance_role: 'global:admin',
				});
				authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
				fetchUserInfoMock.mockResolvedValueOnce({
					email_verified: true,
					email: 'new-instance-role-user@example.com',
				});

				const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
				expect(user).toBeDefined();
				expect(user.email).toEqual('new-instance-role-user@example.com');

				const userFromDB = await userRepository.findOne({
					where: { id: user.id },
					relations: ['role'],
				});
				expect(userFromDB).toBeDefined();
				expect(userFromDB!.role.slug).toEqual('global:admin');
			});

			it('should provision project roles for a new user', async () => {
				const project = await createTeamProject('oidc-provisioning-test-project');

				const provisioningService = Container.get(ProvisioningService);
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProvisionInstanceRole = false;
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProvisionProjectRoles = true;
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProjectsRolesClaimName = 'n8n_projects';

				const state = oidcService.generateState();
				const nonce = oidcService.generateNonce();
				const callbackUrl = new URL(
					`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
				);

				const mockTokens = createProvisioningMockTokens('new-project-role-sub', {
					n8n_projects: [`${project.id}:editor`],
				});
				authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
				fetchUserInfoMock.mockResolvedValueOnce({
					email_verified: true,
					email: 'new-project-role-user@example.com',
				});

				const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
				expect(user).toBeDefined();
				expect(user.email).toEqual('new-project-role-user@example.com');

				const projectRole = await getProjectRoleForUser(project.id, user.id);
				expect(projectRole).toEqual('project:editor');
			});

			it('should provision both instance role and project roles for a new user', async () => {
				const project = await createTeamProject('oidc-provisioning-both-test-project');

				const provisioningService = Container.get(ProvisioningService);
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProvisionInstanceRole = true;
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProvisionProjectRoles = true;
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesInstanceRoleClaimName = 'n8n_instance_role';
				// @ts-expect-error - provisioningConfig is private
				provisioningService.provisioningConfig.scopesProjectsRolesClaimName = 'n8n_projects';

				const state = oidcService.generateState();
				const nonce = oidcService.generateNonce();
				const callbackUrl = new URL(
					`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
				);

				const mockTokens = createProvisioningMockTokens('new-both-provisioning-sub', {
					n8n_instance_role: 'global:admin',
					n8n_projects: [`${project.id}:editor`],
				});
				authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
				fetchUserInfoMock.mockResolvedValueOnce({
					email_verified: true,
					email: 'new-both-provisioning-user@example.com',
				});

				const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
				expect(user).toBeDefined();
				expect(user.email).toEqual('new-both-provisioning-user@example.com');

				const userFromDB = await userRepository.findOne({
					where: { id: user.id },
					relations: ['role'],
				});
				expect(userFromDB).toBeDefined();
				expect(userFromDB!.role.slug).toEqual('global:admin');

				const projectRole = await getProjectRoleForUser(project.id, user.id);
				expect(projectRole).toEqual('project:editor');
			});

			describe('expression-based role mapping', () => {
				let roleMappingRuleRepository: RoleMappingRuleRepository;
				let roleRepository: RoleRepository;

				beforeAll(() => {
					roleMappingRuleRepository = Container.get(RoleMappingRuleRepository);
					roleRepository = Container.get(RoleRepository);
				});

				afterEach(async () => {
					await roleMappingRuleRepository.delete({});
				});

				it('should provision instance role via expression mapping', async () => {
					const provisioningService = Container.get(ProvisioningService);
					// @ts-expect-error - provisioningConfig is private
					provisioningService.provisioningConfig.scopesUseExpressionMapping = true;

					const adminRole = await roleRepository.findOneOrFail({
						where: { slug: 'global:admin' },
					});
					await roleMappingRuleRepository.save(
						roleMappingRuleRepository.create({
							expression: "{{ $claims.n8n_role === 'admin' }}",
							role: adminRole,
							type: 'instance',
							order: 0,
						}),
					);

					const state = oidcService.generateState();
					const nonce = oidcService.generateNonce();
					const callbackUrl = new URL(
						`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
					);

					const mockTokens = createProvisioningMockTokens('oidc-expr-instance-role-sub', {
						n8n_role: 'admin',
					});
					authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
					fetchUserInfoMock.mockResolvedValueOnce({
						email_verified: true,
						email: 'oidc-expr-instance-role@example.com',
					});

					const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
					expect(user).toBeDefined();

					const userFromDB = await userRepository.findOne({
						where: { id: user.id },
						relations: ['role'],
					});
					expect(userFromDB!.role.slug).toEqual('global:admin');
				});

				it('should provision a custom global role with its scopes via expression mapping', async () => {
					const provisioningService = Container.get(ProvisioningService);
					// @ts-expect-error - provisioningConfig is private
					provisioningService.provisioningConfig.scopesUseExpressionMapping = true;

					// Custom roles are license-gated when assigned via provisioning.
					const licenseState = Container.get(LicenseState);
					licenseState.setLicenseProvider(Container.get(License));
					const customRolesLicensed = jest
						.spyOn(licenseState, 'isCustomRolesLicensed')
						.mockReturnValue(true);

					// Custom global role with a scope, targeted by an instance rule.
					const auditScope = await createScope();
					const customRole = await createCustomRoleWithScopes([auditScope], {
						slug: 'global:test-auditor',
						displayName: 'Auditor',
						roleType: 'global',
					});
					await roleMappingRuleRepository.save(
						roleMappingRuleRepository.create({
							expression: "{{ $claims.department === 'audit' }}",
							role: customRole,
							type: 'instance',
							order: 0,
						}),
					);

					const state = oidcService.generateState();
					const nonce = oidcService.generateNonce();
					const callbackUrl = new URL(
						`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
					);

					const mockTokens = createProvisioningMockTokens('oidc-expr-custom-role-sub', {
						department: 'audit',
					});
					authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
					fetchUserInfoMock.mockResolvedValueOnce({
						email_verified: true,
						email: 'oidc-expr-custom-role@example.com',
					});

					const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
					expect(user).toBeDefined();

					const userFromDB = await userRepository.findOne({
						where: { id: user.id },
						relations: ['role', 'role.scopes'],
					});
					expect(userFromDB!.role.slug).toEqual('global:test-auditor');
					expect(userFromDB!.role.scopes.map((s) => s.slug)).toContain(auditScope.slug);

					customRolesLicensed.mockRestore();
				});

				it('should provision project role via expression mapping', async () => {
					const project = await createTeamProject('oidc-expr-project-role-test');

					const provisioningService = Container.get(ProvisioningService);
					// @ts-expect-error - provisioningConfig is private
					provisioningService.provisioningConfig.scopesUseExpressionMapping = true;

					const editorRole = await roleRepository.findOneOrFail({
						where: { slug: 'project:editor' },
					});
					const rule = roleMappingRuleRepository.create({
						expression: "{{ $claims.department === 'engineering' }}",
						role: editorRole,
						type: 'project',
						order: 0,
					});
					rule.projects = [project];
					await roleMappingRuleRepository.save(rule);

					const state = oidcService.generateState();
					const nonce = oidcService.generateNonce();
					const callbackUrl = new URL(
						`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
					);

					const mockTokens = createProvisioningMockTokens('oidc-expr-project-role-sub', {
						department: 'engineering',
					});
					authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);
					fetchUserInfoMock.mockResolvedValueOnce({
						email_verified: true,
						email: 'oidc-expr-project-role@example.com',
					});

					const user = await oidcService.loginUser(callbackUrl, state.signed, nonce.signed);
					expect(user).toBeDefined();

					const projectRole = await getProjectRoleForUser(project.id, user.id);
					expect(projectRole).toEqual('project:editor');
				});
			});
		});

		it('should throw `BadRequestError` with "Invalid token" when fetchUserInfo fails', async () => {
			const state = oidcService.generateState();
			const nonce = oidcService.generateNonce();
			const callbackUrl = new URL(
				`http://localhost:5678/rest/sso/oidc/callback?code=valid-code&state=${state.plaintext}`,
			);

			const mockTokens: mocked_oidc_client.TokenEndpointResponse &
				mocked_oidc_client.TokenEndpointResponseHelpers = {
				access_token: 'mock-access-token-userinfo-error',
				id_token: 'mock-id-token-userinfo-error',
				token_type: 'bearer',
				claims: () => {
					return {
						sub: 'mock-subject-userinfo-error',
						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;

			// Reset and setup mocks in the right order
			authorizationCodeGrantMock.mockReset();
			fetchUserInfoMock.mockReset();

			authorizationCodeGrantMock.mockResolvedValueOnce(mockTokens);

			// Mock fetchUserInfo to throw an error
			fetchUserInfoMock.mockRejectedValueOnce(new Error('Failed to fetch user info'));

			const error = await oidcService
				.loginUser(callbackUrl, state.signed, nonce.signed)
				.catch((e) => e);

			expect(error).toBeInstanceOf(BadRequestError);
			expect(error.message).toBe('Invalid token');
			expect(fetchUserInfoMock).toHaveBeenCalledWith(
				expect.any(Object), // configuration
				'mock-access-token-userinfo-error',
				'mock-subject-userinfo-error',
			);
		});
	});

	describe('State and nonce', () => {
		it('should generate and verify a valid state', () => {
			const state = oidcService.generateState();
			const decoded = oidcService.verifyState(state.signed);
			expect(decoded).toEqual({ state: state.plaintext, testMode: undefined });
		});

		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);
		});
	});
});
