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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 20:51:20 +02:00
commit e42e1c1b31
4 changed files with 90 additions and 58 deletions

View file

@ -2,7 +2,7 @@
- Owner: `GameCult/Heimdall` - Owner: `GameCult/Heimdall`
- Plugin: `gamecult.heimdall.access` - Plugin: `gamecult.heimdall.access`
- Source commit: `b9ab8c0` - Source commit: `e8e2832`
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts` - Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
- Vendoring mode: exact semantic copy with formatting only - Vendoring mode: exact semantic copy with formatting only

View file

@ -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 // gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
// adapter because its release must remain self-contained. // adapter because its release must remain self-contained.
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt"; const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
@ -14,54 +14,59 @@ export interface HeimdallAccessResumeOptions {
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) { export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin)); const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
return { return {
pluginId: "gamecult.heimdall.access", pluginId: "gamecult.heimdall.access",
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"], capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"], componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
schemas: ["heimdall.access_gate_state.v1"], schemas: ["heimdall.access_gate_state.v1"],
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown { normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
return value; return value;
}, },
renderComponent(component: { id?: string }, props: Record<string, unknown>): HTMLElement { renderComponent(
const view = document.createElement("section"); component: { id?: string },
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`; props: Record<string, unknown>,
if (component.id) view.id = component.id; ): HTMLElement {
view.setAttribute("role", "status"); const view = document.createElement("section");
view.setAttribute("aria-live", "polite"); view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
const title = document.createElement("h2"); if (component.id) view.id = component.id;
title.textContent = String(props.title || "Access"); view.setAttribute("role", "status");
const detail = document.createElement("p"); view.setAttribute("aria-live", "polite");
detail.textContent = String(props.detail || ""); const title = document.createElement("h2");
view.append(title, detail); title.textContent = String(props.title || "Access");
const displayName = String(props.displayName || ""); const detail = document.createElement("p");
if (displayName) { detail.textContent = String(props.detail || "");
const identity = document.createElement("p"); view.append(title, detail);
identity.className = "heimdall-identity"; const displayName = String(props.displayName || "");
identity.textContent = displayName; if (displayName) {
view.append(identity); const identity = document.createElement("p");
identity.className = "heimdall-identity";
identity.textContent = displayName;
view.append(identity);
}
return view;
},
async consumeCommandResult(
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
): Promise<void> {
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; sessionStorage.setItem(ATTEMPT_KEY, handle);
}, window.location.assign(url.toString());
async consumeCommandResult(pluginPayload: { schemaId: string; payload: Record<string, unknown> }): Promise<void> { return;
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") { }
const handle = String(pluginPayload.payload.handle || ""); if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
const navigation = object(pluginPayload.payload.navigation); const status = String(pluginPayload.payload.status || "");
const url = new URL(String(navigation.url || "")); if (status === "authenticated" || status === "denied" || status === "expired") {
const allowedOrigins = Array.isArray(navigation.allowedOrigins) sessionStorage.removeItem(ATTEMPT_KEY);
? 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);
}
} }
}
}, },
}; };
} }
@ -114,12 +119,15 @@ export function readHeimdallBrowserReturn(
|| "Heimdall authentication failed.", || "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 attemptId = parameters.get("heimdall_attempt_id") || "";
const completionCode = parameters.get("heimdall_completion_code") || ""; if (!attemptId) {
if (!attemptId || !completionCode || attemptId !== completionCode) { return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
return { status: "error", message: "Heimdall returned a malformed authentication completion witness." };
} }
return { status: "success", handle: completionCode }; return { status: "success", handle: attemptId };
} }
function clearHeimdallBrowserReturn(): void { function clearHeimdallBrowserReturn(): void {

View file

@ -146,8 +146,8 @@ function trySubscribeToRevisions(): void {
await host.start(); await host.start();
trySubscribeToRevisions(); trySubscribeToRevisions();
await resumeHeimdallAccess({ const resumeTarget = {
complete: async handle => { complete: async (handle: string) => {
const result = await api.completeAuthentication(handle); const result = await api.completeAuthentication(handle);
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) { if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload); await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
@ -155,9 +155,29 @@ await resumeHeimdallAccess({
await host.refresh(); await host.refresh();
trySubscribeToRevisions(); trySubscribeToRevisions();
}, },
}, { appSlug: "ghostlight" }); };
await resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" });
trySubscribeToRevisions(); 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 // 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 // resolves inside the vendored host's `submit()` (a command dispatch) and
// its own `window.setTimeout(() => void host.refresh(), 100)`, which is the // its own `window.setTimeout(() => void host.refresh(), 100)`, which is the

View file

@ -19,12 +19,14 @@ afterEach(() => {
restoreGlobal("window", originalGlobals.window); 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"; const handle = "d0a9c1e7-89ac-46f9-8c85-c2484ed18f76";
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle }); assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle });
assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined); assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined);
assert.equal(readHeimdallBrowserReturn(returnUrl(handle), "another-app")?.status, "error"); 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 () => { 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 }); 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({ const parameters = new URLSearchParams({
heimdall_status: "success", heimdall_status: "success",
heimdall_provider: "discord", heimdall_provider: "discord",