import {
	InvalidGrantError,
	InvalidTargetError,
} from '@modelcontextprotocol/sdk/server/auth/errors.js';
import { Logger, type LicenseState, type ModuleRegistry } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { Response } from 'express';
import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';

import { AuthService } from '@/auth/auth.service';
import type { EventService } from '@/events/event.service';
import { McpProtectedResource } from '@/modules/mcp/mcp-protected-resource';
import type { McpConfig } from '@/modules/mcp/mcp.config';
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
import type { UrlService } from '@/services/url.service';
import { UserManagementMailer } from '@/user-management/email';

import type { AuthorizationCode } from '../database/entities/oauth-authorization-code.entity';
import type { OAuthClient } from '../database/entities/oauth-client.entity';
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
import { OAuthAuthorizationCodeService } from '../oauth-authorization-code.service';
import { OAuthConsentService } from '../oauth-consent.service';
import { OAuthServerService } from '../oauth-server.service';
import { OAuthSessionService } from '../oauth-session.service';
import { OAuthTokenService } from '../oauth-token.service';

const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
const TEST_RESOURCE_URL = 'https://n8n.example.com/mcp-server/http';

let logger: Mocked<Logger>;
let oauthSessionService: Mocked<OAuthSessionService>;
let oauthClientRepository: Mocked<OAuthClientRepository>;
let tokenService: Mocked<OAuthTokenService>;
let authorizationCodeService: Mocked<OAuthAuthorizationCodeService>;
let service: OAuthServerService;
let userConsentRepository: Mocked<UserConsentRepository>;
let mailer: Mocked<UserManagementMailer>;
let getAllowedRedirectUris: Mock<() => Promise<string[]>>;
let isResourceAvailable: Mock<() => Promise<boolean>>;
let eventService: Mocked<EventService>;
let authService: Mocked<AuthService>;
let oauthConsentService: Mocked<OAuthConsentService>;

// Shared, immutable across tests: the base URLs gate the first-party client_id guard.
const urlServiceMock = mock<UrlService>();

