From a7cdf0fea7df3ba443b506456bbcc61bc1cbaeab Mon Sep 17 00:00:00 2001 From: Heimdall Date: Fri, 6 Nov 2026 12:17:14 +0000 Subject: [PATCH 01/10] Draft: Joystick consent flow design and skipped test sketch Documents what a viewer sees today on the Joystick path and what consent is missing, then drafts a visible ask, typed refusal and auditable receipt. Design only; the test file is a skipped sketch and was not run. Co-Authored-By: Claude Sonnet 5.5 --- docs/joystick-consent-flow.md | 123 +++++++++++++++++++++++++++++++++ tests/joystick-consent.test.ts | 62 +++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 docs/joystick-consent-flow.md create mode 100644 tests/joystick-consent.test.ts diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md new file mode 100644 index 0000000..498c8e4 --- /dev/null +++ b/docs/joystick-consent-flow.md @@ -0,0 +1,123 @@ +# Joystick sign-in and consent: what exists, what is missing, a draft flow + +Status: DRAFT design, no code. Source-grounded at `fefd2dd` (main). Follow-up +`heimdall-body:follow_up:chat-s3124-3129`. Not for merge until a Heimdall +steward rules on the open questions below. + +## 1. What a viewer sees today + +**Joystick viewer sign-in does not exist.** `streampixelsProfile` +(`src/app-profiles.ts:109-117`) lists Joystick only in +`managedConnectionProviders`. `admitsProviderForMode` (`:243`) admits `sign_in` +and `link` only for `identityProviders`, so +`POST /v1/oauth/joystick/start` with `mode: sign_in` returns +`400 provider_not_supported_for_app` as JSON before any redirect +(`startOAuthFlow`, `src/app.ts:598-603`). The viewer sees whatever the app does +with a failed start; Heimdall shows them nothing. The architecture doc records +this as deliberate until the public bot is registered +(`docs/architecture.md:461`, `joystick:follow_up:joystick-viewer-signin-public-bot`). + +**The only live Joystick path is a creator `connect`.** Walking it as the +person in the browser: + +1. The app backend starts the flow (authenticated by app secret, `connection` + `{kind: creator, targetId}`). The person's browser is sent to + `/v1/oauth/joystick/launch?state=...`. +2. `launch` (`src/app.ts:1130-1194`) claims the state, sets the binding cookie + and answers `302` straight to `joystick.tv/api/oauth/authorize`. **Heimdall + renders no page of its own.** Nothing says who is asking, for what, or what + will be kept. +3. The only ask the person sees is Joystick's own screen, listing the scopes in + `providerCatalog.joystick.defaultScopes` (`src/providers.ts:162`): + `identity:read stream:read chat:read followers:read subscribers:read`. + Joystick words that screen, not us. +4. The callback (`:1196`) exchanges the code, stores the credential encrypted + (rotating refresh token, managed custody), and returns the generic handoff + page (`src/browser-handoff.ts`): title "Authentication Complete", body "You + can return to the app now." It does not name the app, the provider, the + scopes granted, or that a long-lived refresh token is now held by Heimdall. +5. Refusal: if the person clicks Deny at Joystick, the callback gets + `error=access_denied`, and the page says "Authentication Failed ... Return to + the app to continue." The provider's own text is relayed only in + `errorDescription` in the fallback URL fragment / postMessage payload, not + shown on the page. Refusing *at Heimdall's step* is impossible: there is no + Heimdall step. +6. After: the audit rows `oauth_callback_succeeded`, `auth_completion_created`, + `managed_credential_revoked` etc. (`createAuditEvent` call sites, e.g. + `app.ts:1582`) are written for operators. There is **no route a viewer can + call to read them**, and no route a viewer can call to revoke a credential. + Revocation exists only inside the app backend plane (`managed-credential.ts:172`). + +## 2. What consent is missing + +| Gap | Today | Why it matters | +| --- | --- | --- | +| Visible ask | None from Heimdall; provider screen only | The person cannot tell Heimdall custody (refresh token kept, rotated, used for stream/chat/follower reads) from a one-off login. | +| Who is asking | Not shown | Two parties act: the app (StreamPixels) and Heimdall as custodian. Neither is named. | +| Scope truth | Provider screen lists scopes; Heimdall's `requestedScopes` / `defaultScopes` are not shown or compared with what was granted | Granted scopes can differ from requested; the stored `scopes` (`app.ts:1406`) are never shown. | +| Typed refusal | A deny is an untyped `provider_error` | The app and the audit log cannot tell "viewer said no" from "provider broke". | +| Receipt | Operator audit rows only | The viewer cannot audit what was agreed, when, or revoke it. | +| Viewer vs creator | The `connect` binding proves a binding exists, not that the browser's owner is the creator (`joystick:follow_up:connect-start-needs-app-auth`) | Consent from the wrong person is not consent. | + +## 3. Draft consent flow + +Applies to any Joystick completion (creator connect now; viewer sign-in when +the public bot lands) and, if ruled, to every provider whose state carries +`managed_credential`. + +### 3.1 The ask (visible, before the redirect) + +`GET /v1/oauth/:provider/launch` stops answering `302`. It renders a Heimdall +page, **after** the state is verified but **before** the use is claimed, with: + +- the app by display name and return origin; +- the provider and the exact scope list that will be requested; +- what Heimdall keeps (a refresh token, encrypted, rotating) versus what the + app receives (no token; a claim) and for how long; +- two forms, both POST, CSRF-bound by the existing binding cookie: + **Continue to Joystick** and **No, return to the app**; +- a link to the receipt of any earlier consent for this account and app. + +Only Continue claims the launch use and answers `302`. The page is plain +server-rendered HTML with no script, matching the handoff page's escape rule. + +### 3.2 Typed refusal + +**No** (and a provider `access_denied` on return) produces a distinct result +`consent_refused` with `{provider, app, mode, by: "viewer" | "provider"}`: +delivered to the app by the same handoff channel as any result, written as an +audit event `consent_refused`, no credential stored, state consumed. A provider +failure stays `provider_error`. The app is told not to ask again in the same +session; whether it may ask later is the app's rule, not Heimdall's. + +### 3.3 Receipt + +On a granted completion Heimdall writes one `consent_receipt` row: receipt id +(random, non-guessable), account (or creator target), app, provider, scopes +asked, scopes granted, what Heimdall retains, `grantedAt`, `consentPageDigest` +(hash of the exact text shown, so a later wording change is visible), and a +`revokedAt` slot. The handoff page shows the receipt id and a "View receipt" link. + +`GET /v1/receipts/:id` (viewer-bound by the same cookie or by the account's +access token) shows the row and offers **Revoke**, which calls the existing +managed-credential revocation and stamps `revokedAt`. Revoke is itself receipted. +Receipts are append-only; a revocation is a second row, not an edit. + +## 4. Open questions for the steward + +1. Who gets the ask: every provider, or only `managed_credential` ones? (Recommend: only managed custody, where Heimdall holds something beyond a login.) +2. Where does the receipt live: Heimdall store as a new document, or the existing audit table with a new `eventType`? (Recommend: new typed document; the audit table is operator-shaped and has no viewer read path.) +3. Viewer identity for the receipt page when the viewer has no Heimdall session yet (a first-time creator connect). Binding cookie only, or require sign-in first? +4. Does `consent_refused` need to reach a backend callback app, or only the browser handoff? +5. Wording ownership: the page text is legal-adjacent (CultCoin legal research is a sibling thread); who signs the copy? + +## 5. Out of scope here + +Enabling Joystick in `identityProviders`; fixing `connect-start-needs-app-auth`; +Postgres schema/migration for the receipt; any deploy. Each needs its own cut. + +## 6. Test sketch + +`tests/joystick-consent.test.ts` holds the behaviours above as skipped tests +(they cannot pass until the cut is built). Unrun: the author's workspace could +not install dependencies, so treat the file as a sketch to be typechecked. diff --git a/tests/joystick-consent.test.ts b/tests/joystick-consent.test.ts new file mode 100644 index 0000000..6881f8f --- /dev/null +++ b/tests/joystick-consent.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { type FastifyInstance } from "fastify"; +import { buildApp } from "../src/app.js"; +import { createTestConfig } from "./oauth-flow.js"; + +// SKETCH for docs/joystick-consent-flow.md. Skipped: every test here names a +// behaviour the consent cut must build. Remove `.skip` as each one lands. + +const apps: FastifyInstance[] = []; +afterEach(async () => { + while (apps.length) await apps.pop()?.close(); +}); + +async function creatorConnectStart(app: FastifyInstance) { + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { + appSlug: "streampixels", + mode: "connect", + connection: { kind: "creator", targetId: "creator-1" }, + returnTo: "https://streampixels.gamecult.org/settings", + handoff: { kind: "browser_completion" }, + }, + }); + expect(response.statusCode).toBe(201); + const url = new URL(response.json().authorizationUrl as string); + return `${url.pathname}${url.search}`; +} + +describe.skip("Joystick consent flow (draft)", () => { + it("launch shows a Heimdall ask naming app, provider and scopes, and does not redirect", async () => { + const app = await buildApp({ config: createTestConfig() }); + apps.push(app); + const launch = await app.inject({ method: "GET", url: await creatorConnectStart(app), headers: { accept: "text/html" } }); + expect(launch.statusCode).toBe(200); + expect(launch.headers.location).toBeUndefined(); + for (const needle of ["StreamPixels", "Joystick", "identity:read", "subscribers:read", "refresh token"]) { + expect(launch.body).toContain(needle); + } + }); + + it("a viewer who declines yields consent_refused by viewer, stores no credential, and consumes the state", async () => { + // POST the decline form from the launch page with the binding cookie; + // expect result {error: "consent_refused", by: "viewer"}, an audit event + // "consent_refused", no managed credential row, and a second launch refused. + expect.fail("not built"); + }); + + it("a provider access_denied is consent_refused by provider, distinct from provider_error", async () => { + expect.fail("not built"); + }); + + it("a granted completion writes one receipt with asked and granted scopes and the page digest", async () => { + expect.fail("not built"); + }); + + it("the receipt page is readable only by the browser or account that consented, and revoke stamps revokedAt without editing the grant row", async () => { + expect.fail("not built"); + }); +}); From 7ca4e2355f4ba9955123ad87b907cd3135431744 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:22:36 +0000 Subject: [PATCH 02/10] Joystick consent: the ask, typed refusal, receipts, and a signed-in grants page Managed custody (callbackMint custody_only) now stops at a Heimdall page before the provider redirect. A decline or a provider access_denied is consent_refused (by viewer|provider), distinct from provider_error. A granted completion writes an append-only consent receipt with the asked and granted scopes and the digest of the wording shown. GET /v1/me/grants lists every grant and receipt of the signed-in account; revoking one strips the credential, so the app's next token request is refused, and writes a revocation receipt. Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 384 +++++++++++++++++++++++--- src/backend-handoff.ts | 4 + src/browser-handoff.ts | 31 ++- src/consent.ts | 170 ++++++++++++ src/providers.ts | 7 +- src/store/in-memory.ts | 13 + src/store/postgres.ts | 71 +++++ src/store/schema.ts | 18 ++ src/store/types.ts | 28 ++ tests/joystick-consent.test.ts | 437 ++++++++++++++++++++++++++++-- tests/oauth-flow.ts | 18 +- tests/oauth-state-binding.test.ts | 5 +- tests/store.test.ts | 8 +- 13 files changed, 1110 insertions(+), 84 deletions(-) create mode 100644 src/consent.ts diff --git a/src/app.ts b/src/app.ts index 2707bbe..c4d8e93 100644 --- a/src/app.ts +++ b/src/app.ts @@ -26,6 +26,7 @@ import { import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; +import { consentDigest, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; import { createTokenCustody, type TokenCustody } from "./custody.js"; import { grantFact } from "./facts.js"; @@ -37,7 +38,7 @@ import { type OAuthRuntimeRegistry, } from "./oauth.js"; import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js"; -import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; +import { authorizationScopes, buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; import { createStore, type HeimdallStore } from "./store/index.js"; import { @@ -809,6 +810,47 @@ async function resolveLinkAccount( return claims.account_id; } +/** The account a Heimdall access token for any app names, when the token and its session are live. */ +async function resolveAccessAccount( + ctx: Pick, + authorization: string | undefined +): Promise { + const token = /^Bearer\s+(\S+)$/i.exec(authorization ?? "")?.[1]; + if (!token) return null; + const verification = verifyJwt(token, ctx.keys.publicKey); + if (!verification.valid) return null; + const audience = verification.payload.aud; + return isAppSlug(audience) ? resolveLinkAccount(ctx, audience, authorization) : null; +} + +const grantsPageTokenType = "heimdall_grants_page"; +const grantsPageTokenTtlSeconds = 600; + +/** A short, purpose-bound proof of a signed-in account, so the grants page can be opened and its forms posted by a browser that holds no session. */ +function signGrantsPageToken(config: HeimdallConfig, keys: ReturnType, accountId: string): string { + const iat = nowEpochSeconds(); + return signJwt({ typ: grantsPageTokenType, iss: config.issuer, account_id: accountId, iat, exp: iat + grantsPageTokenTtlSeconds }, keys); +} + +function grantsPageTokenAccount(config: HeimdallConfig, keys: ReturnType, token: unknown): string | null { + if (typeof token !== "string") return null; + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) return null; + const { typ, iss, account_id: accountId, exp } = verification.payload; + return typ === grantsPageTokenType && iss === config.issuer && typeof accountId === "string" && typeof exp === "number" && exp > nowEpochSeconds() + ? accountId + : null; +} + +function consentAskFor(state: OAuthStatePayload): ConsentAsk { + return { + appSlug: state.app_slug, + provider: state.provider, + scopes: authorizationScopes(state.provider, state.requested_scopes ?? undefined), + returnOrigin: new URL(state.return_to).origin, + }; +} + /** * Session refresh, extracted for the same reason as startOAuthFlow: the HTTP * route and the plane's `refreshAuth` (which owns Ghostlight identity) call @@ -948,6 +990,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + done(null, Object.fromEntries(new URLSearchParams(body as string))); + }); app.get("/healthz", async () => ({ ok: true, @@ -1126,6 +1172,106 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { + const handoff = state.handoff ?? { kind: "browser_completion" }; + if (handoff.kind !== "browser_completion" || !handoff.attemptId) return; + await store.updateAuthAttempt(state.app_slug, handoff.attemptId, { + status: "denied", + at: new Date().toISOString(), + denialCode, + }); + }; + + /** + * The person said no, at Heimdall's ask (`viewer`) or at the provider + * (`provider`, a returned access_denied). It is its own result, never a + * provider_error: the app and the audit log can tell a refusal from a fault. + * Nothing is stored beyond an audit row that names no one. + */ + const refuseConsent = async (request: FastifyRequest, reply: FastifyReply, state: OAuthStatePayload, by: "viewer" | "provider") => { + const handoff = state.handoff ?? { kind: "browser_completion" }; + await denyAttempt(state, "consent_refused"); + await store.createAuditEvent({ + appSlug: state.app_slug, + eventType: "consent_refused", + eventPayloadJson: { provider: state.provider, mode: state.mode, by }, + createdAt: new Date().toISOString(), + }); + if (handoff.kind === "backend_callback") { + await maybeDeliverBackendHandoff(handoff, { + source: "heimdall", + kind: "oauth_result", + handoffKind: "backend_callback", + attemptId: handoff.attemptId, + status: "error", + provider: state.provider, + appSlug: state.app_slug, + mode: state.mode, + returnTo: state.return_to, + connection: state.connection, + error: "consent_refused", + by, + }); + } + if (prefersHtml(request.headers.accept)) { + reply.type("text/html; charset=utf-8"); + return reply.send( + renderBrowserHandoffPage({ + handoffKind: handoff.kind, + status: "error", + provider: state.provider, + appSlug: state.app_slug, + mode: state.mode, + returnTo: state.return_to, + ...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}), + error: "consent_refused", + by, + }) + ); + } + reply.code(400); + return { error: "consent_refused", by, provider: state.provider, returnTo: state.return_to }; + }; + + const providerNotConfigured = (reply: FastifyReply, state: OAuthStatePayload) => { + reply.code(503); + return { error: "provider_not_configured", provider: state.provider, expectedEnv: providerExpectedEnv(state.provider) }; + }; + + /** Claim the launch use, bind the browser, and send it to the provider. */ + const beginProviderRedirect = async (request: FastifyRequest, reply: FastifyReply, state: OAuthStatePayload, rawState: string) => { + const clientId = config.providers[state.provider].clientId; + if (!clientId) { + return providerNotConfigured(reply, state); + } + const first = await store.claimOAuthStateUse({ + jti: state.jti, + phase: "launch", + appSlug: state.app_slug, + usedAt: new Date().toISOString(), + expiresAt: new Date(state.exp * 1000).toISOString(), + }); + if (!first) { + return refuseVerifiedState(request, reply, state, "state_already_launched"); + } + + const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; + setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds()); + reply.header("Cache-Control", "no-store"); + reply.header("Referrer-Policy", "no-referrer"); + return reply.redirect( + buildAuthorizationUrl({ + provider: state.provider, + clientId, + redirectUri: oauthCallbackUrl(config, state.provider), + state: rawState, + requestedScopes: state.requested_scopes ?? undefined, + ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), + }), + 302 + ); + }; + app.get<{ Params: { provider: Provider }; Querystring: { state: string }; @@ -1158,38 +1304,82 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + "/v1/oauth/:provider/consent", + { + schema: { + params: { + type: "object", + required: ["provider"], + additionalProperties: false, + properties: { provider: { type: "string", enum: [...providers] } }, + }, + body: { + type: "object", + required: ["state", "decision"], + additionalProperties: false, + properties: { + state: { type: "string", minLength: 1 }, + decision: { type: "string", enum: ["continue", "decline"] }, + }, + }, + }, + }, + async (request, reply) => { + const verified = verifyOAuthState(config, keys, request.body.state, request.params.provider); + if (!verified.ok) { + reply.code(400); + return { error: verified.error }; + } + const state = verified.payload; + const profile = getAppProfile(state.app_slug); + if (!profile || callbackMint(profile, state.provider, state.mode, state.connection) !== "custody_only") { + reply.code(400); + return { error: "consent_not_required" }; + } + // Either answer must come from the browser the ask was shown to. + if (!oauthBindingHolds(config, keys, request.cookies[oauthBindingCookieName(state.jti)], state)) { + return refuseVerifiedState(request, reply, state, "state_not_bound"); + } + if (request.body.decision === "continue") { + return beginProviderRedirect(request, reply, state, request.body.state); + } + // A decline spends both uses of the state: it can neither launch nor complete afterwards. + const usedAt = new Date().toISOString(); + const expiresAt = new Date(state.exp * 1000).toISOString(); + if (!(await store.claimOAuthStateUse({ jti: state.jti, phase: "launch", appSlug: state.app_slug, usedAt, expiresAt }))) { return refuseVerifiedState(request, reply, state, "state_already_launched"); } - - const oidc = providerCatalog[state.provider].oidc ? deriveOidcSecrets(state.jti, keys.privateKey) : undefined; - setOAuthBindingCookie(reply, state, signOAuthBinding(config, keys, state), state.exp - nowEpochSeconds()); - reply.header("Cache-Control", "no-store"); - reply.header("Referrer-Policy", "no-referrer"); - return reply.redirect( - buildAuthorizationUrl({ - provider: state.provider, - clientId, - redirectUri: oauthCallbackUrl(config, state.provider), - state: request.query.state, - requestedScopes: state.requested_scopes ?? undefined, - ...(oidc ? { oidc: { nonce: oidc.nonce, codeChallenge: oidc.codeChallenge } } : {}), - }), - 302 - ); + await store.claimOAuthStateUse({ jti: state.jti, phase: "callback", appSlug: state.app_slug, usedAt, expiresAt }); + setOAuthBindingCookie(reply, state, "", 0); + return refuseConsent(request, reply, state, "viewer"); } ); @@ -1233,14 +1423,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { - if (handoff.kind !== "browser_completion" || !handoff.attemptId) return; - await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, { - status: "denied", - at: new Date().toISOString(), - denialCode, - }); - }; + const denyBrowserAttempt = (denialCode: string): Promise => denyAttempt(statePayload, denialCode); // The cookie is checked before the claim: a stranger without it must not be // able to burn the state that the browser that launched it still needs. // The attempt is denied, though: a launcher whose cookie lapsed can never @@ -1262,6 +1445,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => + (await resolveAccessAccount({ config, keys, store }, request.headers.authorization)) ?? + grantsPageTokenAccount(config, keys, pageToken); + + const grantsReply = (reply: FastifyReply) => { + reply.header("Cache-Control", "no-store"); + reply.header("Referrer-Policy", "no-referrer"); + reply.header("X-Frame-Options", "DENY"); + reply.header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); + }; + + const signInRequired = (reply: FastifyReply) => { + reply.code(401); + return { error: "sign_in_required", detail: "Send a Heimdall access token as a Bearer token, or open a link from POST /v1/me/grants/link." }; + }; + + // The signed-in surface for consent: every grant and receipt the account + // holds, and the one-click way to take each back. + app.get<{ Querystring: { t?: string } }>( + GRANTS_PATH, + { schema: { querystring: { type: "object", additionalProperties: false, properties: { t: { type: "string" } } } } }, + async (request, reply) => { + const accountId = await grantsAccount(request, request.query.t); + if (!accountId) return signInRequired(reply); + grantsReply(reply); + const receipts = await store.listConsentReceipts(accountId); + if (prefersHtml(request.headers.accept)) { + reply.type("text/html; charset=utf-8"); + return reply.send(renderGrantsPage(grantViews(receipts), signGrantsPageToken(config, keys, accountId))); + } + return { grants: grantViews(receipts), receipts }; + } + ); + + // A link a browser can open: short-lived, opens the grants page and nothing else. + app.post(`${GRANTS_PATH}/link`, async (request, reply) => { + const accountId = await resolveAccessAccount({ config, keys, store }, request.headers.authorization); + if (!accountId) return signInRequired(reply); + grantsReply(reply); + reply.code(201); + return { + url: `${config.publicBaseUrl}${GRANTS_PATH}?t=${encodeURIComponent(signGrantsPageToken(config, keys, accountId))}`, + expiresInSeconds: grantsPageTokenTtlSeconds, + }; + }); + + app.post<{ Params: { grantId: string }; Body?: { t?: string } }>( + `${GRANTS_PATH}/:grantId/revoke`, + { + schema: { + params: { type: "object", required: ["grantId"], additionalProperties: false, properties: { grantId: { type: "string", minLength: 1 } } }, + body: { type: ["object", "null"], additionalProperties: false, properties: { t: { type: "string" } } }, + }, + }, + async (request, reply) => { + const accountId = await grantsAccount(request, request.body?.t); + if (!accountId) return signInRequired(reply); + grantsReply(reply); + const receipts = await store.listConsentReceipts(accountId); + const grant = grantViews(receipts).find((view) => view.id === request.params.grantId); + if (!grant) { + reply.code(404); + return { error: "grant_not_found" }; + } + const revoked: string[] = []; + if (grant.status === "active") { + // The credential is one row per account and provider, whichever app + // asked, so taking it back ends every live grant that rests on it. + // It is stripped first: a failure after that leaves a receipt that + // still reads active over a credential that is gone, never the reverse. + const identity = await store.findStoredLinkedIdentityForAccount(accountId, grant.provider); + const at = new Date().toISOString(); + if (identity) { + await store.updateLinkedIdentityCredential(identity.id, async () => ({ kind: "revoke", updatedAt: at })); + } + for (const live of grantViews(receipts).filter((view) => view.provider === grant.provider && view.status === "active")) { + await store.createConsentReceipt({ + kind: "revocation", + accountId, + appSlug: live.appSlug, + provider: live.provider, + createdAt: at, + revokesReceiptId: live.id, + }); + revoked.push(live.id); + } + await store.createAuditEvent({ + accountId, + eventType: "consent_revoked", + eventPayloadJson: { provider: grant.provider, grantIds: revoked }, + createdAt: at, + }); + } + if (prefersHtml(request.headers.accept) || request.headers["content-type"]?.startsWith("application/x-www-form-urlencoded")) { + reply.type("text/html; charset=utf-8"); + const views = grantViews(await store.listConsentReceipts(accountId)); + return reply.send(renderGrantsPage(views, signGrantsPageToken(config, keys, accountId), revoked.length ? "Taken back." : "Already taken back.")); + } + return { revoked, grants: grantViews(await store.listConsentReceipts(accountId)) }; + } + ); + return app; } diff --git a/src/backend-handoff.ts b/src/backend-handoff.ts index d49a125..b26a59f 100644 --- a/src/backend-handoff.ts +++ b/src/backend-handoff.ts @@ -30,6 +30,8 @@ export interface BackendHandoffSuccessPayload mode: OAuthMode; account: AccountSummary; entitlements: EntitlementEvaluation; + /** The receipt of the consent a managed connection rests on. */ + consentReceiptId?: string; } export interface BackendHandoffErrorPayload extends BackendHandoffBase { @@ -37,6 +39,8 @@ export interface BackendHandoffErrorPayload extends BackendHandoffBase { mode?: OAuthMode; error: string; errorDescription?: string; + /** Who said no, when `error` is consent_refused. */ + by?: "viewer" | "provider"; } /** A verified link that waits for the initiating account's confirm: no session, no tokens. */ diff --git a/src/browser-handoff.ts b/src/browser-handoff.ts index c21c01e..b8da567 100644 --- a/src/browser-handoff.ts +++ b/src/browser-handoff.ts @@ -15,6 +15,8 @@ export interface BrowserHandoffSuccess extends BrowserHandoffBase { /** A verified link waiting for its initiator's confirm; carries no session. */ pendingLinkId?: string; attemptId?: string; + /** The receipt of the consent a managed connection rests on. */ + consentReceiptId?: string; } export interface BrowserHandoffError extends BrowserHandoffBase { @@ -22,6 +24,8 @@ export interface BrowserHandoffError extends BrowserHandoffBase { error: string; errorDescription?: string; attemptId?: string; + /** Who said no, when `error` is consent_refused. */ + by?: "viewer" | "provider"; } export type BrowserHandoffPayload = BrowserHandoffSuccess | BrowserHandoffError; @@ -47,6 +51,9 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string { fragment.set("heimdall_attempt_id", payload.attemptId); } + if (payload.status === "success" && payload.consentReceiptId) { + fragment.set("heimdall_consent_receipt_id", payload.consentReceiptId); + } if (payload.status === "success" && payload.completionCode) { fragment.set("heimdall_completion_code", payload.completionCode); } else if (payload.status === "success" && payload.pendingLinkId) { @@ -54,6 +61,9 @@ function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string { } else { if (payload.status === "error") { fragment.set("heimdall_error", payload.error); + if (payload.by) { + fragment.set("heimdall_refused_by", payload.by); + } if (payload.errorDescription) { fragment.set("heimdall_error_description", payload.errorDescription); } @@ -77,14 +87,19 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string }); const scriptOrigin = serializeForScript(targetOrigin); const scriptFallbackUrl = serializeForScript(fallbackUrl); - const scriptTitle = serializeForScript( - payload.status === "success" ? "Authentication Complete" : "Authentication Failed" - ); + const refused = payload.status === "error" && payload.error === "consent_refused"; + const pageTitle = refused ? "Not Connected" : payload.status === "success" ? "Authentication Complete" : "Authentication Failed"; + const scriptTitle = serializeForScript(pageTitle); const scriptBody = serializeForScript( - payload.status === "success" - ? payload.handoffKind === "backend_callback" - ? "The app backend has the auth result already. You can return to the app now." - : "You can return to the app now." + refused + ? "Nothing was connected and nothing was kept. You can return to the app." + : payload.status === "success" + ? (payload.handoffKind === "backend_callback" + ? "The app backend has the auth result already. You can return to the app now." + : "You can return to the app now.") + + (payload.consentReceiptId + ? ` Your consent is recorded as receipt ${payload.consentReceiptId}; you can review it and take it back from your Heimdall connections page.` + : "") : "The authentication flow failed. Return to the app to continue." ); @@ -93,7 +108,7 @@ export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string - ${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"} + ${pageTitle} + + +
+${body} +
+ +`; +} + +/** The ask. Two POST forms carry the signed state; the page runs no script. */ +export function renderConsentPage(ask: ConsentAsk, state: string): string { + const lines = consentLines(ask); + const provider = providerCatalog[ask.provider].displayName; + const [headline, ...rest] = lines; + const form = (decision: "continue" | "decline", label: string) => + ``; + return page( + `${appName(ask.appSlug)} wants to connect ${provider}`, + `

