mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 19:41:32 +00:00
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:
1 parent
6509a2ea0c
commit
3ade6330d2
6 files changed
+810
-1
No files matched your search
@@ -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"
|
||||
|
||||
Generated
+656
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user