Resume Heimdall auth from browser return witness
This commit is contained in:
parent
e9635038c5
commit
b9ab8c0022
3 changed files with 155 additions and 4 deletions
|
|
@ -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> : {};
|
||||
}
|
||||
|
|
|
|||
98
tests/browser-adapter.test.ts
Normal file
98
tests/browser-adapter.test.ts
Normal 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); },
|
||||
};
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue