From 76938c77e8a4ac62f70ecd9b1a799afe0b6f9ea0 Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Tue, 18 Feb 2025 10:57:24 +0000 Subject: [PATCH] Claude-authored tests --- src/server/auth/clients.test.ts | 143 +++++++ src/server/auth/handlers/authorize.test.ts | 322 ++++++++++++++++ src/server/auth/handlers/metadata.test.ts | 79 ++++ src/server/auth/handlers/register.test.ts | 226 ++++++++++++ src/server/auth/handlers/register.ts | 2 +- src/server/auth/handlers/revoke.test.ts | 216 +++++++++++ src/server/auth/handlers/token.test.ts | 345 +++++++++++++++++ src/server/auth/middleware/clientAuth.test.ts | 128 +++++++ src/server/auth/router.test.ts | 348 ++++++++++++++++++ 9 files changed, 1808 insertions(+), 1 deletion(-) create mode 100644 src/server/auth/clients.test.ts create mode 100644 src/server/auth/handlers/authorize.test.ts create mode 100644 src/server/auth/handlers/metadata.test.ts create mode 100644 src/server/auth/handlers/register.test.ts create mode 100644 src/server/auth/handlers/revoke.test.ts create mode 100644 src/server/auth/handlers/token.test.ts create mode 100644 src/server/auth/middleware/clientAuth.test.ts create mode 100644 src/server/auth/router.test.ts diff --git a/src/server/auth/clients.test.ts b/src/server/auth/clients.test.ts new file mode 100644 index 00000000..321c416c --- /dev/null +++ b/src/server/auth/clients.test.ts @@ -0,0 +1,143 @@ +import { OAuthRegisteredClientsStore } from './clients.js'; +import { OAuthClientInformationFull } from '../../shared/auth.js'; + +describe('OAuthRegisteredClientsStore', () => { + // Create a mock implementation class for testing + class MockClientStore implements OAuthRegisteredClientsStore { + private clients: Record = {}; + + async getClient(clientId: string): Promise { + const client = this.clients[clientId]; + + // Return undefined for non-existent client + if (!client) return undefined; + + // Check if client secret has expired + if (client.client_secret && + client.client_secret_expires_at && + client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { + // If expired, retain client but remove the secret + const { client_secret: _unused, ...clientWithoutSecret } = client; + return clientWithoutSecret as OAuthClientInformationFull; + } + + return client; + } + + async registerClient(client: OAuthClientInformationFull): Promise { + this.clients[client.client_id] = { ...client }; + return client; + } + } + + let mockStore: MockClientStore; + + beforeEach(() => { + mockStore = new MockClientStore(); + }); + + describe('getClient', () => { + it('returns undefined for non-existent client', async () => { + const result = await mockStore.getClient('non-existent-id'); + expect(result).toBeUndefined(); + }); + + it('returns client information for existing client', async () => { + const mockClient: OAuthClientInformationFull = { + client_id: 'test-client-123', + client_secret: 'secret456', + redirect_uris: ['https://example.com/callback'] + }; + + await mockStore.registerClient(mockClient); + const result = await mockStore.getClient('test-client-123'); + + expect(result).toEqual(mockClient); + }); + + it('handles expired client secrets correctly', async () => { + const now = Math.floor(Date.now() / 1000); + + // Client with expired secret (one hour in the past) + const expiredClient: OAuthClientInformationFull = { + client_id: 'expired-client', + client_secret: 'expired-secret', + client_secret_expires_at: now - 3600, + redirect_uris: ['https://example.com/callback'] + }; + + await mockStore.registerClient(expiredClient); + const result = await mockStore.getClient('expired-client'); + + // Expect client to be returned but without the secret + expect(result).toBeDefined(); + expect(result!.client_id).toBe('expired-client'); + expect(result!.client_secret).toBeUndefined(); + }); + + it('keeps valid client secrets', async () => { + const now = Math.floor(Date.now() / 1000); + + // Client with valid secret (expires one hour in the future) + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + client_secret_expires_at: now + 3600, + redirect_uris: ['https://example.com/callback'] + }; + + await mockStore.registerClient(validClient); + const result = await mockStore.getClient('valid-client'); + + // Secret should still be present + expect(result?.client_secret).toBe('valid-secret'); + }); + }); + + describe('registerClient', () => { + it('successfully registers a new client', async () => { + const newClient: OAuthClientInformationFull = { + client_id: 'new-client-id', + client_secret: 'new-client-secret', + redirect_uris: ['https://example.com/callback'] + }; + + const result = await mockStore.registerClient(newClient); + + // Verify registration returns the client + expect(result).toEqual(newClient); + + // Verify the client is retrievable + const storedClient = await mockStore.getClient('new-client-id'); + expect(storedClient).toEqual(newClient); + }); + + it('handles clients with all metadata fields', async () => { + const fullClient: OAuthClientInformationFull = { + client_id: 'full-client', + client_secret: 'full-secret', + client_id_issued_at: Math.floor(Date.now() / 1000), + client_secret_expires_at: Math.floor(Date.now() / 1000) + 86400, // 24 hours + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'client_secret_basic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: 'Test Client', + client_uri: 'https://example.com', + logo_uri: 'https://example.com/logo.png', + scope: 'profile email', + contacts: ['dev@example.com'], + tos_uri: 'https://example.com/tos', + policy_uri: 'https://example.com/privacy', + jwks_uri: 'https://example.com/jwks', + software_id: 'test-software', + software_version: '1.0.0' + }; + + await mockStore.registerClient(fullClient); + const result = await mockStore.getClient('full-client'); + + expect(result).toEqual(fullClient); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts new file mode 100644 index 00000000..6372befe --- /dev/null +++ b/src/server/auth/handlers/authorize.test.ts @@ -0,0 +1,322 @@ +import { authorizationHandler, AuthorizationHandlerOptions } from './authorize.js'; +import { OAuthServerProvider, AuthorizationParams } from '../provider.js'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { OAuthClientInformationFull, OAuthTokens } from '../../../shared/auth.js'; +import express, { Response } from 'express'; +import supertest from 'supertest'; + +describe('Authorization Handler', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'], + scope: 'profile email' + }; + + const multiRedirectClient: OAuthClientInformationFull = { + client_id: 'multi-redirect-client', + client_secret: 'valid-secret', + redirect_uris: [ + 'https://example.com/callback1', + 'https://example.com/callback2' + ], + scope: 'profile email' + }; + + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return validClient; + } else if (clientId === 'multi-redirect-client') { + return multiRedirectClient; + } + return undefined; + } + }; + + // Mock provider + const mockProvider: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + // Mock implementation - redirects to redirectUri with code and state + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async revokeToken(): Promise { + // Do nothing in mock + } + }; + + // Setup express app with handler + let app: express.Express; + let options: AuthorizationHandlerOptions; + + beforeEach(() => { + app = express(); + options = { provider: mockProvider }; + app.get('/authorize', authorizationHandler(options)); + app.post('/authorize', authorizationHandler(options)); + }); + + describe('HTTP method validation', () => { + it('rejects non-GET/POST methods', async () => { + const response = await supertest(app) + .put('/authorize') + .query({ client_id: 'valid-client' }); + + expect(response.status).toBe(405); + }); + }); + + describe('Client validation', () => { + it('requires client_id parameter', async () => { + const response = await supertest(app) + .get('/authorize'); + + expect(response.status).toBe(400); + expect(response.text).toContain('client_id'); + }); + + it('validates that client exists', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ client_id: 'nonexistent-client' }); + + expect(response.status).toBe(400); + expect(response.text).toContain('invalid client_id'); + }); + }); + + describe('Redirect URI validation', () => { + it('uses the only redirect_uri if client has just one and none provided', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.origin + location.pathname).toBe('https://example.com/callback'); + }); + + it('requires redirect_uri if client has multiple', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'multi-redirect-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + expect(response.text).toContain('missing redirect_uri'); + }); + + it('validates redirect_uri against client registered URIs', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://malicious.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(400); + expect(response.text).toContain('unregistered redirect_uri'); + }); + + it('accepts valid redirect_uri that client registered with', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.origin + location.pathname).toBe('https://example.com/callback'); + }); + }); + + describe('Authorization request validation', () => { + it('requires response_type=code', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'token', // invalid - we only support code flow + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('error')).toBe('invalid_request'); + }); + + it('requires code_challenge parameter', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge_method: 'S256' + // Missing code_challenge + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('error')).toBe('invalid_request'); + }); + + it('requires code_challenge_method=S256', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'plain' // Only S256 is supported + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('error')).toBe('invalid_request'); + }); + }); + + describe('Scope validation', () => { + it('validates requested scopes against client registered scopes', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + scope: 'profile email admin' // 'admin' not in client scopes + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('error')).toBe('invalid_scope'); + }); + + it('accepts valid scopes subset', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + scope: 'profile' // subset of client scopes + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.has('code')).toBe(true); + }); + }); + + describe('Successful authorization', () => { + it('handles successful authorization with all parameters', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + scope: 'profile email', + state: 'xyz789' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.origin + location.pathname).toBe('https://example.com/callback'); + expect(location.searchParams.get('code')).toBe('mock_auth_code'); + expect(location.searchParams.get('state')).toBe('xyz789'); + }); + + it('preserves state parameter in response', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + state: 'state-value-123' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('state')).toBe('state-value-123'); + }); + + it('handles POST requests the same as GET', async () => { + const response = await supertest(app) + .post('/authorize') + .type('form') + .send({ + client_id: 'valid-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.has('code')).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/metadata.test.ts b/src/server/auth/handlers/metadata.test.ts new file mode 100644 index 00000000..73a9075d --- /dev/null +++ b/src/server/auth/handlers/metadata.test.ts @@ -0,0 +1,79 @@ +import { metadataHandler } from './metadata.js'; +import { OAuthMetadata } from '../../../shared/auth.js'; +import express from 'express'; +import supertest from 'supertest'; + +describe('Metadata Handler', () => { + const exampleMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + registration_endpoint: 'https://auth.example.com/register', + revocation_endpoint: 'https://auth.example.com/revoke', + scopes_supported: ['profile', 'email'], + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + code_challenge_methods_supported: ['S256'] + }; + + let app: express.Express; + + beforeEach(() => { + // Setup express app with metadata handler + app = express(); + app.use('/.well-known/oauth-authorization-server', metadataHandler(exampleMetadata)); + }); + + it('requires GET method', async () => { + const response = await supertest(app) + .post('/.well-known/oauth-authorization-server') + .send({}); + + expect(response.status).toBe(404); // 404 since router only handles GET + }); + + it('returns the metadata object', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(exampleMetadata); + }); + + it('includes CORS headers in response', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-authorization-server') + .set('Origin', 'https://example.com'); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + + it('supports OPTIONS preflight requests', async () => { + const response = await supertest(app) + .options('/.well-known/oauth-authorization-server') + .set('Origin', 'https://example.com') + .set('Access-Control-Request-Method', 'GET'); + + expect(response.status).toBe(204); + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + + it('works with minimal metadata', async () => { + // Setup a new express app with minimal metadata + const minimalApp = express(); + const minimalMetadata: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + minimalApp.use('/.well-known/oauth-authorization-server', metadataHandler(minimalMetadata)); + + const response = await supertest(minimalApp) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(minimalMetadata); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/register.test.ts b/src/server/auth/handlers/register.test.ts new file mode 100644 index 00000000..719075f8 --- /dev/null +++ b/src/server/auth/handlers/register.test.ts @@ -0,0 +1,226 @@ +import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from './register.js'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../shared/auth.js'; +import express from 'express'; +import supertest from 'supertest'; + +describe('Client Registration Handler', () => { + // Mock client store with registration support + const mockClientStoreWithRegistration: OAuthRegisteredClientsStore = { + async getClient(_clientId: string): Promise { + return undefined; + }, + + async registerClient(client: OAuthClientInformationFull): Promise { + // Return the client info as-is in the mock + return client; + } + }; + + // Mock client store without registration support + const mockClientStoreWithoutRegistration: OAuthRegisteredClientsStore = { + async getClient(_clientId: string): Promise { + return undefined; + } + // No registerClient method + }; + + describe('Handler creation', () => { + it('throws error if client store does not support registration', () => { + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithoutRegistration + }; + + expect(() => clientRegistrationHandler(options)).toThrow('does not support registering clients'); + }); + + it('creates handler if client store supports registration', () => { + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration + }; + + expect(() => clientRegistrationHandler(options)).not.toThrow(); + }); + }); + + describe('Request handling', () => { + let app: express.Express; + let spyRegisterClient: jest.SpyInstance; + + beforeEach(() => { + // Setup express app with registration handler + app = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientSecretExpirySeconds: 86400 // 1 day for testing + }; + + app.use('/register', clientRegistrationHandler(options)); + + // Spy on the registerClient method + spyRegisterClient = jest.spyOn(mockClientStoreWithRegistration, 'registerClient'); + }); + + afterEach(() => { + spyRegisterClient.mockRestore(); + }); + + it('requires POST method', async () => { + const response = await supertest(app) + .get('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(404); // 404 since router only handles POST + expect(spyRegisterClient).not.toHaveBeenCalled(); + }); + + it('validates required client metadata', async () => { + const response = await supertest(app) + .post('/register') + .send({ + // Missing redirect_uris (required) + client_name: 'Test Client' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client_metadata'); + expect(spyRegisterClient).not.toHaveBeenCalled(); + }); + + it('validates redirect URIs format', async () => { + const response = await supertest(app) + .post('/register') + .send({ + redirect_uris: ['invalid-url'] // Invalid URL format + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client_metadata'); + expect(response.body.error_description).toContain('redirect_uris'); + expect(spyRegisterClient).not.toHaveBeenCalled(); + }); + + it('successfully registers client with minimal metadata', async () => { + const clientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'] + }; + + const response = await supertest(app) + .post('/register') + .send(clientMetadata); + + expect(response.status).toBe(201); + + // Verify the generated client information + expect(response.body.client_id).toBeDefined(); + expect(response.body.client_secret).toBeDefined(); + expect(response.body.client_id_issued_at).toBeDefined(); + expect(response.body.client_secret_expires_at).toBeDefined(); + expect(response.body.redirect_uris).toEqual(['https://example.com/callback']); + + // Verify client was registered + expect(spyRegisterClient).toHaveBeenCalledTimes(1); + }); + + it('sets client_secret to undefined for token_endpoint_auth_method=none', async () => { + const clientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'none' + }; + + const response = await supertest(app) + .post('/register') + .send(clientMetadata); + + expect(response.status).toBe(201); + expect(response.body.client_secret).toBeUndefined(); + }); + + it('sets expiry based on clientSecretExpirySeconds', async () => { + // Create handler with custom expiry time + const customApp = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientSecretExpirySeconds: 3600 // 1 hour + }; + + customApp.use('/register', clientRegistrationHandler(options)); + + const response = await supertest(customApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(201); + + // Verify the expiration time (~1 hour from now) + const issuedAt = response.body.client_id_issued_at; + const expiresAt = response.body.client_secret_expires_at; + expect(expiresAt - issuedAt).toBe(3600); + }); + + it('sets no expiry when clientSecretExpirySeconds=0', async () => { + // Create handler with no expiry + const customApp = express(); + const options: ClientRegistrationHandlerOptions = { + clientsStore: mockClientStoreWithRegistration, + clientSecretExpirySeconds: 0 // No expiry + }; + + customApp.use('/register', clientRegistrationHandler(options)); + + const response = await supertest(customApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.status).toBe(201); + expect(response.body.client_secret_expires_at).toBe(0); + }); + + it('handles client with all metadata fields', async () => { + const fullClientMetadata: OAuthClientMetadata = { + redirect_uris: ['https://example.com/callback'], + token_endpoint_auth_method: 'client_secret_basic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + client_name: 'Test Client', + client_uri: 'https://example.com', + logo_uri: 'https://example.com/logo.png', + scope: 'profile email', + contacts: ['dev@example.com'], + tos_uri: 'https://example.com/tos', + policy_uri: 'https://example.com/privacy', + jwks_uri: 'https://example.com/jwks', + software_id: 'test-software', + software_version: '1.0.0' + }; + + const response = await supertest(app) + .post('/register') + .send(fullClientMetadata); + + expect(response.status).toBe(201); + + // Verify all metadata was preserved + Object.entries(fullClientMetadata).forEach(([key, value]) => { + expect(response.body[key]).toEqual(value); + }); + }); + + it('includes CORS headers in response', async () => { + const response = await supertest(app) + .post('/register') + .set('Origin', 'https://example.com') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/register.ts b/src/server/auth/handlers/register.ts index 52ed3db2..12395341 100644 --- a/src/server/auth/handlers/register.ts +++ b/src/server/auth/handlers/register.ts @@ -1,5 +1,5 @@ import express, { RequestHandler } from "express"; -import { OAuthClientInformationFull, OAuthClientMetadataSchema, OAuthClientRegistrationError } from "../../../shared/auth.js"; +import { OAuthClientInformationFull, OAuthClientMetadataSchema } from "../../../shared/auth.js"; import crypto from 'node:crypto'; import cors from 'cors'; import { OAuthRegisteredClientsStore } from "../clients.js"; diff --git a/src/server/auth/handlers/revoke.test.ts b/src/server/auth/handlers/revoke.test.ts new file mode 100644 index 00000000..3daccc25 --- /dev/null +++ b/src/server/auth/handlers/revoke.test.ts @@ -0,0 +1,216 @@ +import { revocationHandler, RevocationHandlerOptions } from './revoke.js'; +import { OAuthServerProvider, AuthorizationParams } from '../provider.js'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js'; +import express, { Response } from 'express'; +import supertest from 'supertest'; + +describe('Revocation Handler', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return validClient; + } + return undefined; + } + }; + + // Mock provider with revocation capability + const mockProviderWithRevocation: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + res.redirect('https://example.com/callback?code=mock_auth_code'); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Success - do nothing in mock + } + }; + + // Mock provider without revocation capability + const mockProviderWithoutRevocation: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + res.redirect('https://example.com/callback?code=mock_auth_code'); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + } + // No revokeToken method + }; + + describe('Handler creation', () => { + it('throws error if provider does not support token revocation', () => { + const options: RevocationHandlerOptions = { provider: mockProviderWithoutRevocation }; + expect(() => revocationHandler(options)).toThrow('does not support revoking tokens'); + }); + + it('creates handler if provider supports token revocation', () => { + const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; + expect(() => revocationHandler(options)).not.toThrow(); + }); + }); + + describe('Request handling', () => { + let app: express.Express; + let spyRevokeToken: jest.SpyInstance; + + beforeEach(() => { + // Setup express app with revocation handler + app = express(); + const options: RevocationHandlerOptions = { provider: mockProviderWithRevocation }; + app.use('/revoke', revocationHandler(options)); + + // Spy on the revokeToken method + spyRevokeToken = jest.spyOn(mockProviderWithRevocation, 'revokeToken'); + }); + + afterEach(() => { + spyRevokeToken.mockRestore(); + }); + + it('requires POST method', async () => { + const response = await supertest(app) + .get('/revoke') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + expect(response.status).toBe(404); // 404 since router only handles POST + expect(spyRevokeToken).not.toHaveBeenCalled(); + }); + + it('requires token parameter', async () => { + const response = await supertest(app) + .post('/revoke') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret' + // Missing token + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(spyRevokeToken).not.toHaveBeenCalled(); + }); + + it('authenticates client before revoking token', async () => { + const response = await supertest(app) + .post('/revoke') + .type('form') + .send({ + client_id: 'invalid-client', + client_secret: 'wrong-secret', + token: 'token_to_revoke' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(spyRevokeToken).not.toHaveBeenCalled(); + }); + + it('successfully revokes token', async () => { + const response = await supertest(app) + .post('/revoke') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({}); // Empty response on success + expect(spyRevokeToken).toHaveBeenCalledTimes(1); + expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { + token: 'token_to_revoke' + }); + }); + + it('accepts optional token_type_hint', async () => { + const response = await supertest(app) + .post('/revoke') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke', + token_type_hint: 'refresh_token' + }); + + expect(response.status).toBe(200); + expect(spyRevokeToken).toHaveBeenCalledWith(validClient, { + token: 'token_to_revoke', + token_type_hint: 'refresh_token' + }); + }); + + it('includes CORS headers in response', async () => { + const response = await supertest(app) + .post('/revoke') + .type('form') + .set('Origin', 'https://example.com') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts new file mode 100644 index 00000000..61859094 --- /dev/null +++ b/src/server/auth/handlers/token.test.ts @@ -0,0 +1,345 @@ +import { tokenHandler, TokenHandlerOptions } from './token.js'; +import { OAuthServerProvider, AuthorizationParams } from '../provider.js'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../shared/auth.js'; +import express, { Response } from 'express'; +import supertest from 'supertest'; +import * as pkceChallenge from 'pkce-challenge'; + +// Mock pkce-challenge +jest.mock('pkce-challenge', () => ({ + verifyChallenge: jest.fn() +})); + +describe('Token Handler', () => { + // Mock client data + const validClient: OAuthClientInformationFull = { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return validClient; + } + return undefined; + } + }; + + // Mock provider + let mockProvider: OAuthServerProvider; + let app: express.Express; + + beforeEach(() => { + // Create fresh mocks for each test + mockProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + res.redirect('https://example.com/callback?code=mock_auth_code'); + }, + + async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { + if (authorizationCode === 'valid_code') { + return 'mock_challenge'; + } else if (authorizationCode === 'expired_code') { + throw new Error('invalid_grant'); + } + throw new Error('invalid_grant'); + }, + + async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise { + if (authorizationCode === 'valid_code') { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + } + throw new Error('invalid_grant'); + }, + + async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise { + if (refreshToken === 'valid_refresh_token') { + const response: OAuthTokens = { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + + if (scopes) { + response.scope = scopes.join(' '); + } + + return response; + } + throw new Error('invalid_grant'); + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Do nothing in mock + } + }; + + // Mock PKCE verification + (pkceChallenge.verifyChallenge as jest.Mock).mockImplementation( + (verifier: string, challenge: string) => { + return verifier === 'valid_verifier' && challenge === 'mock_challenge'; + } + ); + + // Setup express app with token handler + app = express(); + const options: TokenHandlerOptions = { provider: mockProvider }; + app.use('/token', tokenHandler(options)); + }); + + describe('Basic request validation', () => { + it('requires POST method', async () => { + const response = await supertest(app) + .get('/token') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code' + }); + + expect(response.status).toBe(404); // Express router handles method not allowed + }); + + it('requires grant_type parameter', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret' + // Missing grant_type + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('rejects unsupported grant types', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'password' // Unsupported grant type + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('unsupported_grant_type'); + }); + }); + + describe('Client authentication', () => { + it('requires valid client credentials', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'invalid-client', + client_secret: 'wrong-secret', + grant_type: 'authorization_code' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + }); + + it('accepts valid client credentials', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + }); + }); + + describe('Authorization code grant', () => { + it('requires code parameter', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + // Missing code + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('requires code_verifier parameter', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code' + // Missing code_verifier + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('verifies code_verifier against challenge', async () => { + // Setup invalid verifier + (pkceChallenge.verifyChallenge as jest.Mock).mockReturnValueOnce(false); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'invalid_verifier' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('code_verifier'); + }); + + it('rejects expired or invalid authorization codes', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'expired_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(400); + }); + + it('returns tokens for valid code exchange', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('mock_access_token'); + expect(response.body.token_type).toBe('bearer'); + expect(response.body.expires_in).toBe(3600); + expect(response.body.refresh_token).toBe('mock_refresh_token'); + }); + }); + + describe('Refresh token grant', () => { + it('requires refresh_token parameter', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token' + // Missing refresh_token + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('rejects invalid refresh tokens', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'invalid_refresh_token' + }); + + expect(response.status).toBe(400); + }); + + it('returns new tokens for valid refresh token', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token' + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('new_mock_access_token'); + expect(response.body.token_type).toBe('bearer'); + expect(response.body.expires_in).toBe(3600); + expect(response.body.refresh_token).toBe('new_mock_refresh_token'); + }); + + it('respects requested scopes on refresh', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + scope: 'profile email' + }); + + expect(response.status).toBe(200); + expect(response.body.scope).toBe('profile email'); + }); + }); + + describe('CORS support', () => { + it('includes CORS headers in response', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .set('Origin', 'https://example.com') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.header['access-control-allow-origin']).toBe('*'); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/middleware/clientAuth.test.ts b/src/server/auth/middleware/clientAuth.test.ts new file mode 100644 index 00000000..4a71c596 --- /dev/null +++ b/src/server/auth/middleware/clientAuth.test.ts @@ -0,0 +1,128 @@ +import { authenticateClient, ClientAuthenticationMiddlewareOptions } from './clientAuth.js'; +import { OAuthRegisteredClientsStore } from '../clients.js'; +import { OAuthClientInformationFull } from '../../../shared/auth.js'; +import express from 'express'; +import supertest from 'supertest'; + +describe('clientAuth middleware', () => { + // Mock client store + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } else if (clientId === 'expired-client') { + // Client with expired secret + return { + client_id: 'expired-client', + redirect_uris: ['https://example.com/callback'] + // No client_secret due to expiration + }; + } + return undefined; + } + }; + + // Setup Express app with middleware + let app: express.Express; + let options: ClientAuthenticationMiddlewareOptions; + + beforeEach(() => { + app = express(); + app.use(express.json()); + + options = { + clientsStore: mockClientStore + }; + + // Setup route with client auth + app.post('/protected', authenticateClient(options), (req, res) => { + res.status(200).json({ success: true, client: req.client }); + }); + }); + + it('authenticates valid client credentials', async () => { + const response = await supertest(app) + .post('/protected') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret' + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.client.client_id).toBe('valid-client'); + }); + + it('rejects invalid client_id', async () => { + const response = await supertest(app) + .post('/protected') + .send({ + client_id: 'non-existent-client', + client_secret: 'some-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.error_description).toBe('Invalid client_id'); + }); + + it('rejects invalid client_secret', async () => { + const response = await supertest(app) + .post('/protected') + .send({ + client_id: 'valid-client', + client_secret: 'wrong-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_client'); + expect(response.body.error_description).toBe('Invalid client_secret'); + }); + + it('rejects missing client_id', async () => { + const response = await supertest(app) + .post('/protected') + .send({ + client_secret: 'valid-secret' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + }); + + it('allows missing client_secret if client has none', async () => { + const response = await supertest(app) + .post('/protected') + .send({ + client_id: 'expired-client' + }); + + // Since the expired client has no secret, this should pass without providing one + expect(response.status).toBe(200); + }); + + it('handles malformed request body', async () => { + const response = await supertest(app) + .post('/protected') + .send('not-json-format'); + + expect(response.status).toBe(400); + }); + + // Testing request with extra fields to ensure they're ignored + it('ignores extra fields in request', async () => { + const response = await supertest(app) + .post('/protected') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + extra_field: 'should be ignored' + }); + + expect(response.status).toBe(200); + }); +}); \ No newline at end of file diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts new file mode 100644 index 00000000..7e070a00 --- /dev/null +++ b/src/server/auth/router.test.ts @@ -0,0 +1,348 @@ +import { mcpAuthRouter, AuthRouterOptions } from './router.js'; +import { OAuthServerProvider, AuthorizationParams } from './provider.js'; +import { OAuthRegisteredClientsStore } from './clients.js'; +import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js'; +import express, { Response } from 'express'; +import supertest from 'supertest'; + +describe('MCP Auth Router', () => { + // Setup mock provider with full capabilities + const mockClientStore: OAuthRegisteredClientsStore = { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } + return undefined; + }, + + async registerClient(client: OAuthClientInformationFull): Promise { + return client; + } + }; + + const mockProvider: OAuthServerProvider = { + clientsStore: mockClientStore, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + }, + + async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { + // Success - do nothing in mock + } + }; + + // Provider without registration and revocation + const mockProviderMinimal: OAuthServerProvider = { + clientsStore: { + async getClient(clientId: string): Promise { + if (clientId === 'valid-client') { + return { + client_id: 'valid-client', + client_secret: 'valid-secret', + redirect_uris: ['https://example.com/callback'] + }; + } + return undefined; + } + }, + + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set('code', 'mock_auth_code'); + if (params.state) { + redirectUrl.searchParams.set('state', params.state); + } + res.redirect(302, redirectUrl.toString()); + }, + + async challengeForAuthorizationCode(): Promise { + return 'mock_challenge'; + }, + + async exchangeAuthorizationCode(): Promise { + return { + access_token: 'mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'mock_refresh_token' + }; + }, + + async exchangeRefreshToken(): Promise { + return { + access_token: 'new_mock_access_token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new_mock_refresh_token' + }; + } + }; + + describe('Router creation', () => { + it('throws error for non-HTTPS issuer URL', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('http://auth.example.com') + } + }; + + expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must be HTTPS'); + }); + + it('allows localhost HTTP for development', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('http://localhost:3000') + } + }; + + expect(() => mcpAuthRouter(options)).not.toThrow(); + }); + + it('throws error for issuer URL with fragment', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('https://auth.example.com#fragment') + } + }; + + expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a fragment'); + }); + + it('throws error for issuer URL with query string', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('https://auth.example.com?param=value') + } + }; + + expect(() => mcpAuthRouter(options)).toThrow('Issuer URL must not have a query string'); + }); + + it('successfully creates router with valid options', () => { + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('https://auth.example.com') + } + }; + + expect(() => mcpAuthRouter(options)).not.toThrow(); + }); + }); + + describe('Metadata endpoint', () => { + let app: express.Express; + + beforeEach(() => { + // Setup full-featured router + app = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('https://auth.example.com'), + serviceDocumentationUrl: new URL('https://docs.example.com') + } + }; + app.use(mcpAuthRouter(options)); + }); + + it('returns complete metadata for full-featured router', async () => { + const response = await supertest(app) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify essential fields + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + expect(response.body.registration_endpoint).toBe('https://auth.example.com/register'); + expect(response.body.revocation_endpoint).toBe('https://auth.example.com/revoke'); + + // Verify supported features + expect(response.body.response_types_supported).toEqual(['code']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.code_challenge_methods_supported).toEqual(['S256']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']); + + // Verify optional fields + expect(response.body.service_documentation).toBe('https://docs.example.com/'); + }); + + it('returns minimal metadata for minimal router', async () => { + // Setup minimal router + const minimalApp = express(); + const options: AuthRouterOptions = { + provider: mockProviderMinimal, + metadata: { + issuerUrl: new URL('https://auth.example.com') + } + }; + minimalApp.use(mcpAuthRouter(options)); + + const response = await supertest(minimalApp) + .get('/.well-known/oauth-authorization-server'); + + expect(response.status).toBe(200); + + // Verify essential endpoints + expect(response.body.issuer).toBe('https://auth.example.com/'); + expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize'); + expect(response.body.token_endpoint).toBe('https://auth.example.com/token'); + + // Verify missing optional endpoints + expect(response.body.registration_endpoint).toBeUndefined(); + expect(response.body.revocation_endpoint).toBeUndefined(); + expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); + expect(response.body.service_documentation).toBeUndefined(); + }); + }); + + describe('Endpoint routing', () => { + let app: express.Express; + + beforeEach(() => { + // Setup full-featured router + app = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + metadata: { + issuerUrl: new URL('https://auth.example.com') + } + }; + app.use(mcpAuthRouter(options)); + }); + + it('routes to authorization endpoint', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.has('code')).toBe(true); + }); + + it('routes to token endpoint', async () => { + // Setup verifyChallenge mock for token handler + jest.mock('pkce-challenge', () => ({ + verifyChallenge: jest.fn().mockResolvedValue(true) + })); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + // The request will fail in testing due to mocking limitations, + // but we can verify the route was matched + expect(response.status).not.toBe(404); + }); + + it('routes to registration endpoint', async () => { + const response = await supertest(app) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + + // The request will fail in testing due to mocking limitations, + // but we can verify the route was matched + expect(response.status).not.toBe(404); + }); + + it('routes to revocation endpoint', async () => { + const response = await supertest(app) + .post('/revoke') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + + // The request will fail in testing due to mocking limitations, + // but we can verify the route was matched + expect(response.status).not.toBe(404); + }); + + it('excludes endpoints for unsupported features', async () => { + // Setup minimal router + const minimalApp = express(); + const options: AuthRouterOptions = { + provider: mockProviderMinimal, + metadata: { + issuerUrl: new URL('https://auth.example.com') + } + }; + minimalApp.use(mcpAuthRouter(options)); + + // Registration should not be available + const regResponse = await supertest(minimalApp) + .post('/register') + .send({ + redirect_uris: ['https://example.com/callback'] + }); + expect(regResponse.status).toBe(404); + + // Revocation should not be available + const revokeResponse = await supertest(minimalApp) + .post('/revoke') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + token: 'token_to_revoke' + }); + expect(revokeResponse.status).toBe(404); + }); + }); +}); \ No newline at end of file