Joystick consent: ask page, consent_refused, receipts, signed-in grants page with revoke #5

Merged
heimdall merged 10 commits from heimdall/joystick-consent-flow-design into main 2026-11-08 13:45:59 +00:00
16 changed files with 1888 additions and 68 deletions

View file

@ -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-<jti>`), 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.

View file

@ -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}"

View file

@ -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<typeof createRuntimeKeyMaterial>,
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<typeof createRuntimeKeyMaterial>, 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<typeof createRuntimeKeyMaterial>,
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<typeof createRuntimeKeyMaterial>,
@ -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<HeimdallRuntimeContext, "config" | "keys" | "store">,
authorization: string | undefined
): Promise<string | null> {
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<typeof createRuntimeKeyMaterial>,
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<typeof createRuntimeKeyMaterial>,
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<FastifyIn
credentials: true,
});
await app.register(cookie);
// The consent and grants pages are plain HTML forms.
app.addContentTypeParser("application/x-www-form-urlencoded", { parseAs: "string" }, (_request, body, done) => {
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<FastifyIn
request: FastifyRequest,
reply: FastifyReply,
state: OAuthStatePayload,
error: "state_not_bound" | "state_already_launched" | "state_already_used"
error: "state_not_bound" | "state_already_launched" | "state_already_used" | "consent_text_missing"
) => {
reply.code(400);
const profile = getAppProfile(state.app_slug);
@ -1126,6 +1260,106 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error };
};
const denyAttempt = async (state: OAuthStatePayload, denialCode: string): Promise<void> => {
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<FastifyIn
return { error: verified.error };
}
const state = verified.payload;
const clientId = config.providers[state.provider].clientId;
if (!clientId) {
reply.code(503);
return { error: "provider_not_configured", provider: state.provider, expectedEnv: providerExpectedEnv(state.provider) };
const askProfile = getAppProfile(state.app_slug);
if (askProfile && callbackMint(askProfile, state.provider, state.mode, state.connection) === "custody_only") {
if (!config.providers[state.provider].clientId) {
return providerNotConfigured(reply, state);
}
// Heimdall is about to hold a credential for the app: ask first. Viewing
// the ask claims nothing and binds nothing; only Continue spends the
// launch use and sets the launch binding. The ask's own cookie rides on
// the page so that the form's answer can only come from the browser
// that was shown it, and carries the text it was shown.
const ask = consentAskFor(state);
const shown = consentText(ask);
const askCookie = signConsentAsk(config, keys, state, shown);
// An ask the cookies cannot carry is refused, not shown: the person
// could press Continue and nothing would bind.
if (Buffer.byteLength(shown, "utf8") > 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<FastifyIn
}
const statePayload = verified.payload;
const handoff = statePayload.handoff ?? { kind: "browser_completion" };
const denyBrowserAttempt = async (denialCode: string): Promise<void> => {
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<void> => 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<FastifyIn
await denyBrowserAttempt("state_not_bound");
return refuseVerifiedState(request, reply, statePayload, "state_not_bound");
}
// A custody flow carries the wording its person was shown in the launch
// binding. A binding without it did not pass through the ask (a launch
// from before it existed): refuse here, before the state is spent and
// before any code is exchanged, so nothing is kept and nothing is burned.
const shownText = cookieConsentText(config, keys, request.cookies[oauthBindingCookieName(statePayload.jti)], oauthBindingType, statePayload);
const callbackProfile = getAppProfile(statePayload.app_slug);
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode, statePayload.connection) : "refused";
if (mint === "custody_only" && shownText === null) {
await denyBrowserAttempt("consent_text_missing");
return refuseVerifiedState(request, reply, statePayload, "consent_text_missing");
}
setOAuthBindingCookie(reply, statePayload, "", 0);
const firstCallback = await store.claimOAuthStateUse({
jti: statePayload.jti,
@ -1262,6 +1556,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return refuseVerifiedState(request, reply, statePayload, "state_already_used");
}
if (request.query.error === "access_denied") {
return refuseConsent(request, reply, statePayload, "provider");
}
if (request.query.error) {
const providerError = relayedProviderText(request.query.error, 64);
const providerErrorDescription = relayedProviderText(request.query.error_description, 256);
@ -1354,8 +1651,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
const runtime = oauthRuntimes[request.params.provider];
try {
const callbackProfile = getAppProfile(statePayload.app_slug);
const mint = callbackProfile ? callbackMint(callbackProfile, request.params.provider, statePayload.mode, statePayload.connection) : "refused";
if (mint === "refused") {
throw new Error("The signed state names a mode the provider has no role for in this app.");
}
@ -1519,6 +1814,25 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
await store.upsertLinkedIdentity(buildLinkedIdentityInput(account.id));
// Heimdall now holds a credential the person was asked about: the
// receipt is the record of that agreement, written with the credential.
let consentReceiptId: string | undefined;
if (mint === "custody_only" && shownText !== null) {
const ask = consentAskFor(statePayload);
const { receipt } = await store.createConsentReceipt({
kind: "grant",
accountId: account.id,
appSlug: statePayload.app_slug,
provider: identity.provider,
createdAt: nowIso,
scopesAsked: [...ask.scopes],
scopesGranted: tokenSet.scope,
consentPageDigest: digestOfText(shownText),
consentText: shownText,
});
consentReceiptId = receipt.id;
}
const entitlementEvaluation = await runtime.evaluateEntitlements({
config,
callback: {
@ -1613,6 +1927,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
entitlements: entitlementEvaluation,
returnTo: statePayload.return_to,
connection: statePayload.connection,
...(consentReceiptId ? { consentReceiptId } : {}),
...delivered,
};
if (handoff.kind === "backend_callback") {
@ -1629,6 +1944,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
connection: statePayload.connection,
account: accountSummary,
entitlements: entitlementEvaluation,
...(consentReceiptId ? { consentReceiptId } : {}),
...delivered,
});
@ -1658,6 +1974,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
attemptId: handoff.attemptId,
...(consentReceiptId ? { consentReceiptId } : {}),
})
);
}
@ -1731,6 +2048,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
completionCode: completion.code,
...(consentReceiptId ? { consentReceiptId } : {}),
...(handoff.kind === "browser_completion" && handoff.attemptId
? { attemptId: handoff.attemptId }
: {}),
@ -2296,5 +2614,115 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
);
/** The signed-in account, and the expiry the page's own forms get: a bearer starts a fresh window, a page link only passes on the one it has. */
const grantsAccount = async (request: FastifyRequest, pageToken?: unknown): Promise<{ accountId: string; exp: number } | null> => {
const bearerAccount = await resolveAccessAccount({ config, keys, store }, request.headers.authorization);
if (bearerAccount) return { accountId: bearerAccount, exp: nowEpochSeconds() + grantsPageTokenTtlSeconds };
return grantsPageTokenAccount(config, keys, pageToken);
};
const grantsReply = (reply: FastifyReply) => {
reply.header("Cache-Control", "no-store");
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;
}

View file

@ -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. */

View file

@ -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
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"}</title>
<title>${pageTitle}</title>
<style>
:root {
color-scheme: light dark;

191
src/consent.ts Normal file
View file

@ -0,0 +1,191 @@
import { createHash } from "node:crypto";
import { type AppSlug, type Provider } from "./contracts.js";
import { getAppProfile } from "./app-profiles.js";
import { providerCatalog } from "./providers.js";
import { type StoredConsentReceipt } from "./store/index.js";
/**
* What Heimdall tells a person before it holds a provider credential for an
* app, and the page that lists what they agreed to. The wording is built from
* the facts the code holds (the app, the provider, the scopes sent), never
* from a promise the code does not keep. Any change to a line changes the
* digest every later receipt carries, so a reader can tell which wording a
* person saw.
*/
export interface ConsentAsk {
appSlug: AppSlug;
provider: Provider;
/** The scopes Heimdall will send the provider. */
scopes: readonly string[];
/** The origin the person returns to afterwards. */
returnOrigin: string;
}
export const GRANTS_PATH = "/v1/me/grants";
function appName(slug: AppSlug): string {
return getAppProfile(slug)?.displayName ?? slug;
}
/** The exact lines shown on the ask, apart from the two buttons. The digest is over these. */
export function consentLines(ask: ConsentAsk): string[] {
const app = appName(ask.appSlug);
const provider = providerCatalog[ask.provider].displayName;
return [
`${app} asks Heimdall to hold a connection to your ${provider} account.`,
`Heimdall will ask ${provider} for these permissions: ${ask.scopes.join(", ")}.`,
`Heimdall keeps an encrypted refresh token and access token for this connection until you take it back or ${provider} ends it.`,
`${app}'s server can ask Heimdall for a current access token and use it for those permissions. It never receives the refresh token.`,
`If you say no, nothing is kept.`,
`You can take this back at any time. Heimdall then deletes its copy of the tokens and stops handing out access tokens. The permission stays with ${provider} until you remove it in your ${provider} settings.`,
`Afterwards you are returned to ${ask.returnOrigin}.`,
];
}
/** The labels on the two buttons. They are wording the person agrees to, so they are part of the text. */
export function consentButtons(ask: ConsentAsk): { go: string; no: string } {
return { go: `Continue to ${providerCatalog[ask.provider].displayName}`, no: "No, return to the app" };
}
/**
* Everything the person reads before answering, as one text: the lines and the
* button labels. It is built once, when the ask is rendered, and that exact
* text travels with the flow to the receipt; nothing rebuilds it later.
*/
export function consentText(ask: ConsentAsk): string {
const { go, no } = consentButtons(ask);
return [...consentLines(ask), go, no].join("\n");
}
export function digestOfText(text: string): string {
return createHash("sha256").update(text, "utf8").digest("hex");
}
export function consentDigest(ask: ConsentAsk): string {
return digestOfText(consentText(ask));
}
export function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
const pageStyle = `
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #11161d; color: #f4f7fb; }
main { width: min(38rem, calc(100vw - 2rem)); border-radius: 1rem; padding: 1.5rem; background: rgba(20, 27, 36, 0.96); box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3); }
h1 { margin: 0 0 0.75rem; font-size: 1.35rem; }
p, li { line-height: 1.5; color: #d5dce6; }
ul { padding-left: 1.2rem; }
code { color: #d8eefe; }
button { font: inherit; font-weight: 600; border: 0; border-radius: 999px; padding: 0.75rem 1rem; margin: 0.25rem 0.5rem 0.25rem 0; cursor: pointer; }
button.go { color: #11161d; background: #d8eefe; }
button.no { color: #f4f7fb; background: #2c3745; }
section { border-top: 1px solid #2c3745; padding: 0.75rem 0; }
small { color: #9aacbe; }`;
function page(title: string, body: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>${pageStyle}
</style>
</head>
<body>
<main>
${body}
</main>
</body>
</html>`;
}
/** 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) =>
`<button class="${decision === "continue" ? "go" : "no"}" type="submit" name="decision" value="${decision}">${escapeHtml(label)}</button>`;
return page(
`${appName(ask.appSlug)} wants to connect ${provider}`,
` <h1>${escapeHtml(headline!)}</h1>
<ul>
${rest.map((line) => ` <li>${escapeHtml(line)}</li>`).join("\n")}
</ul>
<form method="post" action="/v1/oauth/${ask.provider}/consent">
<input type="hidden" name="state" value="${escapeHtml(state)}">
${form("continue", consentButtons(ask).go)}
${form("decline", consentButtons(ask).no)}
</form>`
);
}
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<string, StoredConsentReceipt & { kind: "revocation" }>();
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 <code>${escapeHtml(grant.id)}</code>; wording <code>${escapeHtml(grant.consentPageDigest.slice(0, 12))}</code>`;
if (grant.status === "revoked") {
return ` <section><strong>${header}</strong> <small>taken back ${escapeHtml(grant.revokedAt ?? "")}; receipt <code>${escapeHtml(grant.revocationReceiptId ?? "")}</code></small><br><small>${detail}</small></section>`;
}
return ` <section><strong>${header}</strong> <small>active</small><br><small>${detail}</small>
<form method="post" action="${GRANTS_PATH}/${escapeHtml(grant.id)}/revoke">
<input type="hidden" name="t" value="${escapeHtml(pageToken)}">
<button class="no" type="submit">Take this back</button>
</form></section>`;
})
.join("\n")
: ` <p>You have not connected anything through Heimdall.</p>`;
return page(
"Your connections",
` <h1>Your connections</h1>
${notice ? ` <p><strong>${escapeHtml(notice)}</strong></p>\n` : ""}${cards}
<p><small>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.</small></p>`
);
}

