Relocate tests under /test (#1220)

This commit is contained in:
Konstantin Konstantinov 2025-12-06 01:56:12 +02:00 • committed by GitHub
commit 5a9de1c638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 630 additions and 524 deletions

View file

@ -0,0 +1,294 @@
import { authorizationHandler, AuthorizationHandlerOptions } from '../../../../src/server/auth/handlers/authorize.js';
import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js';
import express, { Response } from 'express';
import supertest from 'supertest';
import { AuthInfo } from '../../../../src/server/auth/types.js';
import { InvalidTokenError } from '../../../../src/server/auth/errors.js';
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 verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
},
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 };
const handler = authorizationHandler(options);
app.use('/authorize', handler);
});
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); // Method not allowed response from handler
});
});
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);
});
});
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);
});
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);
});
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('Resource parameter validation', () => {
it('propagates resource parameter', async () => {
const mockProviderWithResource = vi.spyOn(mockProvider, 'authorize');
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',
resource: 'https://api.example.com/resource'
});
expect(response.status).toBe(302);
expect(mockProviderWithResource).toHaveBeenCalledWith(
validClient,
expect.objectContaining({
resource: new URL('https://api.example.com/resource'),
redirectUri: 'https://example.com/callback',
codeChallenge: 'challenge123'
}),
expect.any(Object)
);
});
});
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);
});
});
});

View file

@ -0,0 +1,78 @@
import { metadataHandler } from '../../../../src/server/auth/handlers/metadata.js';
import { OAuthMetadata } from '../../../../src/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(405);
expect(response.headers.allow).toBe('GET, OPTIONS');
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: 'The method POST is not allowed for this endpoint'
});
});
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);
});
});

View file

@ -0,0 +1,272 @@
import { clientRegistrationHandler, ClientRegistrationHandlerOptions } from '../../../../src/server/auth/handlers/register.js';
import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthClientMetadata } from '../../../../src/shared/auth.js';
import express from 'express';
import supertest from 'supertest';
import { MockInstance } from 'vitest';
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: MockInstance;
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 = vi.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(405);
expect(response.headers.allow).toBe('POST');
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: 'The method GET is not allowed for this endpoint'
});
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();
expect(response.body.client_secret_expires_at).toBeUndefined();
});
it('sets client_secret_expires_at for public clients only', async () => {
// Test for public client (token_endpoint_auth_method not 'none')
const publicClientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback'],
token_endpoint_auth_method: 'client_secret_basic'
};
const publicResponse = await supertest(app).post('/register').send(publicClientMetadata);
expect(publicResponse.status).toBe(201);
expect(publicResponse.body.client_secret).toBeDefined();
expect(publicResponse.body.client_secret_expires_at).toBeDefined();
// Test for non-public client (token_endpoint_auth_method is 'none')
const nonPublicClientMetadata: OAuthClientMetadata = {
redirect_uris: ['https://example.com/callback'],
token_endpoint_auth_method: 'none'
};
const nonPublicResponse = await supertest(app).post('/register').send(nonPublicClientMetadata);
expect(nonPublicResponse.status).toBe(201);
expect(nonPublicResponse.body.client_secret).toBeUndefined();
expect(nonPublicResponse.body.client_secret_expires_at).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('sets no client_id when clientIdGeneration=false', async () => {
// Create handler with no expiry
const customApp = express();
const options: ClientRegistrationHandlerOptions = {
clientsStore: mockClientStoreWithRegistration,
clientIdGeneration: false
};
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_id).toBeUndefined();
expect(response.body.client_id_issued_at).toBeUndefined();
});
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('*');
});
});
});

View file

@ -0,0 +1,231 @@
import { revocationHandler, RevocationHandlerOptions } from '../../../../src/server/auth/handlers/revoke.js';
import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../../src/shared/auth.js';
import express, { Response } from 'express';
import supertest from 'supertest';
import { AuthInfo } from '../../../../src/server/auth/types.js';
import { InvalidTokenError } from '../../../../src/server/auth/errors.js';
import { MockInstance } from 'vitest';
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 verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
},
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'
};
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
}
// 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: MockInstance;
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 = vi.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(405);
expect(response.headers.allow).toBe('POST');
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: 'The method GET is not allowed for this endpoint'
});
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('*');
});
});
});

