diff --git a/mcp/src/upstream/credential.ts b/mcp/src/upstream/credential.ts new file mode 100644 index 0000000000..33c3ced0be --- /dev/null +++ b/mcp/src/upstream/credential.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; + +/** + * What this service holds on a user's behalf and how it is attached to an upstream request. + * + * Today there is one kind — an Artcraft session (the `signed_session` JWT returned by + * `POST /v1/login` / `google_sso`), sent as the `session` header. The interface exists so a + * stronger backend-issued credential can replace it later without touching tools or the + * client (mcp/CLAUDE.md → Architecture, swap point 2). `kind` is stored in grant props from + * day one; nothing outside this module may assume "session". + * + * Revocation (`POST /v1/logout` with the session header) is performed by the auth module + * through the upstream client; it is not a method here so the credential stays a value. + */ + +export const SESSION_HEADER_NAME = "session"; + +export type CredentialKind = "session"; + +export interface UpstreamCredential { + readonly kind: CredentialKind; + /** Attach the credential to an outgoing upstream request. Replaces any existing value. */ + applyTo(headers: Headers): void; + /** The serialisable form kept (encrypted) in grant props. Contains the secret — never log. */ + toProps(): CredentialProps; + /** Safe for logs and error messages: names the kind, never the secret. */ + describe(): string; + /** Accidental string or JSON conversion must yield `describe()`, never the secret. */ + toString(): string; + toJSON(): string; +} + +const SessionPropsSchema = z.object({ + kind: z.literal("session"), + signedSession: z.string().min(1), +}); + +const CredentialPropsSchema = z.discriminatedUnion("kind", [SessionPropsSchema]); + +export type CredentialProps = z.infer; + +export class CredentialError extends Error { + override readonly name = "CredentialError"; +} + +/** Build a session credential from the `signed_session` value returned by sign-in. */ +export function createSessionCredential(signedSession: string): UpstreamCredential { + assertHeaderSafe(signedSession); + return new SessionCredential(signedSession); +} + +/** Rebuild a credential from grant props. Unknown kinds are an error, not a fallthrough. */ +export function credentialFromProps(props: unknown): UpstreamCredential { + const parsed = CredentialPropsSchema.safeParse(props); + if (!parsed.success) { + throw new CredentialError("grant props do not hold a recognised upstream credential"); + } + return createSessionCredential(parsed.data.signedSession); +} + +class SessionCredential implements UpstreamCredential { + readonly kind = "session" as const; + readonly #signedSession: string; + + constructor(signedSession: string) { + this.#signedSession = signedSession; + } + + applyTo(headers: Headers): void { + headers.set(SESSION_HEADER_NAME, this.#signedSession); + } + + toProps(): CredentialProps { + return { kind: this.kind, signedSession: this.#signedSession }; + } + + describe(): string { + return "session credential"; + } + + toString(): string { + return this.describe(); + } + + toJSON(): string { + return this.describe(); + } +} + +/** A header value must be a single line of visible ASCII; anything else is rejected outright. */ +function assertHeaderSafe(value: string): void { + if (value.length === 0) throw new CredentialError("session credential is empty"); + if (!/^[\x21-\x7e]+$/.test(value)) { + throw new CredentialError("session credential contains whitespace or non-ASCII characters"); + } +} diff --git a/mcp/test/upstream/credential.test.ts b/mcp/test/upstream/credential.test.ts new file mode 100644 index 0000000000..2984990d92 --- /dev/null +++ b/mcp/test/upstream/credential.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + createSessionCredential, + CredentialError, + credentialFromProps, + SESSION_HEADER_NAME, +} from "../../src/upstream/credential"; + +// Shaped like the upstream's signed session (an HS256 JWT) but not a real one. +const SIGNED_SESSION = + "eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uX3Rva2VuIjoic2Vzc2lvbl90ZXN0In0.c2lnbmF0dXJl"; + +describe("session credential", () => { + it("attaches itself as the `session` header and nothing else", () => { + const headers = new Headers({ accept: "application/json" }); + createSessionCredential(SIGNED_SESSION).applyTo(headers); + expect(headers.get(SESSION_HEADER_NAME)).toBe(SIGNED_SESSION); + expect(headers.get("cookie")).toBeNull(); + expect(headers.get("authorization")).toBeNull(); + expect([...headers.keys()].sort()).toEqual(["accept", "session"]); + }); + + it("replaces an existing session header rather than appending", () => { + const headers = new Headers({ session: "stale" }); + createSessionCredential(SIGNED_SESSION).applyTo(headers); + expect(headers.get(SESSION_HEADER_NAME)).toBe(SIGNED_SESSION); + }); + + it("round-trips through grant props", () => { + const props = createSessionCredential(SIGNED_SESSION).toProps(); + expect(props).toEqual({ kind: "session", signedSession: SIGNED_SESSION }); + const headers = new Headers(); + credentialFromProps(props).applyTo(headers); + expect(headers.get(SESSION_HEADER_NAME)).toBe(SIGNED_SESSION); + }); + + it("never reveals the secret through describe, toString, or JSON", () => { + const credential = createSessionCredential(SIGNED_SESSION); + expect(credential.describe()).not.toContain(SIGNED_SESSION); + expect(String(credential)).not.toContain(SIGNED_SESSION); + expect(credential.toString()).toBe(credential.describe()); + expect(JSON.stringify(credential)).not.toContain(SIGNED_SESSION); + expect(JSON.stringify({ credential })).not.toContain(SIGNED_SESSION); + expect(Object.keys(credential)).toEqual(["kind"]); + }); +}); + +describe("credential validation fires", () => { + it("rejects an empty session", () => { + expect(() => createSessionCredential("")).toThrow(CredentialError); + }); + + it("rejects header-injection characters", () => { + expect(() => createSessionCredential("abc\r\nx-injected: 1")).toThrow( + /whitespace or non-ASCII/, + ); + expect(() => createSessionCredential("abc def")).toThrow(CredentialError); + expect(() => createSessionCredential("abcé")).toThrow(CredentialError); + }); + + it("rejects props of an unknown kind", () => { + expect(() => credentialFromProps({ kind: "api_key", apiKey: "artcraft_api_x" })).toThrow( + /recognised upstream credential/, + ); + }); + + it("rejects props missing the secret", () => { + expect(() => credentialFromProps({ kind: "session" })).toThrow(CredentialError); + expect(() => credentialFromProps({ kind: "session", signedSession: "" })).toThrow( + CredentialError, + ); + }); + + it("rejects non-object props", () => { + expect(() => credentialFromProps(null)).toThrow(CredentialError); + expect(() => credentialFromProps(SIGNED_SESSION)).toThrow(CredentialError); + }); +});