mcp: enforce the 90-day absolute grant lifetime on every token exchange

src/auth/grant-age.ts owns GRANT_MAX_AGE_SECONDS and a pure check over
the grant's `grantIssuedAt` prop; oauth.ts wires it into the provider's
tokenExchangeCallback so both code exchanges and refreshes fail closed
with invalid_grant once a grant is older than 90 days — or whenever the
timestamp is missing or malformed. The upstream session cannot be
refreshed, so a grant must eventually re-consent; this is also the
runway for swapping the upstream credential later.

Tests: 6 function-level cases (fresh, boundary ±1s, 90-day default,
malformed/missing timestamps fail closed, explicit maximum) and 2
end-to-end cases through /token (aged-out grant and timestamp-less
grant both → 400 invalid_grant). A test bug found on the way — passing
undefined to a defaulted parameter applies the default — is fixed with
an explicit null sentinel.

pnpm check: typecheck, eslint, prettier, 105 tests passing.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
Tanish Manku committed 2026-08-24 20:34:03 -07:00
1 parent f9398235ff
commit 99fce07806
4 files changed
+151 -8

No files matched your search

+42
View File
@@ -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",
});
}
}
+8 -7
View File
@@ -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(
+68
View File
@@ -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();
});
});
+33 -1
View File
@@ -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<URL> {
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<TokenError>()).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<TokenError>()).error).toBe("invalid_grant");
});
});