View file

@ -0,0 +1,479 @@
import { tokenHandler, TokenHandlerOptions } from '../../../../src/server/auth/handlers/token.js';
import { OAuthServerProvider, AuthorizationParams } from '../../../../src/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../../../src/shared/auth.js';
import express, { Response } from 'express';
import supertest from 'supertest';
import * as pkceChallenge from 'pkce-challenge';
import { InvalidGrantError, InvalidTokenError } from '../../../../src/server/auth/errors.js';
import { AuthInfo } from '../../../../src/server/auth/types.js';
import { ProxyOAuthServerProvider } from '../../../../src/server/auth/providers/proxyProvider.js';
import { type Mock } from 'vitest';
// Mock pkce-challenge
vi.mock('pkce-challenge', () => ({
verifyChallenge: vi.fn().mockImplementation(async (verifier, challenge) => {
return verifier === 'valid_verifier' && challenge === 'mock_challenge';
})
}));
const mockTokens = {
access_token: 'mock_access_token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'mock_refresh_token'
};
const mockTokensWithIdToken = {
...mockTokens,
id_token: 'mock_id_token'
};
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 InvalidGrantError('The authorization code has expired');
}
throw new InvalidGrantError('The authorization code is invalid');
},
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
if (authorizationCode === 'valid_code') {
return mockTokens;
}
throw new InvalidGrantError('The authorization code is invalid or has expired');
},
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 InvalidGrantError('The refresh token is invalid or has expired');
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
},
async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise<void> {
// Do nothing in mock
}
};
// Mock PKCE verification
(pkceChallenge.verifyChallenge as Mock).mockImplementation(async (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(405);
expect(response.headers.allow).toBe('POST');
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: 'The method GET is not allowed for this endpoint'
});
});
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 Mock).mockResolvedValueOnce(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_grant');
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);
expect(response.body.error).toBe('invalid_grant');
});
it('returns tokens for valid code exchange', async () => {
const mockExchangeCode = vi.spyOn(mockProvider, 'exchangeAuthorizationCode');
const response = await supertest(app).post('/token').type('form').send({
client_id: 'valid-client',
client_secret: 'valid-secret',
resource: 'https://api.example.com/resource',
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');
expect(mockExchangeCode).toHaveBeenCalledWith(
validClient,
'valid_code',
undefined, // code_verifier is undefined after PKCE validation
undefined, // redirect_uri
new URL('https://api.example.com/resource') // resource parameter
);
});
it('returns id token in code exchange if provided', async () => {
mockProvider.exchangeAuthorizationCode = async (
client: OAuthClientInformationFull,
authorizationCode: string
): Promise<OAuthTokens> => {
if (authorizationCode === 'valid_code') {
return mockTokensWithIdToken;
}
throw new InvalidGrantError('The authorization code is invalid or has expired');
};
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.id_token).toBe('mock_id_token');
});
it('passes through code verifier when using proxy provider', async () => {
const originalFetch = global.fetch;
try {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokens)
});
const proxyProvider = new ProxyOAuthServerProvider({
endpoints: {
authorizationUrl: 'https://example.com/authorize',
tokenUrl: 'https://example.com/token'
},
verifyAccessToken: async token => ({
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
}),
getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined)
});
const proxyApp = express();
const options: TokenHandlerOptions = { provider: proxyProvider };
proxyApp.use('/token', tokenHandler(options));
const response = await supertest(proxyApp).post('/token').type('form').send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'any_verifier',
redirect_uri: 'https://example.com/callback'
});
expect(response.status).toBe(200);
expect(response.body.access_token).toBe('mock_access_token');
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining('code_verifier=any_verifier')
})
);
} finally {
global.fetch = originalFetch;
}
});
it('passes through redirect_uri when using proxy provider', async () => {
const originalFetch = global.fetch;
try {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockTokens)
});
const proxyProvider = new ProxyOAuthServerProvider({
endpoints: {
authorizationUrl: 'https://example.com/authorize',
tokenUrl: 'https://example.com/token'
},
verifyAccessToken: async token => ({
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
}),
getClient: async clientId => (clientId === 'valid-client' ? validClient : undefined)
});
const proxyApp = express();
const options: TokenHandlerOptions = { provider: proxyProvider };
proxyApp.use('/token', tokenHandler(options));
const redirectUri = 'https://example.com/callback';
const response = await supertest(proxyApp).post('/token').type('form').send({
client_id: 'valid-client',
client_secret: 'valid-secret',
grant_type: 'authorization_code',
code: 'valid_code',
code_verifier: 'any_verifier',
redirect_uri: redirectUri
});
expect(response.status).toBe(200);
expect(response.body.access_token).toBe('mock_access_token');
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
})
);
} finally {
global.fetch = originalFetch;
}
});
});
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);
expect(response.body.error).toBe('invalid_grant');
});
it('returns new tokens for valid refresh token', async () => {
const mockExchangeRefresh = vi.spyOn(mockProvider, 'exchangeRefreshToken');
const response = await supertest(app).post('/token').type('form').send({
client_id: 'valid-client',
client_secret: 'valid-secret',
resource: 'https://api.example.com/resource',
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');
expect(mockExchangeRefresh).toHaveBeenCalledWith(
validClient,
'valid_refresh_token',
undefined, // scopes
new URL('https://api.example.com/resource') // resource parameter
);
});
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('*');
});
});
});

