mcp: Principal — the one shape every token resolves to, rebuilt from grant props

src/tokens/principal.ts: userToken, username, displayName, scopes, and
the upstream credential. The provider hands the protected handler only
the decrypted grant props, so finishAuthorization now stores userToken
and the granted scopes in props alongside the credential and issue
time; principalFromProps validates all of it and fails closed with
PrincipalError (→ 403) on anything this build does not understand —
unknown scopes, wrong token prefix, unrecognised credential kind,
missing issue time. Personal tokens (M2) will produce the same props
through resolveExternalToken and land on the same function.

Also drops @hono/mcp: the SDK's WebStandardStreamableHTTPServerTransport
is the right fit for Workers and carries our principal via
AuthInfo.extra.

Tests (8): rebuild, scope checks, no credential leak through JSON, and
the fail-closed cases.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
Tanish Manku committed 2026-08-24 21:11:31 -07:00
1 parent 6509a2ea0c
commit 3ade6330d2
6 files changed
+810 -1

No files matched your search

+1
View File
@@ -24,6 +24,7 @@
},
"dependencies": {
"@cloudflare/workers-oauth-provider": "0.10.3",
"@modelcontextprotocol/sdk": "1.30.0",
"hono": "4.13.4",
"openapi-fetch": "0.17.0",
"zod": "4.4.3"
+656
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -10,11 +10,17 @@ import { type Scope, SCOPES } from "./oauth";
* in its encrypted props, and hand back the redirect the client is waiting for.
*/
/**
* Everything the protected handler needs to rebuild a `Principal` from a token: the provider
* hands the API handler only these (decrypted) props, not the grant record.
*/
export interface GrantProps {
readonly credential: ReturnType<AuthenticatedUser["credential"]["toProps"]>;
readonly [GRANT_ISSUED_AT_PROP]: number;
readonly userToken: string;
readonly username: string;
readonly displayName: string;
readonly scopes: readonly Scope[];
}
/**
@@ -33,18 +39,21 @@ export async function finishAuthorization(
user: AuthenticatedUser,
nowMs: number = Date.now(),
): Promise<string> {
const scopes = grantedScopes(authRequest.scope);
const props: GrantProps = {
credential: user.credential.toProps(),
[GRANT_ISSUED_AT_PROP]: nowMs,
userToken: user.userToken,
username: user.username,
displayName: user.displayName,
scopes,
};
const { redirectTo } = await helpers.completeAuthorization({
request: authRequest,
userId: user.userToken,
// Storage-visible (not encrypted): only what the connections page needs to label a grant.
metadata: { clientName: client.clientName ?? client.clientId },
scope: grantedScopes(authRequest.scope),
scope: scopes,
props,
});
return redirectTo;
+67
View File
@@ -0,0 +1,67 @@
import { z } from "zod";
import { GRANT_ISSUED_AT_PROP } from "../auth/grant-age";
import { type Scope, SCOPES } from "../auth/oauth";
import {
CredentialError,
credentialFromProps,
type UpstreamCredential,
} from "../upstream/credential";
/**
* Who is calling, and with what — the single shape every token kind resolves to
* (mcp/CLAUDE.md → Architecture, swap point 2/3). OAuth access tokens resolve here through the
* provider's decrypted grant props; personal tokens (M2) will resolve here through
* `resolveExternalToken`. Tools and the upstream client never learn which kind it was.
*/
export interface Principal {
/** The Artcraft user token (`user_…`). */
readonly userToken: string;
readonly username: string;
readonly displayName: string;
readonly scopes: readonly Scope[];
readonly credential: UpstreamCredential;
}
export class PrincipalError extends Error {
override readonly name = "PrincipalError";
}
const PropsSchema = z.object({
userToken: z.string().regex(/^user_/),
username: z.string().min(1),
displayName: z.string(),
scopes: z.array(z.enum(SCOPES)),
credential: z.unknown(),
[GRANT_ISSUED_AT_PROP]: z.number().int().positive(),
});
/**
* Build a `Principal` from decrypted grant props. Anything unexpected is a `PrincipalError`,
* which the handler turns into a 403 — a valid token whose grant this build cannot read is
* refused, never guessed at.
*/
export function principalFromProps(props: unknown): Principal {
const parsed = PropsSchema.safeParse(props);
if (!parsed.success) {
throw new PrincipalError("grant props do not describe a principal this build understands");
}
let credential: UpstreamCredential;
try {
credential = credentialFromProps(parsed.data.credential);
} catch (error) {
if (error instanceof CredentialError) throw new PrincipalError(error.message);
throw error;
}
return {
userToken: parsed.data.userToken,
username: parsed.data.username,
displayName: parsed.data.displayName,
scopes: parsed.data.scopes,
credential,
};
}
export function hasScope(principal: Principal, scope: Scope): boolean {
return principal.scopes.includes(scope);
}
@@ -108,8 +108,10 @@ describe("finishAuthorization", () => {
expect(unwrapped?.grant.props).toMatchObject({
credential: { kind: "session", signedSession: SIGNED_SESSION },
[GRANT_ISSUED_AT_PROP]: now,
userToken: "user_finish",
username: "finisher",
displayName: "Finisher",
scopes: ["read:jobs"],
});
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { GRANT_ISSUED_AT_PROP } from "../../src/auth/grant-age";
import { hasScope, principalFromProps, PrincipalError } from "../../src/tokens/principal";
import { SESSION_HEADER_NAME } from "../../src/upstream/credential";
const SIGNED_SESSION =
"eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX3Rva2VuIjoic2Vzc2lvbl90ZXN0In0.c2lnbmF0dXJl";
const PROPS = {
credential: { kind: "session", signedSession: SIGNED_SESSION },
[GRANT_ISSUED_AT_PROP]: 1_800_000_000_000,
userToken: "user_abc",
username: "abc",
displayName: "Abc",
scopes: ["read:account", "read:jobs"],
};
describe("principalFromProps", () => {
it("rebuilds the principal from grant props", () => {
const principal = principalFromProps(PROPS);
expect(principal.userToken).toBe("user_abc");
expect(principal.username).toBe("abc");
expect(principal.displayName).toBe("Abc");
expect(principal.scopes).toEqual(["read:account", "read:jobs"]);
const headers = new Headers();
principal.credential.applyTo(headers);
expect(headers.get(SESSION_HEADER_NAME)).toBe(SIGNED_SESSION);
});
it("answers scope questions", () => {
const principal = principalFromProps(PROPS);
expect(hasScope(principal, "read:account")).toBe(true);
expect(hasScope(principal, "read:catalog")).toBe(false);
});
it("never leaks the credential through the principal's string forms", () => {
const principal = principalFromProps(PROPS);
expect(JSON.stringify(principal)).not.toContain(SIGNED_SESSION);
});
});
describe("principalFromProps fails closed", () => {
it("on props from a grant this build does not understand", () => {
expect(() => principalFromProps({ userId: "user_abc" })).toThrow(PrincipalError);
expect(() => principalFromProps(null)).toThrow(PrincipalError);
expect(() => principalFromProps("nope")).toThrow(PrincipalError);
});
it("on a user token with the wrong prefix", () => {
expect(() => principalFromProps({ ...PROPS, userToken: "session_abc" })).toThrow(
PrincipalError,
);
});
it("on unknown scopes (a future build's grant must not run on this one)", () => {
expect(() => principalFromProps({ ...PROPS, scopes: ["read:account", "generate"] })).toThrow(
PrincipalError,
);
});
it("on an unrecognised credential kind", () => {
expect(() =>
principalFromProps({ ...PROPS, credential: { kind: "api_key", apiKey: "artcraft_api_x" } }),
).toThrow(PrincipalError);
});
it("on a missing issue time", () => {
const withoutIssuedAt = Object.fromEntries(
Object.entries(PROPS).filter(([key]) => key !== GRANT_ISSUED_AT_PROP),
);
expect(() => principalFromProps(withoutIssuedAt)).toThrow(PrincipalError);
});
});