Merge pull request #151 from modelcontextprotocol/justin/server-auth

Server implementation of MCP auth
This commit is contained in:
Justin Spahr-Summers 2025-02-21 15:47:43 +00:00 • committed by GitHub
commit 80e148491c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 4038 additions and 404 deletions

1038
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -47,7 +47,10 @@
},
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
"eventsource": "^3.0.2",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^4.1.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
@ -55,16 +58,19 @@
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@jest-mock/express": "^3.0.0",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/eslint__js": "^8.42.3",
"@types/eventsource": "^1.1.15",
"@types/express": "^4.17.21",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.2",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.12",
"eslint": "^9.8.0",
"express": "^4.19.2",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.4",
"tsx": "^4.16.5",
"typescript": "^5.5.4",
@ -74,4 +80,4 @@
"resolutions": {
"strip-ansi": "6.0.1"
}
}
}

View file

@ -6,7 +6,6 @@ import {
registerClient,
} from "./auth.js";
// Mock fetch globally
const mockFetch = jest.fn();
global.fetch = mockFetch;

View file

@ -1,84 +1,7 @@
import pkceChallenge from "pkce-challenge";
import { z } from "zod";
import { LATEST_PROTOCOL_VERSION } from "../types.js";
export const OAuthMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
registration_endpoint: z.string().optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
token_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
service_documentation: z.string().optional(),
revocation_endpoint: z.string().optional(),
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
revocation_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
introspection_endpoint: z.string().optional(),
introspection_endpoint_auth_methods_supported: z
.array(z.string())
.optional(),
introspection_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
code_challenge_methods_supported: z.array(z.string()).optional(),
})
.passthrough();
export const OAuthTokensSchema = z
.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number().optional(),
scope: z.string().optional(),
refresh_token: z.string().optional(),
})
.strip();
/**
* Client metadata schema according to RFC 7591 OAuth 2.0 Dynamic Client Registration
*/
export const OAuthClientMetadataSchema = z.object({
redirect_uris: z.array(z.string()),
token_endpoint_auth_method: z.string().optional(),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks: z.any().optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),
}).passthrough();
/**
* Client information response schema according to RFC 7591
*/
export const OAuthClientInformationSchema = z.object({
client_id: z.string(),
client_secret: z.string().optional(),
client_id_issued_at: z.number().optional(),
client_secret_expires_at: z.number().optional(),
}).passthrough();
export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;
export type OAuthClientMetadata = z.infer<typeof OAuthClientMetadataSchema>;
export type OAuthClientInformation = z.infer<typeof OAuthClientInformationSchema>;
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";
/**
* Implements an end-to-end OAuth client to be used with one MCP server.
@ -113,7 +36,7 @@ export interface OAuthClientProvider {
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*/
saveClientInformation?(clientInformation: OAuthClientInformation): void | Promise<void>;
saveClientInformation?(clientInformation: OAuthClientInformationFull): void | Promise<void>;
/**
* Loads any existing OAuth tokens for the current session, or returns
@ -175,12 +98,13 @@ export async function auth(
throw new Error("OAuth client information must be saveable for dynamic registration");
}
clientInformation = await registerClient(serverUrl, {
const fullInformation = await registerClient(serverUrl, {
metadata,
clientMetadata: provider.clientMetadata,
});
await provider.saveClientInformation(clientInformation);
await provider.saveClientInformation(fullInformation);
clientInformation = fullInformation;
}
// Exchange authorization code for tokens
@ -448,7 +372,7 @@ export async function registerClient(
metadata?: OAuthMetadata;
clientMetadata: OAuthClientMetadata;
},
): Promise<OAuthClientInformation> {
): Promise<OAuthClientInformationFull> {
let registrationUrl: URL;
if (metadata) {
@ -473,5 +397,5 @@ export async function registerClient(
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
}
return OAuthClientInformationSchema.parse(await response.json());
return OAuthClientInformationFullSchema.parse(await response.json());
}

View file

@ -2,7 +2,8 @@ import { createServer, type IncomingMessage, type Server } from "http";
import { AddressInfo } from "net";
import { JSONRPCMessage } from "../types.js";
import { SSEClientTransport } from "./sse.js";
import { OAuthClientProvider, OAuthTokens, UnauthorizedError } from "./auth.js";
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthTokens } from "../shared/auth.js";
describe("SSEClientTransport", () => {
let server: Server;

View file

@ -0,0 +1,20 @@
import { OAuthClientInformationFull } from "../../shared/auth.js";
/**
* Stores information about registered OAuth clients for this server.
*/
export interface OAuthRegisteredClientsStore {
/**
* Returns information about a registered client, based on its ID.
*/
getClient(clientId: string): OAuthClientInformationFull | undefined | Promise<OAuthClientInformationFull | undefined>;
/**
* Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server.
*
* NOTE: Implementations should NOT delete expired client secrets in-place. Auth middleware provided by this library will automatically check the `client_secret_expires_at` field and reject requests with expired secrets. Any custom logic for authenticating clients should check the `client_secret_expires_at` field as well.
*
* If unimplemented, dynamic client registration is unsupported.
*/
registerClient?(client: OAuthClientInformationFull): OAuthClientInformationFull | Promise<OAuthClientInformationFull>;
}

191
src/server/auth/errors.ts Normal file
View file

@ -0,0 +1,191 @@
import { OAuthErrorResponse } from "../../shared/auth.js";
/**
* Base class for all OAuth errors
*/
export class OAuthError extends Error {
constructor(
public readonly errorCode: string,
message: string,
public readonly errorUri?: string
) {
super(message);
this.name = this.constructor.name;
}
/**
* Converts the error to a standard OAuth error response object
*/
toResponseObject(): OAuthErrorResponse {
const response: OAuthErrorResponse = {
error: this.errorCode,
error_description: this.message
};
if (this.errorUri) {
response.error_uri = this.errorUri;
}
return response;
}
}
/**
* Invalid request error - The request is missing a required parameter,
* includes an invalid parameter value, includes a parameter more than once,
* or is otherwise malformed.
*/
export class InvalidRequestError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_request", message, errorUri);
}
}
/**
* Invalid client error - Client authentication failed (e.g., unknown client, no client
* authentication included, or unsupported authentication method).
*/
export class InvalidClientError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_client", message, errorUri);
}
}
/**
* Invalid grant error - The provided authorization grant or refresh token is
* invalid, expired, revoked, does not match the redirection URI used in the
* authorization request, or was issued to another client.
*/
export class InvalidGrantError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_grant", message, errorUri);
}
}
/**
* Unauthorized client error - The authenticated client is not authorized to use
* this authorization grant type.
*/
export class UnauthorizedClientError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unauthorized_client", message, errorUri);
}
}
/**
* Unsupported grant type error - The authorization grant type is not supported
* by the authorization server.
*/
export class UnsupportedGrantTypeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unsupported_grant_type", message, errorUri);
}
}
/**
* Invalid scope error - The requested scope is invalid, unknown, malformed, or
* exceeds the scope granted by the resource owner.
*/
export class InvalidScopeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_scope", message, errorUri);
}
}
/**
* Access denied error - The resource owner or authorization server denied the request.
*/
export class AccessDeniedError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("access_denied", message, errorUri);
}
}
/**
* Server error - The authorization server encountered an unexpected condition
* that prevented it from fulfilling the request.
*/
export class ServerError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("server_error", message, errorUri);
}
}
/**
* Temporarily unavailable error - The authorization server is currently unable to
* handle the request due to a temporary overloading or maintenance of the server.
*/
export class TemporarilyUnavailableError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("temporarily_unavailable", message, errorUri);
}
}
/**
* Unsupported response type error - The authorization server does not support
* obtaining an authorization code using this method.
*/
export class UnsupportedResponseTypeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unsupported_response_type", message, errorUri);
}
}
/**
* Unsupported token type error - The authorization server does not support
* the requested token type.
*/
export class UnsupportedTokenTypeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("unsupported_token_type", message, errorUri);
}
}
/**
* Invalid token error - The access token provided is expired, revoked, malformed,
* or invalid for other reasons.
*/
export class InvalidTokenError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_token", message, errorUri);
}
}
/**
* Method not allowed error - The HTTP method used is not allowed for this endpoint.
* (Custom, non-standard error)
*/
export class MethodNotAllowedError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("method_not_allowed", message, errorUri);
}
}
/**
* Too many requests error - Rate limit exceeded.
* (Custom, non-standard error based on RFC 6585)
*/
export class TooManyRequestsError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("too_many_requests", message, errorUri);
}
}
/**
* Invalid client metadata error - The client metadata is invalid.
* (Custom error for dynamic client registration - RFC 7591)
*/
export class InvalidClientMetadataError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_client_metadata", message, errorUri);
}
}
/**
* Insufficient scope error - The request requires higher privileges than provided by the access token.
*/
export class InsufficientScopeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("insufficient_scope", message, errorUri);
}
}

View file

@ -0,0 +1,333 @@
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';
import { AuthInfo } from '../types.js';
import { InvalidTokenError } from '../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('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);
});
});
});

View file

@ -0,0 +1,171 @@
import { RequestHandler } from "express";
import { z } from "zod";
import express from "express";
import { OAuthServerProvider } from "../provider.js";
import { rateLimit, Options as RateLimitOptions } from "express-rate-limit";
import { allowedMethods } from "../middleware/allowedMethods.js";
import {
InvalidRequestError,
InvalidClientError,
InvalidScopeError,
ServerError,
TooManyRequestsError,
OAuthError
} from "../errors.js";
export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
/**
* Rate limiting configuration for the authorization endpoint.
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
// Parameters that must be validated in order to issue redirects.
const ClientAuthorizationParamsSchema = z.object({
client_id: z.string(),
redirect_uri: z.string().optional().refine((value) => value === undefined || URL.canParse(value), { message: "redirect_uri must be a valid URL" }),
});
// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
const RequestAuthorizationParamsSchema = z.object({
response_type: z.literal("code"),
code_challenge: z.string(),
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
});
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
// Create a router to apply middleware
const router = express.Router();
router.use(allowedMethods(["GET", "POST"]));
router.use(express.urlencoded({ extended: false }));
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for authorization requests').toResponseObject(),
...rateLimitConfig
}));
}
router.all("/", async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
// In the authorization flow, errors are split into two categories:
// 1. Pre-redirect errors (direct response with 400)
// 2. Post-redirect errors (redirect with error parameters)
// Phase 1: Validate client_id and redirect_uri. Any errors here must be direct responses.
let client_id, redirect_uri, client;
try {
const result = ClientAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
if (!result.success) {
throw new InvalidRequestError(result.error.message);
}
client_id = result.data.client_id;
redirect_uri = result.data.redirect_uri;
client = await provider.clientsStore.getClient(client_id);
if (!client) {
throw new InvalidClientError("Invalid client_id");
}
if (redirect_uri !== undefined) {
if (!client.redirect_uris.includes(redirect_uri)) {
throw new InvalidRequestError("Unregistered redirect_uri");
}
} else if (client.redirect_uris.length === 1) {
redirect_uri = client.redirect_uris[0];
} else {
throw new InvalidRequestError("redirect_uri must be specified when client has multiple registered URIs");
}
} catch (error) {
// Pre-redirect errors - return direct response
//
// These don't need to be JSON encoded, as they'll be displayed in a user
// agent, but OTOH they all represent exceptional situations (arguably,
// "programmer error"), so presenting a nice HTML page doesn't help the
// user anyway.
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error looking up client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}
return;
}
// Phase 2: Validate other parameters. Any errors here should go into redirect responses.
let state;
try {
// Parse and validate authorization parameters
const parseResult = RequestAuthorizationParamsSchema.safeParse(req.method === 'POST' ? req.body : req.query);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { scope, code_challenge } = parseResult.data;
state = parseResult.data.state;
// Validate scopes
let requestedScopes: string[] = [];
if (scope !== undefined) {
requestedScopes = scope.split(" ");
const allowedScopes = new Set(client.scope?.split(" "));
// Check each requested scope against allowed scopes
for (const scope of requestedScopes) {
if (!allowedScopes.has(scope)) {
throw new InvalidScopeError(`Client was not registered with scope ${scope}`);
}
}
}
// All validation passed, proceed with authorization
await provider.authorize(client, {
state,
scopes: requestedScopes,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
}, res);
} catch (error) {
// Post-redirect errors - redirect with error parameters
if (error instanceof OAuthError) {
res.redirect(302, createErrorRedirect(redirect_uri, error, state));
} else {
console.error("Unexpected error during authorization:", error);
const serverError = new ServerError("Internal Server Error");
res.redirect(302, createErrorRedirect(redirect_uri, serverError, state));
}
}
});
return router;
}
/**
* Helper function to create redirect URL with error parameters
*/
function createErrorRedirect(redirectUri: string, error: OAuthError, state?: string): string {
const errorUrl = new URL(redirectUri);
errorUrl.searchParams.set("error", error.errorCode);
errorUrl.searchParams.set("error_description", error.message);
if (error.errorUri) {
errorUrl.searchParams.set("error_uri", error.errorUri);
}
if (state) {
errorUrl.searchParams.set("state", state);
}
return errorUrl.href;
}

View file

@ -0,0 +1,84 @@
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(405);
expect(response.headers.allow).toBe('GET');
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,19 @@
import express, { RequestHandler } from "express";
import { OAuthMetadata } from "../../../shared/auth.js";
import cors from 'cors';
import { allowedMethods } from "../middleware/allowedMethods.js";
export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(['GET']));
router.get("/", (req, res) => {
res.status(200).json(metadata);
});
return router;
}

View file

@ -0,0 +1,231 @@
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(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();
});
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('*');
});
});
});

View file

@ -0,0 +1,109 @@
import express, { RequestHandler } from "express";
import { OAuthClientInformationFull, OAuthClientMetadataSchema } from "../../../shared/auth.js";
import crypto from 'node:crypto';
import cors from 'cors';
import { OAuthRegisteredClientsStore } from "../clients.js";
import { rateLimit, Options as RateLimitOptions } from "express-rate-limit";
import { allowedMethods } from "../middleware/allowedMethods.js";
import {
InvalidClientMetadataError,
ServerError,
TooManyRequestsError,
OAuthError
} from "../errors.js";
export type ClientRegistrationHandlerOptions = {
/**
* A store used to save information about dynamically registered OAuth clients.
*/
clientsStore: OAuthRegisteredClientsStore;
/**
* The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
*
* If not set, defaults to 30 days.
*/
clientSecretExpirySeconds?: number;
/**
* Rate limiting configuration for the client registration endpoint.
* Set to false to disable rate limiting for this endpoint.
* Registration endpoints are particularly sensitive to abuse and should be rate limited.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days
export function clientRegistrationHandler({
clientsStore,
clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS,
rateLimit: rateLimitConfig
}: ClientRegistrationHandlerOptions): RequestHandler {
if (!clientsStore.registerClient) {
throw new Error("Client registration store does not support registering clients");
}
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(["POST"]));
router.use(express.json());
// Apply rate limiting unless explicitly disabled - stricter limits for registration
if (rateLimitConfig !== false) {
router.use(rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 20, // 20 requests per hour - stricter as registration is sensitive
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for client registration requests').toResponseObject(),
...rateLimitConfig
}));
}
router.post("/", async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const parseResult = OAuthClientMetadataSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidClientMetadataError(parseResult.error.message);
}
const clientMetadata = parseResult.data;
// Generate client credentials
const clientId = crypto.randomUUID();
const clientSecret = clientMetadata.token_endpoint_auth_method !== 'none'
? crypto.randomBytes(32).toString('hex')
: undefined;
const clientIdIssuedAt = Math.floor(Date.now() / 1000);
let clientInfo: OAuthClientInformationFull = {
...clientMetadata,
client_id: clientId,
client_secret: clientSecret,
client_id_issued_at: clientIdIssuedAt,
client_secret_expires_at: clientSecretExpirySeconds > 0 ? clientIdIssuedAt + clientSecretExpirySeconds : 0
};
clientInfo = await clientsStore.registerClient!(clientInfo);
res.status(201).json(clientInfo);
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error registering client:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}
}
});
return router;
}

View file

@ -0,0 +1,247 @@
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';
import { AuthInfo } from '../types.js';
import { InvalidTokenError } from '../errors.js';
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: 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(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,84 @@
import { OAuthServerProvider } from "../provider.js";
import express, { RequestHandler } from "express";
import cors from "cors";
import { authenticateClient } from "../middleware/clientAuth.js";
import { OAuthTokenRevocationRequestSchema } from "../../../shared/auth.js";
import { rateLimit, Options as RateLimitOptions } from "express-rate-limit";
import { allowedMethods } from "../middleware/allowedMethods.js";
import {
InvalidRequestError,
ServerError,
TooManyRequestsError,
OAuthError
} from "../errors.js";
export type RevocationHandlerOptions = {
provider: OAuthServerProvider;
/**
* Rate limiting configuration for the token revocation endpoint.
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
if (!provider.revokeToken) {
throw new Error("Auth provider does not support revoking tokens");
}
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(["POST"]));
router.use(express.urlencoded({ extended: false }));
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // 50 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for token revocation requests').toResponseObject(),
...rateLimitConfig
}));
}
// Authenticate and extract client details
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
router.post("/", async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const client = req.client;
if (!client) {
// This should never happen
console.error("Missing client information after authentication");
throw new ServerError("Internal Server Error");
}
await provider.revokeToken!(client, parseResult.data);
res.status(200).json({});
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error revoking token:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}
}
});
return router;
}

View file

@ -0,0 +1,368 @@
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';
import { InvalidGrantError, InvalidTokenError } from '../errors.js';
import { AuthInfo } from '../types.js';
// Mock pkce-challenge
jest.mock('pkce-challenge', () => ({
verifyChallenge: jest.fn().mockImplementation(async (verifier, challenge) => {
return verifier === 'valid_verifier' && challenge === 'mock_challenge';
})
}));
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 {
access_token: 'mock_access_token',
token_type: 'bearer',
expires_in: 3600,
refresh_token: 'mock_refresh_token'
};
}
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 jest.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 jest.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 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);
expect(response.body.error).toBe('invalid_grant');
});
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('*');
});
});
});

View file

@ -0,0 +1,139 @@
import { z } from "zod";
import express, { RequestHandler } from "express";
import { OAuthServerProvider } from "../provider.js";
import cors from "cors";
import { verifyChallenge } from "pkce-challenge";
import { authenticateClient } from "../middleware/clientAuth.js";
import { rateLimit, Options as RateLimitOptions } from "express-rate-limit";
import { allowedMethods } from "../middleware/allowedMethods.js";
import {
InvalidRequestError,
InvalidGrantError,
UnsupportedGrantTypeError,
ServerError,
TooManyRequestsError,
OAuthError
} from "../errors.js";
export type TokenHandlerOptions = {
provider: OAuthServerProvider;
/**
* Rate limiting configuration for the token endpoint.
* Set to false to disable rate limiting for this endpoint.
*/
rateLimit?: Partial<RateLimitOptions> | false;
};
const TokenRequestSchema = z.object({
grant_type: z.string(),
});
const AuthorizationCodeGrantSchema = z.object({
code: z.string(),
code_verifier: z.string(),
});
const RefreshTokenGrantSchema = z.object({
refresh_token: z.string(),
scope: z.string().optional(),
});
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());
router.use(allowedMethods(["POST"]));
router.use(express.urlencoded({ extended: false }));
// Apply rate limiting unless explicitly disabled
if (rateLimitConfig !== false) {
router.use(rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // 50 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
message: new TooManyRequestsError('You have exceeded the rate limit for token requests').toResponseObject(),
...rateLimitConfig
}));
}
// Authenticate and extract client details
router.use(authenticateClient({ clientsStore: provider.clientsStore }));
router.post("/", async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try {
const parseResult = TokenRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { grant_type } = parseResult.data;
const client = req.client;
if (!client) {
// This should never happen
console.error("Missing client information after authentication");
throw new ServerError("Internal Server Error");
}
switch (grant_type) {
case "authorization_code": {
const parseResult = AuthorizationCodeGrantSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { code, code_verifier } = parseResult.data;
// Verify PKCE challenge
const codeChallenge = await provider.challengeForAuthorizationCode(client, code);
if (!(await verifyChallenge(code_verifier, codeChallenge))) {
throw new InvalidGrantError("code_verifier does not match the challenge");
}
const tokens = await provider.exchangeAuthorizationCode(client, code);
res.status(200).json(tokens);
break;
}
case "refresh_token": {
const parseResult = RefreshTokenGrantSchema.safeParse(req.body);
if (!parseResult.success) {
throw new InvalidRequestError(parseResult.error.message);
}
const { refresh_token, scope } = parseResult.data;
const scopes = scope?.split(" ");
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes);
res.status(200).json(tokens);
break;
}
// Not supported right now
//case "client_credentials":
default:
throw new UnsupportedGrantTypeError(
"The grant type is not supported by this authorization server."
);
}
} catch (error) {
if (error instanceof OAuthError) {
const status = error instanceof ServerError ? 500 : 400;
res.status(status).json(error.toResponseObject());
} else {
console.error("Unexpected error exchanging token:", error);
const serverError = new ServerError("Internal Server Error");
res.status(500).json(serverError.toResponseObject());
}
}
});
return router;
}

View file

@ -0,0 +1,75 @@
import { allowedMethods } from "./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,22 @@
import { RequestHandler } from "express";
import { MethodNotAllowedError } from "../errors.js";
/**
* Middleware to handle unsupported HTTP methods with a 405 Method Not Allowed response.
*
* @param allowedMethods Array of allowed HTTP methods for this endpoint (e.g., ['GET', 'POST'])
* @returns Express middleware that returns a 405 error if method not in allowed list
*/
export function allowedMethods(allowedMethods: string[]): RequestHandler {
return (req, res, next) => {
if (allowedMethods.includes(req.method)) {
next();
return;
}
const error = new MethodNotAllowedError(`The method ${req.method} is not allowed for this endpoint`);
res.status(405)
.set('Allow', allowedMethods.join(', '))
.json(error.toResponseObject());
};
}

View file

@ -0,0 +1,252 @@
import { Request, Response } from "express";
import { requireBearerAuth } from "./bearerAuth.js";
import { AuthInfo } from "../types.js";
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
import { OAuthServerProvider } from "../provider.js";
import { OAuthRegisteredClientsStore } from "../clients.js";
// Mock provider
const mockVerifyAccessToken = jest.fn();
const mockProvider: OAuthServerProvider = {
clientsStore: {} as OAuthRegisteredClientsStore,
authorize: jest.fn(),
challengeForAuthorizationCode: jest.fn(),
exchangeAuthorizationCode: jest.fn(),
exchangeRefreshToken: jest.fn(),
verifyAccessToken: mockVerifyAccessToken,
};
describe("requireBearerAuth middleware", () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: jest.Mock;
beforeEach(() => {
mockRequest = {
headers: {},
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
set: jest.fn().mockReturnThis(),
};
nextFunction = jest.fn();
jest.clearAllMocks();
});
it("should call next when token is valid", async () => {
const validAuthInfo: AuthInfo = {
token: "valid-token",
clientId: "client-123",
scopes: ["read", "write"],
};
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
mockRequest.headers = {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({ provider: mockProvider });
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("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({
provider: mockProvider,
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"],
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({
provider: mockProvider,
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({ provider: mockProvider });
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({ provider: mockProvider });
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({ provider: mockProvider });
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({ provider: mockProvider });
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({ provider: mockProvider });
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 OAuthError("custom_error", "Some OAuth error"));
const middleware = requireBearerAuth({ provider: mockProvider });
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({ provider: mockProvider });
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();
});
});

Some files were not shown because too many files have changed in this diff Show more