Joystick consent: ask page, consent_refused, receipts, signed-in grants page with revoke #5
16 changed files with 1888 additions and 68 deletions
209
docs/joystick-consent-flow.md
Normal file
209
docs/joystick-consent-flow.md
Normal 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.
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
512
src/app.ts
512
src/app.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { createHmac, randomUUID } from "node:crypto";
|
||||
import { deflateRawSync, inflateRawSync } from "node:zlib";
|
||||
import cookie from "@fastify/cookie";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify, { type FastifyError, type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { builtInAppProfiles, getAppProfile, isAllowedReturnOrigin, serializeAppProfile, admitsProviderForMode, callbackMint } from "./app-profiles.js";
|
||||
import { renderBrowserHandoffPage } from "./browser-handoff.js";
|
||||
import { consentText, digestOfText, grantViews, GRANTS_PATH, renderConsentPage, renderGrantsPage, type ConsentAsk } from "./consent.js";
|
||||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { createTokenCustody, type TokenCustody } from "./custody.js";
|
||||
import { grantFact } from "./facts.js";
|
||||
|
|
@ -37,7 +39,7 @@ import {
|
|||
type OAuthRuntimeRegistry,
|
||||
} from "./oauth.js";
|
||||
import { ManagedCredentialRefreshFailed, refreshManagedCredential } from "./managed-credential.js";
|
||||
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||
import { authorizationScopes, buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
|
||||
import { createStore, type HeimdallStore } from "./store/index.js";
|
||||
import {
|
||||
|
|
@ -251,14 +253,82 @@ function oauthBindingCookieName(jti: string): string {
|
|||
function signOAuthBinding(
|
||||
config: HeimdallConfig,
|
||||
keys: ReturnType<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
191
src/consent.ts
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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>`
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
}
|
||||
|
|
|
|||
65
tests/consent-drift.test.ts
Normal file
65
tests/consent-drift.test.ts
Normal 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");
|
||||
});
|
||||
597
tests/joystick-consent.test.ts
Normal file
597
tests/joystick-consent.test.ts
Normal 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(/'/g, "'").replace(/"/g, '"').replace(/>/g, ">").replace(/</g, "<").replace(/&/g, "&");
|
||||
for (const line of receipt.consentText.split("\n")) expect(shown, line).toContain(line);
|
||||
expect(receipt.consentText).toContain("Continue to Joystick");
|
||||
expect(receipt.consentText).toContain("No, return to the app");
|
||||
});
|
||||
|
||||
it("a refusal leaves no receipt", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
await answer(app, state, "decline", cookie);
|
||||
expect(await store.listConsentReceipts("anyone")).toEqual([]);
|
||||
expect((await store.findStoredLinkedIdentity("joystick", "joy-a"))).toBeNull();
|
||||
});
|
||||
|
||||
it("the success page names the receipt", async () => {
|
||||
const { app } = await newApp();
|
||||
const state = await startConnect(app);
|
||||
const { cookie } = await seeAsk(app, state);
|
||||
const go = await answer(app, state, "continue", cookie);
|
||||
const page = await callback(app, state, bindingCookieOf(go)!, "code=code-a", "text/html");
|
||||
expect(page.statusCode).toBe(200);
|
||||
expect(page.body).toMatch(/receipt [0-9a-f-]{36}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("my grants", () => {
|
||||
it("is closed to anyone without a live Heimdall access token", async () => {
|
||||
const { app } = await newApp();
|
||||
for (const headers of [{}, bearer("not-a-token")]) {
|
||||
const response = await app.inject({ method: "GET", url: "/v1/me/grants", headers });
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.json().error).toBe("sign_in_required");
|
||||
}
|
||||
const link = await app.inject({ method: "POST", url: "/v1/me/grants/link" });
|
||||
expect(link.statusCode).toBe(401);
|
||||
const revoke = await app.inject({ method: "POST", url: "/v1/me/grants/x/revoke" });
|
||||
expect(revoke.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("lists the signed-in account's grants and receipts, and no one else's", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const b = await grantAs(app, "b");
|
||||
const mine = await grantsOf(app, a.accessToken);
|
||||
expect(mine.grants).toHaveLength(1);
|
||||
expect(mine.grants[0]).toMatchObject({ id: a.consentReceiptId, status: "active", provider: "joystick", appSlug: "streampixels", scopesGranted: ["identity:read", "stream:read"] });
|
||||
expect(JSON.stringify(mine)).not.toContain(b.consentReceiptId);
|
||||
expect(mine.receipts.map((receipt) => receipt.accountId)).toEqual([a.account.id]);
|
||||
});
|
||||
|
||||
it("revoke takes effect on the next token use, edits no row, and receipts the revocation", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
|
||||
const before = await store.listConsentReceipts(a.account.id);
|
||||
|
||||
const revoke = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(a.accessToken) });
|
||||
expect(revoke.statusCode).toBe(200);
|
||||
expect(revoke.json().revoked).toEqual([a.consentReceiptId]);
|
||||
|
||||
const next = await resolveCredential(app, a.account.id);
|
||||
expect(next.statusCode).toBe(410);
|
||||
expect(next.json().error).toBe("managed_credential_revoked");
|
||||
|
||||
const after = await store.listConsentReceipts(a.account.id);
|
||||
expect(after).toHaveLength(2);
|
||||
expect(after[0]).toEqual(before[0]);
|
||||
expect(after[1]).toMatchObject({ kind: "revocation", revokesReceiptId: a.consentReceiptId, accountId: a.account.id });
|
||||
const view = (await grantsOf(app, a.accessToken)).grants[0]!;
|
||||
expect(view).toMatchObject({ status: "revoked", revocationReceiptId: after[1]!.id });
|
||||
expect(view.revokedAt).toBe(after[1]!.createdAt);
|
||||
});
|
||||
|
||||
it("revoke is the owner's alone: another account's grant id is not found and its credential is untouched", async () => {
|
||||
const { app } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const b = await grantAs(app, "b");
|
||||
const attempt = await app.inject({ method: "POST", url: `/v1/me/grants/${a.consentReceiptId}/revoke`, headers: bearer(b.accessToken) });
|
||||
expect(attempt.statusCode).toBe(404);
|
||||
expect((await resolveCredential(app, a.account.id)).statusCode).toBe(200);
|
||||
expect((await resolveCredential(app, b.account.id)).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("revoking twice changes nothing the second time", async () => {
|
||||
const { app, store } = await newApp();
|
||||
const a = await grantAs(app, "a");
|
||||
const url = `/v1/me/grants/${a.consentReceiptId}/revoke`;
|
||||
await app.inject({ method: "POST", url, headers: bearer(a.accessToken) });
|
||||
const second = await app.inject({ method: "POST", url, headers: bearer(a.accessToken) });
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.json().revoked).toEqual([]);
|
||||
expect(await store.listConsentReceipts(a.account.id)).toHaveLength(2);
|
||||
const events = [...(store as unknown as { auditEvents: Map<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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue