Skip to content

Server implementation of MCP auth #151

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 45 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
afae421
Move OAuth definitions into shared, add more
jspahrsummers Feb 16, 2025
a0069e2
Split full/partial client information
jspahrsummers Feb 17, 2025
9ba6ce0
Add `express` as a production dependency
jspahrsummers Feb 17, 2025
76f367b
WIP client registration handler for Express
jspahrsummers Feb 17, 2025
6bfbd20
Client registration using a "store" interface
jspahrsummers Feb 17, 2025
e27907d
Add `cors` package
jspahrsummers Feb 17, 2025
a776f5f
Build a router to chain middleware better
jspahrsummers Feb 17, 2025
6d33a7c
Rename handler file to register.ts to make path more obvious
jspahrsummers Feb 17, 2025
9dc4a4f
WIP /authorize endpoint
jspahrsummers Feb 17, 2025
54f643c
Validate redirect URIs _as_ URIs
jspahrsummers Feb 17, 2025
62cd952
Authorization flow handoff
jspahrsummers Feb 17, 2025
5738f0c
WIP /token endpoint
jspahrsummers Feb 17, 2025
26ffe00
Auth code and refresh token flows
jspahrsummers Feb 17, 2025
f4002e5
Add type definition for token revocation requests
jspahrsummers Feb 18, 2025
19f2ac7
Extract client authentication into reusable middleware
jspahrsummers Feb 18, 2025
9abcccf
Token revocation endpoint
jspahrsummers Feb 18, 2025
bff65e6
Add client information to all OAuth provider methods
jspahrsummers Feb 18, 2025
6830c92
Remove redundant comment
jspahrsummers Feb 18, 2025
43f433c
Match style for registration request handler
jspahrsummers Feb 18, 2025
a6e78b7
Add handler for auth server metadata
jspahrsummers Feb 18, 2025
bb49189
Router for all MCP auth endpoints + behaviors
jspahrsummers Feb 18, 2025
f292b12
Remove `generateToken` until needed
jspahrsummers Feb 18, 2025
76b9781
Use `URL.canParse` instead of custom `isValidUrl`
jspahrsummers Feb 18, 2025
6a6dcba
Install `supertest` and `@jest-mock/express`
jspahrsummers Feb 18, 2025
76938c7
Claude-authored tests
jspahrsummers Feb 18, 2025
36f9c6b
Get tests passing
jspahrsummers Feb 18, 2025
4d02ed9
Install `express-rate-limit`
jspahrsummers Feb 18, 2025
1415c2d
Rate limiting on individual handlers
jspahrsummers Feb 18, 2025
48cddd9
Pass through rate limits and other options cleanly from router
jspahrsummers Feb 18, 2025
07e8ce9
Fix router test after `metadata` key removed
jspahrsummers Feb 18, 2025
8d62612
Extract middleware for 405 Method Not Allowed
jspahrsummers Feb 18, 2025
5d7bc7a
Fix unused variable lint
jspahrsummers Feb 18, 2025
85e3cad
Fix `Request` type extension to not use namespacing
jspahrsummers Feb 18, 2025
7b81b4f
Handle POST bodies in /authorize
jspahrsummers Feb 18, 2025
63db322
Set Cache-Control: no-store
jspahrsummers Feb 18, 2025
f6e47c4
Add missing `name` to `McpError`
jspahrsummers Feb 18, 2025
1dfb9aa
Classes for standard OAuth error types
jspahrsummers Feb 18, 2025
57fe3b3
Use standard error types in middleware
jspahrsummers Feb 18, 2025
7fb8e4a
Catch and format standard error types in responses
jspahrsummers Feb 18, 2025
c8f4d62
Render /authorize errors as JSON
jspahrsummers Feb 18, 2025
b8b816c
Bearer auth middleware
jspahrsummers Feb 18, 2025
529d017
Merge branch 'main' into justin/server-auth
jspahrsummers Feb 21, 2025
fadcee3
Remove tests that are just testing a mock
jspahrsummers Feb 21, 2025
094b81f
Throw error if scopes are requested and client has none
jspahrsummers Feb 21, 2025
f0777d2
Client secrets should not be removed, but expiry checked in middleware
jspahrsummers Feb 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
781 changes: 445 additions & 336 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
},
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
"eventsource": "^3.0.2",
"express": "^5.0.1",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] do we want this to be optional

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly… my main concern is really browser-based apps, but I think tree-shaking will ensure that none of Express makes it into those (it wouldn't be compatible anyway). I think it's pretty low-concern to have here, but willing to change it later if it becomes a problem.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably include CORS, or browser based clients won't be able to connect

"pkce-challenge": "^4.1.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
Expand All @@ -56,14 +58,14 @@
"devDependencies": {
"@eslint/js": "^9.8.0",
"@types/content-type": "^1.1.8",
"@types/cors": "^2.8.17",
"@types/eslint__js": "^8.42.3",
"@types/eventsource": "^1.1.15",
"@types/express": "^4.17.21",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.0.2",
"@types/ws": "^8.5.12",
"eslint": "^9.8.0",
"express": "^4.19.2",
"jest": "^29.7.0",
"ts-jest": "^29.2.4",
"tsx": "^4.16.5",
Expand All @@ -74,4 +76,4 @@
"resolutions": {
"strip-ansi": "6.0.1"
}
}
}
1 change: 0 additions & 1 deletion src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
registerClient,
} from "./auth.js";


// Mock fetch globally
const mockFetch = jest.fn();
global.fetch = mockFetch;
Expand Down
92 changes: 8 additions & 84 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,7 @@
import pkceChallenge from "pkce-challenge";
import { z } from "zod";
import { LATEST_PROTOCOL_VERSION } from "../types.js";

export const OAuthMetadataSchema = z
.object({
issuer: z.string(),
authorization_endpoint: z.string(),
token_endpoint: z.string(),
registration_endpoint: z.string().optional(),
scopes_supported: z.array(z.string()).optional(),
response_types_supported: z.array(z.string()),
response_modes_supported: z.array(z.string()).optional(),
grant_types_supported: z.array(z.string()).optional(),
token_endpoint_auth_methods_supported: z.array(z.string()).optional(),
token_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
service_documentation: z.string().optional(),
revocation_endpoint: z.string().optional(),
revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(),
revocation_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
introspection_endpoint: z.string().optional(),
introspection_endpoint_auth_methods_supported: z
.array(z.string())
.optional(),
introspection_endpoint_auth_signing_alg_values_supported: z
.array(z.string())
.optional(),
code_challenge_methods_supported: z.array(z.string()).optional(),
})
.passthrough();

export const OAuthTokensSchema = z
.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number().optional(),
scope: z.string().optional(),
refresh_token: z.string().optional(),
})
.strip();

/**
* Client metadata schema according to RFC 7591 OAuth 2.0 Dynamic Client Registration
*/
export const OAuthClientMetadataSchema = z.object({
redirect_uris: z.array(z.string()),
token_endpoint_auth_method: z.string().optional(),
grant_types: z.array(z.string()).optional(),
response_types: z.array(z.string()).optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks: z.any().optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),
}).passthrough();

/**
* Client information response schema according to RFC 7591
*/
export const OAuthClientInformationSchema = z.object({
client_id: z.string(),
client_secret: z.string().optional(),
client_id_issued_at: z.number().optional(),
client_secret_expires_at: z.number().optional(),
}).passthrough();

export type OAuthMetadata = z.infer<typeof OAuthMetadataSchema>;
export type OAuthTokens = z.infer<typeof OAuthTokensSchema>;

export type OAuthClientMetadata = z.infer<typeof OAuthClientMetadataSchema>;
export type OAuthClientInformation = z.infer<typeof OAuthClientInformationSchema>;
import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull } from "../shared/auth.js";
import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthTokensSchema } from "../shared/auth.js";

/**
* Implements an end-to-end OAuth client to be used with one MCP server.
Expand Down Expand Up @@ -113,7 +36,7 @@ export interface OAuthClientProvider {
* This method is not required to be implemented if client information is
* statically known (e.g., pre-registered).
*/
saveClientInformation?(clientInformation: OAuthClientInformation): void | Promise<void>;
saveClientInformation?(clientInformation: OAuthClientInformationFull): void | Promise<void>;

