mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-10 03:51:25 +00:00
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:
1 parent
44e5774746
commit
4f810e44d6
2 files changed
+175
No files matched your search
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user