From 0ea6691cd384a93c15f9188d000c9fec5421fb84 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Sun, 8 Nov 2026 13:50:58 +0000 Subject: [PATCH] Refusal return contract: pin the fragment, carry the attempt id, document it The consent_refused page's return link is tested for heimdall_status, heimdall_error, heimdall_refused_by and no completion code, for both a decline and a provider access_denied. A browser_completion refusal now carries heimdall_attempt_id. The contract for app authors is in the consent doc. Co-Authored-By: Claude Sonnet 5.5 --- docs/joystick-consent-flow.md | 28 +++++++++++++ src/app.ts | 2 +- tests/joystick-consent.test.ts | 74 +++++++++++++++++++++++++++++++++- tests/store.test.ts | 2 +- 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/docs/joystick-consent-flow.md b/docs/joystick-consent-flow.md index 157e99c..3fafec6 100644 --- a/docs/joystick-consent-flow.md +++ b/docs/joystick-consent-flow.md @@ -192,6 +192,34 @@ the least stored. 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. +### Refusal return contract (for app authors) + +A person's No at the ask, or a provider `access_denied`, is `consent_refused`. +No completion code is minted and the state is spent. What each side gets: + +- **Browser.** A 200 HTML page titled "Not Connected" (a non-HTML caller gets a 400 + JSON `{error, by, provider, returnTo}`). The page does not redirect: by ruling + it never navigates on its own. It posts the payload to `window.opener` + (target origin = the origin of `returnTo`), tries `window.close()` after 50 ms, + and offers a "Return to the app" link. In a plain tab the person clicks it. +- **The link** is `returnTo` with a fragment (read `location.hash`; it never + reaches a server): `heimdall_status=error`, `heimdall_error=consent_refused`, + `heimdall_refused_by=viewer|provider`, `heimdall_provider`, + `heimdall_handoff_kind`, `heimdall_app_slug`, `heimdall_mode`, and + `heimdall_attempt_id` when the flow carries an attempt. There is no + `heimdall_completion_code`; a page with none, and `heimdall_error`, is a refusal. + A fault is a different `heimdall_error` (`provider_error` and others). +- **postMessage** carries `{source: "heimdall", kind: "oauth_result", + status: "error", error: "consent_refused", by, provider, appSlug, mode, + returnTo, handoffKind, attemptId?}`. +- **Backend callback apps** get the same result POSTed to their callback URL + (`status: "error"`, `error: "consent_refused"`, `by`, `attemptId`). An auth + attempt is marked `denied` with `denialCode: "consent_refused"`, which + `completeAuth` returns as `denied`. + +`tests/joystick-consent.test.ts` ("the refusal return contract") pins the +fragment for both refusals and the attempt id. + ## 5. Out of scope Enabling Joystick in `identityProviders`; fixing diff --git a/src/app.ts b/src/app.ts index f4a48e5..2051499 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1311,7 +1311,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { }); }); +/** What an app's landing page can read: the fragment on the page's "Return to the app" link. */ +function returnFragment(page: { body: string }): URLSearchParams { + const href = /id="return-link" href="([^"]+)"/.exec(page.body)?.[1]; + expect(href, "the page carries a return link").toBeDefined(); + const url = new URL(href!); + expect(url.origin + url.pathname).toBe("https://streampixels.gamecult.org/settings"); + return new URLSearchParams(url.hash.slice(1)); +} + +describe("the refusal return contract", () => { + const refusedFragment = (by: "viewer" | "provider") => ({ + heimdall_status: "error", + heimdall_provider: "joystick", + heimdall_handoff_kind: "browser_completion", + heimdall_app_slug: "streampixels", + heimdall_mode: "connect", + heimdall_error: "consent_refused", + heimdall_refused_by: by, + }); + + it("a decline returns a fragment that says the viewer refused, with no completion code", async () => { + const { app } = await newApp(); + const state = await startConnect(app); + const { cookie } = await seeAsk(app, state); + const fragment = returnFragment(await answer(app, state, "decline", cookie)); + expect(Object.fromEntries(fragment)).toEqual(refusedFragment("viewer")); + expect(fragment.has("heimdall_completion_code")).toBe(false); + }); + + it("a provider access_denied returns a fragment that says the provider refused, with no completion code", async () => { + const { app } = await newApp(); + const flow = await newFlow(app); + const fragment = returnFragment(await callback(app, flow.state, flow.cookie, "error=access_denied", "text/html")); + expect(Object.fromEntries(fragment)).toEqual(refusedFragment("provider")); + expect(fragment.has("heimdall_completion_code")).toBe(false); + }); + + it.each(["viewer", "provider"] as const)("a browser_completion attempt refused by the %s is named in the fragment", async (by) => { + const { app, store } = await newApp(); + const ctx = getHeimdallRuntimeContext(app); + const attempt = await store.createAuthAttempt({ + handle: "attempt-refused", + appSlug: "streampixels", + provider: "joystick", + mode: "connect", + returnTo: "https://streampixels.gamecult.org/settings", + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 600_000).toISOString(), + }); + const caller = resolveAppCaller(ctx.config, "streampixels", secret); + const started = await startOAuthFlow( + ctx, + "joystick", + caller, + { appSlug: "streampixels", mode: "connect", connection: { kind: "creator", targetId: "creator-1" }, returnTo: "https://streampixels.gamecult.org/settings", handoff: { kind: "browser_completion" } }, + { trustedBrowserAttemptId: attempt.handle } + ); + expect(started.statusCode).toBe(201); + const state = stateOf(started.body as Parameters[0]); + const { cookie } = await seeAsk(app, state); + let page; + if (by === "viewer") { + page = await answer(app, state, "decline", cookie); + } else { + const go = await answer(app, state, "continue", cookie); + page = await callback(app, state, bindingCookieOf(go)!, "error=access_denied", "text/html"); + } + expect(Object.fromEntries(returnFragment(page))).toEqual({ ...refusedFragment(by), heimdall_attempt_id: "attempt-refused" }); + }); +}); + async function newFlow(app: FastifyInstance) { const state = await startConnect(app); const { cookie } = await seeAsk(app, state); diff --git a/tests/store.test.ts b/tests/store.test.ts index 7c2bbd6..7599ff6 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -241,7 +241,7 @@ describe("PostgresStore.createConsentReceipt revocation conflict", () => { 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); + expect(second.receipt).toMatchObject({ kind: "revocation", revokesReceiptId: grant.id }); }); }); -- 2.47.3