From b8b816ca9ffb2d8a40d20a737b1446e99c6f414a Mon Sep 17 00:00:00 2001 From: Justin Spahr-Summers Date: Tue, 18 Feb 2025 16:09:51 +0000 Subject: [PATCH] Bearer auth middleware --- src/server/auth/errors.ts | 11 +- src/server/auth/handlers/authorize.test.ts | 14 + src/server/auth/handlers/revoke.test.ts | 26 ++ src/server/auth/handlers/token.test.ts | 15 +- src/server/auth/middleware/bearerAuth.test.ts | 252 ++++++++++++++++++ src/server/auth/middleware/bearerAuth.ts | 78 ++++++ src/server/auth/provider.ts | 6 + src/server/auth/router.test.ts | 26 ++ src/server/auth/types.ts | 24 ++ 9 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 src/server/auth/middleware/bearerAuth.test.ts create mode 100644 src/server/auth/middleware/bearerAuth.ts create mode 100644 src/server/auth/types.ts diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index cdca4123..428199ce 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -179,4 +179,13 @@ export class InvalidClientMetadataError extends OAuthError { constructor(message: string, errorUri?: string) { super("invalid_client_metadata", message, errorUri); } -} \ No newline at end of file +} + +/** + * 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); + } +} diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 8bb454b3..e921d5ea 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -4,6 +4,8 @@ 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 @@ -72,6 +74,18 @@ describe('Authorization Handler', () => { }; }, + async verifyAccessToken(token: string): Promise { + 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 { // Do nothing in mock } diff --git a/src/server/auth/handlers/revoke.test.ts b/src/server/auth/handlers/revoke.test.ts index ddda759b..bd34cab7 100644 --- a/src/server/auth/handlers/revoke.test.ts +++ b/src/server/auth/handlers/revoke.test.ts @@ -4,6 +4,8 @@ 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 @@ -53,6 +55,18 @@ describe('Revocation Handler', () => { }; }, + async verifyAccessToken(token: string): Promise { + 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 { // Success - do nothing in mock } @@ -86,6 +100,18 @@ describe('Revocation Handler', () => { expires_in: 3600, refresh_token: 'new_mock_refresh_token' }; + }, + + async verifyAccessToken(token: string): Promise { + 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 }; diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 3071ea46..7d15e44a 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -5,7 +5,8 @@ import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } import express, { Response } from 'express'; import supertest from 'supertest'; import * as pkceChallenge from 'pkce-challenge'; -import { InvalidGrantError } from '../errors.js'; +import { InvalidGrantError, InvalidTokenError } from '../errors.js'; +import { AuthInfo } from '../types.js'; // Mock pkce-challenge jest.mock('pkce-challenge', () => ({ @@ -84,6 +85,18 @@ describe('Token Handler', () => { throw new InvalidGrantError('The refresh token is invalid or has expired'); }, + async verifyAccessToken(token: string): Promise { + 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 { // Do nothing in mock } diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts new file mode 100644 index 00000000..8c0b595e --- /dev/null +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -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; + let mockResponse: Partial; + 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(); + }); +}); \ No newline at end of file diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts new file mode 100644 index 00000000..14109e17 --- /dev/null +++ b/src/server/auth/middleware/bearerAuth.ts @@ -0,0 +1,78 @@ +import { RequestHandler } from "express"; +import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; +import { OAuthServerProvider } from "../provider.js"; +import { AuthInfo } from "../types.js"; + +export type BearerAuthMiddlewareOptions = { + /** + * A provider used to verify tokens. + */ + provider: OAuthServerProvider; + + /** + * Optional scopes that the token must have. + */ + requiredScopes?: string[]; +}; + +declare module "express-serve-static-core" { + interface Request { + /** + * Information about the validated access token, if the `requireBearerAuth` middleware was used. + */ + auth?: AuthInfo; + } +} + +/** + * Middleware that requires a valid Bearer token in the Authorization header. + * + * This will validate the token with the auth provider and add the resulting auth info to the request object. + */ +export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler { + return async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + throw new InvalidTokenError("Missing Authorization header"); + } + + const [type, token] = authHeader.split(' '); + if (type.toLowerCase() !== 'bearer' || !token) { + throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); + } + + const authInfo = await provider.verifyAccessToken(token); + + // Check if token has the required scopes (if any) + if (requiredScopes.length > 0) { + const hasAllScopes = requiredScopes.every(scope => + authInfo.scopes.includes(scope) + ); + + if (!hasAllScopes) { + throw new InsufficientScopeError("Insufficient scope"); + } + } + + req.auth = authInfo; + next(); + } catch (error) { + if (error instanceof InvalidTokenError) { + res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); + res.status(401).json(error.toResponseObject()); + } else if (error instanceof InsufficientScopeError) { + res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`); + res.status(403).json(error.toResponseObject()); + } else if (error instanceof ServerError) { + res.status(500).json(error.toResponseObject()); + } else if (error instanceof OAuthError) { + res.status(400).json(error.toResponseObject()); + } else { + console.error("Unexpected error authenticating bearer token:", error); + const serverError = new ServerError("Internal Server Error"); + res.status(500).json(serverError.toResponseObject()); + } + } + }; +} \ No newline at end of file diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 7e4260a0..7416c554 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -1,6 +1,7 @@ import { Response } from "express"; import { OAuthRegisteredClientsStore } from "./clients.js"; import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from "../../shared/auth.js"; +import { AuthInfo } from "./types.js"; export type AuthorizationParams = { state?: string; @@ -42,6 +43,11 @@ export interface OAuthServerProvider { */ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise; + /** + * Verifies an access token and returns information about it. + */ + verifyAccessToken(token: string): Promise; + /** * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). * diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 901ac9ca..38817cc1 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -4,6 +4,8 @@ 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('MCP Auth Router', () => { // Setup mock provider with full capabilities @@ -58,6 +60,18 @@ describe('MCP Auth Router', () => { }; }, + async verifyAccessToken(token: string): Promise { + 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 { // Success - do nothing in mock } @@ -107,6 +121,18 @@ describe('MCP Auth Router', () => { expires_in: 3600, refresh_token: 'new_mock_refresh_token' }; + }, + + async verifyAccessToken(token: string): Promise { + if (token === 'valid_token') { + return { + token, + clientId: 'valid-client', + scopes: ['read'], + expiresAt: Date.now() / 1000 + 3600 + }; + } + throw new InvalidTokenError('Token is invalid or expired'); } }; diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts new file mode 100644 index 00000000..93c5a493 --- /dev/null +++ b/src/server/auth/types.ts @@ -0,0 +1,24 @@ +/** + * Information about a validated access token, provided to request handlers. + */ +export interface AuthInfo { + /** + * The access token. + */ + token: string; + + /** + * The client ID associated with this token. + */ + clientId: string; + + /** + * Scopes associated with this token. + */ + scopes: string[]; + + /** + * When the token expires (in seconds since epoch). + */ + expiresAt?: number; +} \ No newline at end of file