diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md new file mode 100644 index 0000000..157e99c --- /dev/null +++ b/docs/joystick-consent-flow.md @@ -0,0 +1,209 @@ +# Joystick sign-in and consent: what was missing, and the flow that fills it + +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 + +**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. The consent flow + +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 + +`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 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 +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 + +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 `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), `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, 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 + +Signed in means a live Heimdall access token for any app (`Authorization: +Bearer`), or a page link minted from one. + +- `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. 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 + 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`. + +Heimdall can delete its copy; it cannot withdraw the permission at the +provider, and the page says so. + +## 4. The steward's rulings + +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. 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. 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 + 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. +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 + +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/scripts/test-live-pg.sh b/scripts/test-live-pg.sh index a25c3e9..e028caa 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 (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 || 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/src/app.ts b/src/app.ts index 2707bbe..f4a48e5 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,6 +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 { 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"; @@ -37,7 +39,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 { @@ -251,14 +253,82 @@ 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"; + +/** 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}`; +} + +/** + * 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"), { maxOutputLength: maxConsentTextBytes }).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, @@ -684,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, @@ -809,6 +889,56 @@ 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, + exp: number = nowEpochSeconds() + grantsPageTokenTtlSeconds +): string { + return signJwt({ typ: grantsPageTokenType, iss: config.issuer, account_id: accountId, iat: nowEpochSeconds(), exp }, keys); +} + +/** 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, exp } + : 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 +1078,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { + done(null, Object.fromEntries(new URLSearchParams(body as string))); + }); app.get("/healthz", async () => ({ ok: true, @@ -1105,7 +1239,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { reply.code(400); const profile = getAppProfile(state.app_slug); @@ -1126,6 +1260,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, shownText?: 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, shownText), 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 +1392,94 @@ 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"); + reply.header( + "Content-Security-Policy", + `default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' ${new URL(providerCatalog[state.provider].authorizationEndpoint).origin}` + ); + reply.type("text/html; charset=utf-8"); + return reply.send(renderConsentPage(ask, request.query.state)); } + return beginProviderRedirect(request, reply, state, request.query.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) { + app.post<{ + Params: { provider: Provider }; + Body: { state: string; decision: "continue" | "decline" }; + }>( + "/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. + const shownText = cookieConsentText(config, keys, request.cookies[consentAskCookieName(state.jti)], consentAskType, state); + if (shownText === null) { + return refuseVerifiedState(request, reply, state, "state_not_bound"); + } + setConsentAskCookie(reply, state, "", 0); + if (request.body.decision === "continue") { + return beginProviderRedirect(request, reply, state, request.body.state, shownText); + } + // 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 +1523,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 @@ -1250,6 +1533,17 @@ export async function buildApp(options: BuildAppOptions = {}): Promise => { + 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"); + 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 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, caller.exp))); + } + 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 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); + 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")) { + const { created } = await store.createConsentReceipt({ + kind: "revocation", + accountId, + appSlug: live.appSlug, + provider: live.provider, + createdAt: at, + revokesReceiptId: live.id, + }); + // A concurrent revoke may have written it first; only the writer reports it. + if (created) revoked.push(live.id); + } + if (revoked.length) { + 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, caller.exp), 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", consentButtons(ask).go)} + ${form("decline", consentButtons(ask).no)} +
` + ); +} + +export interface GrantView { + consentText: string; + 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, + consentText: receipt.consentText, + 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..e829900 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,22 @@ export class InMemoryStore implements HeimdallStore { }); this.auditEvents.set(event.id ?? randomUUID(), event); } + + async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> { + 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 }; + } + const receipt = { id: randomUUID(), ...clone(input) } as StoredConsentReceipt; + this.consentReceipts.push(clone(receipt)); + return { receipt: clone(receipt), created: true }; + } + + 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..3d34047 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,41 @@ 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; + consent_text: 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 ?? "", + consentText: row.consent_text ?? "", + }; +} + function mapPendingLinkRow(row: PendingLinkRow): StoredPendingLink { const link: StoredPendingLink = { id: row.id, @@ -421,6 +458,7 @@ export const STORE_TABLE_PRIVILEGES: Readonly { + 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, consent_text, revokes_receipt_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT DO NOTHING + 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, + grant ? grant.consentText : null, + input.kind === "revocation" ? input.revokesReceiptId : null, + ] + ); + 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 { + 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..294ee2d 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -180,6 +180,34 @@ 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, + consent_text TEXT, + revokes_receipt_id TEXT, + UNIQUE(id, account_id), + FOREIGN KEY (revokes_receipt_id, account_id) REFERENCES consent_receipts(id, account_id) +); + +-- 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; + +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, @@ -241,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/src/store/types.ts b/src/store/types.ts index 982bccc..bb2e080 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -252,6 +252,33 @@ 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 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; + accountId: string; + appSlug: AppSlug; + provider: Provider; + createdAt: string; +} & ( + | { kind: "grant"; scopesAsked: string[]; scopesGranted: string[]; consentPageDigest: string; consentText: 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 +352,8 @@ export interface HeimdallStore { */ claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise; createAuditEvent(input: CreateAuditEventInput): 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 new file mode 100644 index 0000000..5dd9ead --- /dev/null +++ b/tests/joystick-consent.test.ts @@ -0,0 +1,597 @@ +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"; +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"; +import { createTestConfig, setCookieOf, stateOf } from "./oauth-flow.js"; + +// 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.useRealTimers(); + vi.restoreAllMocks(); + while (apps.length) await apps.pop()?.close(); +}); + +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: secret, + payload: { + appSlug: "streampixels", + mode: "connect", + connection: { kind: "creator", targetId: "creator-1" }, + returnTo: "https://streampixels.gamecult.org/settings", + handoff, + }, + }); + expect(response.statusCode).toBe(201); + return stateOf(response.json()); +} + +const launchUrl = (state: string) => `/v1/oauth/joystick/launch?state=${encodeURIComponent(state)}`; +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 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); + 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") { + 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 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, bindingCookieOf(go)!, `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(" { + 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("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 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("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("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 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 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).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 () => { + 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 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" }, + { ...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_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([ + { 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); + 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"], + ["error", "consent_refused", "provider"], + ]); + }); +}); + +async function newFlow(app: FastifyInstance) { + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + const go = await answer(app, state, "continue", cookie); + expect(go.statusCode).toBe(302); + return { state, cookie: bindingCookieOf(go)! }; +} + +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("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); + 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); + 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}/); + }); +}); + +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); + 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 () => { + 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"); + 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); + // 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"); + 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..2616da1 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-ask-")); + expect(asked, "the ask sets its own 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..01abba0 100644 --- a/tests/oauth-state-binding.test.ts +++ b/tests/oauth-state-binding.test.ts @@ -299,18 +299,51 @@ 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" }); }); + // 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 f17a834..7c2bbd6 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,66 @@ 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("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(); + 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", @@ -270,9 +330,10 @@ describe("PostgresStore.checkSchema", () => { "oauth_state_uses", "capability_grants", "entitlement_snapshots", + "consent_receipts", "audit_events", ]); - expect(REQUIRED_COLUMNS).toHaveLength(101); + expect(REQUIRED_COLUMNS).toHaveLength(112); expect(REQUIRED_COLUMNS).toEqual( expect.arrayContaining([ { table: "auth_completions", column: "code", type: "TEXT", nullable: false }, @@ -283,12 +344,14 @@ describe("PostgresStore.checkSchema", () => { { table: "auth_completions", column: "attempt_id", type: "TEXT", nullable: true }, ]) ); - expect(REQUIRED_KEYS).toHaveLength(14); + 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" }, ]) @@ -418,6 +481,15 @@ 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 { 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" }); + 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; } @@ -577,12 +649,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 () => {