diff --git a/packages/mcp-server/src/oauth.test.ts b/packages/mcp-server/src/oauth.test.ts index 8fd695d5..1eff1733 100644 --- a/packages/mcp-server/src/oauth.test.ts +++ b/packages/mcp-server/src/oauth.test.ts @@ -5,6 +5,7 @@ import { createInMemoryOAuthAccessTokenStore, createInMemoryOAuthAuthorizationCodeStore, createInMemoryOAuthClientsStore, + createInMemoryOAuthRefreshTokenStore, createMcpOAuthMetadata, createMcpProtectedResourceMetadata, exchangeMcpOAuthAuthorizationCode, @@ -715,6 +716,68 @@ describe("MCP OAuth metadata and dynamic client registration", () => { reason: "unknown_token", }); }); + + it("allows refresh-token scope downscoping without expansion", () => { + const refreshStore = createInMemoryOAuthRefreshTokenStore(); + const issued = refreshStore.issueGrant({ + clientId: "client-scope", + scopes: ["memory:read", "memory:write"], + }); + if (!issued) throw new Error("expected refresh token grant"); + + const downscoped = refreshStore.rotateRefreshToken("client-scope", issued.refreshToken, { + scopes: ["memory:read"], + }); + expect(downscoped).toMatchObject({ ok: true, grant: { scopes: ["memory:read"] } }); + if (!downscoped.ok) throw new Error("expected refresh token downscope"); + expect( + refreshStore.rotateRefreshToken("client-scope", downscoped.refreshToken, { + scopes: ["memory:admin"], + }), + ).toMatchObject({ ok: false, reason: "scope_mismatch" }); + }); + + it("cleans refresh-token hash indexes when grants are revoked", () => { + const refreshStore = createInMemoryOAuthRefreshTokenStore(); + const issued = refreshStore.issueGrant({ clientId: "client-revoke" }); + if (!issued) throw new Error("expected refresh token grant"); + const rotated = refreshStore.rotateRefreshToken("client-revoke", issued.refreshToken); + if (!rotated.ok) throw new Error("expected refresh token rotation"); + + expect(refreshStore.revokeRefreshToken(rotated.refreshToken)).toBe(issued.grant.grantId); + expect(refreshStore.revokeRefreshToken(rotated.refreshToken)).toBeUndefined(); + }); + + it("cleans every refresh-token hash for multi-rotated grants", () => { + const refreshStore = createInMemoryOAuthRefreshTokenStore(); + const issued = refreshStore.issueGrant({ clientId: "client-multi" }, 1_000); + if (!issued) throw new Error("expected refresh token grant"); + const first = refreshStore.rotateRefreshToken("client-multi", issued.refreshToken, {}, 2_000); + if (!first.ok) throw new Error("expected first refresh token rotation"); + const second = refreshStore.rotateRefreshToken("client-multi", first.refreshToken, {}, 3_000); + if (!second.ok) throw new Error("expected second refresh token rotation"); + + expect(refreshStore.revokeRefreshToken(second.refreshToken, 4_000)).toBe(issued.grant.grantId); + expect(refreshStore.revokeRefreshToken(issued.refreshToken, 4_001)).toBeUndefined(); + expect(refreshStore.revokeRefreshToken(first.refreshToken, 4_001)).toBeUndefined(); + expect(refreshStore.revokeRefreshToken(second.refreshToken, 4_001)).toBeUndefined(); + }); + + it("cleans every refresh-token hash for expired multi-rotated grants", () => { + const refreshStore = createInMemoryOAuthRefreshTokenStore(); + const issued = refreshStore.issueGrant({ clientId: "client-expire" }, 1_000); + if (!issued) throw new Error("expected refresh token grant"); + const first = refreshStore.rotateRefreshToken("client-expire", issued.refreshToken, {}, 2_000); + if (!first.ok) throw new Error("expected first refresh token rotation"); + const second = refreshStore.rotateRefreshToken("client-expire", first.refreshToken, {}, 3_000); + if (!second.ok) throw new Error("expected second refresh token rotation"); + + const afterRefreshTtl = 31 * 24 * 60 * 60 * 1000; + expect(refreshStore.issueGrant({ clientId: "client-next" }, afterRefreshTtl)).toBeDefined(); + expect(refreshStore.revokeRefreshToken(issued.refreshToken, afterRefreshTtl)).toBeUndefined(); + expect(refreshStore.revokeRefreshToken(first.refreshToken, afterRefreshTtl)).toBeUndefined(); + expect(refreshStore.revokeRefreshToken(second.refreshToken, afterRefreshTtl)).toBeUndefined(); + }); }); function pkceS256(verifier: string): string { diff --git a/packages/mcp-server/src/oauth.ts b/packages/mcp-server/src/oauth.ts index 9674c568..0f8f89f4 100644 --- a/packages/mcp-server/src/oauth.ts +++ b/packages/mcp-server/src/oauth.ts @@ -24,8 +24,10 @@ const SUPPORTED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]); const SUPPORTED_RESPONSE_TYPES = new Set(["code"]); const AUTHORIZATION_CODE_TTL_MS = 5 * 60 * 1000; const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; const MAX_AUTHORIZATION_CODES = 100; const MAX_ACCESS_TOKENS = 100; +const MAX_REFRESH_GRANTS = 100; const ACCESS_TOKEN_BYTES = 32; const ACCESS_TOKEN_BASE64URL_LENGTH = 43; const ACCESS_TOKEN_BASE64URL = /^[A-Za-z0-9_-]{43}$/; @@ -64,6 +66,7 @@ export interface OAuthAuthorizationCodeStore { export interface AccessTokenRecord { clientId: string; + grantId?: string; resource?: string; tokenHash: string; issuedAt: number; @@ -81,9 +84,54 @@ export interface OAuthAccessTokenStore { clientId: string, now?: number, resource?: string, + grantId?: string, ): { token: string; expiresIn: number } | undefined; verifyToken(token: string, now?: number): AccessTokenVerificationResult; revokeToken(token: string, now?: number): boolean; + revokeTokensForGrant?(grantId: string, now?: number): number; +} + +export interface RefreshTokenGrantRecord { + grantId: string; + clientId: string; + scopes: string[]; + resource?: string; + currentRefreshTokenHash: string; + previousRefreshTokenHash: string | null; + issuedAt: number; + expiresAt: number; + rotatedAt: number | null; + revokedAt: number | null; +} + +export type RefreshTokenRotationResult = + | { ok: true; grant: RefreshTokenGrantRecord; refreshToken: string; expiresIn: number } + | { + ok: false; + reason: + | "unknown_refresh_token" + | "expired_refresh_token" + | "revoked_refresh_token" + | "client_mismatch" + | "scope_mismatch" + | "resource_mismatch" + | "refresh_token_replay"; + grantId?: string; + }; + +export interface OAuthRefreshTokenStore { + issueGrant( + record: { clientId: string; scopes?: string[]; resource?: string }, + now?: number, + ): { grant: RefreshTokenGrantRecord; refreshToken: string; expiresIn: number } | undefined; + rotateRefreshToken( + clientId: string, + refreshToken: string, + options?: { scopes?: string[]; resource?: string }, + now?: number, + ): RefreshTokenRotationResult; + revokeRefreshToken(token: string, now?: number): string | undefined; + revokeGrant(grantId: string, now?: number): boolean; } export interface McpOAuthRedirectResult { @@ -193,6 +241,7 @@ export class InMemoryOAuthAccessTokenStore implements OAuthAccessTokenStore { clientId: string, now = Date.now(), resource?: string, + grantId?: string, ): { token: string; expiresIn: number } | undefined { this.#deleteInactiveTokens(now); if (this.#tokensByHash.size >= MAX_ACCESS_TOKENS) return undefined; @@ -200,6 +249,7 @@ export class InMemoryOAuthAccessTokenStore implements OAuthAccessTokenStore { const tokenHash = signOAuthAccessTokenBytes(tokenBytes, this.#tokenHashKey); this.#tokensByHash.set(tokenHash, { clientId, + grantId, resource, tokenHash, issuedAt: now, @@ -238,6 +288,16 @@ export class InMemoryOAuthAccessTokenStore implements OAuthAccessTokenStore { return true; } + revokeTokensForGrant(grantId: string, now = Date.now()): number { + let revoked = 0; + for (const record of this.#tokensByHash.values()) { + if (record.grantId !== grantId || record.revokedAt !== null) continue; + record.revokedAt = now; + revoked += 1; + } + return revoked; + } + #deleteInactiveTokens(now: number): void { for (const [tokenHash, record] of this.#tokensByHash) { if (record.expiresAt <= now || record.revokedAt !== null) @@ -246,6 +306,133 @@ export class InMemoryOAuthAccessTokenStore implements OAuthAccessTokenStore { } } +export class InMemoryOAuthRefreshTokenStore implements OAuthRefreshTokenStore { + readonly #grantsById = new Map(); + readonly #grantIdsByRefreshHash = new Map(); + readonly #refreshHashesByGrantId = new Map>(); + readonly #tokenHashKey = randomBytes(32); + + issueGrant( + record: { clientId: string; scopes?: string[]; resource?: string }, + now = Date.now(), + ): { grant: RefreshTokenGrantRecord; refreshToken: string; expiresIn: number } | undefined { + this.#deleteInactiveGrants(now); + if (this.#grantsById.size >= MAX_REFRESH_GRANTS) return undefined; + const refreshToken = randomToken(); + const refreshTokenHash = hashSerializedOAuthToken(refreshToken, this.#tokenHashKey); + if (!refreshTokenHash) return undefined; + const grant: RefreshTokenGrantRecord = { + grantId: randomUUID(), + clientId: record.clientId, + scopes: [...(record.scopes ?? [])], + resource: record.resource, + currentRefreshTokenHash: refreshTokenHash, + previousRefreshTokenHash: null, + issuedAt: now, + expiresAt: now + REFRESH_TOKEN_TTL_SECONDS * 1000, + rotatedAt: null, + revokedAt: null, + }; + this.#grantsById.set(grant.grantId, grant); + this.#grantIdsByRefreshHash.set(refreshTokenHash, grant.grantId); + this.#refreshHashesByGrantId.set(grant.grantId, new Set([refreshTokenHash])); + return { + grant: { ...grant, scopes: [...grant.scopes] }, + refreshToken, + expiresIn: REFRESH_TOKEN_TTL_SECONDS, + }; + } + + rotateRefreshToken( + clientId: string, + refreshToken: string, + options: { scopes?: string[]; resource?: string } = {}, + now = Date.now(), + ): RefreshTokenRotationResult { + const tokenHash = hashSerializedOAuthToken(refreshToken, this.#tokenHashKey); + if (!tokenHash) return { ok: false, reason: "unknown_refresh_token" }; + const grantId = this.#grantIdsByRefreshHash.get(tokenHash); + if (!grantId) return { ok: false, reason: "unknown_refresh_token" }; + const grant = this.#grantsById.get(grantId); + if (!grant) return { ok: false, reason: "unknown_refresh_token" }; + if (grant.revokedAt !== null) return { ok: false, reason: "revoked_refresh_token", grantId }; + if (grant.expiresAt <= now) { + this.revokeGrant(grantId, now); + return { ok: false, reason: "expired_refresh_token", grantId }; + } + if (grant.clientId !== clientId) return { ok: false, reason: "client_mismatch", grantId }; + if (!isScopeSubset(options.scopes ?? grant.scopes, grant.scopes)) { + return { ok: false, reason: "scope_mismatch", grantId }; + } + if ((grant.resource ?? null) !== (options.resource ?? grant.resource ?? null)) { + return { ok: false, reason: "resource_mismatch", grantId }; + } + + const matchesCurrent = isSameTokenHash(grant.currentRefreshTokenHash, tokenHash); + const matchesPrevious = + grant.previousRefreshTokenHash !== null && + isSameTokenHash(grant.previousRefreshTokenHash, tokenHash); + if (!matchesCurrent && !matchesPrevious) { + this.revokeGrant(grantId, now); + return { ok: false, reason: "refresh_token_replay", grantId }; + } + + const nextRefreshToken = randomToken(); + const nextRefreshTokenHash = hashSerializedOAuthToken(nextRefreshToken, this.#tokenHashKey); + if (!nextRefreshTokenHash) return { ok: false, reason: "unknown_refresh_token" }; + grant.scopes = [...(options.scopes ?? grant.scopes)]; + grant.previousRefreshTokenHash = matchesCurrent ? grant.currentRefreshTokenHash : null; + grant.currentRefreshTokenHash = nextRefreshTokenHash; + grant.rotatedAt = now; + this.#grantIdsByRefreshHash.set(nextRefreshTokenHash, grantId); + this.#refreshHashesByGrantId.get(grantId)?.add(nextRefreshTokenHash); + return { + ok: true, + grant: { ...grant, scopes: [...grant.scopes] }, + refreshToken: nextRefreshToken, + expiresIn: Math.max(0, Math.floor((grant.expiresAt - now) / 1000)), + }; + } + + revokeRefreshToken(token: string, now = Date.now()): string | undefined { + const tokenHash = hashSerializedOAuthToken(token, this.#tokenHashKey); + if (!tokenHash) return undefined; + const grantId = this.#grantIdsByRefreshHash.get(tokenHash); + if (!grantId) return undefined; + this.revokeGrant(grantId, now); + return grantId; + } + + revokeGrant(grantId: string, now = Date.now()): boolean { + const grant = this.#grantsById.get(grantId); + if (!grant) return false; + if (grant.revokedAt !== null) return true; + grant.revokedAt = now; + this.#deleteGrantRefreshHashIndexEntries(grant); + return true; + } + + #deleteInactiveGrants(now: number): void { + for (const [grantId, grant] of this.#grantsById) { + if (grant.expiresAt > now && grant.revokedAt === null) continue; + this.#grantsById.delete(grantId); + this.#deleteGrantRefreshHashIndexEntries(grant); + } + } + + #deleteGrantRefreshHashIndexEntries(grant: RefreshTokenGrantRecord): void { + const refreshHashes = this.#refreshHashesByGrantId.get(grant.grantId); + if (refreshHashes) { + for (const refreshHash of refreshHashes) this.#grantIdsByRefreshHash.delete(refreshHash); + this.#refreshHashesByGrantId.delete(grant.grantId); + return; + } + this.#grantIdsByRefreshHash.delete(grant.currentRefreshTokenHash); + if (grant.previousRefreshTokenHash) + this.#grantIdsByRefreshHash.delete(grant.previousRefreshTokenHash); + } +} + export function createInMemoryOAuthClientsStore(): OAuthRegisteredClientsStore { return new InMemoryOAuthClientsStore(); } @@ -258,6 +445,10 @@ export function createInMemoryOAuthAccessTokenStore(): OAuthAccessTokenStore { return new InMemoryOAuthAccessTokenStore(); } +export function createInMemoryOAuthRefreshTokenStore(): OAuthRefreshTokenStore { + return new InMemoryOAuthRefreshTokenStore(); +} + export function createMcpOAuthMetadata(options: McpOAuthMetadataOptions): OAuthMetadata { const mcpUrl = normalizeMcpPublicUrl(options.mcpUrl); const issuerUrl = getOriginUrl(mcpUrl); @@ -585,11 +776,25 @@ function decodeOAuthAccessToken(serialized: string): Buffer | null { return bytes; } +function randomToken(): string { + return randomBytes(ACCESS_TOKEN_BYTES).toString("base64url"); +} + +function hashSerializedOAuthToken(serialized: string, key: Buffer): string | null { + const tokenBytes = decodeOAuthAccessToken(serialized); + if (!tokenBytes) return null; + return signOAuthToken(tokenBytes, key); +} + // Compute the HMAC-SHA256 signature of the binary access-token material using // the per-store random key. This is an integrity signature over a random // 256-bit value, not password hashing; tokens are validated by re-signing the // presented bytes and comparing the digest to the stored signature. function signOAuthAccessTokenBytes(material: Buffer, key: Buffer): string { + return signOAuthToken(material, key); +} + +function signOAuthToken(material: Buffer | string, key: Buffer): string { return createHmac("sha256", key).update(material).digest("base64url"); } @@ -599,6 +804,11 @@ function isSameTokenHash(left: string, right: string): boolean { return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); } +function isScopeSubset(requested: string[], granted: string[]): boolean { + const grantedScopes = new Set(granted); + return requested.every((scope) => grantedScopes.has(scope)); +} + function createMetadataOnlyProvider( clientsStore: OAuthRegisteredClientsStore, ): OAuthServerProvider { diff --git a/packages/mcp-server/src/provider.test.ts b/packages/mcp-server/src/provider.test.ts index ac957bd1..4d8fab81 100644 --- a/packages/mcp-server/src/provider.test.ts +++ b/packages/mcp-server/src/provider.test.ts @@ -1,3 +1,4 @@ +import { InvalidGrantError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { Response } from "express"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthAccessTokenStore, OAuthAuthorizationCodeStore } from "./oauth.js"; @@ -5,6 +6,7 @@ import { createInMemoryOAuthAccessTokenStore, createInMemoryOAuthAuthorizationCodeStore, createInMemoryOAuthClientsStore, + createInMemoryOAuthRefreshTokenStore, registerMcpOAuthClient, } from "./oauth.js"; import { createInMemoryOidcPendingAuthorizationStore, type OidcConfig } from "./oidc.js"; @@ -121,9 +123,10 @@ describe("MemoryOAuthServerProvider", () => { expect(tokens).toMatchObject({ token_type: "Bearer", expires_in: 3600 }); expect(tokens.access_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(tokens.refresh_token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(tokenStore.verifyToken(tokens.access_token, NOW)).toMatchObject({ ok: true, - record: { clientId: client.client_id }, + record: { clientId: client.client_id, grantId: expect.any(String) }, }); await expect( provider.exchangeAuthorizationCode(client, code, undefined, REDIRECT_URI), @@ -167,6 +170,23 @@ describe("MemoryOAuthServerProvider", () => { }); }); + it("rejects access tokens whose bound audience does not match this resource server", 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, "https://other.example.test/mcp"); + if (!issued) throw new Error("expected token issuance"); + + await expect(provider.verifyAccessToken(issued.token)).rejects.toThrow(/audience/); + }); + it("rejects token redemption that omits redirect_uri", async () => { const clientsStore = createInMemoryOAuthClientsStore(); const codeStore = createInMemoryOAuthAuthorizationCodeStore(); @@ -295,16 +315,167 @@ describe("MemoryOAuthServerProvider", () => { await expect(provider.verifyAccessToken("not-a-token")).rejects.toThrow(/invalid/); }); - it("rejects refresh-token exchange until rotation support lands", async () => { + it("exchanges refresh tokens with dual-token rotation and preserves resource binding", async () => { const clientsStore = createInMemoryOAuthClientsStore(); + const codeStore = createInMemoryOAuthAuthorizationCodeStore(); + const tokenStore = createInMemoryOAuthAccessTokenStore(); const provider = new MemoryOAuthServerProvider({ clientsStore, - codeStore: createInMemoryOAuthAuthorizationCodeStore(), + codeStore, + tokenStore, + publicMcpUrl: PUBLIC_MCP_URL, + now: () => NOW, + }); + const client = registerClient(clientsStore); + const initial = await provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id, PUBLIC_MCP_URL), + undefined, + REDIRECT_URI, + new URL(PUBLIC_MCP_URL), + ); + if (!initial.refresh_token) throw new Error("expected refresh token"); + + const refreshed = await provider.exchangeRefreshToken( + client, + initial.refresh_token, + undefined, + new URL(PUBLIC_MCP_URL), + ); + + expect(refreshed.access_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(refreshed.refresh_token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(refreshed.refresh_token).not.toBe(initial.refresh_token); + await expect(provider.verifyAccessToken(refreshed.access_token)).resolves.toMatchObject({ + clientId: client.client_id, + resource: new URL(PUBLIC_MCP_URL), + }); + }); + + it("accepts one retry with the previous refresh token and then treats older tokens as replay", 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 initial = await provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id), + undefined, + REDIRECT_URI, + ); + if (!initial.refresh_token) throw new Error("expected refresh token"); + const rotated = await provider.exchangeRefreshToken(client, initial.refresh_token); + const retry = await provider.exchangeRefreshToken(client, initial.refresh_token); + + expect(rotated.refresh_token).not.toBe(initial.refresh_token); + expect(retry.refresh_token).not.toBe(rotated.refresh_token); + await expect( + provider.exchangeRefreshToken(client, rotated.refresh_token ?? ""), + ).rejects.toThrow(/replay/i); + await expect(provider.verifyAccessToken(rotated.access_token)).rejects.toThrow(/revoked/); + await expect(provider.exchangeRefreshToken(client, initial.refresh_token)).rejects.toThrow( + /invalid refresh token/i, + ); + }); + + it("revokes refresh-token grants and cascades issued access tokens", 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 initial = await provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id), + undefined, + REDIRECT_URI, + ); + if (!initial.refresh_token) throw new Error("expected refresh token"); + + await provider.revokeToken(client, { + token: initial.refresh_token, + token_type_hint: "refresh_token", + }); + + await expect(provider.exchangeRefreshToken(client, initial.refresh_token)).rejects.toThrow( + /invalid refresh token/i, + ); + await expect(provider.verifyAccessToken(initial.access_token)).rejects.toThrow(/revoked/i); + }); + + it("stores refresh-token HMACs instead of plaintext token values", () => { + const refreshStore = createInMemoryOAuthRefreshTokenStore(); + const issued = refreshStore.issueGrant({ clientId: "client-hash" }, NOW); + if (!issued) throw new Error("expected refresh grant"); + + expect(issued.refreshToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(issued.grant.currentRefreshTokenHash).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(issued.grant.currentRefreshTokenHash).not.toBe(issued.refreshToken); + expect(issued.grant.previousRefreshTokenHash).toBeNull(); + }); + + it("rejects refresh-token client, scope, and resource mismatches as invalid grants", 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); + const initial = await provider.exchangeAuthorizationCode( + client, + issueCode(codeStore, client.client_id, PUBLIC_MCP_URL), + undefined, + REDIRECT_URI, + new URL(PUBLIC_MCP_URL), + ); + if (!initial.refresh_token) throw new Error("expected refresh token"); - await expect(provider.exchangeRefreshToken()).rejects.toThrow(/refresh_token grant/); + await expect(provider.exchangeRefreshToken(otherClient, initial.refresh_token)).rejects.toThrow( + InvalidGrantError, + ); + await expect(provider.exchangeRefreshToken(otherClient, initial.refresh_token)).rejects.toThrow( + /client/i, + ); + await expect( + provider.exchangeRefreshToken(client, initial.refresh_token, ["memory:write"]), + ).rejects.toThrow(InvalidGrantError); + await expect( + provider.exchangeRefreshToken(client, initial.refresh_token, ["memory:write"]), + ).rejects.toThrow(/scope/i); + await expect( + provider.exchangeRefreshToken( + client, + initial.refresh_token, + undefined, + new URL("https://other.example.test/mcp"), + ), + ).rejects.toThrow(InvalidGrantError); + await expect( + provider.exchangeRefreshToken( + client, + initial.refresh_token, + undefined, + new URL("https://other.example.test/mcp"), + ), + ).rejects.toThrow(/resource/i); }); }); diff --git a/packages/mcp-server/src/provider.ts b/packages/mcp-server/src/provider.ts index f7cb964a..f6c61b72 100644 --- a/packages/mcp-server/src/provider.ts +++ b/packages/mcp-server/src/provider.ts @@ -7,7 +7,6 @@ import { InvalidTokenError, ServerError, TemporarilyUnavailableError, - UnsupportedGrantTypeError, UnsupportedResponseTypeError, } from "@modelcontextprotocol/sdk/server/auth/errors.js"; import type { @@ -23,7 +22,12 @@ import { OAuthTokensSchema, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { Response } from "express"; -import type { OAuthAccessTokenStore, OAuthAuthorizationCodeStore } from "./oauth.js"; +import { + createInMemoryOAuthRefreshTokenStore, + type OAuthAccessTokenStore, + type OAuthAuthorizationCodeStore, + type OAuthRefreshTokenStore, +} from "./oauth.js"; import { beginOidcAuthorization, type OidcConfig, @@ -34,6 +38,7 @@ export interface MemoryOAuthServerProviderOptions { clientsStore: OAuthRegisteredClientsStore; codeStore: OAuthAuthorizationCodeStore; tokenStore: OAuthAccessTokenStore; + refreshTokenStore?: OAuthRefreshTokenStore; publicMcpUrl: string; oidc?: { config: OidcConfig; pendingStore: OidcPendingAuthorizationStore }; now?: () => number; @@ -55,14 +60,17 @@ export interface MemoryOAuthServerProviderOptions { * 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. + * - Refresh-token grants use dual-token rotation: the current token and one + * previous token are accepted to tolerate client retry races, while older + * replayed tokens revoke the whole grant. */ export class MemoryOAuthServerProvider implements OAuthServerProvider { readonly #clientsStore: OAuthRegisteredClientsStore; readonly #codeStore: OAuthAuthorizationCodeStore; readonly #tokenStore: OAuthAccessTokenStore; + readonly #refreshTokenStore: OAuthRefreshTokenStore; readonly #publicMcpUrl: string; + readonly #resourceServerUrl: string; readonly #oidc?: { config: OidcConfig; pendingStore: OidcPendingAuthorizationStore }; readonly #now: () => number; @@ -70,7 +78,9 @@ export class MemoryOAuthServerProvider implements OAuthServerProvider { this.#clientsStore = options.clientsStore; this.#codeStore = options.codeStore; this.#tokenStore = options.tokenStore; + this.#refreshTokenStore = options.refreshTokenStore ?? createInMemoryOAuthRefreshTokenStore(); this.#publicMcpUrl = options.publicMcpUrl; + this.#resourceServerUrl = new URL(options.publicMcpUrl).href; this.#oidc = options.oidc; this.#now = options.now ?? Date.now; } @@ -150,16 +160,30 @@ export class MemoryOAuthServerProvider implements OAuthServerProvider { throw new InvalidGrantError("Code does not match resource"); } - const issued = this.#tokenStore.issueToken(client.client_id, now, record.resource); + const grant = this.#refreshTokenStore.issueGrant( + { clientId: client.client_id, resource: record.resource }, + now, + ); + if (!grant) { + throw new TemporarilyUnavailableError("Too many active refresh grants"); + } + const issued = this.#tokenStore.issueToken( + client.client_id, + now, + record.resource, + grant.grant.grantId, + ); if (!issued) { // Token-store overload is transient: leave the auth code unused so the // client can retry token exchange without restarting the OAuth flow. + this.#refreshTokenStore.revokeGrant(grant.grant.grantId, now); throw new TemporarilyUnavailableError("Too many active access tokens"); } const consumed = this.#codeStore.consumeCode(authorizationCode); if (!consumed) { this.#tokenStore.revokeToken(issued.token, now); + this.#refreshTokenStore.revokeGrant(grant.grant.grantId, 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. @@ -170,11 +194,42 @@ export class MemoryOAuthServerProvider implements OAuthServerProvider { access_token: issued.token, token_type: "Bearer", expires_in: issued.expiresIn, + refresh_token: grant.refreshToken, }); } - async exchangeRefreshToken(): Promise { - throw new UnsupportedGrantTypeError("refresh_token grant is not yet supported"); + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + resource?: URL, + ): Promise { + const now = this.#now(); + const rotation = this.#refreshTokenStore.rotateRefreshToken( + client.client_id, + refreshToken, + { scopes, ...(resource ? { resource: resource.href } : {}) }, + now, + ); + if (!rotation.ok) { + if (rotation.reason === "refresh_token_replay" && rotation.grantId) { + this.#tokenStore.revokeTokensForGrant?.(rotation.grantId, now); + } + throw new InvalidGrantError(refreshTokenErrorDescription(rotation.reason)); + } + const issued = this.#tokenStore.issueToken( + client.client_id, + now, + rotation.grant.resource, + rotation.grant.grantId, + ); + if (!issued) throw new TemporarilyUnavailableError("Too many active access tokens"); + return OAuthTokensSchema.parse({ + access_token: issued.token, + token_type: "Bearer", + expires_in: issued.expiresIn, + refresh_token: rotation.refreshToken, + }); } async verifyAccessToken(token: string): Promise { @@ -194,7 +249,12 @@ export class MemoryOAuthServerProvider implements OAuthServerProvider { scopes: [], expiresAt: Math.floor(result.record.expiresAt / 1000), }; - if (result.record.resource) authInfo.resource = new URL(result.record.resource); + if (result.record.resource) { + if (new URL(result.record.resource).href !== this.#resourceServerUrl) { + throw new InvalidTokenError("Token audience does not match this resource server"); + } + authInfo.resource = new URL(result.record.resource); + } return authInfo; } @@ -202,7 +262,10 @@ export class MemoryOAuthServerProvider implements OAuthServerProvider { _client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest, ): Promise { - this.#tokenStore.revokeToken(request.token, this.#now()); + const now = this.#now(); + this.#tokenStore.revokeToken(request.token, now); + const grantId = this.#refreshTokenStore.revokeRefreshToken(request.token, now); + if (grantId) this.#tokenStore.revokeTokensForGrant?.(grantId, now); } } @@ -239,3 +302,22 @@ function mapOAuthErrorBody(body: OAuthErrorResponse): Error { return new ServerError(description); } } + +function refreshTokenErrorDescription(reason: string): string { + switch (reason) { + case "expired_refresh_token": + return "Refresh token has expired"; + case "revoked_refresh_token": + return "Refresh token has been revoked"; + case "client_mismatch": + return "Refresh token does not match client"; + case "scope_mismatch": + return "Refresh token scope cannot be expanded"; + case "resource_mismatch": + return "Refresh token does not match resource"; + case "refresh_token_replay": + return "Refresh token replay detected"; + default: + return "Invalid refresh token"; + } +}