Resume Heimdall auth from browser return witness

This commit is contained in:
Meta Vens 2026-08-22 11:42:28 +02:00
commit b9ab8c0022
3 changed files with 155 additions and 4 deletions

View file

@ -4,6 +4,10 @@ export interface HeimdallAccessResumeTarget {
complete(handle: string): Promise<void>;
}
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<boolean> {
const handle = sessionStorage.getItem(ATTEMPT_KEY);
export async function resumeHeimdallAccess(
target: HeimdallAccessResumeTarget,
options: HeimdallAccessResumeOptions,
): Promise<boolean> {
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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

View file

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

View file

@ -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"]
}