/**
* Loads any existing OAuth tokens for the current session, or returns
Expand Down Expand Up @@ -175,12 +98,13 @@ export async function auth(
throw new Error("OAuth client information must be saveable for dynamic registration");
}

clientInformation = await registerClient(serverUrl, {
const fullInformation = await registerClient(serverUrl, {
metadata,
clientMetadata: provider.clientMetadata,
});

await provider.saveClientInformation(clientInformation);
await provider.saveClientInformation(fullInformation);
clientInformation = fullInformation;
}

// Exchange authorization code for tokens
Expand Down Expand Up @@ -448,7 +372,7 @@ export async function registerClient(
metadata?: OAuthMetadata;
clientMetadata: OAuthClientMetadata;
},
): Promise<OAuthClientInformation> {
): Promise<OAuthClientInformationFull> {
let registrationUrl: URL;

if (metadata) {
Expand All @@ -473,5 +397,5 @@ export async function registerClient(
throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
}

return OAuthClientInformationSchema.parse(await response.json());
return OAuthClientInformationFullSchema.parse(await response.json());
}
3 changes: 2 additions & 1 deletion src/client/sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { createServer, type IncomingMessage, type Server } from "http";
import { AddressInfo } from "net";
import { JSONRPCMessage } from "../types.js";
import { SSEClientTransport } from "./sse.js";
import { OAuthClientProvider, OAuthTokens, UnauthorizedError } from "./auth.js";
import { OAuthClientProvider, UnauthorizedError } from "./auth.js";
import { OAuthTokens } from "../shared/auth.js";

describe("SSEClientTransport", () => {
let server: Server;
Expand Down
20 changes: 20 additions & 0 deletions src/server/auth/clients.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { OAuthClientInformationFull } from "../../shared/auth.js";

/**
* Stores information about registered OAuth clients for this server.
*/
export interface OAuthRegisteredClientsStore {
/**
* Returns information about a registered client, based on its ID.
*/
getClient(clientId: string): OAuthClientInformationFull | undefined | Promise<OAuthClientInformationFull | undefined>;

/**
* Registers a new client with the server. The client ID and secret will be automatically generated by the library. A modified version of the client information can be returned to reflect specific values enforced by the server.
*
* NOTE: Implementations must ensure that client secrets, if present, are expired in accordance with the `client_secret_expires_at` field.
*
* If unimplemented, dynamic client registration is unsupported.
*/
registerClient?(client: OAuthClientInformationFull): OAuthClientInformationFull | Promise<OAuthClientInformationFull>;
}
5 changes: 5 additions & 0 deletions src/server/auth/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import crypto from "node:crypto";

export function generateToken(): string {
return crypto.randomBytes(32).toString("hex");
}
93 changes: 93 additions & 0 deletions src/server/auth/handlers/authorize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { RequestHandler } from "express";
import { z } from "zod";
import { isValidUrl } from "../validation.js";
import { OAuthServerProvider } from "../provider.js";

export type AuthorizationHandlerOptions = {
provider: OAuthServerProvider;
};

// Parameters that must be validated in order to issue redirects.
const ClientAuthorizationParamsSchema = z.object({
client_id: z.string(),
redirect_uri: z.string().optional().refine((value) => value === undefined || isValidUrl(value), { message: "redirect_uri must be a valid URL" }),
});

// Parameters that must be validated for a successful authorization request. Failure can be reported to the redirect URI.
const RequestAuthorizationParamsSchema = z.object({
response_type: z.literal("code"),
code_challenge: z.string(),
code_challenge_method: z.literal("S256"),
scope: z.string().optional(),
state: z.string().optional(),
});

export function authorizationHandler({ provider }: AuthorizationHandlerOptions): RequestHandler {
return async (req, res) => {
if (req.method !== "GET" && req.method !== "POST") {
res.status(405).end("Method Not Allowed");
return;
}

let client_id, redirect_uri;
try {
({ client_id, redirect_uri } = ClientAuthorizationParamsSchema.parse(req.query));
} catch (error) {
res.status(400).end(`Bad Request: ${error}`);
return;
}

const client = await provider.clientsStore.getClient(client_id);
if (!client) {
res.status(400).end("Bad Request: invalid client_id");
return;
}

if (redirect_uri !== undefined) {
if (!client.redirect_uris.includes(redirect_uri)) {
res.status(400).end("Bad Request: unregistered redirect_uri");
return;
}
} else if (client.redirect_uris.length === 1) {
redirect_uri = client.redirect_uris[0];
} else {
res.status(400).end("Bad Request: missing redirect_uri");
return;
}

let params;
try {
params = RequestAuthorizationParamsSchema.parse(req.query);
} catch (error) {
const errorUrl = new URL(redirect_uri);
errorUrl.searchParams.set("error", "invalid_request");
errorUrl.searchParams.set("error_description", String(error));
res.redirect(302, errorUrl.href);
return;
}

let requestedScopes: string[] = [];
if (params.scope !== undefined && client.scope !== undefined) {
requestedScopes = params.scope.split(" ");
const allowedScopes = new Set(client.scope.split(" "));

// If any requested scope is not in the client's registered scopes, error out
for (const scope of requestedScopes) {
if (!allowedScopes.has(scope)) {
const errorUrl = new URL(redirect_uri);
errorUrl.searchParams.set("error", "invalid_scope");
errorUrl.searchParams.set("error_description", `Client was not registered with scope ${scope}`);
res.redirect(302, errorUrl.href);
return;
}
}
}

await provider.authorize(client, {
state: params.state,
scopes: requestedScopes,
redirectUri: redirect_uri,
codeChallenge: params.code_challenge,
}, res);
};
}
66 changes: 66 additions & 0 deletions src/server/auth/handlers/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import express, { RequestHandler } from "express";
import { OAuthClientInformationFull, OAuthClientMetadataSchema, OAuthClientRegistrationError } from "../../../shared/auth.js";

Check failure on line 2 in src/server/auth/handlers/register.ts

View workflow job for this annotation

GitHub Actions / build

'OAuthClientRegistrationError' is defined but never used
import crypto from 'node:crypto';
import cors from 'cors';
import { OAuthRegisteredClientsStore } from "../clients.js";

export type ClientRegistrationHandlerOptions = {
/**
* A store used to save information about dynamically registered OAuth clients.
*/
clientsStore: OAuthRegisteredClientsStore;

/**
* The number of seconds after which to expire issued client secrets, or 0 to prevent expiration of client secrets (not recommended).
*
* If not set, defaults to 30 days.
*/
clientSecretExpirySeconds?: number;
};

const DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS = 30 * 24 * 60 * 60; // 30 days

export function clientRegistrationHandler({ clientsStore, clientSecretExpirySeconds = DEFAULT_CLIENT_SECRET_EXPIRY_SECONDS }: ClientRegistrationHandlerOptions): RequestHandler {
if (!clientsStore.registerClient) {
throw new Error("Client registration store does not support registering clients");
}

// Nested router so we can configure middleware and restrict HTTP method
const router = express.Router();
router.use(express.json());

// Configure CORS to allow any origin, to make accessible to web-based MCP clients
router.use(cors());

router.post("/", async (req, res) => {
let clientMetadata;
try {
clientMetadata = OAuthClientMetadataSchema.parse(req.body);
} catch (error) {
res.status(400).json({
error: "invalid_client_metadata",
error_description: String(error),
});
return;
}

const clientId = crypto.randomUUID();
const clientSecret = clientMetadata.token_endpoint_auth_method !== 'none'
? crypto.randomBytes(32).toString('hex')
: undefined;
const clientIdIssuedAt = Math.floor(Date.now() / 1000);

let clientInfo: OAuthClientInformationFull = {
...clientMetadata,
client_id: clientId,
client_secret: clientSecret,
client_id_issued_at: clientIdIssuedAt,
client_secret_expires_at: clientSecretExpirySeconds > 0 ? clientIdIssuedAt + clientSecretExpirySeconds : 0
};

clientInfo = await clientsStore.registerClient!(clientInfo);
res.status(201).json(clientInfo);
});

return router;
}
Loading
Loading