Merge pull request #151 from modelcontextprotocol/justin/server-auth
Server implementation of MCP auth
This commit is contained in:
commit
80e148491c
29 changed files with 4038 additions and 404 deletions
1038
package-lock.json
generated
1038
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
registerClient,
|
||||
} from "./auth.js";
|
||||
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
20
src/server/auth/clients.ts
Normal file
20
src/server/auth/clients.ts
Normal 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
191
src/server/auth/errors.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
333
src/server/auth/handlers/authorize.test.ts
Normal file
333
src/server/auth/handlers/authorize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
171
src/server/auth/handlers/authorize.ts
Normal file
171
src/server/auth/handlers/authorize.ts
Normal 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;
|
||||
}
|
||||
84
src/server/auth/handlers/metadata.test.ts
Normal file
84
src/server/auth/handlers/metadata.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
19
src/server/auth/handlers/metadata.ts
Normal file
19
src/server/auth/handlers/metadata.ts
Normal 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;
|
||||
}
|
||||
231
src/server/auth/handlers/register.test.ts
Normal file
231
src/server/auth/handlers/register.test.ts
Normal 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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
109
src/server/auth/handlers/register.ts
Normal file
109
src/server/auth/handlers/register.ts
Normal 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;
|
||||
}
|
||||
247
src/server/auth/handlers/revoke.test.ts
Normal file
247
src/server/auth/handlers/revoke.test.ts
Normal 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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
84
src/server/auth/handlers/revoke.ts
Normal file
84
src/server/auth/handlers/revoke.ts
Normal 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;
|
||||
}
|
||||
368
src/server/auth/handlers/token.test.ts
Normal file
368
src/server/auth/handlers/token.test.ts
Normal 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('*');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
src/server/auth/handlers/token.ts
Normal file
139
src/server/auth/handlers/token.ts
Normal 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;
|
||||
}
|
||||
75
src/server/auth/middleware/allowedMethods.test.ts
Normal file
75
src/server/auth/middleware/allowedMethods.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
22
src/server/auth/middleware/allowedMethods.ts
Normal file
22
src/server/auth/middleware/allowedMethods.ts
Normal 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());
|
||||
};
|
||||
}
|
||||
252
src/server/auth/middleware/bearerAuth.test.ts
Normal file
252
src/server/auth/middleware/bearerAuth.test.ts
Normal 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
Loading…
Reference in a new issue