View file

@ -0,0 +1,75 @@
import { allowedMethods } from '../../../../src/server/auth/middleware/allowedMethods.js';
import express, { Request, Response } from 'express';
import request from 'supertest';
describe('allowedMethods', () => {
let app: express.Express;
beforeEach(() => {
app = express();
// Set up a test router with a GET handler and 405 middleware
const router = express.Router();
router.get('/test', (req, res) => {
res.status(200).send('GET success');
});
// Add method not allowed middleware for all other methods
router.all('/test', allowedMethods(['GET']));
app.use(router);
});
test('allows specified HTTP method', async () => {
const response = await request(app).get('/test');
expect(response.status).toBe(200);
expect(response.text).toBe('GET success');
});
test('returns 405 for unspecified HTTP methods', async () => {
const methods = ['post', 'put', 'delete', 'patch'];
for (const method of methods) {
// @ts-expect-error - dynamic method call
const response = await request(app)[method]('/test');
expect(response.status).toBe(405);
expect(response.body).toEqual({
error: 'method_not_allowed',
error_description: `The method ${method.toUpperCase()} is not allowed for this endpoint`
});
}
});
test('includes Allow header with specified methods', async () => {
const response = await request(app).post('/test');
expect(response.headers.allow).toBe('GET');
});
test('works with multiple allowed methods', async () => {
const multiMethodApp = express();
const router = express.Router();
router.get('/multi', (req: Request, res: Response) => {
res.status(200).send('GET');
});
router.post('/multi', (req: Request, res: Response) => {
res.status(200).send('POST');
});
router.all('/multi', allowedMethods(['GET', 'POST']));
multiMethodApp.use(router);
// Allowed methods should work
const getResponse = await request(multiMethodApp).get('/multi');
expect(getResponse.status).toBe(200);
const postResponse = await request(multiMethodApp).post('/multi');
expect(postResponse.status).toBe(200);
// Unallowed methods should return 405
const putResponse = await request(multiMethodApp).put('/multi');
expect(putResponse.status).toBe(405);
expect(putResponse.headers.allow).toBe('GET, POST');
});
});

View file

