Bearer auth middleware

This commit is contained in:
Justin Spahr-Summers 2025-02-18 16:09:51 +00:00
commit b8b816ca9f
No known key found for this signature in database
9 changed files with 450 additions and 2 deletions

View file

@ -179,4 +179,13 @@ export class InvalidClientMetadataError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("invalid_client_metadata", message, errorUri);
}
}
}
/**
* Insufficient scope error - The request requires higher privileges than provided by the access token.
*/
export class InsufficientScopeError extends OAuthError {
constructor(message: string, errorUri?: string) {
super("insufficient_scope", message, errorUri);
}
}

View file

@ -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<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
}

View file

@ -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<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
}
@ -86,6 +100,18 @@ describe('Revocation Handler', () => {
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
};

View file

@ -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<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
}

View file

@ -0,0 +1,252 @@
import { Request, Response } from "express";
import { requireBearerAuth } from "./bearerAuth.js";
import { AuthInfo } from "../types.js";
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
import { OAuthServerProvider } from "../provider.js";
import { OAuthRegisteredClientsStore } from "../clients.js";
// Mock provider
const mockVerifyAccessToken = jest.fn();
const mockProvider: OAuthServerProvider = {
clientsStore: {} as OAuthRegisteredClientsStore,
authorize: jest.fn(),
challengeForAuthorizationCode: jest.fn(),
exchangeAuthorizationCode: jest.fn(),
exchangeRefreshToken: jest.fn(),
verifyAccessToken: mockVerifyAccessToken,
};
describe("requireBearerAuth middleware", () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: jest.Mock;
beforeEach(() => {
mockRequest = {
headers: {},
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
set: jest.fn().mockReturnThis(),
};
nextFunction = jest.fn();
jest.clearAllMocks();
});
it("should call next when token is valid", async () => {
const validAuthInfo: AuthInfo = {
token: "valid-token",
clientId: "client-123",
scopes: ["read", "write"],
};
mockVerifyAccessToken.mockResolvedValue(validAuthInfo);
mockRequest.headers = {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockRequest.auth).toEqual(validAuthInfo);
expect(nextFunction).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it("should require specific scopes when configured", async () => {
const authInfo: AuthInfo = {
token: "valid-token",
clientId: "client-123",
scopes: ["read"],
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({
provider: mockProvider,
requiredScopes: ["read", "write"]
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
expect.stringContaining('Bearer error="insufficient_scope"')
);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "insufficient_scope", error_description: "Insufficient scope" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should accept token with all required scopes", async () => {
const authInfo: AuthInfo = {
token: "valid-token",
clientId: "client-123",
scopes: ["read", "write", "admin"],
};
mockVerifyAccessToken.mockResolvedValue(authInfo);
mockRequest.headers = {
authorization: "Bearer valid-token",
};
const middleware = requireBearerAuth({
provider: mockProvider,
requiredScopes: ["read", "write"]
});
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockRequest.auth).toEqual(authInfo);
expect(nextFunction).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
expect(mockResponse.json).not.toHaveBeenCalled();
});
it("should return 401 when no Authorization header is present", async () => {
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
expect.stringContaining('Bearer error="invalid_token"')
);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "invalid_token", error_description: "Missing Authorization header" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should return 401 when Authorization header format is invalid", async () => {
mockRequest.headers = {
authorization: "InvalidFormat",
};
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
expect.stringContaining('Bearer error="invalid_token"')
);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({
error: "invalid_token",
error_description: "Invalid Authorization header format, expected 'Bearer TOKEN'"
})
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should return 401 when token verification fails with InvalidTokenError", async () => {
mockRequest.headers = {
authorization: "Bearer invalid-token",
};
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token");
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
expect.stringContaining('Bearer error="invalid_token"')
);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "invalid_token", error_description: "Token expired" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should return 403 when access token has insufficient scopes", async () => {
mockRequest.headers = {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write"));
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockResponse.status).toHaveBeenCalledWith(403);
expect(mockResponse.set).toHaveBeenCalledWith(
"WWW-Authenticate",
expect.stringContaining('Bearer error="insufficient_scope"')
);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "insufficient_scope", error_description: "Required scopes: read, write" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should return 500 when a ServerError occurs", async () => {
mockRequest.headers = {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "server_error", error_description: "Internal server issue" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should return 400 for generic OAuthError", async () => {
mockRequest.headers = {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error"));
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
it("should return 500 when unexpected error occurs", async () => {
mockRequest.headers = {
authorization: "Bearer valid-token",
};
mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error"));
const middleware = requireBearerAuth({ provider: mockProvider });
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith(
expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" })
);
expect(nextFunction).not.toHaveBeenCalled();
});
});

View file

@ -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());
}
}
};
}

View file

@ -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<OAuthTokens>;
/**
* Verifies an access token and returns information about it.
*/
verifyAccessToken(token: string): Promise<AuthInfo>;
/**
* Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
*

View file

@ -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<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
}
@ -107,6 +121,18 @@ describe('MCP Auth Router', () => {
expires_in: 3600,
refresh_token: 'new_mock_refresh_token'
};
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
if (token === 'valid_token') {
return {
token,
clientId: 'valid-client',
scopes: ['read'],
expiresAt: Date.now() / 1000 + 3600
};
}
throw new InvalidTokenError('Token is invalid or expired');
}
};

24
src/server/auth/types.ts Normal file
View file

@ -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;
}