describe('OAuthServerService', () => {
	beforeAll(() => {
		logger = mockInstance(Logger);
		oauthSessionService = mockInstance(OAuthSessionService);
		oauthClientRepository = mockInstance(OAuthClientRepository);
		tokenService = mockInstance(OAuthTokenService);
		authorizationCodeService = mockInstance(OAuthAuthorizationCodeService);
		userConsentRepository = mockInstance(UserConsentRepository);
		mailer = mockInstance(UserManagementMailer);
		urlServiceMock.getWebhookBaseUrl.mockReturnValue('https://n8n.example.com/');
		urlServiceMock.getTestWebhookBaseUrl.mockReturnValue('https://n8n.example.com/');
		getAllowedRedirectUris = vi.fn<(...args: []) => Promise<string[]>>().mockResolvedValue([]);
		isResourceAvailable = vi.fn<(...args: []) => Promise<boolean>>().mockResolvedValue(true);
		eventService = mock<EventService>();
		authService = mockInstance(AuthService);
		oauthConsentService = mockInstance(OAuthConsentService);

		const resourceRegistry = new ProtectedResourceRegistry(mock<Logger>());
		resourceRegistry.register({
			id: 'instance-mcp',
			getResourceUrl: () => TEST_RESOURCE_URL,
			getAudiences: () => [TEST_RESOURCE_URL, 'mcp-server-api'],
			scopes: SUPPORTED_SCOPES,
			isDefault: true,
			getAllowedRedirectUris,
			isAvailable: isResourceAvailable,
			authorize: async () => true,
		});

		service = new OAuthServerService(
			logger,
			mockInstance(GlobalConfig),
			oauthSessionService,
			oauthClientRepository,
			tokenService,
			authorizationCodeService,
			userConsentRepository,
			resourceRegistry,
			mailer,
			urlServiceMock,
			eventService,
			authService,
			oauthConsentService,
		);
	});

	beforeEach(() => {
		vi.clearAllMocks();
		getAllowedRedirectUris.mockResolvedValue([]);
		isResourceAvailable.mockResolvedValue(true);
	});

	describe('clientsStore', () => {
		describe('getClient', () => {
			it('should return client information when client exists', async () => {
				const client = {
					id: 'client-123',
					name: 'Test Client',
					redirectUris: ['https://example.com/callback'],
					grantTypes: ['authorization_code', 'refresh_token'],
					tokenEndpointAuthMethod: 'none',
					clientSecret: null,
					clientSecretExpiresAt: null,
				} as OAuthClient;

				oauthClientRepository.findOneBy.mockResolvedValue(client);

				const result = await service.clientsStore.getClient('client-123');

				expect(result).toEqual({
					client_id: 'client-123',
					client_name: 'Test Client',
					redirect_uris: ['https://example.com/callback'],
					grant_types: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_method: 'none',
					response_types: ['code'],
					...(SUPPORTED_SCOPES.length > 0 && { scope: SUPPORTED_SCOPES.join(' ') }),
					logo_uri: undefined,
					tos_uri: undefined,
				});
			});

			it('should include client secret when present', async () => {
				const client = {
					id: 'client-123',
					name: 'Test Client',
					redirectUris: ['https://example.com/callback'],
					grantTypes: ['authorization_code'],
					tokenEndpointAuthMethod: 'client_secret_post',
					clientSecret: 'secret-value',
					clientSecretExpiresAt: 1234567890,
				} as OAuthClient;

				oauthClientRepository.findOneBy.mockResolvedValue(client);

				const result = await service.clientsStore.getClient('client-123');

				expect(result).toMatchObject({
					client_secret: 'secret-value',
					client_secret_expires_at: 1234567890,
				});
			});

			it('should return undefined when client not found', async () => {
				oauthClientRepository.findOneBy.mockResolvedValue(null);

				const result = await service.clientsStore.getClient('nonexistent');

				expect(result).toBeUndefined();
			});
		});

		describe('getClient — virtual first-party client', () => {
			const FIRST_PARTY_URL = 'https://n8n.example.com/form/abc';
			const CHAT_FIRST_PARTY_URL =
				'https://n8n.example.com/webhook/f0a1b2c3-d4e5-4678-9abc-def012345678/chat';
			const NON_FIRST_PARTY_URL = 'https://n8n.example.com/mcp-server/http';
			let firstPartyService: OAuthServerService;

			beforeAll(() => {
				const registry = new ProtectedResourceRegistry(mock<Logger>());
				registry.register({
					id: 'form-abc',
					isFirstParty: true,
					displayName: 'My Form',
					getResourceUrl: () => FIRST_PARTY_URL,
					getAudiences: () => [FIRST_PARTY_URL],
					getAllowedRedirectUris: async () => [FIRST_PARTY_URL],
					scopes: [],
					authorize: async () => true,
				});
				// A chat trigger's resource: served under the generic webhook base URL rather
				// than a dedicated endpoint, so it covers the client-id guard's prefix check.
				registry.register({
					id: 'chat-abc',
					isFirstParty: true,
					displayName: 'My Chat',
					getResourceUrl: () => CHAT_FIRST_PARTY_URL,
					getAudiences: () => [CHAT_FIRST_PARTY_URL],
					getAllowedRedirectUris: async () => [CHAT_FIRST_PARTY_URL],
					scopes: [],
					authorize: async () => true,
				});
				// A resource that exists but is not first-party (mirror of an MCP resource).
				registry.register({
					id: 'mcp-x',
					getResourceUrl: () => NON_FIRST_PARTY_URL,
					getAudiences: () => [NON_FIRST_PARTY_URL],
					scopes: [],
					authorize: async () => true,
				});
				firstPartyService = new OAuthServerService(
					logger,
					mockInstance(GlobalConfig),
					oauthSessionService,
					oauthClientRepository,
					tokenService,
					authorizationCodeService,
					userConsentRepository,
					registry,
					mailer,
					urlServiceMock,
					mock<EventService>(),
					mock<AuthService>(),
					mock<OAuthConsentService>(),
				);
			});

			it('lazily upserts and returns a virtual client on a DB miss for a first-party resource', async () => {
				oauthClientRepository.findOneBy.mockResolvedValue(null);

				const result = await firstPartyService.clientsStore.getClient(FIRST_PARTY_URL);

				expect(oauthClientRepository.upsert).toHaveBeenCalledWith(
					{
						id: FIRST_PARTY_URL,
						name: 'My Form',
						redirectUris: [FIRST_PARTY_URL],
						// `refresh_token` too: the AS issues one on every code exchange, and a
						// long-lived trigger page rotates it instead of redirecting again.
						grantTypes: ['authorization_code', 'refresh_token'],
						tokenEndpointAuthMethod: 'none',
						clientSecret: null,
						clientSecretExpiresAt: null,
						isFirstParty: true,
					},
					['id'],
				);
				expect(result).toEqual({
					client_id: FIRST_PARTY_URL,
					client_name: 'My Form',
					redirect_uris: [FIRST_PARTY_URL],
					grant_types: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_method: 'none',
					response_types: ['code'],
					logo_uri: undefined,
					tos_uri: undefined,
				});
			});

			it('lazily upserts and returns a virtual client for a chat trigger resource URL', async () => {
				oauthClientRepository.findOneBy.mockResolvedValue(null);

				const result = await firstPartyService.clientsStore.getClient(CHAT_FIRST_PARTY_URL);

				expect(oauthClientRepository.upsert).toHaveBeenCalledWith(
					{
						id: CHAT_FIRST_PARTY_URL,
						name: 'My Chat',
						redirectUris: [CHAT_FIRST_PARTY_URL],
						grantTypes: ['authorization_code', 'refresh_token'],
						tokenEndpointAuthMethod: 'none',
						clientSecret: null,
						clientSecretExpiresAt: null,
						isFirstParty: true,
					},
					['id'],
				);
				expect(result).toMatchObject({
					client_id: CHAT_FIRST_PARTY_URL,
					client_name: 'My Chat',
					redirect_uris: [CHAT_FIRST_PARTY_URL],
				});
			});

			it('returns undefined and does not upsert when the resolved resource is not first-party', async () => {
				oauthClientRepository.findOneBy.mockResolvedValue(null);

				const result = await firstPartyService.clientsStore.getClient(NON_FIRST_PARTY_URL);

				expect(result).toBeUndefined();
				expect(oauthClientRepository.upsert).not.toHaveBeenCalled();
			});

			it('returns undefined and does not upsert when no resource resolves', async () => {
				oauthClientRepository.findOneBy.mockResolvedValue(null);

				const result = await firstPartyService.clientsStore.getClient(
					'https://n8n.example.com/form/unknown',
				);

				expect(result).toBeUndefined();
				expect(oauthClientRepository.upsert).not.toHaveBeenCalled();
			});

			it('short-circuits without consulting the resolver registry for a non-webhook client_id', async () => {
				oauthClientRepository.findOneBy.mockResolvedValue(null);
				const registry = new ProtectedResourceRegistry(mock<Logger>());
				const getByResourceUrl = vi.spyOn(registry, 'getByResourceUrl');
				const svc = new OAuthServerService(
					logger,
					mockInstance(GlobalConfig),
					oauthSessionService,
					oauthClientRepository,
					tokenService,
					authorizationCodeService,
					userConsentRepository,
					registry,
					mailer,
					urlServiceMock,
					mock<EventService>(),
					mock<AuthService>(),
					mock<OAuthConsentService>(),
				);

				const result = await svc.clientsStore.getClient('https://evil.example.com/form/abc');

				expect(result).toBeUndefined();
				expect(getByResourceUrl).not.toHaveBeenCalled();
				expect(oauthClientRepository.upsert).not.toHaveBeenCalled();
			});
		});

		describe('registered-client cap excludes first-party clients', () => {
			it('counts only non-first-party clients for the limit check', async () => {
				oauthClientRepository.countBy.mockResolvedValue(0);

				await service.isClientLimitReached();

				expect(oauthClientRepository.countBy).toHaveBeenCalledWith({ isFirstParty: false });
			});

			it('counts only non-first-party clients for the instance stats', async () => {
				oauthClientRepository.countBy.mockResolvedValue(0);

				await service.getInstanceClientStats();

				expect(oauthClientRepository.countBy).toHaveBeenCalledWith({ isFirstParty: false });
			});
		});

		describe('registerClient', () => {
			it('should save client with all required fields', async () => {
				const clientInfo = {
					client_id: 'new-client-123',
					client_name: 'New Client',
					redirect_uris: ['https://example.com/callback'],
					grant_types: ['authorization_code', 'refresh_token'],
					token_endpoint_auth_method: 'none',
					response_types: ['code'],
					scope: 'read write',
					logo_uri: undefined,
					tos_uri: undefined,
				};

				oauthClientRepository.insert.mockResolvedValue({} as any);

				const result = await service.clientsStore.registerClient!(clientInfo);

				expect(oauthClientRepository.insert).toHaveBeenCalledWith({
					id: 'new-client-123',
					name: 'New Client',
					redirectUris: ['https://example.com/callback'],
					grantTypes: ['authorization_code', 'refresh_token'],
					clientSecret: null,
					clientSecretExpiresAt: null,
					tokenEndpointAuthMethod: 'none',
					isFirstParty: false,
				});
				expect(result).toEqual(clientInfo);
			});

			it('should save client with client secret', async () => {
				const clientInfo = {
					client_id: 'new-client-123',
					client_name: 'New Client',
					redirect_uris: ['https://example.com/callback'],
					grant_types: ['authorization_code'],
					token_endpoint_auth_method: 'client_secret_post',
					client_secret: 'secret-123',
					client_secret_expires_at: 1234567890,
					response_types: ['code'],
					scope: 'read',
					logo_uri: undefined,
					tos_uri: undefined,
				};

				oauthClientRepository.insert.mockResolvedValue({} as any);

				await service.clientsStore.registerClient!(clientInfo);

				expect(oauthClientRepository.insert).toHaveBeenCalledWith({
					id: 'new-client-123',
					name: 'New Client',
					redirectUris: ['https://example.com/callback'],
					grantTypes: ['authorization_code'],
					clientSecret: 'secret-123',
					clientSecretExpiresAt: 1234567890,
					tokenEndpointAuthMethod: 'client_secret_post',
					isFirstParty: false,
				});
			});

			it('should handle save errors gracefully', async () => {
				const clientInfo = {
					client_id: 'new-client-123',
					client_name: 'New Client',
					redirect_uris: ['https://example.com/callback'],
					grant_types: ['authorization_code'],
					token_endpoint_auth_method: 'none',
					response_types: ['code'],
					scope: 'read',
					logo_uri: undefined,
					tos_uri: undefined,
				};

				const error = new Error('Database error');
				oauthClientRepository.insert.mockRejectedValue(error);

				await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
					'Database error',
				);
			});

			describe('validation', () => {
				const baseClientInfo = {
					client_id: 'new-client-123',
					client_name: 'New Client',
					redirect_uris: ['https://example.com/callback'],
					grant_types: ['authorization_code'],
					token_endpoint_auth_method: 'none',
					response_types: ['code'],
					scope: 'read',
					logo_uri: undefined,
					tos_uri: undefined,
				};

				beforeEach(() => {
					oauthClientRepository.insert.mockResolvedValue({} as any);
				});

				it('accepts a client_name at the maximum length', async () => {
					const clientInfo = { ...baseClientInfo, client_name: 'a'.repeat(255) };

					await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual(
						clientInfo,
					);
				});

				it('accepts a client_name at the maximum length containing supplementary-plane characters', async () => {
					// Each 🚀 is one code point but two UTF-16 code units — 255 of them
					// must still be accepted, not treated as 510 characters.
					const clientInfo = { ...baseClientInfo, client_name: '🚀'.repeat(255) };

					await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual(
						clientInfo,
					);
				});

				it('rejects a client_name over the maximum length', async () => {
					const clientInfo = { ...baseClientInfo, client_name: 'a'.repeat(256) };

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'client_name exceeds maximum length',
					);
				});

				it('rejects grant_types containing an unsupported value', async () => {
					const clientInfo = { ...baseClientInfo, grant_types: ['client_credentials'] };

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'grant_types contains an unsupported value',
					);
				});

				it('rejects grant_types containing an oversized entry', async () => {
					const clientInfo = { ...baseClientInfo, grant_types: ['a'.repeat(10_000)] };

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'grant_types contains an unsupported value',
					);
				});

				it('rejects grant_types exceeding the maximum count', async () => {
					const clientInfo = {
						...baseClientInfo,
						grant_types: ['authorization_code', 'refresh_token', 'authorization_code'],
					};

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'grant_types exceeds maximum count',
					);
				});

				it('rejects redirect_uris exceeding the maximum count', async () => {
					const clientInfo = {
						...baseClientInfo,
						redirect_uris: Array.from({ length: 11 }, (_, i) => `https://example.com/callback${i}`),
					};

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'redirect_uris exceeds maximum count',
					);
				});

				it('rejects a redirect_uri over the maximum length', async () => {
					const clientInfo = {
						...baseClientInfo,
						redirect_uris: [`https://example.com/${'a'.repeat(2048)}`],
					};

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'redirect_uri exceeds maximum length',
					);
				});

				it('accepts a supported token_endpoint_auth_method', async () => {
					const clientInfo = {
						...baseClientInfo,
						token_endpoint_auth_method: 'client_secret_post',
					};

					await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual(
						clientInfo,
					);
				});

				it('accepts a missing token_endpoint_auth_method', async () => {
					const clientInfo = { ...baseClientInfo, token_endpoint_auth_method: undefined };

					await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual(
						clientInfo,
					);
				});

				it('rejects an oversized token_endpoint_auth_method', async () => {
					const clientInfo = { ...baseClientInfo, token_endpoint_auth_method: 'a'.repeat(10_000) };

					await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow(
						'token_endpoint_auth_method contains an unsupported value',
					);
				});
			});
		});
	});

	describe('authorize', () => {
		it('should create session and redirect to consent page', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read write',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				state: 'state-xyz',
				resource: new URL('https://n8n.example.com/mcp-server/http'),
			};

			const res = mock<Response>();

			getAllowedRedirectUris.mockResolvedValue(['https://example.com/callback']);

			await service.authorize(client, params, res);

			expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
				clientId: 'client-123',
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				state: 'state-xyz',
				resource: 'https://n8n.example.com/mcp-server/http',
			});
			expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
		});

		it('should reject with invalid_target when the named resource is unavailable', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				resource: new URL(TEST_RESOURCE_URL),
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			isResourceAvailable.mockResolvedValue(false);

			await service.authorize(client, params, res);

			expect(res.status).toHaveBeenCalledWith(400);
			expect(res.json).toHaveBeenCalledWith({
				error: 'invalid_target',
				error_description: 'Resource is not available for authorization',
			});
			expect(oauthSessionService.createSession).not.toHaveBeenCalled();
			expect(res.redirect).not.toHaveBeenCalled();
		});

		it('should reject with invalid_target when no resource is named and the default resource is unavailable', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			isResourceAvailable.mockResolvedValue(false);

			await service.authorize(client, params, res);

			expect(res.status).toHaveBeenCalledWith(400);
			expect(res.json).toHaveBeenCalledWith({
				error: 'invalid_target',
				error_description: 'Resource is not available for authorization',
			});
			expect(oauthSessionService.createSession).not.toHaveBeenCalled();
		});

		describe('reusing a prior consent (auto-approval)', () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read write',
				logo_uri: undefined,
				tos_uri: undefined,
			};
			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				state: 'state-xyz',
				resource: new URL('https://n8n.example.com/mcp-server/http'),
			};

			beforeEach(() => {
				getAllowedRedirectUris.mockResolvedValue(['https://example.com/callback']);
				authService.getCookieToken.mockReturnValue('valid-cookie');
				authService.authenticateUserByCookie.mockResolvedValue(mock<User>({ id: 'user-1' }));
			});

			it('mints a code and skips the consent screen when a grant is reused', async () => {
				const res = mock<Response>();
				oauthConsentService.tryReuseConsent.mockResolvedValue({
					redirectUrl: 'https://example.com/callback?code=reused&state=state-xyz',
				});

				await service.authorize(client, params, res);

				expect(oauthConsentService.tryReuseConsent).toHaveBeenCalled();
				expect(res.redirect).toHaveBeenCalledWith(
					'https://example.com/callback?code=reused&state=state-xyz',
				);
				expect(oauthSessionService.createSession).not.toHaveBeenCalled();
			});

			it('falls back to the consent screen when there is no reusable grant', async () => {
				const res = mock<Response>();
				oauthConsentService.tryReuseConsent.mockResolvedValue(null);

				await service.authorize(client, params, res);

				expect(oauthSessionService.createSession).toHaveBeenCalled();
				expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
			});
		});

		it('should handle null state parameter', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
			};

			const res = mock<Response>();

			getAllowedRedirectUris.mockResolvedValue(['https://example.com/callback']);

			await service.authorize(client, params, res);

			expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
				clientId: 'client-123',
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				state: null,
				resource: undefined,
			});
		});

		it('should reject invalid redirect URI', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://attacker.com/callback',
				codeChallenge: 'challenge-123',
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			getAllowedRedirectUris.mockResolvedValue(['https://example.com/callback']);

			await service.authorize(client, params, res);

			expect(logger.warn).toHaveBeenCalledWith(
				'MCP OAuth authorization rejected: requested redirect URI is not in the configured allowlist',
				{
					clientId: 'client-123',
					attemptedUri: 'https://attacker.com/callback',
				},
			);
			expect(res.status).toHaveBeenCalledWith(400);
			expect(res.json).toHaveBeenCalledWith({
				error: 'invalid_request',
				error_description: 'Redirect URI not in allowed list',
			});
			expect(oauthSessionService.createSession).not.toHaveBeenCalled();
		});

		it('should allow a loopback redirect URI on a different port than the allowlist entry', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['http://localhost:3118/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'http://localhost:52680/callback',
				codeChallenge: 'challenge-123',
				state: 'state-xyz',
			};

			const res = mock<Response>();

			getAllowedRedirectUris.mockResolvedValue(['http://localhost:3118/callback']);

			await service.authorize(client, params, res);

			expect(oauthSessionService.createSession).toHaveBeenCalledWith(
				res,
				expect.objectContaining({ redirectUri: 'http://localhost:52680/callback' }),
			);
			expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
		});

		it('should reject a loopback redirect URI whose path differs from the allowlist entry', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['http://localhost:3118/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'http://localhost:52680/evil',
				codeChallenge: 'challenge-123',
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			getAllowedRedirectUris.mockResolvedValue(['http://localhost:3118/callback']);

			await service.authorize(client, params, res);

			expect(res.status).toHaveBeenCalledWith(400);
			expect(oauthSessionService.createSession).not.toHaveBeenCalled();
		});

		it('should still require an exact match for non-loopback redirect URIs on a different port', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com:8443/callback',
				codeChallenge: 'challenge-123',
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			getAllowedRedirectUris.mockResolvedValue(['https://example.com/callback']);

			await service.authorize(client, params, res);

			expect(res.status).toHaveBeenCalledWith(400);
			expect(oauthSessionService.createSession).not.toHaveBeenCalled();
		});

		it('should allow any redirect URI when whitelist is empty', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://any-domain.com/callback',
				codeChallenge: 'challenge-123',
				state: 'state-xyz',
			};

			const res = mock<Response>();

			getAllowedRedirectUris.mockResolvedValue([]);

			await service.authorize(client, params, res);

			expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
				clientId: 'client-123',
				redirectUri: 'https://any-domain.com/callback',
				codeChallenge: 'challenge-123',
				state: 'state-xyz',
				resource: undefined,
			});
			expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
			expect(logger.warn).not.toHaveBeenCalled();
		});

		it('should reject invalid resource indicators', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				resource: new URL('https://attacker.example.com/mcp-server/http'),
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			await service.authorize(client, params, res);

			expect(oauthSessionService.createSession).not.toHaveBeenCalled();
			expect(oauthSessionService.clearSession).toHaveBeenCalledWith(res);
			expect(res.status).toHaveBeenCalledWith(400);
			expect(res.json).toHaveBeenCalledWith({
				error: 'invalid_target',
				error_description: 'Invalid resource indicator',
			});
		});

		it('should accept a trailing-slash resource and store the normalized URL', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				// trailing slash — semantically equivalent to the canonical URL
				resource: new URL('https://n8n.example.com/mcp-server/http/'),
			};

			const res = mock<Response>();

			await service.authorize(client, params, res);

			// Should succeed and store the slash-stripped canonical form
			expect(oauthSessionService.createSession).toHaveBeenCalledWith(res, {
				clientId: 'client-123',
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
				state: null,
				resource: 'https://n8n.example.com/mcp-server/http',
			});
			expect(res.redirect).toHaveBeenCalledWith('/oauth/consent');
		});

		it('should handle errors and clear session', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const params = {
				redirectUri: 'https://example.com/callback',
				codeChallenge: 'challenge-123',
			};

			const res = mock<Response>();
			res.status.mockReturnThis();
			res.json.mockReturnThis();

			getAllowedRedirectUris.mockResolvedValue(['https://example.com/callback']);

			const error = new Error('Session creation failed');
			oauthSessionService.createSession.mockImplementation(() => {
				throw error;
			});

			await service.authorize(client, params, res);

			expect(logger.error).toHaveBeenCalledWith('Error in authorize method', {
				error,
				clientId: 'client-123',
			});
			expect(oauthSessionService.clearSession).toHaveBeenCalledWith(res);
			expect(res.status).toHaveBeenCalledWith(500);
			expect(res.json).toHaveBeenCalledWith({
				error: 'server_error',
				error_description: 'Internal server error',
			});
		});
	});

	describe('challengeForAuthorizationCode', () => {
		it('should return code challenge from authorization code service', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			authorizationCodeService.getCodeChallenge.mockResolvedValue('challenge-123');

			const result = await service.challengeForAuthorizationCode(client, 'auth-code-123');

			expect(authorizationCodeService.getCodeChallenge).toHaveBeenCalledWith(
				'auth-code-123',
				'client-123',
			);
			expect(result).toBe('challenge-123');
		});
	});

	describe('exchangeAuthorizationCode', () => {
		it('should validate code and return token pair', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const authRecord = {
				userId: 'user-456',
				clientId: 'client-123',
				resource: 'https://n8n.example.com/mcp-server/http',
				scope: ['workflow:read'],
			} as AuthorizationCode;

			authorizationCodeService.findAuthorizationCode.mockResolvedValue(authRecord);
			tokenService.generateTokenPair.mockReturnValue({
				accessToken: 'access-token-123',
				refreshToken: 'refresh-token-456',
				audience: TEST_RESOURCE_URL,
			});
			tokenService.saveTokenPair.mockResolvedValue();
			tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);

			const result = await service.exchangeAuthorizationCode(
				client,
				'auth-code-123',
				'verifier-123',
				'https://example.com/callback',
			);

			expect(authorizationCodeService.findAuthorizationCode).toHaveBeenCalledWith(
				'auth-code-123',
				'client-123',
				'https://example.com/callback',
			);
			expect(authorizationCodeService.markAuthorizationCodeAsUsed).toHaveBeenCalledWith(
				'auth-code-123',
			);
			expect(tokenService.generateTokenPair).toHaveBeenCalledWith(
				'user-456',
				'client-123',
				'https://n8n.example.com/mcp-server/http',
				['workflow:read'],
			);
			expect(tokenService.saveTokenPair).toHaveBeenCalledWith(
				'access-token-123',
				'refresh-token-456',
				'client-123',
				'user-456',
				['workflow:read'],
				TEST_RESOURCE_URL,
			);
			expect(result).toEqual({
				access_token: 'access-token-123',
				token_type: 'Bearer',
				expires_in: 3600,
				refresh_token: 'refresh-token-456',
				scope: 'workflow:read',
			});
			expect(eventService.emit).toHaveBeenCalledWith('mcp-oauth-completed', {
				userId: 'user-456',
				clientId: 'client-123',
				clientName: 'Test Client',
			});
		});

		it('should not emit `mcp-oauth-completed` when the grant targets a non-MCP resource', async () => {
			// The authorization server is shared by all protected resources; a
			// grant for e.g. an n8n Form must not be reported as MCP usage.
			const formResourceUrl = 'https://n8n.example.com/form/abc';
			const registry = new ProtectedResourceRegistry(mock<Logger>());
			registry.register({
				id: 'instance-mcp',
				getResourceUrl: () => TEST_RESOURCE_URL,
				getAudiences: () => [TEST_RESOURCE_URL],
				scopes: SUPPORTED_SCOPES,
				isDefault: true,
				authorize: async () => true,
			});
			registry.register({
				id: 'form-abc',
				getResourceUrl: () => formResourceUrl,
				getAudiences: () => [formResourceUrl],
				scopes: [],
				isFirstParty: true,
				authorize: async () => true,
			});
			const formService = new OAuthServerService(
				logger,
				mockInstance(GlobalConfig),
				oauthSessionService,
				oauthClientRepository,
				tokenService,
				authorizationCodeService,
				userConsentRepository,
				registry,
				mailer,
				urlServiceMock,
				eventService,
				mock<AuthService>(),
				mock<OAuthConsentService>(),
			);

			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};
			const authRecord = {
				userId: 'user-456',
				clientId: 'client-123',
				resource: formResourceUrl,
				scope: [] as string[],
			} as AuthorizationCode;

			authorizationCodeService.findAuthorizationCode.mockResolvedValue(authRecord);
			tokenService.generateTokenPair.mockReturnValue({
				accessToken: 'access-token-123',
				refreshToken: 'refresh-token-456',
				audience: formResourceUrl,
			});
			tokenService.saveTokenPair.mockResolvedValue();
			tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);

			await formService.exchangeAuthorizationCode(
				client,
				'auth-code-123',
				'verifier-123',
				'https://example.com/callback',
			);

			expect(eventService.emit).not.toHaveBeenCalled();
		});

		it('should handle authorization code exchange without redirect URI', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const authRecord = {
				userId: 'user-456',
				clientId: 'client-123',
				resource: null,
				scope: ['workflow:read'],
			} as AuthorizationCode;

			authorizationCodeService.findAuthorizationCode.mockResolvedValue(authRecord);
			tokenService.generateTokenPair.mockReturnValue({
				accessToken: 'access-token-123',
				refreshToken: 'refresh-token-456',
				audience: TEST_RESOURCE_URL,
			});

			await service.exchangeAuthorizationCode(client, 'auth-code-123', 'verifier-123');

			expect(authorizationCodeService.findAuthorizationCode).toHaveBeenCalledWith(
				'auth-code-123',
				'client-123',
				undefined,
			);
			expect(authorizationCodeService.markAuthorizationCodeAsUsed).toHaveBeenCalledWith(
				'auth-code-123',
			);
			// A code carrying no resource was approved against the default resource,
			// so that is what the token is minted for.
			expect(tokenService.generateTokenPair).toHaveBeenCalledWith(
				'user-456',
				'client-123',
				TEST_RESOURCE_URL,
				['workflow:read'],
			);
		});

		describe('resource binding', () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			// A second registered resource the code was never approved for.
			const otherResourceUrl = 'https://n8n.example.com/mcp/other';
			let boundService: OAuthServerService;

			beforeEach(() => {
				const registry = new ProtectedResourceRegistry(mock<Logger>());
				registry.register({
					id: 'instance-mcp',
					getResourceUrl: () => TEST_RESOURCE_URL,
					getAudiences: () => [TEST_RESOURCE_URL],
					scopes: SUPPORTED_SCOPES,
					isDefault: true,
					authorize: async () => true,
				});
				registry.register({
					id: 'other-resource',
					getResourceUrl: () => otherResourceUrl,
					getAudiences: () => [otherResourceUrl],
					scopes: [],
					authorize: async () => true,
				});

				boundService = new OAuthServerService(
					logger,
					mockInstance(GlobalConfig),
					oauthSessionService,
					oauthClientRepository,
					tokenService,
					authorizationCodeService,
					userConsentRepository,
					registry,
					mailer,
					urlServiceMock,
					eventService,
					mock<AuthService>(),
					mock<OAuthConsentService>(),
				);

				tokenService.generateTokenPair.mockReturnValue({
					accessToken: 'access-token-123',
					refreshToken: 'refresh-token-456',
					audience: TEST_RESOURCE_URL,
				});
				tokenService.getAccessTokenExpirySeconds.mockReturnValue(3600);
			});

			const mockCode = (resource: string | null) =>
				authorizationCodeService.findAuthorizationCode.mockResolvedValue({
					userId: 'user-456',
					clientId: 'client-123',
					resource,
					scope: [] as string[],
				} as AuthorizationCode);

			it('should reject a token request naming a resource the code was not approved for', async () => {
				mockCode(TEST_RESOURCE_URL);

				await expect(
					boundService.exchangeAuthorizationCode(
						client,
						'auth-code-123',
						'verifier-123',
						'https://example.com/callback',
						new URL(otherResourceUrl),
					),
				).rejects.toThrow(InvalidTargetError);

				expect(authorizationCodeService.markAuthorizationCodeAsUsed).not.toHaveBeenCalled();
			});

			it('should reject a resource introduced at token time when the code carries none', async () => {
				mockCode(null);

				await expect(
					boundService.exchangeAuthorizationCode(
						client,
						'auth-code-123',
						'verifier-123',
						'https://example.com/callback',
						new URL(otherResourceUrl),
					),
				).rejects.toThrow(InvalidTargetError);
			});

			it('should accept the default resource at token time when the code carries none', async () => {
				mockCode(null);

				await boundService.exchangeAuthorizationCode(
					client,
					'auth-code-123',
					'verifier-123',
					'https://example.com/callback',
					new URL(TEST_RESOURCE_URL),
				);

				expect(tokenService.generateTokenPair).toHaveBeenCalledWith(
					'user-456',
					'client-123',
					TEST_RESOURCE_URL,
					[],
				);
			});
		});

		it('should propagate InvalidGrantError when markAuthorizationCodeAsUsed detects concurrent consumption', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const authRecord = {
				userId: 'user-456',
				clientId: 'client-123',
				resource: 'https://n8n.example.com/mcp-server/http',
			} as AuthorizationCode;

			authorizationCodeService.findAuthorizationCode.mockResolvedValue(authRecord);
			authorizationCodeService.markAuthorizationCodeAsUsed.mockImplementation(async () => {
				throw new InvalidGrantError('Authorization code already used');
			});

			await expect(
				service.exchangeAuthorizationCode(
					client,
					'auth-code-123',
					'verifier-123',
					'https://example.com/callback',
				),
			).rejects.toThrow(InvalidGrantError);
		});
	});

	describe('exchangeRefreshToken', () => {
		it('should validate and rotate refresh token', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			const newTokens = {
				access_token: 'new-access-token',
				token_type: 'Bearer',
				expires_in: 3600,
				refresh_token: 'new-refresh-token',
			};

			tokenService.validateAndRotateRefreshToken.mockResolvedValue(newTokens);

			const result = await service.exchangeRefreshToken(
				client,
				'old-refresh-token',
				['read'],
				new URL('https://n8n.example.com/mcp-server/http'),
			);

			expect(tokenService.validateAndRotateRefreshToken).toHaveBeenCalledWith(
				'old-refresh-token',
				'client-123',
				'https://n8n.example.com/mcp-server/http',
			);
			expect(result).toEqual(newTokens);
		});

		it('should keep legacy behavior when resource is omitted', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.validateAndRotateRefreshToken.mockResolvedValue({
				access_token: 'new-access-token',
				token_type: 'Bearer',
				expires_in: 3600,
				refresh_token: 'new-refresh-token',
			});

			await service.exchangeRefreshToken(client, 'old-refresh-token', ['read']);

			expect(tokenService.validateAndRotateRefreshToken).toHaveBeenCalledWith(
				'old-refresh-token',
				'client-123',
				undefined,
			);
		});

		it('should reject invalid resource indicators on refresh token exchange', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			await expect(
				service.exchangeRefreshToken(
					client,
					'old-refresh-token',
					['read'],
					new URL('https://attacker.example.com/mcp-server/http'),
				),
			).rejects.toThrow(InvalidTargetError);
		});

		it('should normalize a trailing-slash resource before passing it to token rotation', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.validateAndRotateRefreshToken.mockResolvedValue({
				access_token: 'new-access-token',
				token_type: 'Bearer',
				expires_in: 3600,
				refresh_token: 'new-refresh-token',
			});

			await service.exchangeRefreshToken(
				client,
				'old-refresh-token',
				['read'],
				// trailing slash — must be stripped before reaching token service
				new URL('https://n8n.example.com/mcp-server/http/'),
			);

			expect(tokenService.validateAndRotateRefreshToken).toHaveBeenCalledWith(
				'old-refresh-token',
				'client-123',
				'https://n8n.example.com/mcp-server/http', // slash stripped
			);
		});
	});

	describe('verifyAccessToken', () => {
		it('should verify access token and return auth info', async () => {
			const authInfo = {
				token: 'access-token-123',
				userId: 'user-123',
				clientId: 'client-456',
				scopes: ['read', 'write'],
			};

			tokenService.verifyAccessToken.mockResolvedValue(authInfo);

			const result = await service.verifyAccessToken('access-token-123');

			expect(tokenService.verifyAccessToken).toHaveBeenCalledWith('access-token-123');
			expect(result).toEqual(authInfo);
		});
	});

	describe('deleteClient', () => {
		// The GC step deletes the client through a single conditional query
		// (`DELETE ... WHERE id = :clientId AND NOT EXISTS (<remaining consents>)`),
		// so both repositories are driven via query builders rather than the
		// repository `delete()` shortcut. `affected` decides whether the client
		// row was actually removed.
		const mockGarbageCollect = (affected: number) => {
			(userConsentRepository as any).metadata = { tableName: 'oauth_user_consents' };
			const execute = vi.fn().mockResolvedValue({ affected });
			(oauthClientRepository.createQueryBuilder as unknown as Mock).mockReturnValue({
				delete: () => ({ from: () => ({ where: () => ({ execute }) }) }),
			});
			return execute;
		};

		it("should revoke only the user's grant and keep the client while other consents remain", async () => {
			const client = {
				id: 'client-123',
				name: 'Test Client',
			} as OAuthClient;

			oauthClientRepository.findOne.mockResolvedValue(client);
			userConsentRepository.findOne.mockResolvedValue({
				userId: 'user-456',
				clientId: 'client-123',
				user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
			} as any);
			const execute = mockGarbageCollect(0);

			await service.deleteClient('client-123', 'user-456');

			expect(tokenService.revokeAllTokensForGrant).toHaveBeenCalledWith('client-123', 'user-456');
			expect(authorizationCodeService.deleteForGrant).toHaveBeenCalledWith(
				'client-123',
				'user-456',
			);
			expect(userConsentRepository.delete).toHaveBeenCalledWith({
				clientId: 'client-123',
				userId: 'user-456',
			});
			expect(execute).toHaveBeenCalled();
		});

		it('should garbage-collect the client when the last consent is revoked', async () => {
			const client = {
				id: 'client-123',
				name: 'Test Client',
			} as OAuthClient;

			oauthClientRepository.findOne.mockResolvedValue(client);
			userConsentRepository.findOne.mockResolvedValue({
				userId: 'user-456',
				clientId: 'client-123',
				user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
			} as any);
			const execute = mockGarbageCollect(1);

			await service.deleteClient('client-123', 'user-456');

			expect(userConsentRepository.delete).toHaveBeenCalledWith({
				clientId: 'client-123',
				userId: 'user-456',
			});
			expect(execute).toHaveBeenCalled();
		});

		it('should notify the grant owner by email when someone else revokes their client', async () => {
			const client = {
				id: 'client-123',
				name: 'Test Client',
			} as OAuthClient;

			oauthClientRepository.findOne.mockResolvedValue(client);
			userConsentRepository.findOne.mockResolvedValue({
				userId: 'user-456',
				clientId: 'client-123',
				user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
			} as any);
			userConsentRepository.countBy.mockResolvedValue(1);
			mailer.notifyMcpClientRevoked.mockResolvedValue({ emailSent: true });

			const revoker = { id: 'admin-1', email: 'admin@n8n.io' } as any;
			await service.deleteClient('client-123', 'user-456', revoker);

			expect(mailer.notifyMcpClientRevoked).toHaveBeenCalledWith({
				clientName: 'Test Client',
				owner: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
				revoker,
			});
		});

		it('should not send an email when users revoke their own client', async () => {
			const client = {
				id: 'client-123',
				name: 'Test Client',
			} as OAuthClient;

			oauthClientRepository.findOne.mockResolvedValue(client);
			userConsentRepository.findOne.mockResolvedValue({
				userId: 'user-456',
				clientId: 'client-123',
				user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
			} as any);
			userConsentRepository.countBy.mockResolvedValue(1);

			const revoker = { id: 'user-456', email: 'owner@n8n.io' } as any;
			await service.deleteClient('client-123', 'user-456', revoker);

			expect(mailer.notifyMcpClientRevoked).not.toHaveBeenCalled();
		});

		it('should throw when client does not exist', async () => {
			oauthClientRepository.findOne.mockResolvedValue(null);

			await expect(service.deleteClient('nonexistent', 'user-456')).rejects.toThrow(
				'OAuth client with ID nonexistent not found',
			);

			expect(oauthClientRepository.delete).not.toHaveBeenCalled();
		});

		it('should throw when user has no consent for the client', async () => {
			const client = {
				id: 'client-123',
				name: 'Test Client',
			} as OAuthClient;

			oauthClientRepository.findOne.mockResolvedValue(client);
			userConsentRepository.findOne.mockResolvedValue(null);

			await expect(service.deleteClient('client-123', 'other-user')).rejects.toThrow(
				'OAuth client with ID client-123 not found',
			);

			expect(oauthClientRepository.delete).not.toHaveBeenCalled();
		});
	});

	describe('revokeToken', () => {
		it('should revoke access token when type hint is access_token', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.revokeAccessToken.mockResolvedValue(true);

			await service.revokeToken(client, {
				token: 'token-123',
				token_type_hint: 'access_token',
			});

			expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
			expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled();
		});

		it('should revoke refresh token when type hint is refresh_token', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.revokeRefreshToken.mockResolvedValue(true);

			await service.revokeToken(client, {
				token: 'token-123',
				token_type_hint: 'refresh_token',
			});

			expect(tokenService.revokeAccessToken).not.toHaveBeenCalled();
			expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123');
		});

		it('should try access token first when no type hint provided', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code', 'refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.revokeAccessToken.mockResolvedValue(true);

			await service.revokeToken(client, {
				token: 'token-123',
			});

			expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
			expect(tokenService.revokeRefreshToken).not.toHaveBeenCalled();
		});

		it('should try refresh token if access token revocation fails', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code', 'refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.revokeAccessToken.mockResolvedValue(false);
			tokenService.revokeRefreshToken.mockResolvedValue(true);

			await service.revokeToken(client, {
				token: 'token-123',
			});

			expect(tokenService.revokeAccessToken).toHaveBeenCalledWith('token-123', 'client-123');
			expect(tokenService.revokeRefreshToken).toHaveBeenCalledWith('token-123', 'client-123');
		});

		it('should silently succeed when token not found', async () => {
			const client = {
				client_id: 'client-123',
				client_name: 'Test Client',
				redirect_uris: ['https://example.com/callback'],
				grant_types: ['authorization_code', 'refresh_token'],
				token_endpoint_auth_method: 'none',
				response_types: ['code'],
				scope: 'read',
				logo_uri: undefined,
				tos_uri: undefined,
			};

			tokenService.revokeAccessToken.mockResolvedValue(false);
			tokenService.revokeRefreshToken.mockResolvedValue(false);

			await service.revokeToken(client, {
				token: 'unknown-token',
			});

			expect(logger.debug).toHaveBeenCalledWith('Token revocation requested for unknown token', {
				clientId: 'client-123',
			});
		});
	});

	describe('resource indicator validation across multiple resources', () => {
		it('should accept any registered resource and reject unregistered ones', async () => {
			const multiRegistry = new ProtectedResourceRegistry(mock<Logger>());
			multiRegistry.register({
				id: 'instance-mcp',
				getResourceUrl: () => TEST_RESOURCE_URL,
				getAudiences: () => [TEST_RESOURCE_URL, 'mcp-server-api'],
				scopes: SUPPORTED_SCOPES,
				isDefault: true,
				authorize: async () => true,
			});
			const secondResourceUrl = 'https://n8n.example.com/webhook/wf-1/mcp';
			multiRegistry.register({
				id: 'workflow-trigger',
				getResourceUrl: () => secondResourceUrl,
				getAudiences: () => [secondResourceUrl],
				scopes: [],
				authorize: async () => true,
			});

			const multiResourceService = new OAuthServerService(
				logger,
				mockInstance(GlobalConfig),
				oauthSessionService,
				oauthClientRepository,
				tokenService,
				authorizationCodeService,
				userConsentRepository,
				multiRegistry,
				mailer,
				urlServiceMock,
				mock<EventService>(),
				mock<AuthService>(),
				mock<OAuthConsentService>(),
			);

			expect(
				await (multiResourceService as any).resolveAndValidateResourceIndicator(TEST_RESOURCE_URL),
			).toBe(TEST_RESOURCE_URL);
			expect(
				await (multiResourceService as any).resolveAndValidateResourceIndicator(secondResourceUrl),
			).toBe(secondResourceUrl);
			await expect(
				(multiResourceService as any).resolveAndValidateResourceIndicator(
					'https://n8n.example.com/webhook/wf-2/mcp',
				),
			).rejects.toThrow(InvalidTargetError);
		});
	});

	// Chain test with the real MCP protected resource: the resource generated
	// from the configured MCP base URL must pass indicator validation.
	describe('resource indicator validation with a configured MCP base URL', () => {
		const makeConfiguredService = () => {
			const urlService = mock<UrlService>();
			urlService.getInstanceBaseUrl.mockReturnValue('https://n8n.example.com');
			const mcpConfig = mock<McpConfig>();
			mcpConfig.baseUrl = 'https://n8n-mcp.example.com';
			const mcpResource = new McpProtectedResource(
				urlService,
				mock<McpSettingsService>(),
				mcpConfig,
				mock<GlobalConfig>(),
				mock<ModuleRegistry>(),
				mock<LicenseState>(),
			);
			expect(mcpResource.getResourceUrl()).toBe('https://n8n-mcp.example.com/mcp-server/http');

			const configuredRegistry = new ProtectedResourceRegistry(mock<Logger>());
			configuredRegistry.register(mcpResource);

			return new OAuthServerService(
				logger,
				mockInstance(GlobalConfig),
				oauthSessionService,
				oauthClientRepository,
				tokenService,
				authorizationCodeService,
				userConsentRepository,
				configuredRegistry,
				mailer,
				urlService,
				mock<EventService>(),
				mock<AuthService>(),
				mock<OAuthConsentService>(),
			);
		};

		it('should accept the resource generated from the configured base URL', async () => {
			const configuredService = makeConfiguredService();
			expect(
				await (configuredService as any).resolveAndValidateResourceIndicator(
					'https://n8n-mcp.example.com/mcp-server/http',
				),
			).toBe('https://n8n-mcp.example.com/mcp-server/http');
		});

		it('should keep accepting the instance-base-URL-derived resource', async () => {
			const configuredService = makeConfiguredService();
			expect(
				await (configuredService as any).resolveAndValidateResourceIndicator(
					'https://n8n.example.com/mcp-server/http',
				),
			).toBe('https://n8n.example.com/mcp-server/http');
		});

		it('should reject resources on hosts that are not configured', async () => {
			const configuredService = makeConfiguredService();
			await expect(
				(configuredService as any).resolveAndValidateResourceIndicator(
					'https://other.example.com/mcp-server/http',
				),
			).rejects.toThrow(InvalidTargetError);
		});
	});
});
