Claude-authored tests
This commit is contained in:
parent
6a6dcbaf3c
commit
76938c77e8
9 changed files with 1808 additions and 1 deletions
143
src/server/auth/clients.test.ts
Normal file
143
src/server/auth/clients.test.ts
Normal file
|
|
@ -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<string, OAuthClientInformationFull> = {};
|
||||
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
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<OAuthClientInformationFull> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
322
src/server/auth/handlers/authorize.test.ts
Normal file
322
src/server/auth/handlers/authorize.test.ts
Normal file
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
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<void> {
|
||||
// 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<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'new_mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async revokeToken(): Promise<void> {
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
79
src/server/auth/handlers/metadata.test.ts
Normal file
79
src/server/auth/handlers/metadata.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
226
src/server/auth/handlers/register.test.ts
Normal file
226
src/server/auth/handlers/register.test.ts
Normal file
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
return undefined;
|
||||
},
|
||||
|
||||
async registerClient(client: OAuthClientInformationFull): Promise<OAuthClientInformationFull> {
|
||||
// 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<OAuthClientInformationFull | undefined> {
|
||||
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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
216
src/server/auth/handlers/revoke.test.ts
Normal file
216
src/server/auth/handlers/revoke.test.ts
Normal file
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
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<void> {
|
||||
res.redirect('https://example.com/callback?code=mock_auth_code');
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
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<void> {
|
||||
// Success - do nothing in mock
|
||||
}
|
||||
};
|
||||
|
||||
// Mock provider without revocation capability
|
||||
const mockProviderWithoutRevocation: OAuthServerProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
res.redirect('https://example.com/callback?code=mock_auth_code');
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(): Promise<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
345
src/server/auth/handlers/token.test.ts
Normal file
345
src/server/auth/handlers/token.test.ts
Normal file
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
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<void> {
|
||||
res.redirect('https://example.com/callback?code=mock_auth_code');
|
||||
},
|
||||
|
||||
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
|
||||
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<OAuthTokens> {
|
||||
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<OAuthTokens> {
|
||||
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<void> {
|
||||
// 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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
128
src/server/auth/middleware/clientAuth.test.ts
Normal file
128
src/server/auth/middleware/clientAuth.test.ts
Normal file
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
348
src/server/auth/router.test.ts
Normal file
348
src/server/auth/router.test.ts
Normal file
|
|
@ -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<OAuthClientInformationFull | undefined> {
|
||||
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<OAuthClientInformationFull> {
|
||||
return client;
|
||||
}
|
||||
};
|
||||
|
||||
const mockProvider: OAuthServerProvider = {
|
||||
clientsStore: mockClientStore,
|
||||
|
||||
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
|
||||
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<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
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<void> {
|
||||
// Success - do nothing in mock
|
||||
}
|
||||
};
|
||||
|
||||
// Provider without registration and revocation
|
||||
const mockProviderMinimal: OAuthServerProvider = {
|
||||
clientsStore: {
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
return 'mock_challenge';
|
||||
},
|
||||
|
||||
async exchangeAuthorizationCode(): Promise<OAuthTokens> {
|
||||
return {
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
};
|
||||
},
|
||||
|
||||
async exchangeRefreshToken(): Promise<OAuthTokens> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue