diff --git a/packages/mcp-server/src/oauth.test.ts b/packages/mcp-server/src/oauth.test.ts index 938f738b..66ac8e1d 100644 --- a/packages/mcp-server/src/oauth.test.ts +++ b/packages/mcp-server/src/oauth.test.ts @@ -9,6 +9,8 @@ import { createMcpProtectedResourceMetadata, exchangeMcpOAuthAuthorizationCode, normalizeMcpPublicUrl, + type OAuthAccessTokenStore, + type OAuthAuthorizationCodeStore, registerMcpOAuthClient, revokeMcpOAuthAccessToken, } from "./oauth.js"; @@ -443,7 +445,7 @@ describe("MCP OAuth metadata and dynamic client registration", () => { const codeStore = createInMemoryOAuthAuthorizationCodeStore(); const exhaustedTokenStore = { issueToken: () => undefined, - verifyToken: () => undefined, + verifyToken: () => ({ ok: false, reason: "unknown_token" }) as const, revokeToken: () => false, }; const verifier = "j".repeat(43); @@ -491,6 +493,128 @@ describe("MCP OAuth metadata and dynamic client registration", () => { ).toMatchObject({ status: 400, body: { error: "invalid_grant" } }); }); + it("binds exchanged access tokens to the authorized resource", () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const tokenStore = createInMemoryOAuthAccessTokenStore(); + const verifier = "r".repeat(43); + const registered = registerMcpOAuthClient( + { + redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], + token_endpoint_auth_method: "none", + }, + clientsStore, + ); + if (registered.status !== 201) throw new Error("expected successful registration"); + const authorizeParams = new URLSearchParams({ + client_id: registered.body.client_id, + redirect_uri: "https://claude.ai/api/mcp/auth_callback", + response_type: "code", + code_challenge_method: "S256", + code_challenge: pkceS256(verifier), + resource: "https://codemem.example.test/mcp", + }); + + const wrongResourceAuthorize = authorizeMcpOAuthClient( + authorizeParams, + clientsStore, + codeStore, + ); + if (wrongResourceAuthorize.status !== 302) throw new Error("expected authorization redirect"); + expect( + exchangeMcpOAuthAuthorizationCode( + new URLSearchParams({ + grant_type: "authorization_code", + client_id: registered.body.client_id, + redirect_uri: "https://claude.ai/api/mcp/auth_callback", + code: new URL(wrongResourceAuthorize.location).searchParams.get("code") ?? "", + code_verifier: verifier, + resource: "https://other.example.test/mcp", + }), + clientsStore, + codeStore, + tokenStore, + ), + ).toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + + const authorize = authorizeMcpOAuthClient(authorizeParams, clientsStore, codeStore); + if (authorize.status !== 302) throw new Error("expected authorization redirect"); + const token = exchangeMcpOAuthAuthorizationCode( + new URLSearchParams({ + grant_type: "authorization_code", + client_id: registered.body.client_id, + redirect_uri: "https://claude.ai/api/mcp/auth_callback", + code: new URL(authorize.location).searchParams.get("code") ?? "", + code_verifier: verifier, + resource: "https://codemem.example.test/mcp", + }), + clientsStore, + codeStore, + tokenStore, + ); + + expect(token.status).toBe(200); + if (token.status !== 200) throw new Error("expected token response"); + expect(tokenStore.verifyToken(token.body.access_token)).toMatchObject({ + ok: true, + record: { resource: "https://codemem.example.test/mcp" }, + }); + }); + + it("revokes tokens issued during an authorization-code consume race", () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const realTokenStore = createInMemoryOAuthAccessTokenStore(); + const issuedTokens: string[] = []; + const tokenStore: OAuthAccessTokenStore = { + issueToken: (clientId, now, resource) => { + const issued = realTokenStore.issueToken(clientId, now, resource); + if (issued) issuedTokens.push(issued.token); + return issued; + }, + verifyToken: (token, now) => realTokenStore.verifyToken(token, now), + revokeToken: (token, now) => realTokenStore.revokeToken(token, now), + }; + const registered = registerMcpOAuthClient( + { + redirect_uris: ["https://claude.ai/api/mcp/auth_callback"], + token_endpoint_auth_method: "none", + }, + clientsStore, + ); + if (registered.status !== 201) throw new Error("expected successful registration"); + const codeStore: OAuthAuthorizationCodeStore = { + issueCode: () => "race-code", + peekCode: () => ({ + clientId: registered.body.client_id, + redirectUri: "https://claude.ai/api/mcp/auth_callback", + codeChallenge: pkceS256("s".repeat(43)), + expiresAt: Date.now() + 5 * 60 * 1000, + used: false, + }), + consumeCode: () => undefined, + }; + + expect( + exchangeMcpOAuthAuthorizationCode( + new URLSearchParams({ + grant_type: "authorization_code", + client_id: registered.body.client_id, + redirect_uri: "https://claude.ai/api/mcp/auth_callback", + code: "race-code", + code_verifier: "s".repeat(43), + }), + clientsStore, + codeStore, + tokenStore, + ), + ).toMatchObject({ status: 400, body: { error: "invalid_grant" } }); + expect(issuedTokens).toHaveLength(1); + expect(realTokenStore.verifyToken(issuedTokens[0] ?? "")).toMatchObject({ + ok: false, + reason: "revoked_token", + }); + }); + it("consumes the authorization code on permanent grant failures", () => { const clientsStore = createInMemoryOAuthClientsStore(); const codeStore = createInMemoryOAuthAuthorizationCodeStore(); diff --git a/packages/mcp-server/src/oauth.ts b/packages/mcp-server/src/oauth.ts index e0ca0bb0..ab92532c 100644 --- a/packages/mcp-server/src/oauth.ts +++ b/packages/mcp-server/src/oauth.ts @@ -50,6 +50,7 @@ export interface AuthorizationCodeRecord { clientId: string; redirectUri: string; codeChallenge: string; + resource?: string; expiresAt: number; used: boolean; } @@ -62,6 +63,7 @@ export interface OAuthAuthorizationCodeStore { export interface AccessTokenRecord { clientId: string; + resource?: string; tokenHash: string; issuedAt: number; expiresAt: number; @@ -74,7 +76,11 @@ export type AccessTokenVerificationResult = | { ok: false; reason: "unknown_token" | "expired_token" | "revoked_token" }; export interface OAuthAccessTokenStore { - issueToken(clientId: string, now?: number): { token: string; expiresIn: number } | undefined; + issueToken( + clientId: string, + now?: number, + resource?: string, + ): { token: string; expiresIn: number } | undefined; verifyToken(token: string, now?: number): AccessTokenVerificationResult; revokeToken(token: string, now?: number): boolean; } @@ -98,6 +104,7 @@ export interface PreparedMcpOAuthAuthorizationRequest { clientId: string; redirectUri: string; codeChallenge: string; + resource: string | null; state: string | null; } @@ -160,13 +167,18 @@ export class InMemoryOAuthAccessTokenStore implements OAuthAccessTokenStore { readonly #tokensByHash = new Map(); readonly #tokenHashKey = randomBytes(32); - issueToken(clientId: string, now = Date.now()): { token: string; expiresIn: number } | undefined { + issueToken( + clientId: string, + now = Date.now(), + resource?: string, + ): { token: string; expiresIn: number } | undefined { this.#deleteInactiveTokens(now); if (this.#tokensByHash.size >= MAX_ACCESS_TOKENS) return undefined; const tokenBytes = randomBytes(ACCESS_TOKEN_BYTES); const tokenHash = signOAuthAccessTokenBytes(tokenBytes, this.#tokenHashKey); this.#tokensByHash.set(tokenHash, { clientId, + resource, tokenHash, issuedAt: now, expiresAt: now + ACCESS_TOKEN_TTL_SECONDS * 1000, @@ -322,6 +334,7 @@ export function authorizeMcpOAuthClient( clientId: prepared.clientId, redirectUri: prepared.redirectUri, codeChallenge: prepared.codeChallenge, + ...(prepared.resource ? { resource: prepared.resource } : {}), expiresAt: now + AUTHORIZATION_CODE_TTL_MS, }, now, @@ -343,6 +356,7 @@ export function prepareMcpOAuthAuthorizationRequest( const responseType = params.get("response_type") ?? ""; const codeChallenge = params.get("code_challenge") ?? ""; const codeChallengeMethod = params.get("code_challenge_method") ?? ""; + const resource = params.get("resource"); const state = params.get("state"); if (responseType !== "code") return invalidOAuthRequest("unsupported_response_type"); @@ -359,7 +373,7 @@ export function prepareMcpOAuthAuthorizationRequest( return invalidOAuthRequest("invalid_request", "redirect_uri is not registered for this client"); } - return { clientId, redirectUri, codeChallenge, state }; + return { clientId, redirectUri, codeChallenge, resource, state }; } export function exchangeMcpOAuthAuthorizationCode( @@ -377,6 +391,7 @@ export function exchangeMcpOAuthAuthorizationCode( const code = params.get("code") ?? ""; const redirectUri = params.get("redirect_uri") ?? ""; const codeVerifier = params.get("code_verifier") ?? ""; + const resource = params.get("resource"); const client = getRegisteredClient(clientsStore, clientId); if (!client) return invalidOAuthRequest("invalid_client", "Unknown OAuth client_id"); if (!PKCE_VERIFIER.test(codeVerifier)) { @@ -401,8 +416,12 @@ export function exchangeMcpOAuthAuthorizationCode( codeStore.consumeCode(code); return invalidOAuthRequest("invalid_grant", "PKCE verification failed"); } + if ((record.resource ?? null) !== (resource || null)) { + codeStore.consumeCode(code); + return invalidOAuthRequest("invalid_grant", "Code does not match resource"); + } - const issued = tokenStore.issueToken(clientId, now); + const issued = tokenStore.issueToken(clientId, now, record.resource); if (!issued) { // Token-store overload is transient: leave the auth code unused so the // client can retry token exchange without restarting the OAuth flow. @@ -411,9 +430,10 @@ export function exchangeMcpOAuthAuthorizationCode( const consumed = codeStore.consumeCode(code); if (!consumed) { + tokenStore.revokeToken(issued.token, now); // Lost a race with another request that consumed the code in the gap // between peek and consume. Treat as invalid_grant rather than issuing - // the token, since the code is no longer single-use atomic. + // a duplicate usable token, since the code is no longer single-use atomic. return invalidOAuthRequest("invalid_grant", "Authorization code already used"); } diff --git a/packages/mcp-server/src/provider.test.ts b/packages/mcp-server/src/provider.test.ts new file mode 100644 index 00000000..ac957bd1 --- /dev/null +++ b/packages/mcp-server/src/provider.test.ts @@ -0,0 +1,379 @@ +import type { Response } from "express"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OAuthAccessTokenStore, OAuthAuthorizationCodeStore } from "./oauth.js"; +import { + createInMemoryOAuthAccessTokenStore, + createInMemoryOAuthAuthorizationCodeStore, + createInMemoryOAuthClientsStore, + registerMcpOAuthClient, +} from "./oauth.js"; +import { createInMemoryOidcPendingAuthorizationStore, type OidcConfig } from "./oidc.js"; +import { MemoryOAuthServerProvider } from "./provider.js"; + +const NOW = 1_000; +const REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"; +const PUBLIC_MCP_URL = "https://mcp.example.test/mcp"; +const CODE_CHALLENGE = "a".repeat(43); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("MemoryOAuthServerProvider", () => { + it("redirects authorize requests through upstream OIDC and preserves MCP OAuth params", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + issuer: "https://accounts.example.test/", + authorization_endpoint: "https://accounts.example.test/authorize", + token_endpoint: "https://accounts.example.test/token", + jwks_uri: "https://accounts.example.test/jwks", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + const clientsStore = createInMemoryOAuthClientsStore(); + const pendingStore = createInMemoryOidcPendingAuthorizationStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore: createInMemoryOAuthAuthorizationCodeStore(), + tokenStore: createInMemoryOAuthAccessTokenStore(), + publicMcpUrl: PUBLIC_MCP_URL, + oidc: { config: oidcConfig(), pendingStore }, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const response = fakeResponse(); + + await provider.authorize( + client, + { + redirectUri: REDIRECT_URI, + codeChallenge: CODE_CHALLENGE, + state: "claude-state", + scopes: ["memory:read"], + resource: new URL(PUBLIC_MCP_URL), + }, + response, + ); + + expect(response.redirect).toHaveBeenCalledOnce(); + const [status, location] = response.redirect.mock.calls[0] ?? []; + expect(status).toBe(302); + const upstream = new URL(String(location)); + expect(upstream.href).toContain("https://accounts.example.test/authorize?"); + expect(upstream.searchParams.get("client_id")).toBe("codemem-oidc-client"); + expect(upstream.searchParams.get("redirect_uri")).toBe( + "https://mcp.example.test/oauth/callback", + ); + const pending = pendingStore.consume(upstream.searchParams.get("state") ?? ""); + expect(pending).toBeDefined(); + const oauthParams = new URLSearchParams(pending?.oauthParams); + expect(oauthParams.get("client_id")).toBe(client.client_id); + expect(oauthParams.get("redirect_uri")).toBe(REDIRECT_URI); + expect(oauthParams.get("response_type")).toBe("code"); + expect(oauthParams.get("code_challenge")).toBe(CODE_CHALLENGE); + expect(oauthParams.get("code_challenge_method")).toBe("S256"); + expect(oauthParams.get("state")).toBe("claude-state"); + expect(oauthParams.get("scope")).toBe("memory:read"); + expect(oauthParams.get("resource")).toBe(PUBLIC_MCP_URL); + }); + + it("exposes the PKCE challenge for an active authorization code", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore: createInMemoryOAuthAccessTokenStore(), + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const code = issueCode(codeStore, client.client_id); + + await expect(provider.challengeForAuthorizationCode(client, code)).resolves.toBe( + CODE_CHALLENGE, + ); + await expect(provider.challengeForAuthorizationCode(client, "missing-code")).rejects.toThrow( + /Invalid or already used code/, + ); + await expect( + provider.challengeForAuthorizationCode(registerClient(clientsStore), code), + ).rejects.toThrow(/Code does not match client/); + }); + + it("exchanges an authorization code for bearer tokens and consumes the code", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const tokenStore = createInMemoryOAuthAccessTokenStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore, + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const code = issueCode(codeStore, client.client_id); + + const tokens = await provider.exchangeAuthorizationCode(client, code, undefined, REDIRECT_URI); + + expect(tokens).toMatchObject({ token_type: "Bearer", expires_in: 3600 }); + expect(tokens.access_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(tokenStore.verifyToken(tokens.access_token, NOW)).toMatchObject({ + ok: true, + record: { clientId: client.client_id }, + }); + await expect( + provider.exchangeAuthorizationCode(client, code, undefined, REDIRECT_URI), + ).rejects.toThrow(/Invalid or already used code/); + }); + + it("binds authorization-code exchanges and verified tokens to the requested resource", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const tokenStore = createInMemoryOAuthAccessTokenStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore, + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + + await expect( + provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id, PUBLIC_MCP_URL), + undefined, + REDIRECT_URI, + new URL("https://other.example.test/mcp"), + ), + ).rejects.toThrow(/Code does not match resource/); + + const tokens = await provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id, PUBLIC_MCP_URL), + undefined, + REDIRECT_URI, + new URL(PUBLIC_MCP_URL), + ); + + await expect(provider.verifyAccessToken(tokens.access_token)).resolves.toMatchObject({ + clientId: client.client_id, + resource: new URL(PUBLIC_MCP_URL), + }); + }); + + it("rejects token redemption that omits redirect_uri", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore: createInMemoryOAuthAccessTokenStore(), + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const code = issueCode(codeStore, client.client_id); + + await expect( + provider.exchangeAuthorizationCode(client, code, undefined, undefined), + ).rejects.toThrow(/Code does not match redirect_uri/); + await expect(provider.challengeForAuthorizationCode(client, code)).rejects.toThrow( + /Invalid or already used code/, + ); + }); + + it("rejects authorization-code client and redirect mismatches", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore: createInMemoryOAuthAccessTokenStore(), + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const otherClient = registerClient(clientsStore); + + await expect( + provider.exchangeAuthorizationCode( + otherClient, + issueCode(codeStore, client.client_id), + undefined, + REDIRECT_URI, + ), + ).rejects.toThrow(/Code does not match client/); + await expect( + provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id), + undefined, + "https://claude.ai/api/mcp/other_callback", + ), + ).rejects.toThrow(/Code does not match redirect_uri/); + }); + + it("leaves an authorization code reusable when token issuance is temporarily unavailable", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const exhaustedTokenStore: OAuthAccessTokenStore = { + issueToken: () => undefined, + verifyToken: () => ({ ok: false, reason: "unknown_token" }), + revokeToken: () => false, + }; + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore: exhaustedTokenStore, + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const code = issueCode(codeStore, client.client_id); + + await expect( + provider.exchangeAuthorizationCode(client, code, undefined, REDIRECT_URI), + ).rejects.toThrow(/Too many active access tokens/); + await expect(provider.challengeForAuthorizationCode(client, code)).resolves.toBe( + CODE_CHALLENGE, + ); + }); + + it("revokes a token issued during a lost authorization-code consume race", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const tokenStore = createCapturingAccessTokenStore(); + const client = registerClient(clientsStore); + const codeStore = raceLostCodeStore(client.client_id); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore, + tokenStore, + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + + await expect( + provider.exchangeAuthorizationCode(client, "race-code", undefined, REDIRECT_URI), + ).rejects.toThrow(/Authorization code already used/); + + expect(tokenStore.issuedTokens).toHaveLength(1); + expect(tokenStore.verifyToken(tokenStore.issuedTokens[0] ?? "", NOW)).toMatchObject({ + ok: false, + reason: "revoked_token", + }); + }); + + it("verifies and revokes access tokens with SDK AuthInfo semantics", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const tokenStore = createInMemoryOAuthAccessTokenStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore: createInMemoryOAuthAuthorizationCodeStore(), + tokenStore, + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const issued = tokenStore.issueToken(client.client_id, NOW); + if (!issued) throw new Error("expected token issuance"); + + await expect(provider.verifyAccessToken(issued.token)).resolves.toEqual({ + token: issued.token, + clientId: client.client_id, + scopes: [], + expiresAt: 3601, + }); + + await provider.revokeToken(client, { token: issued.token }); + await expect(provider.verifyAccessToken(issued.token)).rejects.toThrow(/revoked/); + await expect(provider.verifyAccessToken("not-a-token")).rejects.toThrow(/invalid/); + }); + + it("rejects refresh-token exchange until rotation support lands", async () => { + const clientsStore = createInMemoryOAuthClientsStore(); + const provider = new MemoryOAuthServerProvider({ + clientsStore, + codeStore: createInMemoryOAuthAuthorizationCodeStore(), + tokenStore: createInMemoryOAuthAccessTokenStore(), + publicMcpUrl: PUBLIC_MCP_URL, + }); + + await expect(provider.exchangeRefreshToken()).rejects.toThrow(/refresh_token grant/); + }); +}); + +function registerClient(clientsStore: ReturnType) { + const registered = registerMcpOAuthClient( + { redirect_uris: [REDIRECT_URI], token_endpoint_auth_method: "none" }, + clientsStore, + ); + if (registered.status !== 201) throw new Error("expected client registration"); + return registered.body; +} + +function issueCode( + codeStore: ReturnType, + clientId: string, + resource?: string, +): string { + const code = codeStore.issueCode( + { + clientId, + redirectUri: REDIRECT_URI, + codeChallenge: CODE_CHALLENGE, + ...(resource ? { resource } : {}), + expiresAt: NOW + 5 * 60 * 1000, + }, + NOW, + ); + if (!code) throw new Error("expected authorization code issuance"); + return code; +} + +function fakeResponse(): Response & { redirect: ReturnType } { + return { redirect: vi.fn() } as unknown as Response & { redirect: ReturnType }; +} + +function oidcConfig(): OidcConfig { + return { + issuerUrl: "https://accounts.example.test/", + clientId: "codemem-oidc-client", + clientSecret: "secret", + allowedSubject: "owner-sub", + }; +} + +function raceLostCodeStore(clientId: string): OAuthAuthorizationCodeStore { + return { + issueCode: () => "race-code", + peekCode: () => ({ + clientId, + redirectUri: REDIRECT_URI, + codeChallenge: CODE_CHALLENGE, + expiresAt: NOW + 5 * 60 * 1000, + used: false, + }), + consumeCode: () => undefined, + }; +} + +function createCapturingAccessTokenStore(): OAuthAccessTokenStore & { issuedTokens: string[] } { + const store = createInMemoryOAuthAccessTokenStore(); + const issuedTokens: string[] = []; + return { + issuedTokens, + issueToken: (clientId, now, resource) => { + const issued = store.issueToken(clientId, now, resource); + if (issued) issuedTokens.push(issued.token); + return issued; + }, + verifyToken: (token, now) => store.verifyToken(token, now), + revokeToken: (token, now) => store.revokeToken(token, now), + }; +} diff --git a/packages/mcp-server/src/provider.ts b/packages/mcp-server/src/provider.ts new file mode 100644 index 00000000..f7cb964a --- /dev/null +++ b/packages/mcp-server/src/provider.ts @@ -0,0 +1,241 @@ +import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; +import { + AccessDeniedError, + InvalidClientError, + InvalidGrantError, + InvalidRequestError, + InvalidTokenError, + ServerError, + TemporarilyUnavailableError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, +} from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import type { + AuthorizationParams, + OAuthServerProvider, +} from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { + type OAuthClientInformationFull, + type OAuthErrorResponse, + type OAuthTokenRevocationRequest, + type OAuthTokens, + OAuthTokensSchema, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { Response } from "express"; +import type { OAuthAccessTokenStore, OAuthAuthorizationCodeStore } from "./oauth.js"; +import { + beginOidcAuthorization, + type OidcConfig, + type OidcPendingAuthorizationStore, +} from "./oidc.js"; + +export interface MemoryOAuthServerProviderOptions { + clientsStore: OAuthRegisteredClientsStore; + codeStore: OAuthAuthorizationCodeStore; + tokenStore: OAuthAccessTokenStore; + publicMcpUrl: string; + oidc?: { config: OidcConfig; pendingStore: OidcPendingAuthorizationStore }; + now?: () => number; +} + +/** + * MemoryOAuthServerProvider adapts codemem's existing in-memory stores to the + * `@modelcontextprotocol/sdk` `OAuthServerProvider` contract so the SDK's + * `mcpAuthRouter` and `requireBearerAuth` middleware can drive the OAuth + * lifecycle. This class is the boundary between SDK-handled HTTP and + * codemem-owned storage / upstream-OIDC bridging. + * + * Conventions encoded here: + * - `authorize()` does NOT mint the MCP authorization code itself. It + * persists pending state and redirects the user-agent to the upstream OIDC + * provider. The MCP code is issued later by codemem's own /oauth/callback + * route (wired in codemem-b20m.3) after the upstream identity is validated. + * - PKCE is validated by the SDK token handler against the challenge returned + * from `challengeForAuthorizationCode`. We do not re-verify here. + * - `expiresAt` returned in `AuthInfo` is epoch SECONDS per the SDK's + * `requireBearerAuth` expectations; the underlying store records ms. + * - Refresh-token grant is intentionally unimplemented in this slice. It is + * added with dual-token rotation in codemem-b20m.4. + */ +export class MemoryOAuthServerProvider implements OAuthServerProvider { + readonly #clientsStore: OAuthRegisteredClientsStore; + readonly #codeStore: OAuthAuthorizationCodeStore; + readonly #tokenStore: OAuthAccessTokenStore; + readonly #publicMcpUrl: string; + readonly #oidc?: { config: OidcConfig; pendingStore: OidcPendingAuthorizationStore }; + readonly #now: () => number; + + constructor(options: MemoryOAuthServerProviderOptions) { + this.#clientsStore = options.clientsStore; + this.#codeStore = options.codeStore; + this.#tokenStore = options.tokenStore; + this.#publicMcpUrl = options.publicMcpUrl; + this.#oidc = options.oidc; + this.#now = options.now ?? Date.now; + } + + get clientsStore(): OAuthRegisteredClientsStore { + return this.#clientsStore; + } + + async authorize( + client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response, + ): Promise { + if (!this.#oidc) { + throw new TemporarilyUnavailableError("OIDC is not configured"); + } + const upstreamParams = buildUpstreamAuthorizationParams(client, params); + const result = await beginOidcAuthorization( + upstreamParams, + this.#clientsStore, + this.#oidc.pendingStore, + this.#oidc.config, + this.#publicMcpUrl, + this.#now(), + ); + if (result.status === 302) { + res.redirect(302, result.location); + return; + } + throw mapOAuthErrorBody(result.body); + } + + async challengeForAuthorizationCode( + _client: OAuthClientInformationFull, + authorizationCode: string, + ): Promise { + const record = this.#codeStore.peekCode(authorizationCode); + if (!record) throw new InvalidGrantError("Invalid or already used code"); + if (record.clientId !== _client.client_id) { + throw new InvalidGrantError("Code does not match client"); + } + if (record.expiresAt <= this.#now()) { + this.#codeStore.consumeCode(authorizationCode); + throw new InvalidGrantError("Expired code"); + } + return record.codeChallenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + _codeVerifier?: string, + redirectUri?: string, + resource?: URL, + ): Promise { + const now = this.#now(); + const record = this.#codeStore.peekCode(authorizationCode); + if (!record) throw new InvalidGrantError("Invalid or already used code"); + if (record.expiresAt <= now) { + this.#codeStore.consumeCode(authorizationCode); + throw new InvalidGrantError("Expired code"); + } + if (record.clientId !== client.client_id) { + this.#codeStore.consumeCode(authorizationCode); + throw new InvalidGrantError("Code does not match client"); + } + // RFC 6749 ยง4.1.3: if the authorization request included redirect_uri, + // the token request MUST include the same value. codemem always binds a + // redirect_uri at /authorize, so a missing value at /token is always a + // grant mismatch, not an optional check. + if (redirectUri === undefined || record.redirectUri !== redirectUri) { + this.#codeStore.consumeCode(authorizationCode); + throw new InvalidGrantError("Code does not match redirect_uri"); + } + if ((record.resource ?? null) !== (resource?.href ?? null)) { + this.#codeStore.consumeCode(authorizationCode); + throw new InvalidGrantError("Code does not match resource"); + } + + const issued = this.#tokenStore.issueToken(client.client_id, now, record.resource); + if (!issued) { + // Token-store overload is transient: leave the auth code unused so the + // client can retry token exchange without restarting the OAuth flow. + throw new TemporarilyUnavailableError("Too many active access tokens"); + } + + const consumed = this.#codeStore.consumeCode(authorizationCode); + if (!consumed) { + this.#tokenStore.revokeToken(issued.token, now); + // Lost a race with a concurrent exchange that consumed the code in + // the gap between peek and consume. Reject rather than issuing a + // duplicate usable token. + throw new InvalidGrantError("Authorization code already used"); + } + + return OAuthTokensSchema.parse({ + access_token: issued.token, + token_type: "Bearer", + expires_in: issued.expiresIn, + }); + } + + async exchangeRefreshToken(): Promise { + throw new UnsupportedGrantTypeError("refresh_token grant is not yet supported"); + } + + async verifyAccessToken(token: string): Promise { + const result = this.#tokenStore.verifyToken(token, this.#now()); + if (!result.ok) { + const reason = + result.reason === "expired_token" + ? "Token has expired" + : result.reason === "revoked_token" + ? "Token has been revoked" + : "Token is invalid"; + throw new InvalidTokenError(reason); + } + const authInfo: AuthInfo = { + token, + clientId: result.record.clientId, + scopes: [], + expiresAt: Math.floor(result.record.expiresAt / 1000), + }; + if (result.record.resource) authInfo.resource = new URL(result.record.resource); + return authInfo; + } + + async revokeToken( + _client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest, + ): Promise { + this.#tokenStore.revokeToken(request.token, this.#now()); + } +} + +function buildUpstreamAuthorizationParams( + client: OAuthClientInformationFull, + params: AuthorizationParams, +): URLSearchParams { + const upstream = new URLSearchParams(); + upstream.set("client_id", client.client_id); + upstream.set("redirect_uri", params.redirectUri); + upstream.set("response_type", "code"); + upstream.set("code_challenge", params.codeChallenge); + upstream.set("code_challenge_method", "S256"); + if (params.state) upstream.set("state", params.state); + if (params.scopes?.length) upstream.set("scope", params.scopes.join(" ")); + if (params.resource) upstream.set("resource", params.resource.href); + return upstream; +} + +function mapOAuthErrorBody(body: OAuthErrorResponse): Error { + const description = body.error_description ?? body.error; + switch (body.error) { + case "temporarily_unavailable": + return new TemporarilyUnavailableError(description); + case "invalid_request": + return new InvalidRequestError(description); + case "invalid_client": + return new InvalidClientError(description); + case "access_denied": + return new AccessDeniedError(description); + case "unsupported_response_type": + return new UnsupportedResponseTypeError(description); + default: + return new ServerError(description); + } +}