Refusal return contract: pin the fragment, carry the attempt id, document it #7
4 changed files with 103 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue