feat(server): runtime-neutral requireBearerAuth for web-standard hosts (#2420)
This commit is contained in:
parent
79dc162efc
commit
7635115d01
3 changed files with 595 additions and 129 deletions
|
|
@ -1,120 +1,47 @@
|
|||
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import type { BearerAuthOptions } from '@modelcontextprotocol/server';
|
||||
import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server';
|
||||
import type { RequestHandler } from 'express';
|
||||
|
||||
import type { OAuthTokenVerifier } from './types';
|
||||
|
||||
/**
|
||||
* Options for {@link requireBearerAuth}.
|
||||
*/
|
||||
export interface BearerAuthMiddlewareOptions {
|
||||
/**
|
||||
* A verifier used to validate access tokens.
|
||||
*/
|
||||
verifier: OAuthTokenVerifier;
|
||||
|
||||
/**
|
||||
* Optional scopes that the token must have. When any are missing the
|
||||
* middleware responds with `403 insufficient_scope`.
|
||||
*/
|
||||
requiredScopes?: string[];
|
||||
|
||||
/**
|
||||
* Optional Protected Resource Metadata URL to advertise in the
|
||||
* `WWW-Authenticate` header on 401/403 responses, per
|
||||
* {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728}.
|
||||
*
|
||||
* Typically built with `getOAuthProtectedResourceMetadataUrl`.
|
||||
*/
|
||||
resourceMetadataUrl?: string;
|
||||
}
|
||||
|
||||
function buildWwwAuthenticateHeader(
|
||||
errorCode: string,
|
||||
description: string,
|
||||
requiredScopes: string[],
|
||||
resourceMetadataUrl: string | undefined
|
||||
): string {
|
||||
let header = `Bearer error="${errorCode}", error_description="${description}"`;
|
||||
if (requiredScopes.length > 0) {
|
||||
header += `, scope="${requiredScopes.join(' ')}"`;
|
||||
}
|
||||
if (resourceMetadataUrl) {
|
||||
header += `, resource_metadata="${resourceMetadataUrl}"`;
|
||||
}
|
||||
return header;
|
||||
}
|
||||
export type BearerAuthMiddlewareOptions = BearerAuthOptions;
|
||||
|
||||
/**
|
||||
* Express middleware that requires a valid Bearer token in the `Authorization`
|
||||
* header.
|
||||
*
|
||||
* The token is validated via the supplied {@link OAuthTokenVerifier} and the
|
||||
* resulting `AuthInfo` (from `@modelcontextprotocol/server`) is attached
|
||||
* to `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and
|
||||
* surfaces it to handlers as `ctx.http.authInfo`.
|
||||
* The Express adapter over the runtime-neutral core in
|
||||
* `@modelcontextprotocol/server` (`verifyBearerToken` /
|
||||
* `bearerAuthChallengeResponse` — or `requireBearerAuth` from that package for
|
||||
* web-standard `fetch(request)` hosts). The token is validated via the
|
||||
* supplied `OAuthTokenVerifier` and the resulting `AuthInfo` is attached to
|
||||
* `req.auth`. The MCP Streamable HTTP transport reads `req.auth` and surfaces
|
||||
* it to handlers as `ctx.http.authInfo`.
|
||||
*
|
||||
* On failure the middleware sends a JSON OAuth error body and a
|
||||
* `WWW-Authenticate: Bearer …` challenge that includes the configured
|
||||
* `resource_metadata` URL so clients can discover the Authorization Server.
|
||||
*/
|
||||
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
// Destructure at creation so a plain-JS caller passing undefined or
|
||||
// malformed options crashes at startup, not on the first request.
|
||||
const { verifier, requiredScopes = [], resourceMetadataUrl } = options;
|
||||
const resolved = { verifier, requiredScopes, resourceMetadataUrl };
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header');
|
||||
}
|
||||
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type?.toLowerCase() !== 'bearer' || !token) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid Authorization header format, expected 'Bearer TOKEN'");
|
||||
}
|
||||
|
||||
const authInfo = await verifier.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 OAuthError(OAuthErrorCode.InsufficientScope, 'Insufficient scope');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the token is set to expire or if it is expired
|
||||
if (typeof authInfo.expiresAt !== 'number' || Number.isNaN(authInfo.expiresAt)) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has no expiration time');
|
||||
} else if (authInfo.expiresAt < Date.now() / 1000) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
|
||||
}
|
||||
|
||||
req.auth = authInfo;
|
||||
req.auth = await verifyBearerToken(req.headers.authorization, resolved);
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof OAuthError) {
|
||||
const challenge = buildWwwAuthenticateHeader(error.code, error.message, requiredScopes, resourceMetadataUrl);
|
||||
switch (error.code) {
|
||||
case OAuthErrorCode.InvalidToken: {
|
||||
res.set('WWW-Authenticate', challenge);
|
||||
res.status(401).json(error.toResponseObject());
|
||||
break;
|
||||
}
|
||||
case OAuthErrorCode.InsufficientScope: {
|
||||
res.set('WWW-Authenticate', challenge);
|
||||
res.status(403).json(error.toResponseObject());
|
||||
break;
|
||||
}
|
||||
case OAuthErrorCode.ServerError: {
|
||||
res.status(500).json(error.toResponseObject());
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
res.status(400).json(error.toResponseObject());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const serverError = new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
// The core Response supplies status and challenge; the body is
|
||||
// derived directly rather than parsed back out of the Response.
|
||||
const response = bearerAuthChallengeResponse(error, resolved);
|
||||
const challenge = response.headers.get('WWW-Authenticate');
|
||||
if (challenge !== null) {
|
||||
res.set('WWW-Authenticate', challenge);
|
||||
}
|
||||
const body = error instanceof OAuthError ? error : new OAuthError(OAuthErrorCode.ServerError, 'Internal Server Error');
|
||||
res.status(response.status).json(body.toResponseObject());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,12 @@
|
|||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
|
||||
/**
|
||||
* Minimal token-verifier interface for MCP servers acting as an OAuth 2.0
|
||||
* Resource Server. Implementations introspect or locally validate an access
|
||||
* token and return the resulting {@link AuthInfo}, which is then attached to
|
||||
* the Express request and surfaced to MCP request handlers via
|
||||
* `ctx.http.authInfo`.
|
||||
*
|
||||
* This is intentionally narrower than a full OAuth Authorization Server
|
||||
* provider — it only covers the verification step a Resource Server needs.
|
||||
* Re-exported from `@modelcontextprotocol/server`, where the runtime-neutral
|
||||
* Bearer authentication core lives — implement it once and use it with this
|
||||
* package's Express middleware or with `requireBearerAuth` from the server
|
||||
* package on web-standard hosts.
|
||||
*/
|
||||
export interface OAuthTokenVerifier {
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
*
|
||||
* Implementations should throw an `OAuthError` (from `@modelcontextprotocol/server`)
|
||||
* with `OAuthErrorCode.InvalidToken` when
|
||||
* the token is unknown, revoked, or otherwise invalid; `requireBearerAuth`
|
||||
* maps that to a 401 with a `WWW-Authenticate` challenge.
|
||||
*
|
||||
* Note: `requireBearerAuth` rejects tokens whose `AuthInfo.expiresAt` is unset
|
||||
* (matches v1 behavior). Ensure your verifier populates it (e.g. from RFC 7662
|
||||
* introspection `exp` or the JWT `exp` claim).
|
||||
*/
|
||||
verifyAccessToken(token: string): Promise<AuthInfo>;
|
||||
}
|
||||
export type { OAuthTokenVerifier } from '@modelcontextprotocol/server';
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
interface Request {
|
||||
|
|
|
|||
|
|
@ -129,6 +129,31 @@ describe('requireBearerAuth middleware', () => {
|
|||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'server_error' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 500 without a WWW-Authenticate challenge', async () => {
|
||||
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.ServerError, 'boom'));
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer valid');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.set).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds 400 without a WWW-Authenticate challenge for other OAuth error codes', async () => {
|
||||
mockVerifyAccessToken.mockRejectedValue(new OAuthError(OAuthErrorCode.InvalidRequest, 'nope'));
|
||||
|
||||
const { req, res, next } = createMockReqResNext('Bearer valid');
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.set).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'invalid_request' }));
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue