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>;
|
complete(handle: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HeimdallAccessResumeOptions {
|
||||||
|
appSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
|
@ -68,13 +72,62 @@ export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
|
||||||
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
|
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function resumeHeimdallAccess(target: HeimdallAccessResumeTarget): Promise<boolean> {
|
export async function resumeHeimdallAccess(
|
||||||
const handle = sessionStorage.getItem(ATTEMPT_KEY);
|
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;
|
if (!handle) return false;
|
||||||
await target.complete(handle);
|
await target.complete(handle);
|
||||||
return true;
|
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> {
|
function object(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value) ? value as 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",
|
"target": "ES2024",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"lib": ["ES2024"],
|
"lib": ["ES2024", "DOM"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
@ -16,5 +16,5 @@
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"types": ["node"]
|
"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