View file

@ -167,6 +167,11 @@ export const providerCatalog: Record<Provider, ProviderDescriptor> = {
},
};
/** 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);

View file

@ -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<string, string>();
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
private readonly consentReceipts: StoredConsentReceipt[] = [];
async ensureSchema(): Promise<void> {
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<StoredConsentReceipt[]> {
return this.consentReceipts.filter((receipt) => receipt.accountId === accountId).map(clone);
}
}

View file

@ -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<Record<string, readonly ("SELECT"
oauth_state_uses: ["INSERT"],
capability_grants: ["SELECT", "INSERT"],
entitlement_snapshots: ["SELECT", "INSERT", "UPDATE"],
consent_receipts: ["SELECT", "INSERT"],
audit_events: ["INSERT"],
};
@ -1196,6 +1234,52 @@ export class PostgresStore implements HeimdallStore {
);
}
async createConsentReceipt(input: CreateConsentReceiptInput): Promise<{ receipt: StoredConsentReceipt; created: boolean }> {
const grant = input.kind === "grant" ? input : null;
// No conflict target and the partial unique index decides: a second
// revocation of one grant inserts nothing and the first is read back.
const result = await this.pool.query<ConsentReceiptRow>(
`
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<ConsentReceiptRow>(
`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<StoredConsentReceipt[]> {
const result = await this.pool.query<ConsentReceiptRow>(
`SELECT * FROM consent_receipts WHERE account_id = $1 ORDER BY created_at, id`,
[accountId]
);
return result.rows.map(mapConsentReceiptRow);
}
async createAuditEvent(input: CreateAuditEventInput): Promise<void> {
await this.pool.query(
`

View file

@ -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) };

View file

@ -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<R, "id">
: never
: never;
export interface HeimdallStore {
ensureSchema(): Promise<void>;
close(): Promise<void>;
@ -325,4 +352,8 @@ export interface HeimdallStore {
*/
claimOAuthStateUse(input: ClaimOAuthStateUseInput): Promise<boolean>;
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
/** `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<StoredConsentReceipt[]>;
}

View file

@ -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<typeof import("../src/consent.js")>();
return { ...real, consentText: (ask: Parameters<typeof real.consentText>[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");
});

View file

@ -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-<user>` 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<string, unknown> = { 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<string, unknown> }) =>
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<Record<string, any>>; receipts: Array<Record<string, any>> };
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("<script");
expect(page.headers["content-security-policy"]).toContain("form-action 'self' https://joystick.tv");
});
it("viewing the ask claims nothing: the launch is still spendable once after any number of views", async () => {
const { app } = await newApp();
const state = await startConnect(app);
await seeAsk(app, state);
const { cookie } = await seeAsk(app, state);
expect((await answer(app, state, "continue", cookie)).statusCode).toBe(302);
const again = await answer(app, state, "continue", cookie, "application/json");
expect(again.statusCode).toBe(400);
expect(again.json()).toEqual({ error: "state_already_launched" });
});
it("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<typeof getHeimdallRuntimeContext>, 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<string, { eventType: string; eventPayloadJson: unknown }> }).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<string, { eventType: string }> }).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<Record<string, unknown>> = [];
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(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, ">").replace(/&lt;/g, "<").replace(/&amp;/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<string, { eventType: string }> }).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("<script");
const token = url.searchParams.get("t")!;
const post = await app.inject({
method: "POST",
url: `/v1/me/grants/${a.consentReceiptId}/revoke`,
headers: { ...form, accept: "text/html" },
payload: new URLSearchParams({ t: token }).toString(),
});
expect(post.statusCode).toBe(200);
expect(post.body).toContain("Taken back.");
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(410);
});
it("a page link is never renewed by being used: the forms on its page expire when the link does", async () => {
const { app } = await newApp();
const a = await grantAs(app, "a");
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link", headers: bearer(a.accessToken) });
const url = new URL(link.json().url as string);
const linkToken = url.searchParams.get("t")!;
// 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<string, { eventType: string }> }).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);
});
});

View file

@ -117,7 +117,18 @@ export function setCookieOf(response: { headers: Record<string, unknown> }): str
async function followLaunch(app: FastifyInstance, state: string): Promise<LaunchedFlow & { providerUrl: URL }> {
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<URL> {
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;
}

View file

@ -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"],

View file

@ -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<Set<string>> {
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 () => {