mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 02:00:46 +00:00
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:
1 parent
f9398235ff
commit
99fce07806
4 files changed
+151
-8
No files matched your search
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user