Refusal return contract: pin the fragment, carry the attempt id, document it #7

Merged
heimdall merged 1 commit from heimdall/refusal-return-contract into main 2026-11-08 13:51:45 +00:00
4 changed files with 103 additions and 3 deletions

View file

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

View file

@ -1311,7 +1311,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
appSlug: state.app_slug,
mode: state.mode,
returnTo: state.return_to,
...(handoff.kind === "backend_callback" ? { attemptId: handoff.attemptId } : {}),
...(handoff.attemptId ? { attemptId: handoff.attemptId } : {}),
error: "consent_refused",
by,
})

View file

@ -2,7 +2,8 @@ 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 { buildApp, getHeimdallRuntimeContext, startOAuthFlow } from "../src/app.js";
import { resolveAppCaller } from "../src/app-caller.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";
@ -368,6 +369,77 @@ describe("refusal", () => {
});
});
/** 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<typeof stateOf>[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);

View file

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