From b9ab8c0022c8f098e8085886bda8c80b34133b4b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 22 Aug 2026 11:42:28 +0200 Subject: [PATCH] Resume Heimdall auth from browser return witness --- .../browser-adapter.ts | 57 ++++++++++- tests/browser-adapter.test.ts | 98 +++++++++++++++++++ tsconfig.json | 4 +- 3 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 tests/browser-adapter.test.ts diff --git a/plugins/gamecult.heimdall.access/browser-adapter.ts b/plugins/gamecult.heimdall.access/browser-adapter.ts index 57f4874..82436ac 100644 --- a/plugins/gamecult.heimdall.access/browser-adapter.ts +++ b/plugins/gamecult.heimdall.access/browser-adapter.ts @@ -4,6 +4,10 @@ export interface HeimdallAccessResumeTarget { complete(handle: string): Promise; } +export interface HeimdallAccessResumeOptions { + appSlug: string; +} + export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) { const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin)); return { @@ -68,13 +72,62 @@ export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({ advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"], }); -export async function resumeHeimdallAccess(target: HeimdallAccessResumeTarget): Promise { - const handle = sessionStorage.getItem(ATTEMPT_KEY); +export async function resumeHeimdallAccess( + target: HeimdallAccessResumeTarget, + options: HeimdallAccessResumeOptions, +): Promise { + const returned = readHeimdallBrowserReturn(window.location.href, options.appSlug); + const storedHandle = sessionStorage.getItem(ATTEMPT_KEY); + if (returned) { + clearHeimdallBrowserReturn(); + if (returned.status === "error") { + sessionStorage.removeItem(ATTEMPT_KEY); + throw new Error(returned.message); + } + if (storedHandle && storedHandle !== returned.handle) { + sessionStorage.removeItem(ATTEMPT_KEY); + throw new Error("Heimdall returned an authentication attempt other than the one this browser started."); + } + sessionStorage.setItem(ATTEMPT_KEY, returned.handle); + } + const handle = returned?.status === "success" ? returned.handle : storedHandle; if (!handle) return false; await target.complete(handle); return true; } +export function readHeimdallBrowserReturn( + href: string, + appSlug: string, +): { status: "success"; handle: string } | { status: "error"; message: string } | undefined { + const url = new URL(href); + const parameters = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); + const status = parameters.get("heimdall_status"); + if (!status) return undefined; + if (parameters.get("heimdall_handoff_kind") !== "browser_completion" + || parameters.get("heimdall_app_slug") !== appSlug) { + return { status: "error", message: "Heimdall returned an authentication result for another application or handoff mode." }; + } + if (status !== "success") { + return { + status: "error", + message: parameters.get("heimdall_error_description") + || parameters.get("heimdall_error") + || "Heimdall authentication failed.", + }; + } + 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." }; + } + return { status: "success", handle: completionCode }; +} + +function clearHeimdallBrowserReturn(): void { + history.replaceState(null, "", `${window.location.pathname}${window.location.search}`); +} + function object(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } diff --git a/tests/browser-adapter.test.ts b/tests/browser-adapter.test.ts new file mode 100644 index 0000000..7ac21b4 --- /dev/null +++ b/tests/browser-adapter.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + heimdallAccessBrowserAdapter, + readHeimdallBrowserReturn, + resumeHeimdallAccess, +} from "../plugins/gamecult.heimdall.access/browser-adapter.js"; + +const attemptKey = "gamecult.heimdall.access.attempt"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Heimdall browser completion fallback", () => { + it("recognizes only a matching Ghostlight browser completion witness", () => { + const handle = "d0a9c1e7-89ac-46f9-8c85-c2484ed18f76"; + expect(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight")).toEqual({ + status: "success", + handle, + }); + expect(readHeimdallBrowserReturn(returnUrl(handle), "another-app")).toMatchObject({ status: "error" }); + expect(readHeimdallBrowserReturn(returnUrl(handle, "different"), "ghostlight")).toMatchObject({ status: "error" }); + expect(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight")).toBeUndefined(); + }); + + it("redeems the URL witness after tab-local attempt state is lost", async () => { + const handle = "0be30b8c-b599-4920-b8b8-733f5e7bc275"; + const storage = memoryStorage(); + const replaced: string[] = []; + vi.stubGlobal("sessionStorage", storage); + vi.stubGlobal("history", { replaceState: (_state: unknown, _title: string, url: string) => replaced.push(url) }); + vi.stubGlobal("window", { + location: { + href: returnUrl(handle), + pathname: "/ghostlight/", + search: "", + }, + }); + const completed: string[] = []; + + await expect(resumeHeimdallAccess({ complete: async value => { completed.push(value); } }, { appSlug: "ghostlight" })) + .resolves.toBe(true); + + expect(completed).toEqual([handle]); + expect(storage.getItem(attemptKey)).toBe(handle); + expect(replaced).toEqual(["/ghostlight/"]); + + await heimdallAccessBrowserAdapter.consumeCommandResult?.({ + schemaId: "heimdall.auth_completion_status.v1", + payload: { status: "authenticated" }, + }); + expect(storage.getItem(attemptKey)).toBeNull(); + }); + + it("does not redeem a returned witness that conflicts with the browser-held attempt", async () => { + const storage = memoryStorage([[attemptKey, "stored-handle"]]); + vi.stubGlobal("sessionStorage", storage); + vi.stubGlobal("history", { replaceState: () => undefined }); + vi.stubGlobal("window", { + location: { + href: returnUrl("returned-handle"), + pathname: "/ghostlight/", + search: "", + }, + }); + const complete = vi.fn(async () => undefined); + + await expect(resumeHeimdallAccess({ complete }, { appSlug: "ghostlight" })) + .rejects.toThrow("other than the one this browser started"); + expect(complete).not.toHaveBeenCalled(); + expect(storage.getItem(attemptKey)).toBeNull(); + }); +}); + +function returnUrl(attemptId: string, completionCode = attemptId): string { + const parameters = new URLSearchParams({ + heimdall_status: "success", + heimdall_provider: "discord", + heimdall_handoff_kind: "browser_completion", + heimdall_app_slug: "ghostlight", + heimdall_mode: "sign_in", + heimdall_attempt_id: attemptId, + heimdall_completion_code: completionCode, + }); + return `https://yggdrasil.gamecult.org/ghostlight/#${parameters}`; +} + +function memoryStorage(initial: Array<[string, string]> = []): Storage { + const values = new Map(initial); + return { + get length() { return values.size; }, + clear: () => values.clear(), + getItem: key => values.get(key) ?? null, + key: index => [...values.keys()][index] ?? null, + removeItem: key => { values.delete(key); }, + setItem: (key, value) => { values.set(key, value); }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index 67a3932..f3c96bc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": ["ES2024"], + "lib": ["ES2024", "DOM"], "strict": true, "noImplicitOverride": true, "noUncheckedIndexedAccess": true, @@ -16,5 +16,5 @@ "verbatimModuleSyntax": true, "types": ["node"] }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "plugins/gamecult.heimdall.access/browser-adapter.ts"] }