${escapeHtml(headline!)}

+
    +${rest.map((line) => `
  • ${escapeHtml(line)}
  • `).join("\n")} +
+
+ + ${form("continue", `Continue to ${provider}`)} + ${form("decline", "No, return to the app")} +
` + ); +} + +export interface GrantView { + id: string; + appSlug: AppSlug; + provider: Provider; + scopesAsked: string[]; + scopesGranted: string[]; + grantedAt: string; + consentPageDigest: string; + status: "active" | "revoked"; + revokedAt?: string; + revocationReceiptId?: string; +} + +/** Every grant the receipts hold, each live unless a revocation names it. */ +export function grantViews(receipts: readonly StoredConsentReceipt[]): GrantView[] { + const revocations = new Map(); + for (const receipt of receipts) { + if (receipt.kind === "revocation") revocations.set(receipt.revokesReceiptId, receipt); + } + const views: GrantView[] = []; + for (const receipt of receipts) { + if (receipt.kind !== "grant") continue; + const revocation = revocations.get(receipt.id); + views.push({ + id: receipt.id, + appSlug: receipt.appSlug, + provider: receipt.provider, + scopesAsked: receipt.scopesAsked, + scopesGranted: receipt.scopesGranted, + grantedAt: receipt.createdAt, + consentPageDigest: receipt.consentPageDigest, + status: revocation ? "revoked" : "active", + ...(revocation ? { revokedAt: revocation.createdAt, revocationReceiptId: revocation.id } : {}), + }); + } + return views; +} + +/** The signed-in page: every grant with its receipt, one button each to take it back. */ +export function renderGrantsPage(grants: readonly GrantView[], pageToken: string, notice?: string): string { + const cards = grants.length + ? grants + .map((grant) => { + const provider = providerCatalog[grant.provider].displayName; + const header = `${escapeHtml(appName(grant.appSlug))} → ${escapeHtml(provider)}`; + const detail = `granted ${escapeHtml(grant.grantedAt)}; permissions ${escapeHtml(grant.scopesGranted.join(", ") || "none returned")}; receipt ${escapeHtml(grant.id)}; wording ${escapeHtml(grant.consentPageDigest.slice(0, 12))}`; + if (grant.status === "revoked") { + return `
${header} taken back ${escapeHtml(grant.revokedAt ?? "")}; receipt ${escapeHtml(grant.revocationReceiptId ?? "")}
${detail}
`; + } + return `
${header} active
${detail} +
+ + +
`; + }) + .join("\n") + : `

You have not connected anything through Heimdall.

`; + return page( + "Your connections", + `

Your connections

+${notice ? `

${escapeHtml(notice)}

\n` : ""}${cards} +

Taking a connection back deletes Heimdall's copy of the tokens. The app's next request for a token is refused. The permission stays with the provider until you remove it in the provider's settings.

