Merge branch 'main' into cb/sse-tests
This commit is contained in:
commit
08c767f8e1
26 changed files with 9519 additions and 894 deletions
|
|
@ -276,6 +276,34 @@ describe('Authorization Handler', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Resource parameter validation', () => {
|
||||
it('propagates resource parameter', async () => {
|
||||
const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize');
|
||||
|
||||
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',
|
||||
resource: 'https://api.example.com/resource'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(mockProviderWithResource).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
expect.objectContaining({
|
||||
resource: new URL('https://api.example.com/resource'),
|
||||
redirectUri: 'https://example.com/callback',
|
||||
codeChallenge: 'challenge123'
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Successful authorization', () => {
|
||||
it('handles successful authorization with all parameters', async () => {
|
||||
const response = await supertest(app)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({
|
|||
code_challenge_method: z.literal("S256"),
|
||||
scope: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
resource: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler {
|
||||
|
|
@ -98,7 +99,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
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());
|
||||
}
|
||||
|
|
@ -115,7 +115,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { scope, code_challenge } = parseResult.data;
|
||||
const { scope, code_challenge, resource } = parseResult.data;
|
||||
state = parseResult.data.state;
|
||||
|
||||
// Validate scopes
|
||||
|
|
@ -138,13 +138,13 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A
|
|||
scopes: requestedScopes,
|
||||
redirectUri: redirect_uri,
|
||||
codeChallenge: code_challenge,
|
||||
resource: resource ? new URL(resource) : undefined,
|
||||
}, 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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import express, { RequestHandler } from "express";
|
||||
import { OAuthMetadata } from "../../../shared/auth.js";
|
||||
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js";
|
||||
import cors from 'cors';
|
||||
import { allowedMethods } from "../middleware/allowedMethods.js";
|
||||
|
||||
export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
|
||||
export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler {
|
||||
// Nested router so we can configure middleware and restrict HTTP method
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler {
|
|||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ export function clientRegistrationHandler({
|
|||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
InvalidRequestError,
|
||||
ServerError,
|
||||
TooManyRequestsError,
|
||||
OAuthError
|
||||
OAuthError,
|
||||
} from "../errors.js";
|
||||
|
||||
export type RevocationHandlerOptions = {
|
||||
|
|
@ -21,7 +21,10 @@ export type RevocationHandlerOptions = {
|
|||
rateLimit?: Partial<RateLimitOptions> | false;
|
||||
};
|
||||
|
||||
export function revocationHandler({ provider, rateLimit: rateLimitConfig }: RevocationHandlerOptions): RequestHandler {
|
||||
export function revocationHandler({
|
||||
provider,
|
||||
rateLimit: rateLimitConfig,
|
||||
}: RevocationHandlerOptions): RequestHandler {
|
||||
if (!provider.revokeToken) {
|
||||
throw new Error("Auth provider does not support revoking tokens");
|
||||
}
|
||||
|
|
@ -37,21 +40,25 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
|
||||
// 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
|
||||
}));
|
||||
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');
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
try {
|
||||
const parseResult = OAuthTokenRevocationRequestSchema.safeParse(req.body);
|
||||
|
|
@ -62,7 +69,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
console.error("Missing client information after authentication");
|
||||
throw new ServerError("Internal Server Error");
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +79,6 @@ export function revocationHandler({ provider, rateLimit: rateLimitConfig }: Revo
|
|||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -264,12 +264,14 @@ describe('Token Handler', () => {
|
|||
});
|
||||
|
||||
it('returns tokens for valid code exchange', async () => {
|
||||
const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode');
|
||||
const response = await supertest(app)
|
||||
.post('/token')
|
||||
.type('form')
|
||||
.send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
resource: 'https://api.example.com/resource',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'valid_verifier'
|
||||
|
|
@ -280,6 +282,13 @@ describe('Token Handler', () => {
|
|||
expect(response.body.token_type).toBe('bearer');
|
||||
expect(response.body.expires_in).toBe(3600);
|
||||
expect(response.body.refresh_token).toBe('mock_refresh_token');
|
||||
expect(mockExchangeCode).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
'valid_code',
|
||||
undefined, // code_verifier is undefined after PKCE validation
|
||||
undefined, // redirect_uri
|
||||
new URL('https://api.example.com/resource') // resource parameter
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through code verifier when using proxy provider', async () => {
|
||||
|
|
@ -322,7 +331,8 @@ describe('Token Handler', () => {
|
|||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'any_verifier'
|
||||
code_verifier: 'any_verifier',
|
||||
redirect_uri: 'https://example.com/callback'
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
|
@ -342,6 +352,69 @@ describe('Token Handler', () => {
|
|||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through redirect_uri when using proxy provider', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
try {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
access_token: 'mock_access_token',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'mock_refresh_token'
|
||||
})
|
||||
});
|
||||
|
||||
const proxyProvider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: 'https://example.com/authorize',
|
||||
tokenUrl: 'https://example.com/token'
|
||||
},
|
||||
verifyAccessToken: async (token) => ({
|
||||
token,
|
||||
clientId: 'valid-client',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
}),
|
||||
getClient: async (clientId) => clientId === 'valid-client' ? validClient : undefined
|
||||
});
|
||||
|
||||
const proxyApp = express();
|
||||
const options: TokenHandlerOptions = { provider: proxyProvider };
|
||||
proxyApp.use('/token', tokenHandler(options));
|
||||
|
||||
const redirectUri = 'https://example.com/callback';
|
||||
const response = await supertest(proxyApp)
|
||||
.post('/token')
|
||||
.type('form')
|
||||
.send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
grant_type: 'authorization_code',
|
||||
code: 'valid_code',
|
||||
code_verifier: 'any_verifier',
|
||||
redirect_uri: redirectUri
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.access_token).toBe('mock_access_token');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh token grant', () => {
|
||||
|
|
@ -376,12 +449,14 @@ describe('Token Handler', () => {
|
|||
});
|
||||
|
||||
it('returns new tokens for valid refresh token', async () => {
|
||||
const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken');
|
||||
const response = await supertest(app)
|
||||
.post('/token')
|
||||
.type('form')
|
||||
.send({
|
||||
client_id: 'valid-client',
|
||||
client_secret: 'valid-secret',
|
||||
resource: 'https://api.example.com/resource',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'valid_refresh_token'
|
||||
});
|
||||
|
|
@ -391,6 +466,12 @@ describe('Token Handler', () => {
|
|||
expect(response.body.token_type).toBe('bearer');
|
||||
expect(response.body.expires_in).toBe(3600);
|
||||
expect(response.body.refresh_token).toBe('new_mock_refresh_token');
|
||||
expect(mockExchangeRefresh).toHaveBeenCalledWith(
|
||||
validClient,
|
||||
'valid_refresh_token',
|
||||
undefined, // scopes
|
||||
new URL('https://api.example.com/resource') // resource parameter
|
||||
);
|
||||
});
|
||||
|
||||
it('respects requested scopes on refresh', async () => {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,14 @@ const TokenRequestSchema = z.object({
|
|||
const AuthorizationCodeGrantSchema = z.object({
|
||||
code: z.string(),
|
||||
code_verifier: z.string(),
|
||||
redirect_uri: z.string().optional(),
|
||||
resource: z.string().url().optional(),
|
||||
});
|
||||
|
||||
const RefreshTokenGrantSchema = z.object({
|
||||
refresh_token: z.string(),
|
||||
scope: z.string().optional(),
|
||||
resource: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler {
|
||||
|
|
@ -77,7 +80,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
const client = req.client;
|
||||
if (!client) {
|
||||
// This should never happen
|
||||
console.error("Missing client information after authentication");
|
||||
throw new ServerError("Internal Server Error");
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +90,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { code, code_verifier } = parseResult.data;
|
||||
const { code, code_verifier, redirect_uri, resource } = parseResult.data;
|
||||
|
||||
const skipLocalPkceValidation = provider.skipLocalPkceValidation;
|
||||
|
||||
|
|
@ -102,7 +104,13 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
}
|
||||
|
||||
// Passes the code_verifier to the provider if PKCE validation didn't occur locally
|
||||
const tokens = await provider.exchangeAuthorizationCode(client, code, skipLocalPkceValidation ? code_verifier : undefined);
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
client,
|
||||
code,
|
||||
skipLocalPkceValidation ? code_verifier : undefined,
|
||||
redirect_uri,
|
||||
resource ? new URL(resource) : undefined
|
||||
);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
|
|
@ -113,10 +121,10 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
throw new InvalidRequestError(parseResult.error.message);
|
||||
}
|
||||
|
||||
const { refresh_token, scope } = parseResult.data;
|
||||
const { refresh_token, scope, resource } = parseResult.data;
|
||||
|
||||
const scopes = scope?.split(" ");
|
||||
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes);
|
||||
const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined);
|
||||
res.status(200).json(tokens);
|
||||
break;
|
||||
}
|
||||
|
|
@ -134,7 +142,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand
|
|||
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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,11 @@ 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";
|
||||
import { OAuthTokenVerifier } from "../provider.js";
|
||||
|
||||
// Mock provider
|
||||
// Mock verifier
|
||||
const mockVerifyAccessToken = jest.fn();
|
||||
const mockProvider: OAuthServerProvider = {
|
||||
clientsStore: {} as OAuthRegisteredClientsStore,
|
||||
authorize: jest.fn(),
|
||||
challengeForAuthorizationCode: jest.fn(),
|
||||
exchangeAuthorizationCode: jest.fn(),
|
||||
exchangeRefreshToken: jest.fn(),
|
||||
const mockVerifier: OAuthTokenVerifier = {
|
||||
verifyAccessToken: mockVerifyAccessToken,
|
||||
};
|
||||
|
||||
|
|
@ -50,7 +44,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
|
|
@ -59,7 +53,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
expect(mockResponse.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it("should reject expired tokens", async () => {
|
||||
const expiredAuthInfo: AuthInfo = {
|
||||
token: "expired-token",
|
||||
|
|
@ -73,7 +67,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
authorization: "Bearer expired-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token");
|
||||
|
|
@ -87,7 +81,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it("should accept non-expired tokens", async () => {
|
||||
const nonExpiredAuthInfo: AuthInfo = {
|
||||
token: "valid-token",
|
||||
|
|
@ -101,7 +95,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
|
|
@ -124,7 +118,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
provider: mockProvider,
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ["read", "write"]
|
||||
});
|
||||
|
||||
|
|
@ -155,7 +149,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
provider: mockProvider,
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ["read", "write"]
|
||||
});
|
||||
|
||||
|
|
@ -169,7 +163,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
});
|
||||
|
||||
it("should return 401 when no Authorization header is present", async () => {
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
|
||||
|
|
@ -189,7 +183,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
authorization: "InvalidFormat",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).not.toHaveBeenCalled();
|
||||
|
|
@ -214,7 +208,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token");
|
||||
|
|
@ -236,7 +230,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
|
|
@ -258,7 +252,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
|
||||
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
|
|
@ -276,7 +270,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
|
||||
mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
|
|
@ -294,7 +288,7 @@ describe("requireBearerAuth middleware", () => {
|
|||
|
||||
mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error"));
|
||||
|
||||
const middleware = requireBearerAuth({ provider: mockProvider });
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token");
|
||||
|
|
@ -304,4 +298,125 @@ describe("requireBearerAuth middleware", () => {
|
|||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with resourceMetadataUrl", () => {
|
||||
const resourceMetadataUrl = "https://api.example.com/.well-known/oauth-protected-resource";
|
||||
|
||||
it("should include resource_metadata in WWW-Authenticate header for 401 responses", async () => {
|
||||
mockRequest.headers = {};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
`Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include resource_metadata in WWW-Authenticate header when token verification fails", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer invalid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired"));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
`Bearer error="invalid_token", error_description="Token expired", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include resource_metadata in WWW-Authenticate header for insufficient scope errors", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: admin"));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
`Bearer error="insufficient_scope", error_description="Required scopes: admin", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include resource_metadata when token is expired", async () => {
|
||||
const expiredAuthInfo: AuthInfo = {
|
||||
token: "expired-token",
|
||||
clientId: "client-123",
|
||||
scopes: ["read", "write"],
|
||||
expiresAt: Math.floor(Date.now() / 1000) - 100,
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(expiredAuthInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer expired-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
`Bearer error="invalid_token", error_description="Token has expired", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include resource_metadata when scope check fails", async () => {
|
||||
const authInfo: AuthInfo = {
|
||||
token: "valid-token",
|
||||
clientId: "client-123",
|
||||
scopes: ["read"],
|
||||
};
|
||||
mockVerifyAccessToken.mockResolvedValue(authInfo);
|
||||
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
const middleware = requireBearerAuth({
|
||||
verifier: mockVerifier,
|
||||
requiredScopes: ["read", "write"],
|
||||
resourceMetadataUrl
|
||||
});
|
||||
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(403);
|
||||
expect(mockResponse.set).toHaveBeenCalledWith(
|
||||
"WWW-Authenticate",
|
||||
`Bearer error="insufficient_scope", error_description="Insufficient scope", resource_metadata="${resourceMetadataUrl}"`
|
||||
);
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not affect server errors (no WWW-Authenticate header)", async () => {
|
||||
mockRequest.headers = {
|
||||
authorization: "Bearer valid-token",
|
||||
};
|
||||
|
||||
mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue"));
|
||||
|
||||
const middleware = requireBearerAuth({ verifier: mockVerifier, resourceMetadataUrl });
|
||||
await middleware(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.set).not.toHaveBeenCalledWith("WWW-Authenticate", expect.anything());
|
||||
expect(nextFunction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
import { RequestHandler } from "express";
|
||||
import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js";
|
||||
import { OAuthServerProvider } from "../provider.js";
|
||||
import { OAuthTokenVerifier } from "../provider.js";
|
||||
import { AuthInfo } from "../types.js";
|
||||
|
||||
export type BearerAuthMiddlewareOptions = {
|
||||
/**
|
||||
* A provider used to verify tokens.
|
||||
*/
|
||||
provider: OAuthServerProvider;
|
||||
verifier: OAuthTokenVerifier;
|
||||
|
||||
/**
|
||||
* Optional scopes that the token must have.
|
||||
*/
|
||||
requiredScopes?: string[];
|
||||
|
||||
/**
|
||||
* Optional resource metadata URL to include in WWW-Authenticate header.
|
||||
*/
|
||||
resourceMetadataUrl?: string;
|
||||
};
|
||||
|
||||
declare module "express-serve-static-core" {
|
||||
|
|
@ -26,10 +31,13 @@ declare module "express-serve-static-core" {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header
|
||||
* for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec.
|
||||
*/
|
||||
export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
|
@ -42,7 +50,7 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM
|
|||
throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'");
|
||||
}
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(token);
|
||||
const authInfo = await verifier.verifyAccessToken(token);
|
||||
|
||||
// Check if token has the required scopes (if any)
|
||||
if (requiredScopes.length > 0) {
|
||||
|
|
@ -64,20 +72,25 @@ export function requireBearerAuth({ provider, requiredScopes = [] }: BearerAuthM
|
|||
next();
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidTokenError) {
|
||||
res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`);
|
||||
const wwwAuthValue = resourceMetadataUrl
|
||||
? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`
|
||||
: `Bearer error="${error.errorCode}", error_description="${error.message}"`;
|
||||
res.set("WWW-Authenticate", wwwAuthValue);
|
||||
res.status(401).json(error.toResponseObject());
|
||||
} else if (error instanceof InsufficientScopeError) {
|
||||
res.set("WWW-Authenticate", `Bearer error="${error.errorCode}", error_description="${error.message}"`);
|
||||
const wwwAuthValue = resourceMetadataUrl
|
||||
? `Bearer error="${error.errorCode}", error_description="${error.message}", resource_metadata="${resourceMetadataUrl}"`
|
||||
: `Bearer error="${error.errorCode}", error_description="${error.message}"`;
|
||||
res.set("WWW-Authenticate", wwwAuthValue);
|
||||
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());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew
|
|||
const status = error instanceof ServerError ? 500 : 400;
|
||||
res.status(status).json(error.toResponseObject());
|
||||
} else {
|
||||
console.error("Unexpected error authenticating client:", error);
|
||||
const serverError = new ServerError("Internal Server Error");
|
||||
res.status(500).json(serverError.toResponseObject());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type AuthorizationParams = {
|
|||
scopes?: string[];
|
||||
codeChallenge: string;
|
||||
redirectUri: string;
|
||||
resource?: URL;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -20,8 +21,8 @@ export interface OAuthServerProvider {
|
|||
get clientsStore(): OAuthRegisteredClientsStore;
|
||||
|
||||
/**
|
||||
* Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
|
||||
*
|
||||
* Begins the authorization flow, which can either be implemented by this server itself or via redirection to a separate authorization server.
|
||||
*
|
||||
* This server must eventually issue a redirect with an authorization response or an error response to the given redirect URI. Per OAuth 2.1:
|
||||
* - In the successful case, the redirect MUST include the `code` and `state` (if present) query parameters.
|
||||
* - In the error case, the redirect MUST include the `error` query parameter, and MAY include an optional `error_description` query parameter.
|
||||
|
|
@ -36,12 +37,18 @@ export interface OAuthServerProvider {
|
|||
/**
|
||||
* Exchanges an authorization code for an access token.
|
||||
*/
|
||||
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string): Promise<OAuthTokens>;
|
||||
exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL
|
||||
): Promise<OAuthTokens>;
|
||||
|
||||
/**
|
||||
* Exchanges a refresh token for an access token.
|
||||
*/
|
||||
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens>;
|
||||
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
|
||||
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
|
|
@ -50,17 +57,28 @@ export interface OAuthServerProvider {
|
|||
|
||||
/**
|
||||
* Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended).
|
||||
*
|
||||
*
|
||||
* If the given token is invalid or already revoked, this method should do nothing.
|
||||
*/
|
||||
revokeToken?(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
|
||||
|
||||
/**
|
||||
* Whether to skip local PKCE validation.
|
||||
*
|
||||
*
|
||||
* If true, the server will not perform PKCE validation locally and will pass the code_verifier to the upstream server.
|
||||
*
|
||||
*
|
||||
* NOTE: This should only be true if the upstream server is performing the actual PKCE validation.
|
||||
*/
|
||||
skipLocalPkceValidation?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Slim implementation useful for token verification
|
||||
*/
|
||||
export interface OAuthTokenVerifier {
|
||||
/**
|
||||
* Verifies an access token and returns information about it.
|
||||
*/
|
||||
verifyAccessToken(token: string): Promise<AuthInfo>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => {
|
|||
codeChallenge: "test-challenge",
|
||||
state: "test-state",
|
||||
scopes: ["read", "write"],
|
||||
resource: new URL('https://api.example.com/resource'),
|
||||
},
|
||||
mockResponse
|
||||
);
|
||||
|
|
@ -100,6 +101,7 @@ describe("Proxy OAuth Server Provider", () => {
|
|||
expectedUrl.searchParams.set("code_challenge_method", "S256");
|
||||
expectedUrl.searchParams.set("state", "test-state");
|
||||
expectedUrl.searchParams.set("scope", "read write");
|
||||
expectedUrl.searchParams.set('resource', 'https://api.example.com/resource');
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString());
|
||||
});
|
||||
|
|
@ -142,6 +144,63 @@ describe("Proxy OAuth Server Provider", () => {
|
|||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it("includes redirect_uri in token request when provided", async () => {
|
||||
const redirectUri = "https://example.com/callback";
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
validClient,
|
||||
"test-code",
|
||||
"test-verifier",
|
||||
redirectUri
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://auth.example.com/token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: expect.stringContaining(`redirect_uri=${encodeURIComponent(redirectUri)}`)
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('includes resource parameter in authorization code exchange', async () => {
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
validClient,
|
||||
'test-code',
|
||||
'test-verifier',
|
||||
'https://example.com/callback',
|
||||
new URL('https://api.example.com/resource')
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('handles authorization code exchange without resource parameter', async () => {
|
||||
const tokens = await provider.exchangeAuthorizationCode(
|
||||
validClient,
|
||||
'test-code',
|
||||
'test-verifier'
|
||||
);
|
||||
|
||||
const fetchCall = (global.fetch as jest.Mock).mock.calls[0];
|
||||
const body = fetchCall[1].body as string;
|
||||
expect(body).not.toContain('resource=');
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it("exchanges refresh token for new tokens", async () => {
|
||||
const tokens = await provider.exchangeRefreshToken(
|
||||
validClient,
|
||||
|
|
@ -162,6 +221,26 @@ describe("Proxy OAuth Server Provider", () => {
|
|||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
|
||||
it('includes resource parameter in refresh token exchange', async () => {
|
||||
const tokens = await provider.exchangeRefreshToken(
|
||||
validClient,
|
||||
'test-refresh-token',
|
||||
['read', 'write'],
|
||||
new URL('https://api.example.com/resource')
|
||||
);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/token',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource'))
|
||||
})
|
||||
);
|
||||
expect(tokens).toEqual(mockTokenResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("client registration", () => {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
|
|||
// Add optional standard OAuth parameters
|
||||
if (params.state) searchParams.set("state", params.state);
|
||||
if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" "));
|
||||
if (params.resource) searchParams.set("resource", params.resource.href);
|
||||
|
||||
targetUrl.search = searchParams.toString();
|
||||
res.redirect(targetUrl.toString());
|
||||
|
|
@ -151,7 +152,9 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
|
|||
async exchangeAuthorizationCode(
|
||||
client: OAuthClientInformationFull,
|
||||
authorizationCode: string,
|
||||
codeVerifier?: string
|
||||
codeVerifier?: string,
|
||||
redirectUri?: string,
|
||||
resource?: URL
|
||||
): Promise<OAuthTokens> {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
|
|
@ -167,6 +170,14 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
|
|||
params.append("code_verifier", codeVerifier);
|
||||
}
|
||||
|
||||
if (redirectUri) {
|
||||
params.append("redirect_uri", redirectUri);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
params.append("resource", resource.href);
|
||||
}
|
||||
|
||||
const response = await fetch(this._endpoints.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -187,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
|
|||
async exchangeRefreshToken(
|
||||
client: OAuthClientInformationFull,
|
||||
refreshToken: string,
|
||||
scopes?: string[]
|
||||
scopes?: string[],
|
||||
resource?: URL
|
||||
): Promise<OAuthTokens> {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
|
@ -204,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider {
|
|||
params.set("scope", scopes.join(" "));
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
params.set("resource", resource.href);
|
||||
}
|
||||
|
||||
const response = await fetch(this._endpoints.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { mcpAuthRouter, AuthRouterOptions } from './router.js';
|
||||
import { mcpAuthRouter, AuthRouterOptions, mcpAuthMetadataRouter, AuthMetadataOptions } from './router.js';
|
||||
import { OAuthServerProvider, AuthorizationParams } from './provider.js';
|
||||
import { OAuthRegisteredClientsStore } from './clients.js';
|
||||
import { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '../../shared/auth.js';
|
||||
import { OAuthClientInformationFull, OAuthMetadata, 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
|
||||
const mockClientStore: OAuthRegisteredClientsStore = {
|
||||
|
|
@ -246,6 +247,29 @@ describe('MCP Auth Router', () => {
|
|||
expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined();
|
||||
expect(response.body.service_documentation).toBeUndefined();
|
||||
});
|
||||
|
||||
it('provides protected resource metadata', async () => {
|
||||
// Setup router with draft protocol version
|
||||
const draftApp = express();
|
||||
const options: AuthRouterOptions = {
|
||||
provider: mockProvider,
|
||||
issuerUrl: new URL('https://mcp.example.com'),
|
||||
scopesSupported: ['read', 'write'],
|
||||
resourceName: 'Test API'
|
||||
};
|
||||
draftApp.use(mcpAuthRouter(options));
|
||||
|
||||
const response = await supertest(draftApp)
|
||||
.get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify protected resource metadata
|
||||
expect(response.body.resource).toBe('https://mcp.example.com/');
|
||||
expect(response.body.authorization_servers).toContain('https://mcp.example.com/');
|
||||
expect(response.body.scopes_supported).toEqual(['read', 'write']);
|
||||
expect(response.body.resource_name).toBe('Test API');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Endpoint routing', () => {
|
||||
|
|
@ -358,4 +382,101 @@ describe('MCP Auth Router', () => {
|
|||
expect(revokeResponse.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP Auth Metadata Router', () => {
|
||||
|
||||
const mockOAuthMetadata : OAuthMetadata = {
|
||||
issuer: 'https://auth.example.com/',
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
}
|
||||
|
||||
describe('Router creation', () => {
|
||||
it('successfully creates router with valid options', () => {
|
||||
const options: AuthMetadataOptions = {
|
||||
oauthMetadata: mockOAuthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com'),
|
||||
};
|
||||
|
||||
expect(() => mcpAuthMetadataRouter(options)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metadata endpoints', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(() => {
|
||||
app = express();
|
||||
const options: AuthMetadataOptions = {
|
||||
oauthMetadata: mockOAuthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com'),
|
||||
serviceDocumentationUrl: new URL('https://docs.example.com'),
|
||||
scopesSupported: ['read', 'write'],
|
||||
resourceName: 'Test API'
|
||||
};
|
||||
app.use(mcpAuthMetadataRouter(options));
|
||||
});
|
||||
|
||||
it('returns OAuth authorization server metadata', async () => {
|
||||
const response = await supertest(app)
|
||||
.get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify metadata points to authorization server
|
||||
expect(response.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(response.body.authorization_endpoint).toBe('https://auth.example.com/authorize');
|
||||
expect(response.body.token_endpoint).toBe('https://auth.example.com/token');
|
||||
expect(response.body.response_types_supported).toEqual(['code']);
|
||||
expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']);
|
||||
expect(response.body.code_challenge_methods_supported).toEqual(['S256']);
|
||||
expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post']);
|
||||
});
|
||||
|
||||
it('returns OAuth protected resource metadata', async () => {
|
||||
const response = await supertest(app)
|
||||
.get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify protected resource metadata
|
||||
expect(response.body.resource).toBe('https://api.example.com/');
|
||||
expect(response.body.authorization_servers).toEqual(['https://auth.example.com/']);
|
||||
expect(response.body.scopes_supported).toEqual(['read', 'write']);
|
||||
expect(response.body.resource_name).toBe('Test API');
|
||||
expect(response.body.resource_documentation).toBe('https://docs.example.com/');
|
||||
});
|
||||
|
||||
it('works with minimal configuration', async () => {
|
||||
const minimalApp = express();
|
||||
const options: AuthMetadataOptions = {
|
||||
oauthMetadata: mockOAuthMetadata,
|
||||
resourceServerUrl: new URL('https://api.example.com'),
|
||||
};
|
||||
minimalApp.use(mcpAuthMetadataRouter(options));
|
||||
|
||||
const authResponse = await supertest(minimalApp)
|
||||
.get('/.well-known/oauth-authorization-server');
|
||||
|
||||
expect(authResponse.status).toBe(200);
|
||||
expect(authResponse.body.issuer).toBe('https://auth.example.com/');
|
||||
expect(authResponse.body.service_documentation).toBeUndefined();
|
||||
expect(authResponse.body.scopes_supported).toBeUndefined();
|
||||
|
||||
const resourceResponse = await supertest(minimalApp)
|
||||
.get('/.well-known/oauth-protected-resource');
|
||||
|
||||
expect(resourceResponse.status).toBe(200);
|
||||
expect(resourceResponse.body.resource).toBe('https://api.example.com/');
|
||||
expect(resourceResponse.body.authorization_servers).toEqual(['https://auth.example.com/']);
|
||||
expect(resourceResponse.body.scopes_supported).toBeUndefined();
|
||||
expect(resourceResponse.body.resource_name).toBeUndefined();
|
||||
expect(resourceResponse.body.resource_documentation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { authorizationHandler, AuthorizationHandlerOptions } from "./handlers/au
|
|||
import { revocationHandler, RevocationHandlerOptions } from "./handlers/revoke.js";
|
||||
import { metadataHandler } from "./handlers/metadata.js";
|
||||
import { OAuthServerProvider } from "./provider.js";
|
||||
import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../shared/auth.js";
|
||||
|
||||
export type AuthRouterOptions = {
|
||||
/**
|
||||
|
|
@ -19,7 +20,7 @@ export type AuthRouterOptions = {
|
|||
|
||||
/**
|
||||
* The base URL of the authorization server to use for the metadata endpoints.
|
||||
*
|
||||
*
|
||||
* If not provided, the issuer URL will be used as the base URL.
|
||||
*/
|
||||
baseUrl?: URL;
|
||||
|
|
@ -29,6 +30,17 @@ export type AuthRouterOptions = {
|
|||
*/
|
||||
serviceDocumentationUrl?: URL;
|
||||
|
||||
/**
|
||||
* An optional list of scopes supported by this authorization server
|
||||
*/
|
||||
scopesSupported?: string[];
|
||||
|
||||
|
||||
/**
|
||||
* The resource name to be displayed in protected resource metadata
|
||||
*/
|
||||
resourceName?: string;
|
||||
|
||||
// Individual options per route
|
||||
authorizationOptions?: Omit<AuthorizationHandlerOptions, "provider">;
|
||||
clientRegistrationOptions?: Omit<ClientRegistrationHandlerOptions, "clientsStore">;
|
||||
|
|
@ -36,37 +48,37 @@ export type AuthRouterOptions = {
|
|||
tokenOptions?: Omit<TokenHandlerOptions, "provider">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs standard MCP authorization endpoints, including dynamic client registration and token revocation (if supported). Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients.
|
||||
*
|
||||
* By default, rate limiting is applied to all endpoints to prevent abuse.
|
||||
*
|
||||
* This router MUST be installed at the application root, like so:
|
||||
*
|
||||
* const app = express();
|
||||
* app.use(mcpAuthRouter(...));
|
||||
*/
|
||||
export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
|
||||
const issuer = options.issuerUrl;
|
||||
const baseUrl = options.baseUrl;
|
||||
|
||||
const checkIssuerUrl = (issuer: URL): void => {
|
||||
// Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing
|
||||
if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") {
|
||||
throw new Error("Issuer URL must be HTTPS");
|
||||
}
|
||||
if (issuer.hash) {
|
||||
throw new Error("Issuer URL must not have a fragment");
|
||||
throw new Error(`Issuer URL must not have a fragment: ${issuer}`);
|
||||
}
|
||||
if (issuer.search) {
|
||||
throw new Error("Issuer URL must not have a query string");
|
||||
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const createOAuthMetadata = (options: {
|
||||
provider: OAuthServerProvider,
|
||||
issuerUrl: URL,
|
||||
baseUrl?: URL
|
||||
serviceDocumentationUrl?: URL,
|
||||
scopesSupported?: string[];
|
||||
}): OAuthMetadata => {
|
||||
const issuer = options.issuerUrl;
|
||||
const baseUrl = options.baseUrl;
|
||||
|
||||
checkIssuerUrl(issuer);
|
||||
|
||||
const authorization_endpoint = "/authorize";
|
||||
const token_endpoint = "/token";
|
||||
const registration_endpoint = options.provider.clientsStore.registerClient ? "/register" : undefined;
|
||||
const revocation_endpoint = options.provider.revokeToken ? "/revoke" : undefined;
|
||||
|
||||
const metadata = {
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: issuer.href,
|
||||
service_documentation: options.serviceDocumentationUrl?.href,
|
||||
|
||||
|
|
@ -78,29 +90,56 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
|
|||
token_endpoint_auth_methods_supported: ["client_secret_post"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
|
||||
scopes_supported: options.scopesSupported,
|
||||
|
||||
revocation_endpoint: revocation_endpoint ? new URL(revocation_endpoint, baseUrl || issuer).href : undefined,
|
||||
revocation_endpoint_auth_methods_supported: revocation_endpoint ? ["client_secret_post"] : undefined,
|
||||
|
||||
registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined,
|
||||
};
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs standard MCP authorization server endpoints, including dynamic client registration and token revocation (if supported).
|
||||
* Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients.
|
||||
* Note: if your MCP server is only a resource server and not an authorization server, use mcpAuthMetadataRouter instead.
|
||||
*
|
||||
* By default, rate limiting is applied to all endpoints to prevent abuse.
|
||||
*
|
||||
* This router MUST be installed at the application root, like so:
|
||||
*
|
||||
* const app = express();
|
||||
* app.use(mcpAuthRouter(...));
|
||||
*/
|
||||
export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
|
||||
const oauthMetadata = createOAuthMetadata(options);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(
|
||||
authorization_endpoint,
|
||||
new URL(oauthMetadata.authorization_endpoint).pathname,
|
||||
authorizationHandler({ provider: options.provider, ...options.authorizationOptions })
|
||||
);
|
||||
|
||||
router.use(
|
||||
token_endpoint,
|
||||
new URL(oauthMetadata.token_endpoint).pathname,
|
||||
tokenHandler({ provider: options.provider, ...options.tokenOptions })
|
||||
);
|
||||
|
||||
router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata));
|
||||
router.use(mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
// This router is used for AS+RS combo's, so the issuer is also the resource server
|
||||
resourceServerUrl: new URL(oauthMetadata.issuer),
|
||||
serviceDocumentationUrl: options.serviceDocumentationUrl,
|
||||
scopesSupported: options.scopesSupported,
|
||||
resourceName: options.resourceName
|
||||
}));
|
||||
|
||||
if (registration_endpoint) {
|
||||
if (oauthMetadata.registration_endpoint) {
|
||||
router.use(
|
||||
registration_endpoint,
|
||||
new URL(oauthMetadata.registration_endpoint).pathname,
|
||||
clientRegistrationHandler({
|
||||
clientsStore: options.provider.clientsStore,
|
||||
...options,
|
||||
|
|
@ -108,12 +147,80 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler {
|
|||
);
|
||||
}
|
||||
|
||||
if (revocation_endpoint) {
|
||||
if (oauthMetadata.revocation_endpoint) {
|
||||
router.use(
|
||||
revocation_endpoint,
|
||||
new URL(oauthMetadata.revocation_endpoint).pathname,
|
||||
revocationHandler({ provider: options.provider, ...options.revocationOptions })
|
||||
);
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
}
|
||||
|
||||
export type AuthMetadataOptions = {
|
||||
/**
|
||||
* OAuth Metadata as would be returned from the authorization server
|
||||
* this MCP server relies on
|
||||
*/
|
||||
oauthMetadata: OAuthMetadata;
|
||||
|
||||
/**
|
||||
* The url of the MCP server, for use in protected resource metadata
|
||||
*/
|
||||
resourceServerUrl: URL;
|
||||
|
||||
/**
|
||||
* The url for documentation for the MCP server
|
||||
*/
|
||||
serviceDocumentationUrl?: URL;
|
||||
|
||||
/**
|
||||
* An optional list of scopes supported by this MCP server
|
||||
*/
|
||||
scopesSupported?: string[];
|
||||
|
||||
/**
|
||||
* An optional resource name to display in resource metadata
|
||||
*/
|
||||
resourceName?: string;
|
||||
}
|
||||
|
||||
export function mcpAuthMetadataRouter(options: AuthMetadataOptions) {
|
||||
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: options.resourceServerUrl.href,
|
||||
|
||||
authorization_servers: [
|
||||
options.oauthMetadata.issuer
|
||||
],
|
||||
|
||||
scopes_supported: options.scopesSupported,
|
||||
resource_name: options.resourceName,
|
||||
resource_documentation: options.serviceDocumentationUrl?.href,
|
||||
};
|
||||
|
||||
router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata));
|
||||
|
||||
// Always add this for backwards compatibility
|
||||
router.use("/.well-known/oauth-authorization-server", metadataHandler(options.oauthMetadata));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to construct the OAuth 2.0 Protected Resource Metadata URL
|
||||
* from a given server URL. This replaces the path with the standard metadata endpoint.
|
||||
*
|
||||
* @param serverUrl - The base URL of the protected resource server
|
||||
* @returns The URL for the OAuth protected resource metadata endpoint
|
||||
*
|
||||
* @example
|
||||
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
|
||||
* // Returns: 'https://api.example.com/.well-known/oauth-protected-resource'
|
||||
*/
|
||||
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
|
||||
return new URL('/.well-known/oauth-protected-resource', serverUrl).href;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export interface AuthInfo {
|
|||
*/
|
||||
expiresAt?: number;
|
||||
|
||||
/**
|
||||
* The RFC 8707 resource server identifier for which this token is valid.
|
||||
* If set, this MUST match the MCP server's resource identifier (minus hash fragment).
|
||||
*/
|
||||
resource?: URL;
|
||||
|
||||
/**
|
||||
* Additional data associated with the token.
|
||||
* This field should be used for any additional data that needs to be attached to the auth info.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ export enum McpZodTypeKind {
|
|||
|
||||
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
|
||||
value: T["_input"],
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
},
|
||||
) => T["_input"][] | Promise<T["_input"][]>;
|
||||
|
||||
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny>
|
||||
|
|
|
|||
|
|
@ -10,11 +10,12 @@ import {
|
|||
LATEST_PROTOCOL_VERSION,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
CreateMessageRequestSchema,
|
||||
ElicitRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
SetLevelRequestSchema,
|
||||
ErrorCode,
|
||||
ErrorCode
|
||||
} from "../types.js";
|
||||
import { Transport } from "../shared/transport.js";
|
||||
import { InMemoryTransport } from "../inMemory.js";
|
||||
|
|
@ -267,6 +268,318 @@ test("should respect client capabilities", async () => {
|
|||
await expect(server.listRoots()).rejects.toThrow(/^Client does not support/);
|
||||
});
|
||||
|
||||
test("should respect client elicitation capabilities", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, (params) => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
username: params.params.message.includes("username") ? "test-user" : undefined,
|
||||
confirmed: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
expect(server.getClientCapabilities()).toEqual({ elicitation: {} });
|
||||
|
||||
// This should work because elicitation is supported by the client
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your username",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
username: {
|
||||
type: "string",
|
||||
title: "Username",
|
||||
description: "Your username",
|
||||
},
|
||||
confirmed: {
|
||||
type: "boolean",
|
||||
title: "Confirm",
|
||||
description: "Please confirm",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ["username"],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: {
|
||||
username: "test-user",
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
|
||||
// This should still throw because sampling is not supported by the client
|
||||
await expect(
|
||||
server.createMessage({
|
||||
messages: [],
|
||||
maxTokens: 10,
|
||||
}),
|
||||
).rejects.toThrow(/^Client does not support/);
|
||||
});
|
||||
|
||||
test("should validate elicitation response against requested schema", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
age: 30,
|
||||
},
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 150,
|
||||
},
|
||||
},
|
||||
required: ["name", "email"],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: {
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
age: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject elicitation response with invalid data", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Set up client to return invalid response (missing required field, invalid age)
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) => ({
|
||||
action: "accept",
|
||||
content: {
|
||||
email: "", // Invalid - too short
|
||||
age: -5, // Invalid age
|
||||
},
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
// Test with invalid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your information",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
email: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
},
|
||||
age: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 150,
|
||||
},
|
||||
},
|
||||
required: ["name", "email"],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test("should allow elicitation reject and cancel without validation", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: "test server",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
logging: {},
|
||||
},
|
||||
enforceStrictCapabilities: true,
|
||||
},
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: "test client",
|
||||
version: "1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, (request) => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
return { action: "reject" };
|
||||
} else {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const schema = {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
name: { type: "string" as const },
|
||||
},
|
||||
required: ["name"],
|
||||
};
|
||||
|
||||
// Test reject - should not validate
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your name",
|
||||
requestedSchema: schema,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "reject",
|
||||
});
|
||||
|
||||
// Test cancel - should not validate
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: "Please provide your name",
|
||||
requestedSchema: schema,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "cancel",
|
||||
});
|
||||
});
|
||||
|
||||
test("should respect server notification capabilities", async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import {
|
|||
ClientCapabilities,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResultSchema,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
Implementation,
|
||||
InitializedNotificationSchema,
|
||||
|
|
@ -18,6 +21,8 @@ import {
|
|||
ListRootsRequest,
|
||||
ListRootsResultSchema,
|
||||
LoggingMessageNotification,
|
||||
McpError,
|
||||
ErrorCode,
|
||||
Notification,
|
||||
Request,
|
||||
ResourceUpdatedNotification,
|
||||
|
|
@ -28,6 +33,7 @@ import {
|
|||
ServerResult,
|
||||
SUPPORTED_PROTOCOL_VERSIONS,
|
||||
} from "../types.js";
|
||||
import Ajv from "ajv";
|
||||
|
||||
export type ServerOptions = ProtocolOptions & {
|
||||
/**
|
||||
|
|
@ -129,6 +135,14 @@ export class Server<
|
|||
}
|
||||
break;
|
||||
|
||||
case "elicitation/create":
|
||||
if (!this._clientCapabilities?.elicitation) {
|
||||
throw new Error(
|
||||
`Client does not support elicitation (required for ${method})`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "roots/list":
|
||||
if (!this._clientCapabilities?.roots) {
|
||||
throw new Error(
|
||||
|
|
@ -251,10 +265,12 @@ export class Server<
|
|||
this._clientCapabilities = request.params.capabilities;
|
||||
this._clientVersion = request.params.clientInfo;
|
||||
|
||||
return {
|
||||
protocolVersion: SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
||||
const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requestedVersion)
|
||||
? requestedVersion
|
||||
: LATEST_PROTOCOL_VERSION,
|
||||
: LATEST_PROTOCOL_VERSION;
|
||||
|
||||
return {
|
||||
protocolVersion,
|
||||
capabilities: this.getCapabilities(),
|
||||
serverInfo: this._serverInfo,
|
||||
...(this._instructions && { instructions: this._instructions }),
|
||||
|
|
@ -294,6 +310,44 @@ export class Server<
|
|||
);
|
||||
}
|
||||
|
||||
async elicitInput(
|
||||
params: ElicitRequest["params"],
|
||||
options?: RequestOptions,
|
||||
): Promise<ElicitResult> {
|
||||
const result = await this.request(
|
||||
{ method: "elicitation/create", params },
|
||||
ElicitResultSchema,
|
||||
options,
|
||||
);
|
||||
|
||||
// Validate the response content against the requested schema if action is "accept"
|
||||
if (result.action === "accept" && result.content) {
|
||||
try {
|
||||
const ajv = new Ajv();
|
||||
|
||||
const validate = ajv.compile(params.requestedSchema);
|
||||
const isValid = validate(result.content);
|
||||
|
||||
if (!isValid) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
`Elicitation response content does not match requested schema: ${ajv.errorsText(validate.errors)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof McpError) {
|
||||
throw error;
|
||||
}
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
`Error validating elicitation response: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async listRoots(
|
||||
params?: ListRootsRequest["params"],
|
||||
options?: RequestOptions,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue