diff --git a/mcp/src/auth/grant-age.ts b/mcp/src/auth/grant-age.ts new file mode 100644 index 0000000000..dfc1b0875a --- /dev/null +++ b/mcp/src/auth/grant-age.ts @@ -0,0 +1,42 @@ +import { OAuthError } from "@cloudflare/workers-oauth-provider"; +import { z } from "zod"; + +/** + * Absolute grant lifetime. The library bounds tokens (1h access, 30d idle refresh) but a grant + * that keeps being refreshed would live forever, holding an upstream session that cannot be + * refreshed — only held or deleted. So every token issuance re-checks the grant's age from + * `grantIssuedAt` in its (encrypted) props and fails closed with `invalid_grant`, which is what + * Claude expects when a refresh is no longer valid. + * + * Props without a timestamp are treated as expired: fail closed, never open. + */ + +/** + * Absolute lifetime of a grant regardless of refresh activity: 90 days. Also the runway for + * swapping the upstream credential later (mcp/CLAUDE.md → Lifetimes). + */ +export const GRANT_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; + +/** Props key holding the grant's issue time (epoch ms); set by consent, checked on every exchange. */ +export const GRANT_ISSUED_AT_PROP = "grantIssuedAt"; + +const IssuedAtSchema = z.object({ [GRANT_ISSUED_AT_PROP]: z.number().int().positive() }); + +export function assertGrantWithinMaxAge( + props: unknown, + nowMs: number, + maxAgeSeconds: number = GRANT_MAX_AGE_SECONDS, +): void { + const parsed = IssuedAtSchema.safeParse(props); + if (!parsed.success) { + throw new OAuthError("invalid_grant", { + description: "grant has no issue time and is treated as expired", + }); + } + const ageMs = nowMs - parsed.data[GRANT_ISSUED_AT_PROP]; + if (ageMs > maxAgeSeconds * 1000) { + throw new OAuthError("invalid_grant", { + description: "grant is older than the maximum lifetime; the user must connect again", + }); + } +} diff --git a/mcp/src/auth/oauth.ts b/mcp/src/auth/oauth.ts index a179654aff..2a5ba7f3c8 100644 --- a/mcp/src/auth/oauth.ts +++ b/mcp/src/auth/oauth.ts @@ -1,5 +1,7 @@ import type { OAuthProviderOptions } from "@cloudflare/workers-oauth-provider"; +import { assertGrantWithinMaxAge } from "./grant-age"; + /** * Configuration of the OAuth 2.1 authorization server that fronts the MCP endpoint. The * library (`@cloudflare/workers-oauth-provider`) owns discovery metadata, client registration @@ -27,13 +29,6 @@ export type Scope = (typeof SCOPES)[number]; export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; export const REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; -/** - * Absolute lifetime of a grant regardless of refresh activity. The upstream session cannot - * be refreshed, only held or deleted, so grants must re-consent eventually; this is also the - * runway for swapping the upstream credential later (mcp/CLAUDE.md → Lifetimes). - */ -export const GRANT_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; - export const RESOURCE_NAME = "Artcraft"; /** An ExportedHandler that definitely has `fetch` — what the provider accepts for handlers. */ @@ -75,6 +70,12 @@ export function createOAuthProviderOptions(handlers: OAuthHandlers): OAuthProvid accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS, refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS, + // Runs on every code exchange and refresh: enforces the absolute grant lifetime + // (grant-age.ts) by failing closed with invalid_grant. + tokenExchangeCallback(options) { + assertGrantWithinMaxAge(options.props, Date.now()); + }, + onError(error) { // Codes and categories only: descriptions can echo client-supplied input. console.warn( diff --git a/mcp/test/auth/grant-age.test.ts b/mcp/test/auth/grant-age.test.ts new file mode 100644 index 0000000000..8b2d6a05d1 --- /dev/null +++ b/mcp/test/auth/grant-age.test.ts @@ -0,0 +1,68 @@ +import { OAuthError } from "@cloudflare/workers-oauth-provider"; +import { describe, expect, it } from "vitest"; + +import { + assertGrantWithinMaxAge, + GRANT_ISSUED_AT_PROP, + GRANT_MAX_AGE_SECONDS, +} from "../../src/auth/grant-age"; + +const NOW = 1_800_000_000_000; +const DAY_MS = 24 * 60 * 60 * 1000; +const MAX_AGE_MS = GRANT_MAX_AGE_SECONDS * 1000; + +function check(props: unknown, maxAgeSeconds?: number): () => void { + return () => { + assertGrantWithinMaxAge(props, NOW, maxAgeSeconds); + }; +} + +function issuedAt(ageMs: number): { [GRANT_ISSUED_AT_PROP]: number } { + return { [GRANT_ISSUED_AT_PROP]: NOW - ageMs }; +} + +describe("assertGrantWithinMaxAge", () => { + it("passes a fresh grant", () => { + expect(check(issuedAt(0))).not.toThrow(); + }); + + it("passes a grant one second inside the limit", () => { + expect(check(issuedAt(MAX_AGE_MS - 1000))).not.toThrow(); + }); + + it("fires one second past the limit with invalid_grant", () => { + expect(check(issuedAt(MAX_AGE_MS + 1000))).toThrow(OAuthError); + expect(check(issuedAt(MAX_AGE_MS + 1000))).toThrow(/older than the maximum lifetime/); + try { + check(issuedAt(MAX_AGE_MS + 1000))(); + } catch (error) { + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe("invalid_grant"); + } + }); + + it("uses 90 days as the default maximum", () => { + expect(GRANT_MAX_AGE_SECONDS).toBe(90 * 24 * 60 * 60); + expect(check(issuedAt(89 * DAY_MS))).not.toThrow(); + expect(check(issuedAt(91 * DAY_MS))).toThrow(OAuthError); + }); + + it("fails closed when the timestamp is missing or malformed", () => { + const malformed: unknown[] = [ + {}, + null, + undefined, + { [GRANT_ISSUED_AT_PROP]: "yesterday" }, + { [GRANT_ISSUED_AT_PROP]: -1 }, + { [GRANT_ISSUED_AT_PROP]: 1.5 }, + ]; + for (const props of malformed) { + expect(check(props)).toThrow(/no issue time/); + } + }); + + it("honours an explicit maximum", () => { + expect(check(issuedAt(2 * DAY_MS), DAY_MS / 1000)).toThrow(OAuthError); + expect(check(issuedAt(2 * DAY_MS), (3 * DAY_MS) / 1000)).not.toThrow(); + }); +}); diff --git a/mcp/test/auth/token-flow.test.ts b/mcp/test/auth/token-flow.test.ts index e97309a176..b5a941a1e4 100644 --- a/mcp/test/auth/token-flow.test.ts +++ b/mcp/test/auth/token-flow.test.ts @@ -74,6 +74,8 @@ async function consent( clientId: string, challenge: string, scope: readonly string[] = SCOPES, + /** `null` omits the timestamp entirely (a legacy or hand-made grant). */ + grantIssuedAt: number | null = Date.now(), ): Promise { const params = new URLSearchParams({ response_type: "code", @@ -96,7 +98,7 @@ async function consent( scope: [...scope], props: { credential: createSessionCredential(SIGNED_SESSION).toProps(), - grantIssuedAt: Date.now(), + ...(grantIssuedAt === null ? {} : { grantIssuedAt }), }, }); return new URL(redirectTo); @@ -261,3 +263,33 @@ describe("storage", () => { } }); }); + +describe("absolute grant lifetime", () => { + const NINETY_ONE_DAYS_MS = 91 * 24 * 60 * 60 * 1000; + + it("refuses to exchange a code for a grant older than 90 days", async () => { + const clientId = await registerClient(); + const { verifier, challenge } = await pkce(); + const redirect = await consent(clientId, challenge, SCOPES, Date.now() - NINETY_ONE_DAYS_MS); + const response = await exchangeCode( + clientId, + redirect.searchParams.get("code") ?? "", + verifier, + ); + expect(response.status).toBe(400); + expect((await response.json()).error).toBe("invalid_grant"); + }); + + it("refuses a grant that carries no issue time (fail closed)", async () => { + const clientId = await registerClient(); + const { verifier, challenge } = await pkce(); + const redirect = await consent(clientId, challenge, SCOPES, null); + const response = await exchangeCode( + clientId, + redirect.searchParams.get("code") ?? "", + verifier, + ); + expect(response.status).toBe(400); + expect((await response.json()).error).toBe("invalid_grant"); + }); +});