` + ); +} diff --git a/src/providers.ts b/src/providers.ts index b649ede..01f749f 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -167,6 +167,11 @@ export const providerCatalog: Record = { }, }; +/** The scopes an authorization request carries: the caller's, or the provider's defaults. */ +export function authorizationScopes(provider: Provider, requested: readonly string[] | undefined): string[] { + return requested?.length ? [...requested] : [...providerCatalog[provider].defaultScopes]; +} + export function buildAuthorizationUrl(options: { provider: Provider; clientId: string; @@ -178,7 +183,7 @@ export function buildAuthorizationUrl(options: { }): string { const descriptor = providerCatalog[options.provider]; const url = new URL(descriptor.authorizationEndpoint); - const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes; + const scopes = authorizationScopes(options.provider, options.requestedScopes); url.searchParams.set("client_id", options.clientId); url.searchParams.set("redirect_uri", options.redirectUri); diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index eafaea5..c31b098 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -5,7 +5,9 @@ import { type CreateAuthAttemptInput, type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, + type CreateConsentReceiptInput, type CreatePendingLinkInput, + type StoredConsentReceipt, type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -59,6 +61,7 @@ export class InMemoryStore implements HeimdallStore { private readonly oauthStateUses = new Map(); private readonly entitlementSnapshots = new Map(); private readonly auditEvents = new Map(); + private readonly consentReceipts: StoredConsentReceipt[] = []; async ensureSchema(): Promise { return; @@ -482,4 +485,14 @@ export class InMemoryStore implements HeimdallStore { }); this.auditEvents.set(event.id ?? randomUUID(), event); } + + async createConsentReceipt(input: CreateConsentReceiptInput): Promise { + const receipt = { id: randomUUID(), ...clone(input) } as StoredConsentReceipt; + this.consentReceipts.push(clone(receipt)); + return clone(receipt); + } + + async listConsentReceipts(accountId: string): Promise { + return this.consentReceipts.filter((receipt) => receipt.accountId === accountId).map(clone); + } } diff --git a/src/store/postgres.ts b/src/store/postgres.ts index e3a1c4d..45240a5 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -7,7 +7,9 @@ import { type CreateAuthAttemptInput, type ClaimOAuthStateUseInput, type CreateAuthCompletionInput, + type CreateConsentReceiptInput, type CreatePendingLinkInput, + type StoredConsentReceipt, type StoredPendingLink, type CreateAuditEventInput, type CreateCapabilityGrantInput, @@ -248,6 +250,39 @@ function mapSessionRow(row: SessionRow): StoredSession { }; } +interface ConsentReceiptRow { + id: string; + kind: "grant" | "revocation"; + account_id: string; + app_slug: AppSlug; + provider: Provider; + created_at: Date | string; + scopes_asked: string | null; + scopes_granted: string | null; + consent_page_digest: string | null; + revokes_receipt_id: string | null; +} + +function mapConsentReceiptRow(row: ConsentReceiptRow): StoredConsentReceipt { + const base = { + id: row.id, + accountId: row.account_id, + appSlug: row.app_slug, + provider: row.provider, + createdAt: normalizeTimestamp(row.created_at), + }; + if (row.kind === "revocation") { + return { ...base, kind: "revocation", revokesReceiptId: row.revokes_receipt_id ?? "" }; + } + return { + ...base, + kind: "grant", + scopesAsked: parseScopes(row.scopes_asked ?? ""), + scopesGranted: parseScopes(row.scopes_granted ?? ""), + consentPageDigest: row.consent_page_digest ?? "", + }; +} + function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink { const link: StoredPendingLink = { id: row.id, @@ -421,6 +456,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + const grant = input.kind === "grant" ? input : null; + const result = await this.pool.query( + ` + INSERT INTO consent_receipts ( + id, kind, account_id, app_slug, provider, created_at, + scopes_asked, scopes_granted, consent_page_digest, revokes_receipt_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `, + [ + randomUUID(), + input.kind, + input.accountId, + input.appSlug, + input.provider, + input.createdAt, + grant ? grant.scopesAsked.join(" ") : null, + grant ? grant.scopesGranted.join(" ") : null, + grant ? grant.consentPageDigest : null, + input.kind === "revocation" ? input.revokesReceiptId : null, + ] + ); + return mapConsentReceiptRow(expectRow(result.rows[0], "createConsentReceipt")); + } + + async listConsentReceipts(accountId: string): Promise { + const result = await this.pool.query( + `SELECT * FROM consent_receipts WHERE account_id = $1 ORDER BY created_at, id`, + [accountId] + ); + return result.rows.map(mapConsentReceiptRow); + } + async createAuditEvent(input: CreateAuditEventInput): Promise { await this.pool.query( ` diff --git a/src/store/schema.ts b/src/store/schema.ts index 607445c..9c87dff 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -180,6 +180,24 @@ CREATE TABLE IF NOT EXISTS entitlement_snapshots ( UNIQUE(account_id, provider, scope) ); +-- Consent receipts are append-only (the store role has INSERT and SELECT +-- only): a revocation is a row that names the grant it takes back. +CREATE TABLE IF NOT EXISTS consent_receipts ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + app_slug TEXT NOT NULL, + provider TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + scopes_asked TEXT, + scopes_granted TEXT, + consent_page_digest TEXT, + revokes_receipt_id TEXT +); + +CREATE INDEX IF NOT EXISTS consent_receipts_account_idx + ON consent_receipts(account_id, created_at); + CREATE TABLE IF NOT EXISTS audit_events ( id TEXT PRIMARY KEY, account_id TEXT, diff --git a/src/store/types.ts b/src/store/types.ts index 982bccc..4c4ce17 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -252,6 +252,31 @@ export interface StoredPrivateCommandReceipt { export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt; +/** + * What a person agreed to, or took back. Append-only: a grant row is never + * edited, and a revocation is a second row naming it, so a grant is live + * exactly when no revocation names it. `scopesAsked` is what Heimdall sent the + * provider, `scopesGranted` what the provider returned, `consentPageDigest` + * the hash of the wording the person was shown. + */ +export type StoredConsentReceipt = { + id: string; + accountId: string; + appSlug: AppSlug; + provider: Provider; + createdAt: string; +} & ( + | { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string } + | { kind: "revocation"; revokesReceiptId: string } +); + +/** The store mints `id`; a caller cannot choose it. */ +export type CreateConsentReceiptInput = StoredConsentReceipt extends infer R + ? R extends StoredConsentReceipt + ? Omit + : never + : never; + export interface HeimdallStore { ensureSchema(): Promise; close(): Promise; @@ -325,4 +350,7 @@ export interface HeimdallStore { */ claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise; createAuditEvent(input: CreateAuditEventInput): Promise; + createConsentReceipt(input: CreateConsentReceiptInput): Promise; + /** Every receipt the account holds, oldest first. */ + listConsentReceipts(accountId: string): Promise; } diff --git a/tests/joystick-consent.test.ts b/tests/joystick-consent.test.ts index 6881f8f..3d4691f 100644 --- a/tests/joystick-consent.test.ts +++ b/tests/joystick-consent.test.ts @@ -1,62 +1,441 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp } from "../src/app.js"; -import { createTestConfig } from "./oauth-flow.js"; +import { consentDigest, consentLines, type ConsentAsk } from "../src/consent.js"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { providerCatalog } from "../src/providers.js"; +import { InMemoryStore } from "../src/store/index.js"; +import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js"; -// SKETCH for docs/joystick-consent-flow.md. Skipped: every test here names a -// behaviour the consent cut must build. Remove `.skip` as each one lands. +// The consent cut for managed custody (docs/joystick-consent-flow.md): the ask, +// the typed refusal, the receipt, and the signed-in page that lists and revokes. const apps: FastifyInstance[] = []; afterEach(async () => { + vi.restoreAllMocks(); while (apps.length) await apps.pop()?.close(); }); -async function creatorConnectStart(app: FastifyInstance) { +const defaultScopes = providerCatalog.joystick.defaultScopes; +const secret = { "x-heimdall-app-secret": "streampixels-secret" }; +const form = { "content-type": "application/x-www-form-urlencoded" }; + +/** A Joystick that returns whatever `code-` names, granting only `granted`. */ +function joystick(granted: string[] = ["identity:read", "stream:read"]): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode({ code }) { + return { accessToken: `joy-access-${code}`, refreshToken: `joy-refresh-${code}`, tokenType: "Bearer", scope: granted, expiresAt: "2099-01-01T00:00:00.000Z", raw: {} }; + }, + async resolveIdentity({ accessToken }) { + const id = accessToken.replace("joy-access-code-", ""); + return { provider: "joystick", providerUserId: `joy-${id}`, username: id, profile: { id } }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }; +} + +async function newApp(options: { granted?: string[]; callbacks?: string[] } = {}) { + const store = new InMemoryStore(); + const config = createTestConfig(); + if (options.callbacks) config.appBackendCallbacks = { streampixels: options.callbacks }; + const app = await buildApp({ config, store, oauthRuntimes: { joystick: joystick(options.granted) } }); + apps.push(app); + return { app, store }; +} + +async function startConnect(app: FastifyInstance, handoff: Record = { kind: "browser_completion" }) { const response = await app.inject({ method: "POST", url: "/v1/oauth/joystick/start", - headers: { "x-heimdall-app-secret": "streampixels-secret" }, + headers: secret, payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "creator-1" }, returnTo: "https://streampixels.gamecult.org/settings", - handoff: { kind: "browser_completion" }, + handoff, }, }); expect(response.statusCode).toBe(201); - const url = new URL(response.json().authorizationUrl as string); - return `${url.pathname}${url.search}`; + return stateOf(response.json()); } -describe.skip("Joystick consent flow (draft)", () => { - it("launch shows a Heimdall ask naming app, provider and scopes, and does not redirect", async () => { - const app = await buildApp({ config: createTestConfig() }); - apps.push(app); - const launch = await app.inject({ method: "GET", url: await creatorConnectStart(app), headers: { accept: "text/html" } }); - expect(launch.statusCode).toBe(200); - expect(launch.headers.location).toBeUndefined(); - for (const needle of ["StreamPixels", "Joystick", "identity:read", "subscribers:read", "refresh token"]) { - expect(launch.body).toContain(needle); +const launchUrl = (state: string) => `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`; +const cookieOf = (response: { headers: Record }) => + setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-"))!.split(";")[0]!; + +/** The browser is shown the ask. */ +async function seeAsk(app: FastifyInstance, state: string) { + const response = await app.inject({ method: "GET", url: launchUrl(state), headers: { accept: "text/html" } }); + expect(response.statusCode).toBe(200); + return { page: response, cookie: cookieOf(response) }; +} + +function answer(app: FastifyInstance, state: string, decision: "continue" | "decline", cookie?: string, accept = "text/html") { + return app.inject({ + method: "POST", + url: "/v1/oauth/joystick/consent", + headers: { ...form, accept, ...(cookie ? { cookie } : {}) }, + payload: new URLSearchParams({ state, decision }).toString(), + }); +} + +function callback(app: FastifyInstance, state: string, cookie: string, query: string, accept = "application/json") { + return app.inject({ + method: "GET", + url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&${query}`, + headers: { cookie, accept }, + }); +} + +/** Ask, accept, and complete as Joystick user `user`; returns the callback's JSON. */ +async function grantAs(app: FastifyInstance, user: string) { + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + const go = await answer(app, state, "continue", cookie); + expect(go.statusCode).toBe(302); + const done = await callback(app, state, cookie, `code=code-${user}`); + expect(done.statusCode).toBe(201); + return done.json() as { accessToken: string; consentReceiptId: string; account: { id: string }; session: { accountId: string } }; +} + +const bearer = (token: string) => ({ authorization: `Bearer ${token}` }); +const grantsOf = async (app: FastifyInstance, token: string) => + (await app.inject({ method: "GET", url: "/v1/me/grants", headers: bearer(token) })).json() as { grants: Array>; receipts: Array> }; +const resolveCredential = (app: FastifyInstance, accountId: string) => + app.inject({ method: "POST", url: "/v1/apps/streampixels/managed-credentials/resolve", headers: secret, payload: { accountId, provider: "joystick" } }); + +describe("the ask", () => { + it("launch shows a Heimdall page naming app, provider, scopes and custody, and does not redirect", async () => { + const { app } = await newApp(); + const { page } = await seeAsk(app, await startConnect(app)); + expect(page.headers.location).toBeUndefined(); + for (const needle of ["StreamPixels", "Joystick", ...defaultScopes, "refresh token", "streampixels.gamecult.org", "Continue to Joystick", "No, return to the app"]) { + expect(page.body).toContain(needle); } + expect(page.body).not.toContain(" { - // POST the decline form from the launch page with the binding cookie; - // expect result {error: "consent_refused", by: "viewer"}, an audit event - // "consent_refused", no managed credential row, and a second launch refused. - expect.fail("not built"); + it("viewing the ask claims nothing: the launch is still spendable once after any number of views", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + await seeAsk(app, state); + const { cookie } = await seeAsk(app, state); + expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302); + const again = await answer(app, state, "continue", cookie, "application/json"); + expect(again.statusCode).toBe(400); + expect(again.json()).toEqual({ error: "state_already_launched" }); }); - it("a provider access_denied is consent_refused by provider, distinct from provider_error", async () => { - expect.fail("not built"); + it("Continue sends exactly the scopes the page listed", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + const go = await answer(app, state, "continue", cookie); + expect(go.statusCode).toBe(302); + const target = new URL(go.headers.location as string); + expect(target.origin).toBe("https://joystick.tv"); + expect(target.searchParams.get("scope")).toBe(defaultScopes.join(" ")); }); - it("a granted completion writes one receipt with asked and granted scopes and the page digest", async () => { - expect.fail("not built"); + it("a flow with no custody (sign-in through an identity provider) gets no ask and redirects as before", async () => { + const { app } = await newApp(); + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/twitch/start", + headers: secret, + payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/", handoff: { kind: "browser_completion" } }, + }); + expect(start.statusCode).toBe(201); + const launch = await app.inject({ method: "GET", url: `/v1/oauth/twitch/launch?state=${encodeURIComponent(stateOf(start.json()))}`, headers: { accept: "text/html" } }); + expect(launch.statusCode).toBe(302); }); - it("the receipt page is readable only by the browser or account that consented, and revoke stamps revokedAt without editing the grant row", async () => { - expect.fail("not built"); + it("an answer without the cookie the ask set is refused and spends nothing", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + for (const decision of ["continue", "decline"] as const) { + const stranger = await answer(app, state, decision, undefined, "application/json"); + expect(stranger.statusCode, decision).toBe(400); + expect(stranger.json(), decision).toEqual({ error: "state_not_bound" }); + } + expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302); + }); + + it("the consent route refuses a flow that has no ask", async () => { + const { app } = await newApp(); + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/twitch/start", + headers: secret, + payload: { appSlug: "streampixels", mode: "sign_in", returnTo: "https://streampixels.gamecult.org/", handoff: { kind: "browser_completion" } }, + }); + const state = stateOf(start.json()); + const response = await app.inject({ + method: "POST", + url: "/v1/oauth/twitch/consent", + headers: form, + payload: new URLSearchParams({ state, decision: "continue" }).toString(), + }); + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "consent_not_required" }); + }); +}); + +describe("the wording digest", () => { + const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: ["identity:read"], returnOrigin: "https://streampixels.gamecult.org" }; + it("is the sha-256 of the lines shown, and moves with the app, provider, scopes and origin", () => { + expect(consentDigest(ask)).toBe(createHash("sha256").update(consentLines(ask).join("\n"), "utf8").digest("hex")); + const variants: ConsentAsk[] = [ + { ...ask, appSlug: "bifrost" }, + { ...ask, provider: "twitch" }, + { ...ask, scopes: ["identity:read", "chat:read"] }, + { ...ask, returnOrigin: "https://elsewhere.example" }, + ]; + for (const variant of variants) expect(consentDigest(variant)).not.toBe(consentDigest(ask)); + }); +}); + +describe("refusal", () => { + it("declining yields consent_refused by the viewer, stores nothing, and spends the state", async () => { + const { app, store } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + const no = await answer(app, state, "decline", cookie, "application/json"); + expect(no.statusCode).toBe(400); + expect(no.json()).toMatchObject({ error: "consent_refused", by: "viewer", provider: "joystick" }); + + // Nothing launches or completes afterwards, and nothing was kept. + expect((await answer(app, state, "continue", cookie, "application/json")).json()).toEqual({ error: "state_already_launched" }); + expect((await callback(app, state, cookie, "code=code-a")).json()).toEqual({ error: "state_already_used" }); + expect(await store.findStoredLinkedIdentity("joystick", "joy-a")).toBeNull(); + const events = [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; + expect(events.filter((event) => event.eventType === "consent_refused").map((event) => event.eventPayloadJson)).toEqual([ + { provider: "joystick", mode: "connect", by: "viewer" }, + ]); + }); + + it("the browser sees a 'Not Connected' page, not an authentication failure", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + const no = await answer(app, state, "decline", cookie); + expect(no.statusCode).toBe(200); + expect(no.body).toContain("Not Connected"); + expect(no.body).toContain("consent_refused"); + expect(no.body).not.toContain("Authentication Failed"); + }); + + it("an access_denied from the provider is consent_refused by the provider, distinct from provider_error", async () => { + const { app, store } = await newApp(); + const denied = await newFlow(app); + const response = await callback(app, denied.state, denied.cookie, "error=access_denied"); + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ error: "consent_refused", by: "provider" }); + + const faulty = await newFlow(app); + expect((await callback(app, faulty.state, faulty.cookie, "error=server_error")).json().error).toBe("provider_error"); + const events = [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; + expect(events.filter((event) => event.eventType === "consent_refused")).toHaveLength(1); + }); + + it("a backend_callback app is told, with who refused", async () => { + const callbackUrl = "https://streampixels.gamecult.org/api/heimdall/callback"; + const { app } = await newApp({ callbacks: [callbackUrl] }); + const delivered: Array> = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => { + delivered.push(JSON.parse(String(init?.body))); + return new Response("ok", { status: 200 }); + }); + const handoff = { kind: "backend_callback", callbackUrl, attemptId: "attempt-1" }; + + const byViewer = await startConnect(app, handoff); + const { cookie } = await seeAsk(app, byViewer); + await answer(app, byViewer, "decline", cookie); + const byProvider = await startConnect(app, handoff); + const second = await seeAsk(app, byProvider); + await answer(app, byProvider, "continue", second.cookie); + await callback(app, byProvider, second.cookie, "error=access_denied"); + + expect(delivered.map((payload) => [payload.status, payload.error, payload.by])).toEqual([ + ["error", "consent_refused", "viewer"], + ["error", "consent_refused", "provider"], + ]); + }); +}); + +async function newFlow(app: FastifyInstance) { + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302); + return { state, cookie }; +} + +describe("the receipt", () => { + it("a granted completion writes one receipt with asked and granted scopes and the digest of the wording shown", async () => { + const { app, store } = await newApp({ granted: ["identity:read", "stream:read"] }); + const done = await grantAs(app, "a"); + const receipts = await store.listConsentReceipts(done.account.id); + expect(receipts).toHaveLength(1); + const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: defaultScopes, returnOrigin: "https://streampixels.gamecult.org" }; + expect(receipts[0]).toMatchObject({ + id: done.consentReceiptId, + kind: "grant", + accountId: done.account.id, + appSlug: "streampixels", + provider: "joystick", + scopesAsked: defaultScopes, + scopesGranted: ["identity:read", "stream:read"], + consentPageDigest: consentDigest(ask), + }); + }); + + it("a refusal leaves no receipt", async () => { + const { app, store } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + await answer(app, state, "decline", cookie); + expect(await store.listConsentReceipts("anyone")).toEqual([]); + expect((await store.findStoredLinkedIdentity("joystick", "joy-a"))).toBeNull(); + }); + + it("the success page names the receipt", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + await answer(app, state, "continue", cookie); + const page = await callback(app, state, cookie, "code=code-a", "text/html"); + expect(page.statusCode).toBe(200); + expect(page.body).toMatch(/receipt [0-9a-f-]{36}/); + }); +}); + +describe("my grants", () => { + it("is closed to anyone without a live Heimdall access token", async () => { + const { app } = await newApp(); + for (const headers of [{}, bearer("not-a-token")]) { + const response = await app.inject({ method: "GET", url: "/v1/me/grants", headers }); + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe("sign_in_required"); + } + const link = await app.inject({ method: "POST", url: "/v1/me/grants/link" }); + expect(link.statusCode).toBe(401); + const revoke = await app.inject({ method: "POST", url: "/v1/me/grants/x/revoke" }); + expect(revoke.statusCode).toBe(401); + }); + + it("lists the signed-in account's grants and receipts, and no one else's", async () => { + const { app } = await newApp(); + const a = await grantAs(app, "a"); + const b = await grantAs(app, "b"); + const mine = await grantsOf(app, a.accessToken); + expect(mine.grants).toHaveLength(1); + expect(mine.grants[0]).toMatchObject({ id: a.consentReceiptId, status: "active", provider: "joystick", appSlug: "streampixels", scopesGranted: ["identity:read", "stream:read"] }); + expect(JSON.stringify(mine)).not.toContain(b.consentReceiptId); + expect(mine.receipts.map((receipt) => receipt.accountId)).toEqual([a.account.id]); + }); + + it("revoke takes effect on the next token use, edits no row, and receipts the revocation", async () => { + const { app, store } = await newApp(); + const a = await grantAs(app, "a"); + expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200); + const before = await store.listConsentReceipts(a.account.id); + + const revoke = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(a.accessToken) }); + expect(revoke.statusCode).toBe(200); + expect(revoke.json().revoked).toEqual([a.consentReceiptId]); + + const next = await resolveCredential(app, a.account.id); + expect(next.statusCode).toBe(410); + expect(next.json().error).toBe("managed_credential_revoked"); + + const after = await store.listConsentReceipts(a.account.id); + expect(after).toHaveLength(2); + expect(after[0]).toEqual(before[0]); + expect(after[1]).toMatchObject({ kind: "revocation", revokesReceiptId: a.consentReceiptId, accountId: a.account.id }); + const view = (await grantsOf(app, a.accessToken)).grants[0]!; + expect(view).toMatchObject({ status: "revoked", revocationReceiptId: after[1]!.id }); + expect(view.revokedAt).toBe(after[1]!.createdAt); + }); + + it("revoke is the owner's alone: another account's grant id is not found and its credential is untouched", async () => { + const { app } = await newApp(); + const a = await grantAs(app, "a"); + const b = await grantAs(app, "b"); + const attempt = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(b.accessToken) }); + expect(attempt.statusCode).toBe(404); + expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200); + expect((await resolveCredential(app, b.account.id)).statusCode).toBe(200); + }); + + it("revoking twice changes nothing the second time", async () => { + const { app, store } = await newApp(); + const a = await grantAs(app, "a"); + const url = `/v1/me/grants/${a.consentReceiptId}/revoke`; + await app.inject({ method: "POST", url, headers: bearer(a.accessToken) }); + const second = await app.inject({ method: "POST", url, headers: bearer(a.accessToken) }); + expect(second.statusCode).toBe(200); + expect(second.json().revoked).toEqual([]); + expect(await store.listConsentReceipts(a.account.id)).toHaveLength(2); + }); + + it("a fresh connection after a revoke is a new grant, live again, and the old one stays revoked", async () => { + const { app } = await newApp(); + const first = await grantAs(app, "a"); + await app.inject({ method: "POST", url: `/v1/me/grants/${first.consentReceiptId}/revoke`, headers: bearer(first.accessToken) }); + const second = await grantAs(app, "a"); + expect(second.consentReceiptId).not.toBe(first.consentReceiptId); + expect((await resolveCredential(app, second.account.id)).statusCode).toBe(200); + const grants = (await grantsOf(app, second.accessToken)).grants; + expect(grants.map((grant) => [grant.id, grant.status])).toEqual([ + [first.consentReceiptId, "revoked"], + [second.consentReceiptId, "active"], + ]); + }); + + it("a browser with no session opens the page from a minted link and revokes with one form post", async () => { + const { app } = await newApp(); + const a = await grantAs(app, "a"); + const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) }); + expect(link.statusCode).toBe(201); + const url = new URL(link.json().url as string); + const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } }); + expect(page.statusCode).toBe(200); + expect(page.body).toContain(a.consentReceiptId); + expect(page.body).toContain("Take this back"); + expect(page.body).not.toContain(" { + const { app } = await newApp(); + const a = await grantAs(app, "a"); + for (const t of [a.accessToken, "garbage", ""]) { + const response = await app.inject({ method: "GET", url: `/v1/me/grants?t=${encodeURIComponent(t)}` }); + expect(response.statusCode, t).toBe(401); + const revoke = await app.inject({ + method: "POST", + url: `/v1/me/grants/${a.consentReceiptId}/revoke`, + headers: form, + payload: new URLSearchParams({ t }).toString(), + }); + expect(revoke.statusCode, t).toBe(401); + } + expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200); }); }); diff --git a/tests/oauth-flow.ts b/tests/oauth-flow.ts index 1a7fbf4..b85b2ce 100644 --- a/tests/oauth-flow.ts +++ b/tests/oauth-flow.ts @@ -117,7 +117,18 @@ export function setCookieOf(response: { headers: Record }): str async function followLaunch(app: FastifyInstance, state: string): Promise { const provider = decodeJwt(state).payload.provider as string; - const response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` }); + let response = await app.inject({ method: "GET", url: `/v1/oauth/${provider}/launch?state=${encodeURIComponent(state)}` }); + if (response.statusCode === 200) { + // Heimdall's ask (managed custody): the person says Continue, with the cookie the ask set. + const asked = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-")); + expect(asked, "the ask sets the binding cookie").toBeDefined(); + response = await app.inject({ + method: "POST", + url: `/v1/oauth/${provider}/consent`, + headers: { "content-type": "application/x-www-form-urlencoded", cookie: (asked as string).split(";")[0] as string }, + payload: new URLSearchParams({ state, decision: "continue" }).toString(), + }); + } expect(response.statusCode, "launch").toBe(302); const set = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-")); expect(set, "launch sets the binding cookie").toBeDefined(); @@ -143,8 +154,5 @@ export async function launchWithRedirect(app: FastifyInstance, start: StartBody) /** The provider URL a browser is redirected to once it has followed authorizationUrl through the launch hop. */ export async function providerRedirect(app: FastifyInstance, start: StartBody): Promise { - const url = new URL(start.authorizationUrl); - const response = await app.inject({ method: "GET", url: `${url.pathname}${url.search}` }); - expect(response.statusCode, "launch").toBe(302); - return new URL(response.headers.location as string); + return (await followLaunch(app, stateOf(start))).providerUrl; } diff --git a/tests/oauth-state-binding.test.ts b/tests/oauth-state-binding.test.ts index 9f576e8..1bffc19 100644 --- a/tests/oauth-state-binding.test.ts +++ b/tests/oauth-state-binding.test.ts @@ -299,14 +299,15 @@ describe("OAuth state binding", () => { expect(await claim("a", 1_000_000 + 30_000)).toBe(true); }); - it("a provider error from the launching browser still denies the attempt, once", async () => { + it("a provider refusal from the launching browser still denies the attempt, once", async () => { const app = await newApp(); const start = await bifrostStart(app); const victim = await launchFlow(app, start); const denied = await callback(app, victim.state, victim.cookie, "error=access_denied"); expect(denied.statusCode).toBe(400); - expect(denied.json().error).toBe("provider_error"); + expect(denied.json().error).toBe("consent_refused"); + expect(denied.json().by).toBe("provider"); const replay = await callback(app, victim.state, victim.cookie, "error=access_denied"); expect(replay.json()).toEqual({ error: "state_already_used" }); }); diff --git a/tests/store.test.ts b/tests/store.test.ts index f17a834..684aa41 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -270,9 +270,10 @@ describe("PostgresStore.checkSchema", () => { "oauth_state_uses", "capability_grants", "entitlement_snapshots", + "consent_receipts", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(101); + expect(REQUIRED_COLUMNS).toHaveLength(111); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false }, @@ -283,7 +284,7 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(14); + expect(REQUIRED_KEYS).toHaveLength(15); expect(REQUIRED_KEYS).toEqual( expect.arrayContaining([ { table: "linked_identities", columns: "provider,provider_user_id", predicate: "" }, @@ -418,6 +419,9 @@ async function exerciseStore(store: PostgresStore): Promise> { expect(await s.claimOAuthStateUse(stateUse)).toBe(true); expect(await s.claimOAuthStateUse(stateUse)).toBe(false); expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true); + const grant = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d" }); + await s.createConsentReceipt({ kind: "revocation", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: later, revokesReceiptId: grant.id }); + expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]); await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now }); return called; } From 48207268d08c8c01428d648a2e5bb1bb34af3716 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:24:48 +0000 Subject: [PATCH 03/10] Consent flow doc: steward rulings and the built design; pin repeat-revoke Co-Authored-By: Claude Sonnet 5.5 --- docs/joystick-consent-flow.md | 164 +++++++++++++++++++++++---------- tests/joystick-consent.test.ts | 2 + 2 files changed, 117 insertions(+), 49 deletions(-) diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md index 498c8e4..86746ea 100644 --- a/docs/joystick-consent-flow.md +++ b/docs/joystick-consent-flow.md @@ -1,8 +1,10 @@ -# Joystick sign-in and consent: what exists, what is missing, a draft flow +# Joystick sign-in and consent: what was missing, and the flow that fills it -Status: DRAFT design, no code. Source-grounded at `fefd2dd` (main). Follow-up -`heimdall-body:follow_up:chat-s3124-3129`. Not for merge until a Heimdall -steward rules on the open questions below. +Status: built (sections 3-6 describe the code on this branch); sections 1-2 are +the state of `fefd2dd` (main) that motivated it. Follow-up +`heimdall-body:follow_up:chat-s3124-3129`, built under +`heimdall-body:follow_up:chat-mind-20261108T131253147Z-t1`. The steward's rulings +on the five open questions are in section 4. ## 1. What a viewer sees today @@ -59,65 +61,129 @@ person in the browser: | Receipt | Operator audit rows only | The viewer cannot audit what was agreed, when, or revoke it. | | Viewer vs creator | The `connect` binding proves a binding exists, not that the browser's owner is the creator (`joystick:follow_up:connect-start-needs-app-auth`) | Consent from the wrong person is not consent. | -## 3. Draft consent flow +## 3. The consent flow -Applies to any Joystick completion (creator connect now; viewer sign-in when -the public bot lands) and, if ruled, to every provider whose state carries -`managed_credential`. +Applies to every completion `callbackMint` calls `custody_only`: a managed +creator connection (Joystick today; Twitch and YouTube on StreamPixels when +they are connected rather than signed in). Viewer sign-in through an identity +provider is unchanged. -### 3.1 The ask (visible, before the redirect) +### 3.1 The ask -`GET /v1/oauth/:provider/launch` stops answering `302`. It renders a Heimdall -page, **after** the state is verified but **before** the use is claimed, with: +`GET /v1/oauth/:provider/launch` renders a Heimdall page (`src/consent.ts`) +instead of answering `302`, after the state verifies and before any use is +claimed. The page names the app and the origin the person returns to, the +provider and the exact scopes Heimdall will send (`authorizationScopes`, the +same function that builds the authorization URL), what Heimdall keeps (an +encrypted refresh token and access token, until the person takes it back or the +provider ends it), what the app receives, and how to take it back. It carries +two POST forms to `/v1/oauth/:provider/consent`, **Continue to Joystick** and +**No, return to the app**, and runs no script. It sets the binding cookie, so +either answer can only come from the browser that was shown the ask; an answer +without the cookie is `state_not_bound` and spends nothing. Viewing the ask +claims nothing. Continue claims the launch use and answers `302`. -- the app by display name and return origin; -- the provider and the exact scope list that will be requested; -- what Heimdall keeps (a refresh token, encrypted, rotating) versus what the - app receives (no token; a claim) and for how long; -- two forms, both POST, CSRF-bound by the existing binding cookie: - **Continue to Joystick** and **No, return to the app**; -- a link to the receipt of any earlier consent for this account and app. - -Only Continue claims the launch use and answers `302`. The page is plain -server-rendered HTML with no script, matching the handoff page's escape rule. +What the app receives, stated as the code does it: not a claim. The app +backend can call `managed-credentials/resolve` and receive the current access +token; it never receives the refresh token. The earlier draft said the app +receives "no token"; that was wrong and the page does not say it. ### 3.2 Typed refusal -**No** (and a provider `access_denied` on return) produces a distinct result -`consent_refused` with `{provider, app, mode, by: "viewer" | "provider"}`: -delivered to the app by the same handoff channel as any result, written as an -audit event `consent_refused`, no credential stored, state consumed. A provider -failure stays `provider_error`. The app is told not to ask again in the same -session; whether it may ask later is the app's rule, not Heimdall's. +A decline, and a provider `error=access_denied` on any provider, produce the +result `consent_refused` with `by: "viewer" | "provider"`: an audit event +`consent_refused` (provider, mode, by; no account, no person), the browser +attempt denied with `denialCode consent_refused`, both uses of the state spent, +a `backend_callback` app told with the same payload, a "Not Connected" page in +the browser, and `400 {error: "consent_refused", by}` as JSON. No credential is +stored and no receipt is written. Every other provider error stays +`provider_error`. This changes the `access_denied` answer for every provider, +including Bifrost's and Repixelizer's sign-in; see the PR for the contract note. ### 3.3 Receipt -On a granted completion Heimdall writes one `consent_receipt` row: receipt id -(random, non-guessable), account (or creator target), app, provider, scopes -asked, scopes granted, what Heimdall retains, `grantedAt`, `consentPageDigest` -(hash of the exact text shown, so a later wording change is visible), and a -`revokedAt` slot. The handoff page shows the receipt id and a "View receipt" link. +On a granted `custody_only` completion Heimdall writes one `consent_receipts` +row of kind `grant`: id (random), account, app, provider, scopes asked, scopes +granted (as the provider returned them), `consentPageDigest` (sha-256 of the +lines the page showed, so a wording change is visible), `createdAt`. The id +reaches the app (`consentReceiptId` in the completion and backend payloads) and +the person (the handoff page). -`GET /v1/receipts/:id` (viewer-bound by the same cookie or by the account's -access token) shows the row and offers **Revoke**, which calls the existing -managed-credential revocation and stamps `revokedAt`. Revoke is itself receipted. -Receipts are append-only; a revocation is a second row, not an edit. +Receipts are append-only: the store role has `SELECT` and `INSERT` on the table +and nothing else. A revocation is a second row of kind `revocation` naming the +grant it takes back; a grant is live exactly when no revocation names it. The +draft's `revokedAt` slot is gone for that reason. -## 4. Open questions for the steward +### 3.4 My grants -1. Who gets the ask: every provider, or only `managed_credential` ones? (Recommend: only managed custody, where Heimdall holds something beyond a login.) -2. Where does the receipt live: Heimdall store as a new document, or the existing audit table with a new `eventType`? (Recommend: new typed document; the audit table is operator-shaped and has no viewer read path.) -3. Viewer identity for the receipt page when the viewer has no Heimdall session yet (a first-time creator connect). Binding cookie only, or require sign-in first? -4. Does `consent_refused` need to reach a backend callback app, or only the browser handoff? -5. Wording ownership: the page text is legal-adjacent (CultCoin legal research is a sibling thread); who signs the copy? +Signed in means a live Heimdall access token for any app (`Authorization: +Bearer`), or a page link minted from one. -## 5. Out of scope here +- `GET /v1/me/grants`: every grant and receipt of the account. `Accept: + text/html` gets the page, one **Take this back** form per live grant; anything + else gets `{grants, receipts}`. +- `POST /v1/me/grants/link` (bearer): `{url}` to the page, valid ten minutes. + The token is signed, purpose-bound (`heimdall_grants_page`), stateless, and + opens only this page and its revoke forms. This is how a browser that holds no + session gets in; Heimdall sets no new cookie and stores nothing for it. +- `POST /v1/me/grants/:id/revoke` (bearer, or the page token in the form): + strips the stored tokens from the credential, then writes a revocation row + for every live grant on that credential. The credential is one row per account + and provider, whichever app asked, so taking it back ends every grant resting + on it. The next `managed-credentials/resolve` answers `410 + managed_credential_revoked`; nothing else needs to learn of it. Revoking a + revoked grant changes nothing. Another account's grant id is `404`. -Enabling Joystick in `identityProviders`; fixing `connect-start-needs-app-auth`; -Postgres schema/migration for the receipt; any deploy. Each needs its own cut. +Heimdall can delete its copy; it cannot withdraw the permission at the +provider, and the page says so. -## 6. Test sketch +## 4. The steward's rulings -`tests/joystick-consent.test.ts` holds the behaviours above as skipped tests -(they cannot pass until the cut is built). Unrun: the author's workspace could -not install dependencies, so treat the file as a sketch to be typechecked. +Each ruling picks the answer that gives the person the most agency and leaves +the least stored. + +1. **Who gets the ask: managed custody only.** Where Heimdall holds a credential + the person cannot see and an app can use, the person is asked and given a + receipt. A login is already asked by the provider's own screen, and a receipt + per login would store a row per sign-in for no agency the provider's screen + does not give. The line is `callbackMint === "custody_only"`, so it needs no + second list to keep in step with the profiles. +2. **The receipt is a new typed table, `consent_receipts`.** The audit table is + operator-shaped and has no read path for the person; a viewer-readable + record in it would make every audit row a potential disclosure. The table + holds ids, scopes and a digest: no IP address, no user agent, no page text. + Refusals leave no receipt, only the anonymous audit row. +3. **Viewer identity: sign-in, not the cookie.** The binding cookie dies with + the state and proves only that a browser launched a flow; it is not an + account. The receipt page and revoke require a live access token or a + ten-minute link minted from one. The cost is that a first-time creator must + hold the session the completion returned to the app; the app mints the link. + The alternative, a new long-lived Heimdall cookie, would store a session for + every person who merely looked. +4. **`consent_refused` reaches a backend callback app.** An app that started the + attempt and is waiting must be told, or it polls to expiry and cannot tell + a person's no from a fault. The person's no is also not held against them: + Heimdall stores nothing and sets no cooldown; whether and when to ask again + is the app's rule. +5. **Wording: derived, not authored.** The text is generated from facts the code + holds and states nothing the code does not do; it is a constant in + `src/consent.ts` and its digest rides every receipt. Legal review of the + wording (the CultCoin legal thread) may only narrow it, and any change moves + the digest. Until that review lands the copy is Heimdall's, signed by the + steward. + +## 5. Out of scope + +Enabling Joystick in `identityProviders`; fixing +`joystick:follow_up:connect-start-needs-app-auth`; revoking at the provider +(Joystick offers no revocation call Heimdall uses); retention or deletion of +consent receipts and of the audit rows; consent for the creator's audience. + +## 6. Tests + +`tests/joystick-consent.test.ts` holds one behaviour per rule above. Rules were +checked by mutation, each mutant run on its own with a 300 s limit under an 8 GB +address-space cap (the PR lists them). The Postgres statements for +`consent_receipts` are exercised by `tests/store.test.ts` against the fake pool +and, when `HEIMDALL_TEST_PG_ADMIN_URL` is set, against a live database +(`scripts/test-live-pg.sh`); the live run was not possible where this was built. diff --git a/tests/joystick-consent.test.ts b/tests/joystick-consent.test.ts index 3d4691f..f674f12 100644 --- a/tests/joystick-consent.test.ts +++ b/tests/joystick-consent.test.ts @@ -382,6 +382,8 @@ describe("my grants", () => { expect(second.statusCode).toBe(200); expect(second.json().revoked).toEqual([]); expect(await store.listConsentReceipts(a.account.id)).toHaveLength(2); + const events = [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; + expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1); }); it("a fresh connection after a revoke is a new grant, live again, and the old one stays revoked", async () => { From 71ce9c16e40424745f33a8a3753a47ba4cdada06 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:30:39 +0000 Subject: [PATCH 04/10] Consent review fixes: ask-only cookie, fixed-expiry grants links, wording carried to the receipt, one revocation per grant The ask sets its own cookie and no launch binding; Continue sets the binding and carries the exact text shown into it; the receipt stores that text and its hash. Grants-page form tokens keep the link's expiry. Revocations are unique per grant and revoke reports only what it wrote. access_denied's attempt denial code is pinned for Bifrost and Repixelizer. Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 161 +++++++++++++++++++++++------- src/consent.ts | 27 ++++- src/store/in-memory.ts | 8 +- src/store/postgres.ts | 21 +++- src/store/schema.ts | 6 ++ src/store/types.ts | 9 +- tests/consent-drift.test.ts | 65 ++++++++++++ tests/joystick-consent.test.ts | 131 ++++++++++++++++++++---- tests/oauth-flow.ts | 4 +- tests/oauth-state-binding.test.ts | 32 ++++++ tests/store.test.ts | 20 +++- 11 files changed, 413 insertions(+), 71 deletions(-) create mode 100644 tests/consent-drift.test.ts diff --git a/src/app.ts b/src/app.ts index c4d8e93..c6c7c68 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import { createHmac, randomUUID } from "node:crypto"; +import { deflateRawSync, inflateRawSync } from "node:zlib"; import cookie from "@fastify/cookie"; import cors from "@fastify/cors"; import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"; @@ -26,7 +27,7 @@ import { import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js"; import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js"; import { renderBrowserHandoffPage } from "./browser-handoff.js"; -import { consentDigest, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js"; +import { consentText, digestOfText, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js"; import { type HeimdallConfig, loadConfig } from "./config.js"; import { createTokenCustody, type TokenCustody } from "./custody.js"; import { grantFact } from "./facts.js"; @@ -252,14 +253,74 @@ function oauthBindingCookieName(jti: string): string { function signOAuthBinding( config: HeimdallConfig, keys: ReturnType, - state: OAuthStatePayload + state: OAuthStatePayload, + shownText?: string ): string { return signJwt( - { typ: oauthBindingType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp }, + { + typ: oauthBindingType, + iss: config.issuer, + jti: state.jti, + iat: nowEpochSeconds(), + exp: state.exp, + // The wording the person agreed to, carried from the ask to the receipt. + ...(shownText === undefined ? {} : { ct: deflateRawSync(shownText).toString("base64url") }), + }, keys ); } +const consentAskType = "heimdall_consent_ask"; + +function consentAskCookieName(jti: string): string { + return `__Host-heimdall-ask-${jti}`; +} + +/** + * The ask's own cookie. It proves a browser was shown the ask and carries the + * exact text shown; only Continue and No accept it. It is not the launch + * binding: that is set when Continue claims the launch, so that merely opening + * the launch URL never makes a browser the state's owner. + */ +function signConsentAsk(config: HeimdallConfig, keys: ReturnType, state: OAuthStatePayload, shownText: string): string { + return signJwt( + { typ: consentAskType, iss: config.issuer, jti: state.jti, iat: nowEpochSeconds(), exp: state.exp, ct: deflateRawSync(shownText).toString("base64url") }, + keys + ); +} + +/** The text a held cookie of the given type carries, or null when the cookie is absent, foreign, expired or carries none. */ +function cookieConsentText( + config: HeimdallConfig, + keys: ReturnType, + token: string | undefined, + type: string, + state: OAuthStatePayload +): string | null { + if (!token) return null; + const verification = verifyJwt(token, keys.publicKey); + if (!verification.valid) return null; + const { typ, iss, jti, exp, ct } = verification.payload; + if (typ !== type || iss !== config.issuer || jti !== state.jti || typeof exp !== "number" || exp <= nowEpochSeconds() || typeof ct !== "string") { + return null; + } + try { + return inflateRawSync(Buffer.from(ct, "base64url")).toString("utf8"); + } catch { + return null; + } +} + +function setConsentAskCookie(reply: FastifyReply, state: OAuthStatePayload, value: string, maxAgeSeconds: number): void { + reply.setCookie(consentAskCookieName(state.jti), value, { + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + maxAge: Math.max(0, maxAgeSeconds), + }); +} + function oauthBindingHolds( config: HeimdallConfig, keys: ReturnType, @@ -827,18 +888,27 @@ const grantsPageTokenType = "heimdall_grants_page"; const grantsPageTokenTtlSeconds = 600; /** A short, purpose-bound proof of a signed-in account, so the grants page can be opened and its forms posted by a browser that holds no session. */ -function signGrantsPageToken(config: HeimdallConfig, keys: ReturnType, accountId: string): string { - const iat = nowEpochSeconds(); - return signJwt({ typ: grantsPageTokenType, iss: config.issuer, account_id: accountId, iat, exp: iat + grantsPageTokenTtlSeconds }, keys); +function signGrantsPageToken( + config: HeimdallConfig, + keys: ReturnType, + accountId: string, + exp: number = nowEpochSeconds() + grantsPageTokenTtlSeconds +): string { + return signJwt({ typ: grantsPageTokenType, iss: config.issuer, account_id: accountId, iat: nowEpochSeconds(), exp }, keys); } -function grantsPageTokenAccount(config: HeimdallConfig, keys: ReturnType, token: unknown): string | null { +/** The account and expiry a page token proves. A page's own forms are signed to this same expiry, so a link is never renewed by being used. */ +function grantsPageTokenAccount( + config: HeimdallConfig, + keys: ReturnType, + token: unknown +): { accountId: string; exp: number } | null { if (typeof token !== "string") return null; const verification = verifyJwt(token, keys.publicKey); if (!verification.valid) return null; const { typ, iss, account_id: accountId, exp } = verification.payload; return typ === grantsPageTokenType && iss === config.issuer && typeof accountId === "string" && typeof exp === "number" && exp > nowEpochSeconds() - ? accountId + ? { accountId, exp } : null; } @@ -1239,7 +1309,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + const beginProviderRedirect = async (request: FastifyRequest, reply: FastifyReply, state: OAuthStatePayload, rawState: string, shownText?: string) => { const clientId = config.providers[state.provider].clientId; if (!clientId) { return providerNotConfigured(reply, state); @@ -1256,7 +1326,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => - (await resolveAccessAccount({ config, keys, store }, request.headers.authorization)) ?? - grantsPageTokenAccount(config, keys, pageToken); + /** The signed-in account, and the expiry the page's own forms get: a bearer starts a fresh window, a page link only passes on the one it has. */ + const grantsAccount = async (request: FastifyRequest, pageToken?: unknown): Promise<{ accountId: string; exp: number } | null> => { + const bearerAccount = await resolveAccessAccount({ config, keys, store }, request.headers.authorization); + if (bearerAccount) return { accountId: bearerAccount, exp: nowEpochSeconds() + grantsPageTokenTtlSeconds }; + return grantsPageTokenAccount(config, keys, pageToken); + }; const grantsReply = (reply: FastifyReply) => { reply.header("Cache-Control", "no-store"); @@ -2526,13 +2610,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { - const accountId = await grantsAccount(request, request.query.t); - if (!accountId) return signInRequired(reply); + const caller = await grantsAccount(request, request.query.t); + if (!caller) return signInRequired(reply); + const { accountId } = caller; grantsReply(reply); const receipts = await store.listConsentReceipts(accountId); if (prefersHtml(request.headers.accept)) { reply.type("text/html; charset=utf-8"); - return reply.send(renderGrantsPage(grantViews(receipts), signGrantsPageToken(config, keys, accountId))); + return reply.send(renderGrantsPage(grantViews(receipts), signGrantsPageToken(config, keys, accountId, caller.exp))); } return { grants: grantViews(receipts), receipts }; } @@ -2559,8 +2644,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { - const accountId = await grantsAccount(request, request.body?.t); - if (!accountId) return signInRequired(reply); + const caller = await grantsAccount(request, request.body?.t); + if (!caller) return signInRequired(reply); + const { accountId } = caller; grantsReply(reply); const receipts = await store.listConsentReceipts(accountId); const grant = grantViews(receipts).find((view) => view.id === request.params.grantId); @@ -2580,7 +2666,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise ({ kind: "revoke", updatedAt: at })); } for (const live of grantViews(receipts).filter((view) => view.provider === grant.provider && view.status === "active")) { - await store.createConsentReceipt({ + const { created } = await store.createConsentReceipt({ kind: "revocation", accountId, appSlug: live.appSlug, @@ -2588,19 +2674,22 @@ export async function buildApp(options: BuildAppOptions = {}): Promise `
  • ${escapeHtml(line)}
  • `).join("\n")}
    - ${form("continue", `Continue to ${provider}`)} - ${form("decline", "No, return to the app")} + ${form("continue", consentButtons(ask).go)} + ${form("decline", consentButtons(ask).no)}
    ` ); } export interface GrantView { + consentText: string; id: string; appSlug: AppSlug; provider: Provider; @@ -135,6 +155,7 @@ export function grantViews(receipts: readonly StoredConsentReceipt[]): GrantView scopesGranted: receipt.scopesGranted, grantedAt: receipt.createdAt, consentPageDigest: receipt.consentPageDigest, + consentText: receipt.consentText, status: revocation ? "revoked" : "active", ...(revocation ? { revokedAt: revocation.createdAt, revocationReceiptId: revocation.id } : {}), }); diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts index c31b098..cfc869e 100644 --- a/src/store/in-memory.ts +++ b/src/store/in-memory.ts @@ -486,10 +486,14 @@ export class InMemoryStore implements HeimdallStore { this.auditEvents.set(event.id ?? randomUUID(), event); } - async createConsentReceipt(input: CreateConsentReceiptInput): Promise { + async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> { + if (input.kind === "revocation") { + const existing = this.consentReceipts.find((r) => r.kind === "revocation" && r.revokesReceiptId === input.revokesReceiptId); + if (existing) return { receipt: clone(existing), created: false }; + } const receipt = { id: randomUUID(), ...clone(input) } as StoredConsentReceipt; this.consentReceipts.push(clone(receipt)); - return clone(receipt); + return { receipt: clone(receipt), created: true }; } async listConsentReceipts(accountId: string): Promise { diff --git a/src/store/postgres.ts b/src/store/postgres.ts index 45240a5..3d34047 100644 --- a/src/store/postgres.ts +++ b/src/store/postgres.ts @@ -260,6 +260,7 @@ interface ConsentReceiptRow { scopes_asked: string | null; scopes_granted: string | null; consent_page_digest: string | null; + consent_text: string | null; revokes_receipt_id: string | null; } @@ -280,6 +281,7 @@ function mapConsentReceiptRow(row: ConsentReceiptRow): StoredConsentReceipt { scopesAsked: parseScopes(row.scopes_asked ?? ""), scopesGranted: parseScopes(row.scopes_granted ?? ""), consentPageDigest: row.consent_page_digest ?? "", + consentText: row.consent_text ?? "", }; } @@ -1232,15 +1234,18 @@ export class PostgresStore implements HeimdallStore { ); } - async createConsentReceipt(input: CreateConsentReceiptInput): Promise { + async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> { const grant = input.kind === "grant" ? input : null; + // No conflict target and the partial unique index decides: a second + // revocation of one grant inserts nothing and the first is read back. const result = await this.pool.query( ` INSERT INTO consent_receipts ( id, kind, account_id, app_slug, provider, created_at, - scopes_asked, scopes_granted, consent_page_digest, revokes_receipt_id + scopes_asked, scopes_granted, consent_page_digest, consent_text, revokes_receipt_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT DO NOTHING RETURNING * `, [ @@ -1253,10 +1258,18 @@ export class PostgresStore implements HeimdallStore { grant ? grant.scopesAsked.join(" ") : null, grant ? grant.scopesGranted.join(" ") : null, grant ? grant.consentPageDigest : null, + grant ? grant.consentText : null, input.kind === "revocation" ? input.revokesReceiptId : null, ] ); - return mapConsentReceiptRow(expectRow(result.rows[0], "createConsentReceipt")); + if (result.rowCount) { + return { receipt: mapConsentReceiptRow(expectRow(result.rows[0], "createConsentReceipt")), created: true }; + } + const existing = await this.pool.query( + `SELECT * FROM consent_receipts WHERE revokes_receipt_id = $1`, + [input.kind === "revocation" ? input.revokesReceiptId : null] + ); + return { receipt: mapConsentReceiptRow(expectRow(existing.rows[0], "createConsentReceipt")), created: false }; } async listConsentReceipts(accountId: string): Promise { diff --git a/src/store/schema.ts b/src/store/schema.ts index 9c87dff..71a4a43 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -192,9 +192,15 @@ CREATE TABLE IF NOT EXISTS consent_receipts ( scopes_asked TEXT, scopes_granted TEXT, consent_page_digest TEXT, + consent_text TEXT, revokes_receipt_id TEXT ); +-- A grant is taken back once: the second revocation naming it conflicts. +CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx + ON consent_receipts(revokes_receipt_id) + WHERE revokes_receipt_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS consent_receipts_account_idx ON consent_receipts(account_id, created_at); diff --git a/src/store/types.ts b/src/store/types.ts index 4c4ce17..bb2e080 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -257,7 +257,9 @@ export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt; * edited, and a revocation is a second row naming it, so a grant is live * exactly when no revocation names it. `scopesAsked` is what Heimdall sent the * provider, `scopesGranted` what the provider returned, `consentPageDigest` - * the hash of the wording the person was shown. + * the hash of the wording the person was shown and `consentText` that wording + * itself. A grant is revoked at most once: a second revocation naming the same + * grant is not written, and the store answers with the first. */ export type StoredConsentReceipt = { id: string; @@ -266,7 +268,7 @@ export type StoredConsentReceipt = { provider: Provider; createdAt: string; } & ( - | { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string } + | { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string; consentText: string } | { kind: "revocation"; revokesReceiptId: string } ); @@ -350,7 +352,8 @@ export interface HeimdallStore { */ claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise; createAuditEvent(input: CreateAuditEventInput): Promise; - createConsentReceipt(input: CreateConsentReceiptInput): Promise; + /** `created` is false when a revocation for that grant already existed and is what comes back. */ + createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }>; /** Every receipt the account holds, oldest first. */ listConsentReceipts(accountId: string): Promise; } diff --git a/tests/consent-drift.test.ts b/tests/consent-drift.test.ts new file mode 100644 index 0000000..8671d4b --- /dev/null +++ b/tests/consent-drift.test.ts @@ -0,0 +1,65 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { type FastifyInstance } from "fastify"; + +// The receipt records the wording the person was shown. If the wording changes +// between the ask and the callback (a deploy inside the state's lifetime), the +// receipt must still hold what they saw. + +const drift = vi.hoisted(() => ({ on: false })); +vi.mock("../src/consent.js", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, consentText: (ask: Parameters[0]) => real.consentText(ask) + (drift.on ? "\nREWORDED AFTER THE ASK" : "") }; +}); + +import { buildApp } from "../src/app.js"; +import { InMemoryStore } from "../src/store/index.js"; +import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js"; + +const apps: FastifyInstance[] = []; +afterEach(async () => { + drift.on = false; + while (apps.length) await apps.pop()?.close(); +}); + +it("a receipt holds the wording of the ask, not wording rebuilt at the callback", async () => { + const store = new InMemoryStore(); + const app = await buildApp({ + config: createTestConfig(), + store, + oauthRuntimes: { + joystick: { + async exchangeAuthorizationCode() { + return { accessToken: "joy-access-code-a", refreshToken: "r", tokenType: "Bearer", scope: ["identity:read"], expiresAt: "2099-01-01T00:00:00.000Z", raw: {} }; + }, + async resolveIdentity() { + return { provider: "joystick", providerUserId: "joy-a", username: "a", profile: {} }; + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, + }, + }, + }); + apps.push(app); + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + headers: { "x-heimdall-app-secret": "streampixels-secret" }, + payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" } }, + }); + const state = stateOf(start.json()); + const ask = await app.inject({ method: "GET", url: `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`, headers: { accept: "text/html" } }); + const askCookie = setCookieOf(ask).find((c) => c.startsWith("__Host-heimdall-ask-"))!.split(";")[0]!; + const go = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/consent", + headers: { "content-type": "application/x-www-form-urlencoded", cookie: askCookie }, + payload: new URLSearchParams({ state, decision: "continue" }).toString(), + }); + const binding = setCookieOf(go).find((c) => c.startsWith("__Host-heimdall-oauth-"))!.split(";")[0]!; + drift.on = true; // the wording changes after the person has agreed + const done = await app.inject({ method: "GET", url: `/v1/oauth/joystick/callback?state=${encodeURIComponent(state)}&code=code-a`, headers: { cookie: binding, accept: "application/json" } }); + expect(done.statusCode).toBe(201); + const [receipt] = await store.listConsentReceipts(done.json().account.id); + expect(receipt?.kind === "grant" && receipt.consentText).not.toContain("REWORDED"); +}); diff --git a/tests/joystick-consent.test.ts b/tests/joystick-consent.test.ts index f674f12..963f1e5 100644 --- a/tests/joystick-consent.test.ts +++ b/tests/joystick-consent.test.ts @@ -1,8 +1,9 @@ import { createHash } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type FastifyInstance } from "fastify"; -import { buildApp } from "../src/app.js"; -import { consentDigest, consentLines, type ConsentAsk } from "../src/consent.js"; +import { buildApp, getHeimdallRuntimeContext } from "../src/app.js"; +import { consentDigest, consentText, digestOfText, type ConsentAsk } from "../src/consent.js"; +import { decodeJwt, signJwt } from "../src/signing.js"; import { type OAuthProviderRuntime } from "../src/oauth.js"; import { providerCatalog } from "../src/providers.js"; import { InMemoryStore } from "../src/store/index.js"; @@ -64,14 +65,17 @@ async function startConnect(app: FastifyInstance, handoff: Record `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`; -const cookieOf = (response: { headers: Record }) => - setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-oauth-"))!.split(";")[0]!; +const cookieNamed = (prefix: string) => (response: { headers: Record }) => + setCookieOf(response).find((value) => value.startsWith(prefix))?.split(";")[0]; +const askCookieOf = cookieNamed("__Host-heimdall-ask-"); +const bindingCookieOf = cookieNamed("__Host-heimdall-oauth-"); -/** The browser is shown the ask. */ +/** The browser is shown the ask. The cookie it keeps is the ask's, which binds nothing. */ async function seeAsk(app: FastifyInstance, state: string) { const response = await app.inject({ method: "GET", url: launchUrl(state), headers: { accept: "text/html" } }); expect(response.statusCode).toBe(200); - return { page: response, cookie: cookieOf(response) }; + expect(bindingCookieOf(response), "the ask must not set the launch binding").toBeUndefined(); + return { page: response, cookie: askCookieOf(response)! }; } function answer(app: FastifyInstance, state: string, decision: "continue" | "decline", cookie?: string, accept = "text/html") { @@ -94,10 +98,10 @@ function callback(app: FastifyInstance, state: string, cookie: string, query: st /** Ask, accept, and complete as Joystick user `user`; returns the callback's JSON. */ async function grantAs(app: FastifyInstance, user: string) { const state = await startConnect(app); - const { cookie } = await seeAsk(app, state); - const go = await answer(app, state, "continue", cookie); + const asked = await seeAsk(app, state); + const go = await answer(app, state, "continue", asked.cookie); expect(go.statusCode).toBe(302); - const done = await callback(app, state, cookie, `code=code-${user}`); + const done = await callback(app, state, bindingCookieOf(go)!, `code=code-${user}`); expect(done.statusCode).toBe(201); return done.json() as { accessToken: string; consentReceiptId: string; account: { id: string }; session: { accountId: string } }; } @@ -167,6 +171,44 @@ describe("the ask", () => { expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302); }); + it("a stranger who only opens the launch URL is not bound to the state and cannot complete it", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const victim = await seeAsk(app, state); + await answer(app, state, "continue", victim.cookie); + // A fresh browser that holds the URL loads the ask and calls back with its own provider code. + const stranger = await seeAsk(app, state); + for (const cookie of [stranger.cookie, ""]) { + const done = await callback(app, state, cookie, "code=code-attacker"); + expect(done.statusCode).toBe(400); + expect(done.json()).toEqual({ error: "state_not_bound" }); + } + }); + + it("only Continue sets the launch binding, and the ask's cookie is no binding: it cannot complete a callback", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const asked = await seeAsk(app, state); + const done = await callback(app, state, asked.cookie, "code=code-a"); + expect(done.json()).toEqual({ error: "state_not_bound" }); + const go = await answer(app, state, "continue", asked.cookie); + expect(bindingCookieOf(go)).toBeDefined(); + expect(bindingCookieOf(go)!.length).toBeLessThan(3500); + }); + + it("a managed connection that never passed through the ask completes nothing and leaves no receipt", async () => { + const { app, store } = await newApp(); + const ctx = getHeimdallRuntimeContext(app); + const state = await startConnect(app); + const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number }; + // A binding that names this state but carries no wording: what any launch outside the ask would leave. + const bare = signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti, iat: exp - 600, exp }, ctx.keys); + const done = await callback(app, state, `__Host-heimdall-oauth-${jti}=${bare}`, "code=code-a"); + expect(done.statusCode).not.toBe(201); + expect(done.json()).not.toHaveProperty("consentReceiptId"); + expect(await store.listConsentReceipts((await store.findStoredLinkedIdentity("joystick", "joy-a"))?.accountId ?? "none")).toEqual([]); + }); + it("the consent route refuses a flow that has no ask", async () => { const { app } = await newApp(); const start = await app.inject({ @@ -189,8 +231,8 @@ describe("the ask", () => { describe("the wording digest", () => { const ask: ConsentAsk = { appSlug: "streampixels", provider: "joystick", scopes: ["identity:read"], returnOrigin: "https://streampixels.gamecult.org" }; - it("is the sha-256 of the lines shown, and moves with the app, provider, scopes and origin", () => { - expect(consentDigest(ask)).toBe(createHash("sha256").update(consentLines(ask).join("\n"), "utf8").digest("hex")); + it("is the sha-256 of the text shown, and moves with the app, provider, scopes and origin", () => { + expect(consentDigest(ask)).toBe(createHash("sha256").update(consentText(ask), "utf8").digest("hex")); const variants: ConsentAsk[] = [ { ...ask, appSlug: "bifrost" }, { ...ask, provider: "twitch" }, @@ -212,7 +254,7 @@ describe("refusal", () => { // Nothing launches or completes afterwards, and nothing was kept. expect((await answer(app, state, "continue", cookie, "application/json")).json()).toEqual({ error: "state_already_launched" }); - expect((await callback(app, state, cookie, "code=code-a")).json()).toEqual({ error: "state_already_used" }); + expect((await callback(app, state, cookie, "code=code-a")).json()).toEqual({ error: "state_not_bound" }); expect(await store.findStoredLinkedIdentity("joystick", "joy-a")).toBeNull(); const events = [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; expect(events.filter((event) => event.eventType === "consent_refused").map((event) => event.eventPayloadJson)).toEqual([ @@ -259,8 +301,8 @@ describe("refusal", () => { await answer(app, byViewer, "decline", cookie); const byProvider = await startConnect(app, handoff); const second = await seeAsk(app, byProvider); - await answer(app, byProvider, "continue", second.cookie); - await callback(app, byProvider, second.cookie, "error=access_denied"); + const proceeded = await answer(app, byProvider, "continue", second.cookie); + await callback(app, byProvider, bindingCookieOf(proceeded)!, "error=access_denied"); expect(delivered.map((payload) => [payload.status, payload.error, payload.by])).toEqual([ ["error", "consent_refused", "viewer"], @@ -272,8 +314,9 @@ describe("refusal", () => { async function newFlow(app: FastifyInstance) { const state = await startConnect(app); const { cookie } = await seeAsk(app, state); - expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302); - return { state, cookie }; + const go = await answer(app, state, "continue", cookie); + expect(go.statusCode).toBe(302); + return { state, cookie: bindingCookieOf(go)! }; } describe("the receipt", () => { @@ -295,6 +338,21 @@ describe("the receipt", () => { }); }); + it("the receipt holds the wording the person was shown, and its digest is that wording's hash", async () => { + const { app, store } = await newApp(); + const state = await startConnect(app); + const asked = await seeAsk(app, state); + const go = await answer(app, state, "continue", asked.cookie); + const done = await callback(app, state, bindingCookieOf(go)!, "code=code-a"); + const [receipt] = await store.listConsentReceipts(done.json().account.id); + if (receipt?.kind !== "grant") throw new Error("no grant"); + expect(receipt.consentPageDigest).toBe(digestOfText(receipt.consentText)); + const shown = asked.page.body.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&"); + for (const line of receipt.consentText.split("\n")) expect(shown, line).toContain(line); + expect(receipt.consentText).toContain("Continue to Joystick"); + expect(receipt.consentText).toContain("No, return to the app"); + }); + it("a refusal leaves no receipt", async () => { const { app, store } = await newApp(); const state = await startConnect(app); @@ -308,8 +366,8 @@ describe("the receipt", () => { const { app } = await newApp(); const state = await startConnect(app); const { cookie } = await seeAsk(app, state); - await answer(app, state, "continue", cookie); - const page = await callback(app, state, cookie, "code=code-a", "text/html"); + const go = await answer(app, state, "continue", cookie); + const page = await callback(app, state, bindingCookieOf(go)!, "code=code-a", "text/html"); expect(page.statusCode).toBe(200); expect(page.body).toMatch(/receipt [0-9a-f-]{36}/); }); @@ -424,6 +482,43 @@ describe("my grants", () => { expect((await resolveCredential(app, a.account.id)).statusCode).toBe(410); }); + it("a page link is never renewed by being used: the forms on its page expire when the link does", async () => { + const { app } = await newApp(); + const a = await grantAs(app, "a"); + const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) }); + const url = new URL(link.json().url as string); + const linkToken = url.searchParams.get("t")!; + const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } }); + const formToken = /name="t" value="([^"]+)"/.exec(page.body)![1]!; + expect(decodeJwt(formToken).payload.exp).toBe(decodeJwt(linkToken).payload.exp); + // The same holds one hop on: the page a form post answers with. + const post = await app.inject({ + method: "POST", + url: "/v1/me/grants/nope/revoke", + headers: { ...form, accept: "text/html" }, + payload: new URLSearchParams({ t: formToken }).toString(), + }); + expect(post.statusCode).toBe(404); + // A bearer, by contrast, opens a fresh window. + const fresh = await app.inject({ method: "GET", url: "/v1/me/grants", headers: { ...bearer(a.accessToken), accept: "text/html" } }); + const freshToken = /name="t" value="([^"]+)"/.exec(fresh.body)![1]!; + expect(decodeJwt(freshToken).payload.exp).toBeGreaterThanOrEqual(decodeJwt(linkToken).payload.exp as number); + }); + + it("two revokes at once take the grant back once: one revocation row, one audit event, one reporter", async () => { + const { app, store } = await newApp(); + const a = await grantAs(app, "a"); + const url = `/v1/me/grants/${a.consentReceiptId}/revoke`; + const [one, two] = await Promise.all([ + app.inject({ method: "POST", url, headers: bearer(a.accessToken) }), + app.inject({ method: "POST", url, headers: bearer(a.accessToken) }), + ]); + expect([one.json().revoked.length, two.json().revoked.length].sort()).toEqual([0, 1]); + expect((await store.listConsentReceipts(a.account.id)).filter((r) => r.kind === "revocation")).toHaveLength(1); + const events = [...(store as unknown as { auditEvents: Map }).auditEvents.values()]; + expect(events.filter((event) => event.eventType === "consent_revoked")).toHaveLength(1); + }); + it("a page link proves only what it was minted for: an access token, a foreign token or none opens nothing", async () => { const { app } = await newApp(); const a = await grantAs(app, "a"); diff --git a/tests/oauth-flow.ts b/tests/oauth-flow.ts index b85b2ce..2616da1 100644 --- a/tests/oauth-flow.ts +++ b/tests/oauth-flow.ts @@ -120,8 +120,8 @@ async function followLaunch(app: FastifyInstance, state: string): Promise value.startsWith("__Host-heimdall-oauth-")); - expect(asked, "the ask sets the binding cookie").toBeDefined(); + const asked = setCookieOf(response).find((value) => value.startsWith("__Host-heimdall-ask-")); + expect(asked, "the ask sets its own cookie").toBeDefined(); response = await app.inject({ method: "POST", url: `/v1/oauth/${provider}/consent`, diff --git a/tests/oauth-state-binding.test.ts b/tests/oauth-state-binding.test.ts index 1bffc19..01abba0 100644 --- a/tests/oauth-state-binding.test.ts +++ b/tests/oauth-state-binding.test.ts @@ -312,6 +312,38 @@ describe("OAuth state binding", () => { expect(replay.json()).toEqual({ error: "state_already_used" }); }); + // The private command plane's completeAuth answers a denied attempt with + // attempt.denialCode as its error (private-command-plane.ts, completeAuth), so + // these two codes are what Bifrost, Repixelizer and Ghostlight poll for. + it.each([ + ["bifrost", "https://bifrost.gamecult.org/auth/heimdall/complete?attempt=a1", "access_denied", "consent_refused"], + ["repixelizer", "https://repixelizer.gamecult.org/app/", "access_denied", "consent_refused"], + ["repixelizer", "https://repixelizer.gamecult.org/app/", "server_error", "provider_error"], + ] as const)("a %s attempt whose provider answers %s is denied with %s", async (appSlug, returnTo, providerError, denialCode) => { + const app = await newApp(); + const ctx = getHeimdallRuntimeContext(app); + const attempt = await ctx.store.createAuthAttempt({ + handle: `attempt-${providerError}`, + appSlug, + provider: "discord", + mode: "sign_in", + returnTo, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + const caller = resolveAppCaller(ctx.config, appSlug, { "x-heimdall-app-secret": `${appSlug}-secret` }); + const started = await startOAuthFlow( + ctx, + "discord", + caller, + { appSlug, mode: "sign_in", returnTo, handoff: { kind: "browser_completion" } }, + { trustedBrowserAttemptId: attempt.handle } + ); + const flow = await launchFlow(app, started.body as unknown as { authorizationUrl: string }); + await callback(app, flow.state, flow.cookie, `error=${providerError}`); + expect(await ctx.store.findAuthAttempt(appSlug, attempt.handle)).toMatchObject({ status: "denied", denialCode }); + }); + describe("a state that is expired, foreign or tampered is refused at launch and at callback", () => { it.each([ ["expired beyond the skew", { exp: Math.floor(Date.now() / 1000) - 100 }, "state_expired"], diff --git a/tests/store.test.ts b/tests/store.test.ts index 684aa41..ddec729 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -198,6 +198,19 @@ describe("PostgresStore", () => { }); }); +describe("InMemoryStore.createConsentReceipt", () => { + it("writes one revocation per grant: the second names the first and is not created", async () => { + const store = new InMemoryStore(); + const base = { accountId: "a", appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" }; + const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" }); + const first = await store.createConsentReceipt({ ...base, kind: "revocation", revokesReceiptId: grant.id }); + const second = await store.createConsentReceipt({ ...base, createdAt: "2026-11-09T00:00:00.000Z", kind: "revocation", revokesReceiptId: grant.id }); + expect([first.created, second.created]).toEqual([true, false]); + expect(second.receipt).toEqual(first.receipt); + expect(await store.listConsentReceipts("a")).toHaveLength(2); + }); +}); + describe("InMemoryStore.claimOAuthStateUse", () => { const use = { jti: "state-1", @@ -273,7 +286,7 @@ describe("PostgresStore.checkSchema", () => { "consent_receipts", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(111); + expect(REQUIRED_COLUMNS).toHaveLength(112); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false }, @@ -284,12 +297,13 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(15); + expect(REQUIRED_KEYS).toHaveLength(16); expect(REQUIRED_KEYS).toEqual( expect.arrayContaining([ { table: "linked_identities", columns: "provider,provider_user_id", predicate: "" }, { table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" }, { table: "oauth_state_uses", columns: "jti,phase", predicate: "" }, + { table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" }, { table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" }, { table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" }, ]) @@ -419,7 +433,7 @@ async function exerciseStore(store: PostgresStore): Promise> { expect(await s.claimOAuthStateUse(stateUse)).toBe(true); expect(await s.claimOAuthStateUse(stateUse)).toBe(false); expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true); - const grant = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d" }); + const { receipt: grant } = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" }); await s.createConsentReceipt({ kind: "revocation", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: later, revokesReceiptId: grant.id }); expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]); await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now }); From d4f7dce098691930c6f8fbf592d14dc7212a46c3 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:31:43 +0000 Subject: [PATCH 05/10] Check the shown text before any code exchange; pin link expiry with a clock Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 18 +++++++++++------- tests/joystick-consent.test.ts | 5 ++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app.ts b/src/app.ts index c6c7c68..bb5c4d9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1619,6 +1619,16 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + vi.useRealTimers(); vi.restoreAllMocks(); while (apps.length) await apps.pop()?.close(); }); @@ -206,7 +207,7 @@ describe("the ask", () => { const done = await callback(app, state, `__Host-heimdall-oauth-${jti}=${bare}`, "code=code-a"); expect(done.statusCode).not.toBe(201); expect(done.json()).not.toHaveProperty("consentReceiptId"); - expect(await store.listConsentReceipts((await store.findStoredLinkedIdentity("joystick", "joy-a"))?.accountId ?? "none")).toEqual([]); + expect(await store.findStoredLinkedIdentity("joystick", "joy-a"), "no credential is kept").toBeNull(); }); it("the consent route refuses a flow that has no ask", async () => { @@ -488,6 +489,8 @@ describe("my grants", () => { const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) }); const url = new URL(link.json().url as string); const linkToken = url.searchParams.get("t")!; + // Two minutes on, so a form token minted afresh would differ from the link's. + vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 120_000 }); const page = await app.inject({ method: "GET", url: `${url.pathname}${url.search}`, headers: { accept: "text/html" } }); const formToken = /name="t" value="([^"]+)"/.exec(page.body)![1]!; expect(decodeJwt(formToken).payload.exp).toBe(decodeJwt(linkToken).payload.exp); From 5ce8f1c14c978dd8fd355a089d10b54f2c9b486f Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:32:21 +0000 Subject: [PATCH 06/10] Consent doc: ask cookie, carried wording, unique revocation, fixed link expiry Co-Authored-By: Claude Sonnet 5.5 --- docs/joystick-consent-flow.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md index 86746ea..8add6a3 100644 --- a/docs/joystick-consent-flow.md +++ b/docs/joystick-consent-flow.md @@ -78,10 +78,14 @@ same function that builds the authorization URL), what Heimdall keeps (an encrypted refresh token and access token, until the person takes it back or the provider ends it), what the app receives, and how to take it back. It carries two POST forms to `/v1/oauth/:provider/consent`, **Continue to Joystick** and -**No, return to the app**, and runs no script. It sets the binding cookie, so -either answer can only come from the browser that was shown the ask; an answer -without the cookie is `state_not_bound` and spends nothing. Viewing the ask -claims nothing. Continue claims the launch use and answers `302`. +**No, return to the app**, and runs no script. It sets the ask's own +cookie (`__Host-heimdall-ask-`), which carries the exact text shown and is +accepted only by Continue and No; an answer without it is `state_not_bound` and +spends nothing. Viewing the ask claims nothing and binds nothing: the launch +binding cookie, which decides who may complete the state, is set only when +Continue claims the launch, and it carries the text shown on to the callback. +(An earlier cut set the binding on the ask, so anyone holding the launch URL +could become the state's owner; review of 2026-11-08 found it.) What the app receives, stated as the code does it: not a claim. The app backend can call `managed-credentials/resolve` and receive the current access @@ -104,14 +108,19 @@ including Bifrost's and Repixelizer's sign-in; see the PR for the contract note. On a granted `custody_only` completion Heimdall writes one `consent_receipts` row of kind `grant`: id (random), account, app, provider, scopes asked, scopes -granted (as the provider returned them), `consentPageDigest` (sha-256 of the -lines the page showed, so a wording change is visible), `createdAt`. The id +granted (as the provider returned them), `consentText` (the lines and +button labels shown, taken from the ask and never rebuilt, so a deploy between +ask and callback cannot change what the receipt says), `consentPageDigest` +(sha-256 of that text), `createdAt`. A custody callback with no shown text +exchanges no code and stores no credential. The id reaches the app (`consentReceiptId` in the completion and backend payloads) and the person (the handoff page). Receipts are append-only: the store role has `SELECT` and `INSERT` on the table and nothing else. A revocation is a second row of kind `revocation` naming the -grant it takes back; a grant is live exactly when no revocation names it. The +grant it takes back, unique per grant (a partial unique index); a grant is live +exactly when no revocation names it, and a concurrent second revoke reports +nothing and writes nothing. The draft's `revokedAt` slot is gone for that reason. ### 3.4 My grants @@ -124,7 +133,8 @@ Bearer`), or a page link minted from one. else gets `{grants, receipts}`. - `POST /v1/me/grants/link` (bearer): `{url}` to the page, valid ten minutes. The token is signed, purpose-bound (`heimdall_grants_page`), stateless, and - opens only this page and its revoke forms. This is how a browser that holds no + opens only this page and its revoke forms. The forms on a page opened by a link + carry the link's own expiry, so using a link never renews it. This is how a browser that holds no session gets in; Heimdall sets no new cookie and stores nothing for it. - `POST /v1/me/grants/:id/revoke` (bearer, or the page token in the form): strips the stored tokens from the credential, then writes a revocation row From 767852ef96ed3d2fc818217fc868b93fe108a275 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:37:13 +0000 Subject: [PATCH 07/10] WIP t10: inflate cap, consent_text_missing before claim, scope caps, revocation FK Tests green locally; mutants not yet run; not pushed; PR body not updated. Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 55 ++++++++++++++++++++-------- src/store/in-memory.ts | 4 +++ src/store/schema.ts | 10 ++++-- tests/joystick-consent.test.ts | 66 +++++++++++++++++++++++++++++++--- tests/store.test.ts | 17 +++++++-- 5 files changed, 127 insertions(+), 25 deletions(-) diff --git a/src/app.ts b/src/app.ts index bb5c4d9..f4a48e5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -272,6 +272,14 @@ function signOAuthBinding( const consentAskType = "heimdall_consent_ask"; +/** The most wording a cookie may carry, before and after compression. The ask refuses what exceeds it; the readers never inflate past it. */ +const maxConsentTextBytes = 16 * 1024; +/** Browsers drop a cookie above 4096 bytes; the ask refuses one it could not keep. */ +const maxConsentCookieChars = 3800; +/** What an app may request: more is not a scope list a person can read. */ +const maxRequestedScopes = 32; +const maxRequestedScopeChars = 512; + function consentAskCookieName(jti: string): string { return `__Host-heimdall-ask-${jti}`; } @@ -305,7 +313,7 @@ function cookieConsentText( return null; } try { - return inflateRawSync(Buffer.from(ct, "base64url")).toString("utf8"); + return inflateRawSync(Buffer.from(ct, "base64url"), { maxOutputLength: maxConsentTextBytes }).toString("utf8"); } catch { return null; } @@ -746,6 +754,16 @@ export async function startOAuthFlow( return { statusCode: 400, body: { error: "initiator_only_in_link_mode" } }; } + if ( + input.requestedScopes !== undefined && + (input.requestedScopes.length > maxRequestedScopes || input.requestedScopes.some((scope) => scope.length > maxRequestedScopeChars)) + ) { + return { + statusCode: 400, + body: { error: "requested_scopes_too_large", detail: `At most ${maxRequestedScopes} scopes of ${maxRequestedScopeChars} characters each.` }, + }; + } + if (!isAllowedReturnOrigin(profile, input.returnTo)) { return { statusCode: 400, @@ -1221,7 +1239,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { reply.code(400); const profile = getAppProfile(state.app_slug); @@ -1385,7 +1403,15 @@ export async function buildApp(options: BuildAppOptions = {}): Promise maxConsentTextBytes || askCookie.length > maxConsentCookieChars) { + reply.code(400); + return { error: "consent_ask_too_large" }; + } + setConsentAskCookie(reply, state, askCookie, state.exp - nowEpochSeconds()); reply.header("Cache-Control", "no-store"); reply.header("Referrer-Policy", "no-referrer"); reply.header("X-Frame-Options", "DENY"); @@ -1507,6 +1533,17 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { if (input.kind === "revocation") { + // The table's foreign key: a revocation names a receipt of the same account. + if (!this.consentReceipts.some((r) => r.id === input.revokesReceiptId && r.accountId === input.accountId)) { + throw new Error("A revocation must name a receipt of its own account."); + } const existing = this.consentReceipts.find((r) => r.kind === "revocation" && r.revokesReceiptId === input.revokesReceiptId); if (existing) return { receipt: clone(existing), created: false }; } diff --git a/src/store/schema.ts b/src/store/schema.ts index 71a4a43..294ee2d 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -193,10 +193,14 @@ CREATE TABLE IF NOT EXISTS consent_receipts ( scopes_granted TEXT, consent_page_digest TEXT, consent_text TEXT, - revokes_receipt_id TEXT + revokes_receipt_id TEXT, + UNIQUE(id, account_id), + FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id) ); --- A grant is taken back once: the second revocation naming it conflicts. +-- A revocation names a receipt of its own account (the composite foreign key; +-- NULL skips it for grants). A grant is taken back once: the second +-- revocation naming it conflicts. CREATE UNIQUE INDEX IF NOT EXISTS consent_receipts_revokes_unique_idx ON consent_receipts(revokes_receipt_id) WHERE revokes_receipt_id IS NOT NULL; @@ -265,7 +269,7 @@ export const REQUIRED_TABLES = createdTables.map(({ table }) => table); export const REQUIRED_COLUMNS = [ ...createdTables.flatMap(({ table, lines }) => lines - .filter((line) => !/^(?:PRIMARY KEY|UNIQUE)\b/.test(line)) + .filter((line) => !/^(?:PRIMARY KEY|UNIQUE|FOREIGN KEY)\b/.test(line)) .map((line) => { const [, column, type] = /^(\w+) (\w+)/.exec(line)!; return { table, column: column!, type: type!, nullable: !/\b(?:NOT NULL|PRIMARY KEY)\b/.test(line) }; diff --git a/tests/joystick-consent.test.ts b/tests/joystick-consent.test.ts index e227522..5dd9ead 100644 --- a/tests/joystick-consent.test.ts +++ b/tests/joystick-consent.test.ts @@ -1,4 +1,5 @@ -import { createHash } from "node:crypto"; +import { createHash, randomBytes } from "node:crypto"; +import { deflateRawSync } from "node:zlib"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type FastifyInstance } from "fastify"; import { buildApp, getHeimdallRuntimeContext } from "../src/app.js"; @@ -197,17 +198,72 @@ describe("the ask", () => { expect(bindingCookieOf(go)!.length).toBeLessThan(3500); }); - it("a managed connection that never passed through the ask completes nothing and leaves no receipt", async () => { + it("a binding without the shown wording is refused with consent_text_missing before the state is spent", async () => { const { app, store } = await newApp(); const ctx = getHeimdallRuntimeContext(app); const state = await startConnect(app); const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number }; - // A binding that names this state but carries no wording: what any launch outside the ask would leave. + // A binding that names this state but carries no wording: what a launch from before the ask would leave. const bare = signJwt({ typ: "heimdall_oauth_binding", iss: ctx.config.issuer, jti, iat: exp - 600, exp }, ctx.keys); const done = await callback(app, state, `__Host-heimdall-oauth-${jti}=${bare}`, "code=code-a"); - expect(done.statusCode).not.toBe(201); - expect(done.json()).not.toHaveProperty("consentReceiptId"); + expect(done.statusCode).toBe(400); + expect(done.json()).toEqual({ error: "consent_text_missing" }); expect(await store.findStoredLinkedIdentity("joystick", "joy-a"), "no credential is kept").toBeNull(); + // Neither use of the state was spent by the refusal. + const usedAt = new Date().toISOString(); + const expiresAt = new Date(exp * 1000).toISOString(); + expect(await store.claimOAuthStateUse({ jti, phase: "callback", appSlug: "streampixels", usedAt, expiresAt })).toBe(true); + }); + + describe("the wording a cookie may carry", () => { + const signedAsk = (ctx: ReturnType, jti: string, exp: number, text: Buffer) => + signJwt({ typ: "heimdall_consent_ask", iss: ctx.config.issuer, jti, iat: exp - 600, exp, ct: deflateRawSync(text).toString("base64url") }, ctx.keys); + + it("is read up to 16 KB and no further: one byte over is refused, and a bomb is refused quickly", async () => { + const { app } = await newApp(); + const ctx = getHeimdallRuntimeContext(app); + const state = await startConnect(app); + const { jti, exp } = decodeJwt(state).payload as { jti: string; exp: number }; + const answerWith = (text: Buffer, decision: "decline" | "continue" = "decline") => + answer(app, state, decision, `__Host-heimdall-ask-${jti}=${signedAsk(ctx, jti, exp, text)}`, "application/json"); + expect((await answerWith(Buffer.alloc(16 * 1024 + 1, 97))).json()).toEqual({ error: "state_not_bound" }); + const t0 = Date.now(); + expect((await answerWith(Buffer.alloc(300 * 1024 * 1024))).json()).toEqual({ error: "state_not_bound" }); + expect(Date.now() - t0).toBeLessThan(1500); + // At the cap it is read. + expect((await answerWith(Buffer.alloc(16 * 1024, 97))).json()).toMatchObject({ error: "consent_refused" }); + }); + + it("requested scopes are capped at 32 of 512 characters, at start", async () => { + const { app } = await newApp(); + const startWith = (requestedScopes: string[]) => + app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + headers: secret, + payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" }, requestedScopes }, + }); + const scopes = (n: number, len: number) => Array.from({ length: n }, (_, i) => `${i}`.padEnd(len, "x")); + expect((await startWith(scopes(33, 10))).json()).toMatchObject({ error: "requested_scopes_too_large" }); + expect((await startWith(scopes(1, 513))).json()).toMatchObject({ error: "requested_scopes_too_large" }); + expect((await startWith(scopes(32, 512))).statusCode).toBe(201); + }); + + it("an ask the cookies could not carry is refused, not shown", async () => { + const { app } = await newApp(); + const noise = Array.from({ length: 32 }, () => randomBytes(256).toString("hex")); // 32 x 512 incompressible + const start = await app.inject({ + method: "POST", + url: "/v1/oauth/joystick/start", + headers: secret, + payload: { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "c" }, returnTo: "https://streampixels.gamecult.org/s", handoff: { kind: "browser_completion" }, requestedScopes: noise }, + }); + expect(start.statusCode).toBe(201); + const page = await app.inject({ method: "GET", url: launchUrl(stateOf(start.json())), headers: { accept: "text/html" } }); + expect(page.statusCode).toBe(400); + expect(page.json()).toEqual({ error: "consent_ask_too_large" }); + expect(askCookieOf(page)).toBeUndefined(); + }); }); it("the consent route refuses a flow that has no ask", async () => { diff --git a/tests/store.test.ts b/tests/store.test.ts index ddec729..b107197 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -14,7 +14,7 @@ import { samePredicate, STORE_TABLE_PRIVILEGES, } from "../src/store/postgres.js"; -import { REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js"; +import { CREATE_SCHEMA_SQL, REQUIRED_COLUMNS, REQUIRED_KEYS, REQUIRED_TABLES } from "../src/store/schema.js"; describe("PostgresStore", () => { it("round-trips core auth records through postgres", async () => { @@ -198,6 +198,18 @@ describe("PostgresStore", () => { }); }); +describe("consent_receipts foreign key", () => { + it("a revocation names a receipt of its own account: the schema says so and the memory store enforces it", async () => { + expect(CREATE_SCHEMA_SQL).toContain("FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id)"); + const store = new InMemoryStore(); + const base = { appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: "2026-11-08T00:00:00.000Z" }; + const { receipt: grant } = await store.createConsentReceipt({ ...base, accountId: "a", kind: "grant", scopesAsked: [], scopesGranted: [], consentPageDigest: "d", consentText: "t" }); + await expect(store.createConsentReceipt({ ...base, accountId: "b", kind: "revocation", revokesReceiptId: grant.id })).rejects.toThrow(/own account/); + await expect(store.createConsentReceipt({ ...base, accountId: "a", kind: "revocation", revokesReceiptId: "no-such" })).rejects.toThrow(/own account/); + expect(await store.listConsentReceipts("a")).toHaveLength(1); + }); +}); + describe("InMemoryStore.createConsentReceipt", () => { it("writes one revocation per grant: the second names the first and is not created", async () => { const store = new InMemoryStore(); @@ -297,13 +309,14 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(16); + expect(REQUIRED_KEYS).toHaveLength(17); expect(REQUIRED_KEYS).toEqual( expect.arrayContaining([ { table: "linked_identities", columns: "provider,provider_user_id", predicate: "" }, { table: "private_command_receipts", columns: "app_slug,idempotency_key", predicate: "" }, { table: "oauth_state_uses", columns: "jti,phase", predicate: "" }, { table: "consent_receipts", columns: "revokes_receipt_id", predicate: "revokes_receipt_id IS NOT NULL" }, + { table: "consent_receipts", columns: "account_id,id", predicate: "" }, { table: "entitlement_snapshots", columns: "account_id,provider,scope", predicate: "" }, { table: "auth_completions", columns: "app_slug,attempt_id", predicate: "attempt_id IS NOT NULL AND consumed_at IS NULL" }, ]) From af554ca01323f61204a707f7631e6e05d5df19df Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:38:35 +0000 Subject: [PATCH 08/10] Addendum: per-index predicate check, live second-revocation and missing-table cases, test-live-pg prerequisites Co-Authored-By: Claude Sonnet 5.5 --- scripts/test-live-pg.sh | 13 +++++++++++++ tests/store.test.ts | 36 +++++++++++++++++++++++++++++------- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/scripts/test-live-pg.sh b/scripts/test-live-pg.sh index a25c3e9..e4b3813 100755 --- a/scripts/test-live-pg.sh +++ b/scripts/test-live-pg.sh @@ -5,6 +5,19 @@ # SOUL_PG_URL's test drops and recreates schema public, so it only ever sees # this disposable cluster. set -euo pipefail +# Prerequisites, done here when missing (each step is skipped once it holds): +# 1. the vendor/CultLib submodule checked out, +# 2. its npm dependencies installed (the vendor packages build with its tsc), +# 3. dist/ built for cultcache-ts, cultnet-ts and cultmesh-ts (package.json +# points main at dist/index.js), +# 4. this repo's pnpm install. +cd "$(dirname "$0")/.." +[ -f vendor/CultLib/package.json ] || git submodule update --init vendor/CultLib +[ -d vendor/CultLib/node_modules/typescript ] || (cd vendor/CultLib && npm ci) +for pkg in cultcache-ts cultnet-ts cultmesh-ts; do + [ -f "vendor/CultLib/packages/$pkg/dist/index.js" ] || (cd "vendor/CultLib/packages/$pkg" && npm run build) +done +[ -d node_modules/vitest ] || pnpm install --frozen-lockfile export PATH="${PG_BIN:-/usr/lib/postgresql/16/bin}:$PATH" dir="$(mktemp -d)" port="${PG_PORT:-54329}" diff --git a/tests/store.test.ts b/tests/store.test.ts index b107197..dac3b95 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -447,7 +447,13 @@ async function exerciseStore(store: PostgresStore): Promise> { expect(await s.claimOAuthStateUse(stateUse)).toBe(false); expect(await s.claimOAuthStateUse({ ...stateUse, phase: "callback" })).toBe(true); const { receipt: grant } = await s.createConsentReceipt({ kind: "grant", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: now, scopesAsked: ["a", "b"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" }); - await s.createConsentReceipt({ kind: "revocation", accountId: account.id, appSlug: "streampixels", provider: "joystick", createdAt: later, revokesReceiptId: grant.id }); + const revocation = { kind: "revocation" as const, accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const, createdAt: later, revokesReceiptId: grant.id }; + const first = await s.createConsentReceipt(revocation); + // The second revocation of one grant hits ON CONFLICT DO NOTHING and reads the first back. + const second = await s.createConsentReceipt(revocation); + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.receipt.id).toBe(first.receipt.id); expect((await s.listConsentReceipts(account.id)).map((r) => r.kind)).toEqual(["grant", "revocation"]); await s.createAuditEvent({ eventType: "probe", eventPayloadJson: {}, createdAt: now }); return called; @@ -608,12 +614,28 @@ describe.skipIf(!adminUrl)("startup against a live Postgres (HEIMDALL_TEST_PG_AD it("prints each declared predicate exactly as Postgres prints the applied index's", async () => { const { owner } = await appliedDatabase(); const printed = await printedDeclaredPredicates(owner, HEIMDALL_SCHEMA_REQUIREMENTS); - const applied = await owner.query<{ predicate: string }>( - "SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass" - ); - const attemptKey = REQUIRED_KEYS.findIndex((key) => key.predicate !== ""); - expect(printed[attemptKey]).toBe(applied.rows[0]!.predicate); - expect(printed.filter((predicate) => predicate !== "")).toHaveLength(1); + const appliedByIndex = { + auth_completions_attempt_unconsumed_unique_idx: "auth_completions", + consent_receipts_revokes_unique_idx: "consent_receipts", + }; + const partialKeys = REQUIRED_KEYS.flatMap((key, index) => (key.predicate === "" ? [] : [{ key, index }])); + expect(partialKeys.map(({ key }) => key.table).sort()).toEqual(Object.values(appliedByIndex).sort()); + for (const [indexName, table] of Object.entries(appliedByIndex)) { + const applied = await owner.query<{ predicate: string }>( + "SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = $1::regclass", + [indexName] + ); + const { index } = partialKeys.find(({ key }) => key.table === table)!; + expect(printed[index], indexName).toBe(applied.rows[0]!.predicate); + } + expect(printed.filter((predicate) => predicate !== "")).toHaveLength(partialKeys.length); + }); + + it("refuses to boot with consent_receipts missing when the schema is not applied on startup", async () => { + const { name, owner } = await appliedDatabase(); + await owner.query("DROP TABLE consent_receipts"); + + expect(await start(urlFor(name), false)).toBe(refusal("SCHEMA_MISSING")); }); it("refuses a table whose primary key was dropped, even after applying the schema", async () => { From f7a38a7d2a3ff5fd88d34635fdce0a5162265f2b Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:39:34 +0000 Subject: [PATCH 09/10] Doc the FK, the cookie and scope bounds, consent_text_missing Co-Authored-By: Claude Sonnet 5.5 --- docs/joystick-consent-flow.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md index 8add6a3..5821320 100644 --- a/docs/joystick-consent-flow.md +++ b/docs/joystick-consent-flow.md @@ -161,7 +161,10 @@ the least stored. 2. **The receipt is a new typed table, `consent_receipts`.** The audit table is operator-shaped and has no read path for the person; a viewer-readable record in it would make every audit row a potential disclosure. The table - holds ids, scopes and a digest: no IP address, no user agent, no page text. + holds ids, scopes and a digest: no IP address, no user agent, no page text. A revocation names a + receipt of its own account: a composite foreign key `(revokes_receipt_id, + account_id)` to `(id, account_id)`, which is why `(id, account_id)` is also a + unique key (`checkSchema` counts it). The in-memory store refuses the same. Refusals leave no receipt, only the anonymous audit row. 3. **Viewer identity: sign-in, not the cookie.** The binding cookie dies with the state and proves only that a browser launched a flow; it is not an @@ -181,6 +184,11 @@ the least stored. wording (the CultCoin legal thread) may only narrow it, and any change moves the digest. Until that review lands the copy is Heimdall's, signed by the steward. +6. **Bounds on what rides the cookies.** The wording is read from a cookie + only up to 16 KB inflated, so a compressed bomb is refused, not expanded. An + app may request at most 32 scopes of 512 characters (`requested_scopes_too_large` + at the ask). A custody callback whose binding carries no wording is refused + with `consent_text_missing` before the state is claimed, so nothing is burned. ## 5. Out of scope From b6372e5a4ffc2f297b74d2544fc3456bb35e01c0 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:45:57 +0000 Subject: [PATCH 10/10] Named revocation-conflict tests on pg-mem, first-create doc line, npm fallback in test-live-pg Co-Authored-By: Claude Sonnet 5.5 --- docs/joystick-consent-flow.md | 4 +++- scripts/test-live-pg.sh | 4 ++-- tests/store.test.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md index 5821320..157e99c 100644 --- a/docs/joystick-consent-flow.md +++ b/docs/joystick-consent-flow.md @@ -164,7 +164,9 @@ the least stored. holds ids, scopes and a digest: no IP address, no user agent, no page text. A revocation names a receipt of its own account: a composite foreign key `(revokes_receipt_id, account_id)` to `(id, account_id)`, which is why `(id, account_id)` is also a - unique key (`checkSchema` counts it). The in-memory store refuses the same. + unique key (`checkSchema` counts it). The in-memory store refuses the same. The table is created + whole and first-created: no deployed database had an earlier shape, so there + is no `ALTER` step. Refusals leave no receipt, only the anonymous audit row. 3. **Viewer identity: sign-in, not the cookie.** The binding cookie dies with the state and proves only that a browser launched a flow; it is not an diff --git a/scripts/test-live-pg.sh b/scripts/test-live-pg.sh index e4b3813..e028caa 100755 --- a/scripts/test-live-pg.sh +++ b/scripts/test-live-pg.sh @@ -10,14 +10,14 @@ set -euo pipefail # 2. its npm dependencies installed (the vendor packages build with its tsc), # 3. dist/ built for cultcache-ts, cultnet-ts and cultmesh-ts (package.json # points main at dist/index.js), -# 4. this repo's pnpm install. +# 4. this repo's pnpm install (npm install if pnpm fails with EACCES). cd "$(dirname "$0")/.." [ -f vendor/CultLib/package.json ] || git submodule update --init vendor/CultLib [ -d vendor/CultLib/node_modules/typescript ] || (cd vendor/CultLib && npm ci) for pkg in cultcache-ts cultnet-ts cultmesh-ts; do [ -f "vendor/CultLib/packages/$pkg/dist/index.js" ] || (cd "vendor/CultLib/packages/$pkg" && npm run build) done -[ -d node_modules/vitest ] || pnpm install --frozen-lockfile +[ -d node_modules/vitest ] || pnpm install --frozen-lockfile || npm install # pnpm can fail with EACCES on its store; npm does not need it export PATH="${PG_BIN:-/usr/lib/postgresql/16/bin}:$PATH" dir="$(mktemp -d)" port="${PG_PORT:-54329}" diff --git a/tests/store.test.ts b/tests/store.test.ts index dac3b95..7c2bbd6 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -210,6 +210,41 @@ describe("consent_receipts foreign key", () => { }); }); +describe("PostgresStore.createConsentReceipt revocation conflict", () => { + async function granted() { + const adapter = newDb().adapters.createPg(); + const store = new PostgresStore(new adapter.Pool()); + await store.ensureSchema(); + const now = "2026-11-08T00:00:00.000Z"; + const account = await store.createAccount({ createdAt: now, lastSeenAt: now, displayName: "Probe" }); + const base = { accountId: account.id, appSlug: "streampixels" as const, provider: "joystick" as const }; + const { receipt: grant } = await store.createConsentReceipt({ ...base, kind: "grant", createdAt: now, scopesAsked: ["a"], scopesGranted: ["a"], consentPageDigest: "d", consentText: "t" }); + return { store, base, grant }; + } + + it("a second revocation of one grant inserts nothing: created:false, the first row's id", async () => { + const { store, base, grant } = await granted(); + const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: grant.id }); + const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id }); + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(second.receipt.id).toBe(first.receipt.id); + expect(second.receipt.createdAt).toBe("2026-11-08T01:00:00.000Z"); + expect((await store.listConsentReceipts(base.accountId)).filter((r) => r.kind === "revocation")).toHaveLength(1); + }); + + it("the read-back returns the revocation of this grant, not another's or the grant", async () => { + const { store, base, grant } = await granted(); + const other = (await store.createConsentReceipt({ ...base, kind: "grant", createdAt: "2026-11-08T00:30:00.000Z", scopesAsked: [], scopesGranted: [], consentPageDigest: "d2", consentText: "t2" })).receipt; + const otherRevocation = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:00:00.000Z", revokesReceiptId: other.id }); + const first = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T01:10:00.000Z", revokesReceiptId: grant.id }); + const second = await store.createConsentReceipt({ ...base, kind: "revocation", createdAt: "2026-11-08T02:00:00.000Z", revokesReceiptId: grant.id }); + expect(second.receipt.id).toBe(first.receipt.id); + expect(second.receipt.id).not.toBe(otherRevocation.receipt.id); + expect(second.receipt.revokesReceiptId).toBe(grant.id); + }); +}); + describe("InMemoryStore.createConsentReceipt", () => { it("writes one revocation per grant: the second names the first and is not created", async () => { const store = new InMemoryStore();