diff --git a/src/app.ts b/src/app.ts index 670320a..a63117c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -249,6 +249,19 @@ function relayedProviderText(value: string | undefined, bound: number): string | return printable ? printable : undefined; } +/** + * What may be recorded or answered about a caught error: its class and its + * code, each only when it is a short identifier. Never its message, which + * carries whatever the thrower quoted (a host, a database, a provider's body). + */ +export function errorIdentity(error: unknown): { errorClass: string; errorCode: string | null } { + const code = (error as { code?: unknown } | null)?.code; + return { + errorClass: error instanceof Error && /^[A-Za-z]{1,64}$/.test(error.name) ? error.name : "Error", + errorCode: typeof code === "string" && /^[A-Za-z0-9_]{1,64}$/.test(code) ? code : null, + }; +} + function prefersHtml(acceptHeader: string | undefined): boolean { if (!acceptHeader) { return false; @@ -1333,9 +1346,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise= 100 && status <= 599 ? status : null, }, createdAt: nowIso, diff --git a/src/oauth.ts b/src/oauth.ts index 0d3872c..33ae495 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -60,29 +60,42 @@ export interface OAuthProviderRuntime { } /** - * A provider answered with a non-2xx status. It carries the status and never - * the response body: a provider's body can quote the request, and whatever - * catches this may record or print it. + * A provider answer Heimdall cannot use: a non-2xx status, or a body that is + * not JSON. It carries the status and a code, never the body or the parser's + * message: a provider's body can quote the request or hold tokens (JSON.parse + * quotes the text around the fault), and whatever catches this may record, + * print or relay it. */ -export class ProviderHttpError extends Error { +export class ProviderResponseError extends Error { readonly status: number; + readonly code: "provider_status" | "provider_body_not_json"; - constructor(status: number, failure: string) { - super(`${failure} (status ${status}).`); - this.name = "ProviderHttpError"; + constructor(status: number, code: ProviderResponseError["code"], failure: string) { + super(`${failure} (status ${status}, ${code}).`); + this.name = "ProviderResponseError"; this.status = status; + this.code = code; } } -async function fetchJson(input: string | URL, init: RequestInit, fallbackError: string): Promise { - const response = await fetch(input, init); +/** The JSON body of a provider's answer; refuses any other answer by status and code alone. */ +async function readProviderJson(response: Response, failure: string): Promise { const text = await response.text(); - if (!response.ok) { - throw new ProviderHttpError(response.status, fallbackError); + throw new ProviderResponseError(response.status, "provider_status", failure); } + if (!text) { + return {} as T; + } + try { + return JSON.parse(text) as T; + } catch { + throw new ProviderResponseError(response.status, "provider_body_not_json", failure); + } +} - return (text ? JSON.parse(text) : {}) as T; +async function fetchJson(input: string | URL, init: RequestInit, failure: string): Promise { + return readProviderJson(await fetch(input, init), failure); } function normalizeScopes(scope: string | string[] | undefined): string[] { @@ -783,7 +796,7 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise }); if (channelResponse.ok) { - const payload = (await channelResponse.json()) as { + const payload = await readProviderJson<{ items?: Array<{ id: string; snippet?: { @@ -796,7 +809,7 @@ async function resolveYouTubeIdentity(options: { accessToken: string }): Promise }; }; }>; - }; + }>(channelResponse, "YouTube channel lookup failed"); const channel = payload.items?.[0]; if (channel?.id) { @@ -937,16 +950,11 @@ async function evaluateDiscordEntitlements(options: { }; } - if (!response.ok) { - throw new ProviderHttpError(response.status, "Discord guild member lookup failed"); - } - const text = await response.text(); - - const member = (text ? JSON.parse(text) : {}) as { + const member = await readProviderJson<{ nick?: string; roles?: string[]; joined_at?: string; - }; + }>(response, "Discord guild member lookup failed"); const roles = member.roles ?? []; const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId)); const facts: string[] = []; diff --git a/src/private-command-plane.ts b/src/private-command-plane.ts index bfa346b..c1912f0 100644 --- a/src/private-command-plane.ts +++ b/src/private-command-plane.ts @@ -7,7 +7,7 @@ import { type CultNetOperationServer, } from "cultnet-ts"; import { type FastifyInstance } from "fastify"; -import { getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js"; +import { errorIdentity, getHeimdallRuntimeContext, refreshAppSession, startOAuthFlow, verifyRefreshToken } from "./app.js"; import { callerFromOpenedEnvelope } from "./app-caller.js"; import { getAppProfile } from "./app-profiles.js"; import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js"; @@ -138,6 +138,12 @@ export async function startHeimdallPrivateCommandPlane( } return response(request, cachedResult.status, cachedResult.envelope, config.daemonId); } catch (error) { + // The reply's diagnostics travel outside the sealed envelope, and the + // caller logs them. Errors caught here carry input: JSON.parse quotes + // a provider's body, a store error names the database. So the + // diagnostic is fixed text plus the error's class and code; anything + // more lives only in a sealed payload the plane authored. + const { errorClass, errorCode } = errorIdentity(error); return { schemaVersion: "cultnet.operation_response.v0", messageId: request.messageId, @@ -147,7 +153,7 @@ export async function startHeimdallPrivateCommandPlane( payloadSchema: "gamecult.cultnet.operation_failure.v1", payloadEncoding: "messagepack-base64", payload: Buffer.from(encode({ code: "private-command-denied", message: "Heimdall denied the private command." })).toString("base64"), - diagnostics: [error instanceof Error ? error.message : "Private command failed."], + diagnostics: [`Heimdall denied the private command (${errorClass}${errorCode ? `, ${errorCode}` : ""}).`], sourceRuntimeId: config.daemonId, }; } diff --git a/tests/app.test.ts b/tests/app.test.ts index a529150..374a3f4 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -1542,13 +1542,29 @@ describe("the failed-callback audit event for a provider's HTTP refusal", () => expect(failed[0]!.eventPayloadJson).toEqual({ provider: "discord", mode: "sign_in", - errorClass: "ProviderHttpError", - errorCode: null, + errorClass: "ProviderResponseError", + errorCode: "provider_status", providerStatus: 401, }); expect(JSON.stringify(failed)).not.toContain("CANARY"); }); + it("keeps a 2xx body that is not JSON out of the event, by class and code alone", async () => { + const failed = await auditAfterTokenExchangeAnswers( + () => new Response('{"access_token":"CANARYaccess","refresh_token":CANARYrefresh}', { status: 200 }) + ); + + expect(failed).toHaveLength(1); + expect(failed[0]!.eventPayloadJson).toEqual({ + provider: "discord", + mode: "sign_in", + errorClass: "ProviderResponseError", + errorCode: "provider_body_not_json", + providerStatus: 200, + }); + expect(JSON.stringify(failed)).not.toContain("CANARY"); + }); + it("keeps no status that is not an HTTP status", async () => { const store = new InMemoryStore(); const app = await buildApp({ diff --git a/tests/private-command-diagnostics.test.ts b/tests/private-command-diagnostics.test.ts new file mode 100644 index 0000000..3204e10 --- /dev/null +++ b/tests/private-command-diagnostics.test.ts @@ -0,0 +1,111 @@ +// A private command's reply travels partly outside its sealed envelope: the +// status, the failure payload and the diagnostics are plaintext, and the +// calling app logs the diagnostics. Soul pass 4 found a provider's refresh +// token there: a 2xx refresh answer that is not JSON made JSON.parse quote the +// text around the fault, and the plane returned that message as a diagnostic. +// These tests drive the real Discord runtime with only fetch stubbed. +import { decode, encode } from "@msgpack/msgpack"; +import { invokeCultNetOperation } from "cultnet-ts"; +import { afterEach, expect, it, vi } from "vitest"; +import { buildApp } from "../src/app.js"; +import { type HeimdallConfig } from "../src/config.js"; +import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js"; +import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js"; +import { InMemoryStore } from "../src/store/index.js"; + +const secret = "ghostlight-private-command-secret"; +const resources: Array<{ close(): Promise }> = []; +afterEach(async () => { + vi.unstubAllGlobals(); + await Promise.all(resources.splice(0).reverse().map((r) => r.close())); +}); + +function config(): HeimdallConfig { + return { + serviceName: "heimdall", host: "127.0.0.1", port: 4100, privateCommandHost: "127.0.0.1", privateCommandPort: 0, + workspaceRoot: "/tmp", dataRoot: "/tmp/.hd", cultCachePath: "/tmp/.hd/t.cc", + publicBaseUrl: "https://heimdall.gamecult.org", issuer: "https://heimdall.gamecult.org", daemonId: "yggdrasil-heimdall", + idunnRudpHealth: undefined, idunnHealthContract: "heimdall.cultnet-rudp-provider-health", providerHealthIdentityPath: "/tmp/.hd/p.cc", + sessionTtlSeconds: 3600, refreshTtlSeconds: 3600, stateTtlSeconds: 600, completionTtlSeconds: 300, + bootstrapSigningPrivateKeyOnMissing: false, tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"), + appSharedSecrets: { ghostlight: secret }, appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] }, appBackendCallbacks: {}, + storage: { backend: "memory", applySchemaOnStartup: true }, + providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {} }, + } as HeimdallConfig; +} + +async function command(endpoint: unknown, operation: string, contentSchema: string, key: string, payload: Record) { + const envelope = sealPrivateEnvelope({ appSlug: "ghostlight", operation, contentSchema, idempotencyKey: key, secret, payload }); + return invokeCultNetOperation(endpoint as never, { + schemaVersion: "cultnet.operation_request.v0", messageId: `m-${key}`, serviceId: "heimdall.private.commands", operation, + payloadSchema: "heimdall.private_command_envelope.v1", payloadEncoding: "messagepack-base64", + payload: Buffer.from(encode(envelope)).toString("base64"), sourceRuntimeId: "yggdrasil-ghostlight", + } as never, { runtimeId: "yggdrasil-ghostlight" }); +} +const open = (reply: { payload: string }) => + openPrivateEnvelope(decode(Buffer.from(reply.payload, "base64")) as HeimdallPrivateEnvelope, secret); + +/** A signed-in Ghostlight session, then a refresh whose provider answer is `refreshAnswer`. */ +async function refreshAgainst(refreshAnswer: () => Response) { + let refreshing = false; + vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => { + const u = String(url); + if (u.endsWith("/oauth2/token")) { + if (new URLSearchParams(String(init?.body)).get("grant_type") === "refresh_token") { + refreshing = true; + return refreshAnswer(); + } + return new Response( + JSON.stringify({ access_token: "first-access", refresh_token: "first-refresh", token_type: "Bearer", expires_in: 60, scope: "identify" }), + { status: 200 } + ); + } + if (u.endsWith("/users/@me")) return new Response(JSON.stringify({ id: "u1", username: "u1" }), { status: 200 }); + if (u.includes("/member")) return new Response(JSON.stringify({ roles: ["r"] }), { status: 200 }); + return new Response("{}", { status: 404 }); + }); + const cfg = config(); + const app = await buildApp({ config: cfg, store: new InMemoryStore() }); + resources.push(app); + const plane = await startHeimdallPrivateCommandPlane(app, cfg); + resources.push(plane); + const policy = { kind: "discord_role_access", guildId: "g", allowedRoleIds: ["r"] }; + const begin = open(await command(plane.endpoint, "heimdall.auth.begin", "heimdall.auth_begin_command.v1", "k1", + { provider: "discord", mode: "sign_in", returnTo: "https://yggdrasil.gamecult.org/ghostlight/", entitlementPolicy: policy })); + const state = new URL(String((begin.navigation as { url: string }).url)).searchParams.get("state")!; + const callback = await app.inject({ + method: "GET", + url: `/v1/oauth/discord/callback?code=c1&state=${encodeURIComponent(state)}`, + headers: { accept: "application/json" }, + }); + expect(callback.statusCode, callback.body).toBe(201); + const done = open(await command(plane.endpoint, "heimdall.auth.complete", "heimdall.auth_complete_command.v1", "k2", { handle: begin.handle })); + expect(done.status).toBe("authenticated"); + const reply = await command(plane.endpoint, "heimdall.auth.refresh", "heimdall.auth_refresh_command.v1", "k3", + { refreshToken: done.refreshToken, entitlementPolicy: policy }); + expect(refreshing).toBe(true); + return reply; +} + +/** Every plaintext byte of a reply: the message itself and its decoded failure payload. */ +function plaintext(reply: { payload: string }): string { + return JSON.stringify(reply) + JSON.stringify(decode(Buffer.from(reply.payload, "base64"))); +} + +it("keeps a provider's malformed 2xx refresh body out of every plaintext part of the reply", async () => { + const reply = await refreshAgainst( + () => new Response('{"access_token":"CANARYaccess0123456789","refresh_token":CANARYrefresh0123456789,"token_type":"Bearer"}', { status: 200 }) + ); + + expect(reply.status).toBe("denied"); + expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_body_not_json)."]); + expect(plaintext(reply)).not.toContain("CANARY"); +}); + +it("keeps a provider's refusal body out of every plaintext part of the reply", async () => { + const reply = await refreshAgainst(() => new Response('{"error":"invalid_grant","echo":"CANARYrefused"}', { status: 400 })); + + expect(reply.status).toBe("denied"); + expect(reply.diagnostics).toEqual(["Heimdall denied the private command (ProviderResponseError, provider_status)."]); + expect(plaintext(reply)).not.toContain("CANARY"); +});