mcp: UpstreamCredential interface with the session implementation

src/upstream/credential.ts defines what this service holds on a user's
behalf and how it reaches an upstream request. The only kind today is an
Artcraft session (the signed_session JWT from sign-in) sent as the
`session` header; `kind` is stored in grant props from the start so a
backend-issued credential can replace it later without touching tools or
the client (swap point 2 in mcp/CLAUDE.md).

The contract includes non-leaking toString/toJSON: accidental string or
JSON conversion yields describe(), never the secret (the secret lives in
a private field). Sign-in values are rejected if empty, non-ASCII, or
containing whitespace/CRLF (header injection). Unknown prop kinds are an
error, not a fallthrough.

Tests (9): header attached exactly once and nothing else set, replace
semantics, props round-trip, no leak via describe/String/JSON/keys, and
validation firing on empty, injection, unknown kind, missing secret,
non-object props.

Claude-Session: https://claude.ai/code/session_01H3gfN2NCx3hBXSrZXYHUQ3
This commit is contained in:
Tanish Manku committed 2026-08-24 20:12:08 -07:00
1 parent 44e5774746
commit 4f810e44d6
2 files changed
+175

No files matched your search

+96
View File
@@ -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<typeof CredentialPropsSchema>;
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");
}
}
+79
View File
@@ -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);
});
});