@ -0,0 +1,501 @@
import { Request, Response } from 'express';
import { Mock } from 'vitest';
import { requireBearerAuth } from '../../../../src/server/auth/middleware/bearerAuth.js';
import { AuthInfo } from '../../../../src/server/auth/types.js';
import { InsufficientScopeError, InvalidTokenError, CustomOAuthError, ServerError } from '../../../../src/server/auth/errors.js';
import { OAuthTokenVerifier } from '../../../../src/server/auth/provider.js';
import { createExpressResponseMock } from '../../../helpers/http.js';
// Mock verifier
const mockVerifyAccessToken = vi.fn();
const mockVerifier: OAuthTokenVerifier = {
verifyAccessToken: mockVerifyAccessToken
};
describe('requireBearerAuth middleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: Mock;
beforeEach(() => {
mockRequest = {
headers: {}
};
mockResponse = createExpressResponseMock();
nextFunction = vi.fn();
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
});
it('should call next when token is valid', async () => {
const validAuthInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
};
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockRequest.auth).toEqual(validAuthInfo);
expect(nextFunction).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it.each([
[100], // Token expired 100 seconds ago
[0] // Token expires at the same time as now
])('should reject expired tokens (expired %s seconds ago)', async (expiredSecondsAgo: number) => {
const expiresAt = Math.floor(Date.now() / 1000) - expiredSecondsAgo;
const expiredAuthInfo: AuthInfo = {
token: 'expired-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt
};
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
mockRequest.headers = {
authorization: 'Bearer expired-token'
};
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token');
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_token', error_description: 'Token has expired' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it.each([
[undefined], // Token has no expiration time
[NaN] // Token has no expiration time
])('should reject tokens with no expiration time (expiresAt: %s)', async (expiresAt: number | undefined) => {
const noExpirationAuthInfo: AuthInfo = {
token: 'no-expiration-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt
};
mockVerifyAccessToken.mockResolvedValue(noExpirationAuthInfo);
mockRequest.headers = {
authorization: 'Bearer expired-token'
};
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('expired-token');
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_token', error_description: 'Token has no expiration time' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should accept non-expired tokens', async () => {
const nonExpiredAuthInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
};
mockVerifyAccessToken.mockResolvedValue(nonExpiredAuthInfo);
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockRequest.auth).toEqual(nonExpiredAuthInfo);
expect(nextFunction).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it('should require specific scopes when configured', async () => {
const authInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read']
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ['read', 'write']
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'insufficient_scope', error_description: 'Insufficient scope' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should accept token with all required scopes', async () => {
const authInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read', 'write', 'admin'],
expiresAt: Math.floor(Date.now() / 1000) + 3600 // Token expires in an hour
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ['read', 'write']
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockRequest.auth).toEqual(authInfo);
expect(nextFunction).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it('should return 401 when no Authorization header is present', async () => {
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_token', error_description: 'Missing Authorization header' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should return 401 when Authorization header format is invalid', async () => {
mockRequest.headers = {
authorization: 'InvalidFormat'
};
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'invalid_token',
error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
})
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should return 401 when token verification fails with InvalidTokenError', async () => {
mockRequest.headers = {
authorization: 'Bearer invalid-token'
};
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired'));
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('invalid-token');
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="invalid_token"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_token', error_description: 'Token expired' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should return 403 when access token has insufficient scopes', async () => {
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: read, write'));
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith('WWW-Authenticate', expect.stringContaining('Bearer error="insufficient_scope"'));
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'insufficient_scope', error_description: 'Required scopes: read, write' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should return 500 when a ServerError occurs', async () => {
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue'));
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'server_error', error_description: 'Internal server issue' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should return 400 for generic OAuthError', async () => {
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
mockVerifyAccessToken.mockRejectedValue(new CustomOAuthError('custom_error', 'Some OAuth error'));
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'custom_error', error_description: 'Some OAuth error' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should return 500 when unexpected error occurs', async () => {
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
mockVerifyAccessToken.mockRejectedValue(new Error('Unexpected error'));
const middleware = requireBearerAuth({ verifier: mockVerifier });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith('valid-token');
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'server_error', error_description: 'Internal Server Error' })
);
expect(nextFunction).not.toHaveBeenCalled();
});
describe('with requiredScopes in WWW-Authenticate header', () => {
it('should include scope in WWW-Authenticate header for 401 responses when requiredScopes is provided', async () => {
mockRequest.headers = {};
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ['read', 'write']
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
'Bearer error="invalid_token", error_description="Missing Authorization header", scope="read write"'
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should include scope in WWW-Authenticate header for 403 insufficient scope responses', async () => {
const authInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read']
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ['read', 'write']
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write"'
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should include both scope and resource_metadata in WWW-Authenticate header when both are provided', async () => {
mockRequest.headers = {};
const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ['admin'],
resourceMetadataUrl
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
`Bearer error="invalid_token", error_description="Missing Authorization header", scope="admin", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
});
describe('with resourceMetadataUrl', () => {
const resourceMetadataUrl = 'https://api.example.com/.well-known/oauth-protected-resource';
it('should include resource_metadata in WWW-Authenticate header for 401 responses', async () => {
mockRequest.headers = {};
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
`Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should include resource_metadata in WWW-Authenticate header when token verification fails', async () => {
mockRequest.headers = {
authorization: 'Bearer invalid-token'
};
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError('Token expired'));
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
`Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should include resource_metadata in WWW-Authenticate header for insufficient scope errors', async () => {
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError('Required scopes: admin'));
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
`Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should include resource_metadata when token is expired', async () => {
const expiredAuthInfo: AuthInfo = {
token: 'expired-token',
clientId: 'client-123',
scopes: ['read', 'write'],
expiresAt: Math.floor(Date.now() / 1000) - 100
};
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
mockRequest.headers = {
authorization: 'Bearer expired-token'
};
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
`Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should include resource_metadata when scope check fails', async () => {
const authInfo: AuthInfo = {
token: 'valid-token',
clientId: 'client-123',
scopes: ['read']
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
const middleware = requireBearerAuth({
verifier: mockVerifier,
requiredScopes: ['read', 'write'],
resourceMetadataUrl
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
'WWW-Authenticate',
`Bearer error="insufficient_scope", error_description="Insufficient scope", scope="read write", resource_metadata="${resourceMetadataUrl}"`
);
expect(nextFunction).not.toHaveBeenCalled();
});
it('should not affect server errors (no WWW-Authenticate header)', async () => {
mockRequest.headers = {
authorization: 'Bearer valid-token'
};
mockVerifyAccessToken.mockRejectedValue(new ServerError('Internal server issue'));
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.set).not.toHaveBeenCalledWith('WWW-Authenticate', expect.anything());
expect(nextFunction).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,132 @@
import { authenticateClient, ClientAuthenticationMiddlewareOptions } from '../../../../src/server/auth/middleware/clientAuth.js';
import { OAuthRegisteredClientsStore } from '../../../../src/server/auth/clients.js';
import { OAuthClientInformationFull } from '../../../../src/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 no secret
return {
client_id: 'expired-client',
redirect_uris: ['https://example.com/callback']
};
} else if (clientId === 'client-with-expired-secret') {
// Client with an expired secret
return {
client_id: 'client-with-expired-secret',
client_secret: 'expired-secret',
client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
redirect_uris: ['https://example.com/callback']
};
}
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 client has no secret, this should pass without providing one
expect(response.status).toBe(200);
});
it('rejects request when client secret has expired', async () => {
const response = await supertest(app).post('/protected').send({
client_id: 'client-with-expired-secret',
client_secret: 'expired-secret'
});
expect(response.status).toBe(400);
expect(response.body.error).toBe('invalid_client');
expect(response.body.error_description).toBe('Client secret has expired');
});
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);
});
});

View file

@ -0,0 +1,344 @@
import { Response } from 'express';
import { ProxyOAuthServerProvider, ProxyOptions } from '../../../../src/server/auth/providers/proxyProvider.js';
import { AuthInfo } from '../../../../src/server/auth/types.js';
import { OAuthClientInformationFull, OAuthTokens } from '../../../../src/shared/auth.js';
import { ServerError } from '../../../../src/server/auth/errors.js';
import { InvalidTokenError } from '../../../../src/server/auth/errors.js';
import { InsufficientScopeError } from '../../../../src/server/auth/errors.js';
import { type Mock } from 'vitest';
describe('Proxy OAuth Server Provider', () => {
// Mock client data
const validClient: OAuthClientInformationFull = {
client_id: 'test-client',
client_secret: 'test-secret',
redirect_uris: ['https://example.com/callback']
};
// Mock response object
const mockResponse = {
redirect: vi.fn()
} as unknown as Response;
// Mock provider functions
const mockVerifyToken = vi.fn();
const mockGetClient = vi.fn();
// Base provider options
const baseOptions: ProxyOptions = {
endpoints: {
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
revocationUrl: 'https://auth.example.com/revoke',
registrationUrl: 'https://auth.example.com/register'
},
verifyAccessToken: mockVerifyToken,
getClient: mockGetClient
};
let provider: ProxyOAuthServerProvider;
let originalFetch: typeof global.fetch;
beforeEach(() => {
provider = new ProxyOAuthServerProvider(baseOptions);
originalFetch = global.fetch;
global.fetch = vi.fn();
// Setup mock implementations
mockVerifyToken.mockImplementation(async (token: string) => {
if (token === 'valid-token') {
return {
token,
clientId: 'test-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
} as AuthInfo;
}
throw new InvalidTokenError('Invalid token');
});
mockGetClient.mockImplementation(async (clientId: string) => {
if (clientId === 'test-client') {
return validClient;
}
return undefined;
});
});
// Add helper function for failed responses
const mockFailedResponse = () => {
(global.fetch as Mock).mockImplementation(() =>
Promise.resolve({
ok: false,
status: 400
})
);
};
afterEach(() => {
global.fetch = originalFetch;
vi.clearAllMocks();
});
describe('authorization', () => {
it('redirects to authorization endpoint with correct parameters', async () => {
await provider.authorize(
validClient,
{
redirectUri: 'https://example.com/callback',
codeChallenge: 'test-challenge',
state: 'test-state',
scopes: ['read', 'write'],
resource: new URL('https://api.example.com/resource')
},
mockResponse
);
const expectedUrl = new URL('https://auth.example.com/authorize');
expectedUrl.searchParams.set('client_id', 'test-client');
expectedUrl.searchParams.set('response_type', 'code');
expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback');
expectedUrl.searchParams.set('code_challenge', 'test-challenge');
expectedUrl.searchParams.set('code_challenge_method', 'S256');
expectedUrl.searchParams.set('state', 'test-state');
expectedUrl.searchParams.set('scope', 'read write');
expectedUrl.searchParams.set('resource', 'https://api.example.com/resource');
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
});
});
describe('token exchange', () => {
const mockTokenResponse: OAuthTokens = {
access_token: 'new-access-token',
token_type: 'Bearer',
expires_in: 3600,
refresh_token: 'new-refresh-token'
};
beforeEach(() => {
(global.fetch as Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockTokenResponse)
})
);
});
it('exchanges authorization code for tokens', async () => {
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier');
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining('grant_type=authorization_code')
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('includes redirect_uri in token request when provided', async () => {
const redirectUri = 'https://example.com/callback';
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier', redirectUri);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in authorization code exchange', async () => {
const tokens = await provider.exchangeAuthorizationCode(
validClient,
'test-code',
'test-verifier',
'https://example.com/callback',
new URL('https://api.example.com/resource')
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('handles authorization code exchange without resource parameter', async () => {
const tokens = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier');
const fetchCall = (global.fetch as Mock).mock.calls[0];
const body = fetchCall[1].body as string;
expect(body).not.toContain('resource=');
expect(tokens).toEqual(mockTokenResponse);
});
it('exchanges refresh token for new tokens', async () => {
const tokens = await provider.exchangeRefreshToken(validClient, 'test-refresh-token', ['read', 'write']);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining('grant_type=refresh_token')
})
);
expect(tokens).toEqual(mockTokenResponse);
});
it('includes resource parameter in refresh token exchange', async () => {
const tokens = await provider.exchangeRefreshToken(
validClient,
'test-refresh-token',
['read', 'write'],
new URL('https://api.example.com/resource')
);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/token',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
})
);
expect(tokens).toEqual(mockTokenResponse);
});
});
describe('client registration', () => {
it('registers new client', async () => {
const newClient: OAuthClientInformationFull = {
client_id: 'new-client',
redirect_uris: ['https://new-client.com/callback']
};
(global.fetch as Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(newClient)
})
);
const result = await provider.clientsStore.registerClient!(newClient);
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/register',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newClient)
})
);
expect(result).toEqual(newClient);
});
it('handles registration failure', async () => {
mockFailedResponse();
const newClient: OAuthClientInformationFull = {
client_id: 'new-client',
redirect_uris: ['https://new-client.com/callback']
};
await expect(provider.clientsStore.registerClient!(newClient)).rejects.toThrow(ServerError);
});
});
describe('token revocation', () => {
it('revokes token', async () => {
(global.fetch as Mock).mockImplementation(() =>
Promise.resolve({
ok: true
})
);
await provider.revokeToken!(validClient, {
token: 'token-to-revoke',
token_type_hint: 'access_token'
});
expect(global.fetch).toHaveBeenCalledWith(
'https://auth.example.com/revoke',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: expect.stringContaining('token=token-to-revoke')
})
);
});
it('handles revocation failure', async () => {
mockFailedResponse();
await expect(
provider.revokeToken!(validClient, {
token: 'invalid-token'
})
).rejects.toThrow(ServerError);
});
});
describe('token verification', () => {
it('verifies valid token', async () => {
const validAuthInfo: AuthInfo = {
token: 'valid-token',
clientId: 'test-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
mockVerifyToken.mockResolvedValue(validAuthInfo);
const authInfo = await provider.verifyAccessToken('valid-token');
expect(authInfo).toEqual(validAuthInfo);
expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
});
it('passes through InvalidTokenError', async () => {
const error = new InvalidTokenError('Token expired');
mockVerifyToken.mockRejectedValue(error);
await expect(provider.verifyAccessToken('invalid-token')).rejects.toBe(error);
expect(mockVerifyToken).toHaveBeenCalledWith('invalid-token');
});
it('passes through InsufficientScopeError', async () => {
const error = new InsufficientScopeError('Required scopes: read, write');
mockVerifyToken.mockRejectedValue(error);
await expect(provider.verifyAccessToken('token-with-insufficient-scope')).rejects.toBe(error);
expect(mockVerifyToken).toHaveBeenCalledWith('token-with-insufficient-scope');
});
it('passes through unexpected errors', async () => {
const error = new Error('Unexpected error');
mockVerifyToken.mockRejectedValue(error);
await expect(provider.verifyAccessToken('valid-token')).rejects.toBe(error);
expect(mockVerifyToken).toHaveBeenCalledWith('valid-token');
});
});
});

View file

@ -0,0 +1,463 @@
import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from '../../../src/server/auth/router.js';
import { OAuthServerProvider, AuthorizationParams } from '../../../src/server/auth/provider.js';
import { OAuthRegisteredClientsStore } from '../../../src/server/auth/clients.js';
import { OAuthClientInformationFull, OAuthMetadata, OAuthTokenRevocationRequest, OAuthTokens } from '../../../src/shared/auth.js';
import express, { Response } from 'express';
import supertest from 'supertest';
import { AuthInfo } from '../../../src/server/auth/types.js';
import { InvalidTokenError } from '../../../src/server/auth/errors.js';
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 verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read', 'write'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
},
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'
};
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
}
};
describe('Router creation', () => {
it('throws error for non-HTTPS issuer URL', () => {
const options: AuthRouterOptions = {
provider: mockProvider,
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,
issuerUrl: new URL('http://localhost:3000')
};
expect(() => mcpAuthRouter(options)).not.toThrow();
});
it('throws error for issuer URL with fragment', () => {
const options: AuthRouterOptions = {
provider: mockProvider,
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,
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,
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,
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', 'none']);
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,
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();
});
it('provides protected resource metadata', async () => {
// Setup router with draft protocol version
const draftApp = express();
const options: AuthRouterOptions = {
provider: mockProvider,
issuerUrl: new URL('https://mcp.example.com'),
scopesSupported: ['read', 'write'],
resourceName: 'Test API'
};
draftApp.use(mcpAuthRouter(options));
const response = await supertest(draftApp).get('/.well-known/oauth-protected-resource');
expect(response.status).toBe(200);
// Verify protected resource metadata
expect(response.body.resource).toBe('https://mcp.example.com/');
expect(response.body.authorization_servers).toContain('https://mcp.example.com/');
expect(response.body.scopes_supported).toEqual(['read', 'write']);
expect(response.body.resource_name).toBe('Test API');
});
});
describe('Endpoint routing', () => {
let app: express.Express;
beforeEach(() => {
// Setup full-featured router
app = express();
const options: AuthRouterOptions = {
provider: mockProvider,
issuerUrl: new URL('https://auth.example.com')
};
app.use(mcpAuthRouter(options));
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
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
vi.mock('pkce-challenge', () => ({
verifyChallenge: vi.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,
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);
});
});
});
describe('MCP Auth Metadata Router', () => {
const mockOAuthMetadata: OAuthMetadata = {
issuer: 'https://auth.example.com/',
authorization_endpoint: 'https://auth.example.com/authorize',
token_endpoint: 'https://auth.example.com/token',
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post']
};
describe('Router creation', () => {
it('successfully creates router with valid options', () => {
const options: AuthMetadataOptions = {
oauthMetadata: mockOAuthMetadata,
resourceServerUrl: new URL('https://api.example.com')
};
expect(() => mcpAuthMetadataRouter(options)).not.toThrow();
});
});
describe('Metadata endpoints', () => {
let app: express.Express;
beforeEach(() => {
app = express();
const options: AuthMetadataOptions = {
oauthMetadata: mockOAuthMetadata,
resourceServerUrl: new URL('https://api.example.com'),
serviceDocumentationUrl: new URL('https://docs.example.com'),
scopesSupported: ['read', 'write'],
resourceName: 'Test API'
};
app.use(mcpAuthMetadataRouter(options));
});
it('returns OAuth authorization server metadata', async () => {
const response = await supertest(app).get('/.well-known/oauth-authorization-server');
expect(response.status).toBe(200);
// Verify metadata points to authorization server
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.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']);
});
it('returns OAuth protected resource metadata', async () => {
const response = await supertest(app).get('/.well-known/oauth-protected-resource');
expect(response.status).toBe(200);
// Verify protected resource metadata
expect(response.body.resource).toBe('https://api.example.com/');
expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']);
expect(response.body.scopes_supported).toEqual(['read', 'write']);
expect(response.body.resource_name).toBe('Test API');
expect(response.body.resource_documentation).toBe('https://docs.example.com/');
});
it('works with minimal configuration', async () => {
const minimalApp = express();
const options: AuthMetadataOptions = {
oauthMetadata: mockOAuthMetadata,
resourceServerUrl: new URL('https://api.example.com')
};
minimalApp.use(mcpAuthMetadataRouter(options));
const authResponse = await supertest(minimalApp).get('/.well-known/oauth-authorization-server');
expect(authResponse.status).toBe(200);
expect(authResponse.body.issuer).toBe('https://auth.example.com/');
expect(authResponse.body.service_documentation).toBeUndefined();
expect(authResponse.body.scopes_supported).toBeUndefined();
const resourceResponse = await supertest(minimalApp).get('/.well-known/oauth-protected-resource');
expect(resourceResponse.status).toBe(200);
expect(resourceResponse.body.resource).toBe('https://api.example.com/');
expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']);
expect(resourceResponse.body.scopes_supported).toBeUndefined();
expect(resourceResponse.body.resource_name).toBeUndefined();
expect(resourceResponse.body.resource_documentation).toBeUndefined();
});
});
});