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:
parent
c5bf090d62
commit
e42e1c1b31
4 changed files with 90 additions and 58 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
- Owner: `GameCult/Heimdall`
|
||||
- Plugin: `gamecult.heimdall.access`
|
||||
- Source commit: `b9ab8c0`
|
||||
- Source commit: `e8e2832`
|
||||
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
|
||||
- Vendoring mode: exact semantic copy with formatting only
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// adapter because its release must remain self-contained.
|
||||
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
|
||||
|
|
@ -14,54 +14,59 @@ export interface HeimdallAccessResumeOptions {
|
|||
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
|
||||
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
|
||||
return {
|
||||
pluginId: "gamecult.heimdall.access",
|
||||
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
|
||||
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
|
||||
schemas: ["heimdall.access_gate_state.v1"],
|
||||
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
},
|
||||
renderComponent(component: { id?: string }, props: Record<string, unknown>): HTMLElement {
|
||||
const view = document.createElement("section");
|
||||
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
|
||||
if (component.id) view.id = component.id;
|
||||
view.setAttribute("role", "status");
|
||||
view.setAttribute("aria-live", "polite");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = String(props.title || "Access");
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = String(props.detail || "");
|
||||
view.append(title, detail);
|
||||
const displayName = String(props.displayName || "");
|
||||
if (displayName) {
|
||||
const identity = document.createElement("p");
|
||||
identity.className = "heimdall-identity";
|
||||
identity.textContent = displayName;
|
||||
view.append(identity);
|
||||
pluginId: "gamecult.heimdall.access",
|
||||
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
|
||||
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
|
||||
schemas: ["heimdall.access_gate_state.v1"],
|
||||
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
},
|
||||
renderComponent(
|
||||
component: { id?: string },
|
||||
props: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const view = document.createElement("section");
|
||||
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
|
||||
if (component.id) view.id = component.id;
|
||||
view.setAttribute("role", "status");
|
||||
view.setAttribute("aria-live", "polite");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = String(props.title || "Access");
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = String(props.detail || "");
|
||||
view.append(title, detail);
|
||||
const displayName = String(props.displayName || "");
|
||||
if (displayName) {
|
||||
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;
|
||||
},
|
||||
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.");
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.",
|
||||
};
|
||||
}
|
||||
// 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 completionCode = parameters.get("heimdall_completion_code") || "";
|
||||
if (!attemptId || !completionCode || attemptId !== completionCode) {
|
||||
return { status: "error", message: "Heimdall returned a malformed authentication completion witness." };
|
||||
if (!attemptId) {
|
||||
return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
|
||||
}
|
||||
return { status: "success", handle: completionCode };
|
||||
return { status: "success", handle: attemptId };
|
||||
}
|
||||
|
||||
function clearHeimdallBrowserReturn(): void {
|
||||
|
|
|
|||
|
|
@ -146,8 +146,8 @@ function trySubscribeToRevisions(): void {
|
|||
|
||||
await host.start();
|
||||
trySubscribeToRevisions();
|
||||
await resumeHeimdallAccess({
|
||||
complete: async handle => {
|
||||
const resumeTarget = {
|
||||
complete: async (handle: string) => {
|
||||
const result = await api.completeAuthentication(handle);
|
||||
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
|
||||
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
|
||||
|
|
@ -155,9 +155,29 @@ await resumeHeimdallAccess({
|
|||
await host.refresh();
|
||||
trySubscribeToRevisions();
|
||||
},
|
||||
}, { appSlug: "ghostlight" });
|
||||
};
|
||||
await resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" });
|
||||
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
|
||||
// resolves inside the vendored host's `submit()` (a command dispatch) and
|
||||
// its own `window.setTimeout(() => void host.refresh(), 100)`, which is the
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ afterEach(() => {
|
|||
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";
|
||||
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle });
|
||||
assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined);
|
||||
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 () => {
|
||||
|
|
@ -76,7 +78,9 @@ function restoreGlobal(name, 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({
|
||||
heimdall_status: "success",
|
||||
heimdall_provider: "discord",
|
||||
|
|
|
|||
Loading…
Reference in a new issue