From e42e1c1b31133cd1ca1069ff17d2d4e05fbcd335 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 20:51:20 +0200 Subject: [PATCH] Finish browser sign-in by attempt id, and on returning to the tab Heimdall's Discord callback issues a random completion code distinct from the attempt id, but the vendored adapter required the two to be equal and rejected every real return as a malformed witness, so no player could sign in. Re-vendored from Heimdall e8e2832, which completes by attempt id and never reads the completion code; the test fixture issues a distinct code as the server does. A player who finishes Discord elsewhere and switches back to this tab never reloads it, so the load-time resume never ran. The page now reruns the same resume when it becomes visible with an attempt in flight. Co-Authored-By: Claude Opus 5.5 --- web/src/heimdall-access-adapter.provenance.md | 2 +- web/src/heimdall-access-adapter.ts | 110 ++++++++++-------- web/src/main.ts | 26 ++++- web/test/heimdall-adapter.test.mjs | 10 +- 4 files changed, 90 insertions(+), 58 deletions(-) diff --git a/web/src/heimdall-access-adapter.provenance.md b/web/src/heimdall-access-adapter.provenance.md index 576efae..2abd153 100644 --- a/web/src/heimdall-access-adapter.provenance.md +++ b/web/src/heimdall-access-adapter.provenance.md @@ -2,7 +2,7 @@ - Owner: `GameCult/Heimdall` - Plugin: `gamecult.heimdall.access` -- Source commit: `b9ab8c0` +- Source commit: `e8e2832` - Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts` - Vendoring mode: exact semantic copy with formatting only diff --git a/web/src/heimdall-access-adapter.ts b/web/src/heimdall-access-adapter.ts index 7ecc17a..21f1e9d 100644 --- a/web/src/heimdall-access-adapter.ts +++ b/web/src/heimdall-access-adapter.ts @@ -1,4 +1,4 @@ -// Vendored from GameCult/Heimdall commit b9ab8c0, plugin +// Vendored from GameCult/Heimdall commit e8e2832, plugin // gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the // adapter because its release must remain self-contained. const ATTEMPT_KEY = "gamecult.heimdall.access.attempt"; @@ -14,54 +14,59 @@ export interface HeimdallAccessResumeOptions { export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) { const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin)); return { - pluginId: "gamecult.heimdall.access", - capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"], - componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"], - schemas: ["heimdall.access_gate_state.v1"], - normalizeDocument(_schemaId: string | undefined, value: unknown): unknown { - return value; - }, - renderComponent(component: { id?: string }, props: Record): HTMLElement { - const view = document.createElement("section"); - view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`; - if (component.id) view.id = component.id; - view.setAttribute("role", "status"); - view.setAttribute("aria-live", "polite"); - const title = document.createElement("h2"); - title.textContent = String(props.title || "Access"); - const detail = document.createElement("p"); - detail.textContent = String(props.detail || ""); - view.append(title, detail); - const displayName = String(props.displayName || ""); - if (displayName) { - const identity = document.createElement("p"); - identity.className = "heimdall-identity"; - identity.textContent = displayName; - view.append(identity); + pluginId: "gamecult.heimdall.access", + capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"], + componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"], + schemas: ["heimdall.access_gate_state.v1"], + normalizeDocument(_schemaId: string | undefined, value: unknown): unknown { + return value; + }, + renderComponent( + component: { id?: string }, + props: Record, + ): HTMLElement { + const view = document.createElement("section"); + view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`; + if (component.id) view.id = component.id; + view.setAttribute("role", "status"); + view.setAttribute("aria-live", "polite"); + const title = document.createElement("h2"); + title.textContent = String(props.title || "Access"); + const detail = document.createElement("p"); + detail.textContent = String(props.detail || ""); + view.append(title, detail); + const displayName = String(props.displayName || ""); + if (displayName) { + const identity = document.createElement("p"); + identity.className = "heimdall-identity"; + identity.textContent = displayName; + view.append(identity); + } + return view; + }, + async consumeCommandResult( + pluginPayload: { schemaId: string; payload: Record }, + ): Promise { + if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") { + const handle = String(pluginPayload.payload.handle || ""); + const navigation = object(pluginPayload.payload.navigation); + const url = new URL(String(navigation.url || "")); + const allowedOrigins = Array.isArray(navigation.allowedOrigins) + ? navigation.allowedOrigins.filter((value): value is string => typeof value === "string") + : []; + if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) { + throw new Error("Heimdall returned an unsafe authentication navigation receipt."); } - return view; - }, - async consumeCommandResult(pluginPayload: { schemaId: string; payload: Record }): Promise { - if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") { - const handle = String(pluginPayload.payload.handle || ""); - const navigation = object(pluginPayload.payload.navigation); - const url = new URL(String(navigation.url || "")); - const allowedOrigins = Array.isArray(navigation.allowedOrigins) - ? navigation.allowedOrigins.filter((value): value is string => typeof value === "string") - : []; - if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) { - throw new Error("Heimdall returned an unsafe authentication navigation receipt."); - } - sessionStorage.setItem(ATTEMPT_KEY, handle); - window.location.assign(url.toString()); - return; - } - if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") { - const status = String(pluginPayload.payload.status || ""); - if (status === "authenticated" || status === "denied" || status === "expired") { - sessionStorage.removeItem(ATTEMPT_KEY); - } + sessionStorage.setItem(ATTEMPT_KEY, handle); + window.location.assign(url.toString()); + return; + } + if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") { + const status = String(pluginPayload.payload.status || ""); + if (status === "authenticated" || status === "denied" || status === "expired") { + sessionStorage.removeItem(ATTEMPT_KEY); } + } }, }; } @@ -114,12 +119,15 @@ export function readHeimdallBrowserReturn( || "Heimdall authentication failed.", }; } + // The attempt id is the handle the app's backend completes over Heimdall's + // private command plane. The completion code in the same fragment is a + // distinct random secret for the HTTP redeem route; this adapter neither + // needs nor forwards it. const attemptId = parameters.get("heimdall_attempt_id") || ""; - const completionCode = parameters.get("heimdall_completion_code") || ""; - if (!attemptId || !completionCode || attemptId !== completionCode) { - return { status: "error", message: "Heimdall returned a malformed authentication completion witness." }; + if (!attemptId) { + return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." }; } - return { status: "success", handle: completionCode }; + return { status: "success", handle: attemptId }; } function clearHeimdallBrowserReturn(): void { diff --git a/web/src/main.ts b/web/src/main.ts index 3df2675..05c8992 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -146,8 +146,8 @@ function trySubscribeToRevisions(): void { await host.start(); trySubscribeToRevisions(); -await resumeHeimdallAccess({ - complete: async handle => { +const resumeTarget = { + complete: async (handle: string) => { const result = await api.completeAuthentication(handle); if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) { await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload); @@ -155,9 +155,29 @@ await resumeHeimdallAccess({ await host.refresh(); trySubscribeToRevisions(); }, -}, { appSlug: "ghostlight" }); +}; +await resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" }); trySubscribeToRevisions(); +// A player who finishes Discord in another tab or app and switches back to +// this one, rather than following Heimdall's "Return to the app" link, never +// reloads this page, so the load-time resume above never sees the result. +// Re-running the same resume when the tab becomes visible asks Heimdall about +// the attempt this tab started; it answers pending or authenticated, and the +// adapter drops the stored attempt once the answer is final. +let resuming = false; +document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible" || resuming || api.hasAuthenticatedSurface()) return; + resuming = true; + resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" }) + .catch(error => { + status.textContent = error instanceof Error ? error.message : String(error); + }) + .finally(() => { + resuming = false; + }); +}); + // The in-page gate's own completion never calls back into this file: it // resolves inside the vendored host's `submit()` (a command dispatch) and // its own `window.setTimeout(() => void host.refresh(), 100)`, which is the diff --git a/web/test/heimdall-adapter.test.mjs b/web/test/heimdall-adapter.test.mjs index b8cd926..dc273be 100644 --- a/web/test/heimdall-adapter.test.mjs +++ b/web/test/heimdall-adapter.test.mjs @@ -19,12 +19,14 @@ afterEach(() => { restoreGlobal("window", originalGlobals.window); }); -test("browser return parser accepts only a matching completion witness", () => { +test("browser return parser completes by attempt id and never reads the completion code", () => { const handle = "d0a9c1e7-89ac-46f9-8c85-c2484ed18f76"; assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle }); assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined); assert.equal(readHeimdallBrowserReturn(returnUrl(handle), "another-app")?.status, "error"); - assert.equal(readHeimdallBrowserReturn(returnUrl(handle, "different"), "ghostlight")?.status, "error"); + assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight"), { status: "success", handle }); + assert.ok(!JSON.stringify(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight")).includes("secret-redeem-code")); + assert.equal(readHeimdallBrowserReturn(returnUrl("", "secret-redeem-code"), "ghostlight")?.status, "error"); }); test("fresh browser context redeems the URL witness and scrubs it", async () => { @@ -76,7 +78,9 @@ function restoreGlobal(name, value) { else Object.defineProperty(globalThis, name, { configurable: true, value }); } -function returnUrl(attemptId, completionCode = attemptId) { +// Heimdall issues a random completion code distinct from the attempt id, as +// the real callback does; the fixture must not restate the old equality. +function returnUrl(attemptId, completionCode = `code-for-${attemptId}`) { const parameters = new URLSearchParams({ heimdall_status: "success", heimdall_provider: "discord",