enrolment: same-tab redirect instead of popup (Safari WebAuthn), verified return (#114)
Safari blocks WebAuthn in a window opened by a cross-origin page, so the enrol popup's create()/get() aborted instantly with NotAllowedError (confirmed: the same ceremony works in a top-level tab). Replace the popup with a same-tab navigation to home /enroll — a plain top-level page where the ceremony works — and redirect back to the instance on success. - backup.js: launchEnrol navigates (location.assign) with the session in the fragment plus a one-time nonce (stored locally); checkEnrolReturn verifies the nonce echoed in the return fragment before marking enrolled, so no other site can forge a "backed up" marker to suppress the reminder. Removes the popup + postMessage machinery. New pure parseReturnNonce + tests. - enroll.js: no window.opener/postMessage; on success redirect back to the instance origin with the nonce; a Cancel returns without it. Pre-fetch begin options so the ceremony fires inside the click (Safari activation). Clearer, phase-tagged errors (create vs prf-assertion). - backup-core.js: beginRegister/beginLogin + runRegister/runLogin accept pre-fetched options.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+191
−122
home/web/static/backup-core.js+41 −18home/web/static/enroll.js+60 −34test/backup.test.mjs+17 −1web/static/backup.js+73 −69
diff --git a/home/web/static/backup-core.js b/home/web/static/backup-core.js| index 2958286..d4ca81c 100644 |
| --- a/home/web/static/backup-core.js |
| +++ b/home/web/static/backup-core.js |
| @@ -49,15 +49,33 @@ export function prfOutputOf(cred) { |
| return first ? new Uint8Array(first) : null; |
| } |
| +// beginRegister/beginLogin fetch the ceremony options WITHOUT touching the |
| +// authenticator. Callers pre-fetch these on page load so the button click can |
| +// reach navigator.credentials.* synchronously — Safari/WebKit drops the |
| +// transient user activation across an intervening network await and then |
| +// rejects the ceremony with NotAllowedError (the "prompt flashes and dies" |
| +// bug), so the WebAuthn call must fire inside the gesture, not after a fetch. |
| +export function beginRegister() { |
| + return apiJSON("/api/register/begin", { method: "POST" }); |
| +} |
| + |
| +export function beginLogin() { |
| + return apiJSON("/api/login/begin", { method: "POST" }); |
| +} |
| + |
| // runRegister creates a NEW passkey (a new anonymous account) with PRF, then |
| -// returns { token, credId, prf }. Some authenticators evaluate PRF at create |
| -// time; the rest only during an assertion, which this handles so the caller |
| -// always gets a usable PRF output (or it throws). |
| -export async function runRegister() { |
| - const { session, options } = await apiJSON("/api/register/begin", { method: "POST" }); |
| +// returns { token, credId, prf }. Pass pre-fetched beginRegister() options so |
| +// create() fires within the click (see above); omit to fetch inline (fallback). |
| +// Some authenticators evaluate PRF at create time; the rest only during an |
| +// assertion, which this handles so the caller always gets a usable PRF output. |
| +export async function runRegister(prefetched) { |
| + const { session, options } = prefetched || await apiJSON("/api/register/begin", { method: "POST" }); |
| const publicKey = decodePublicKeyOptions(options.publicKey); |
| publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } }; |
| - const cred = await navigator.credentials.create({ publicKey }); |
| + let cred; |
| + try { |
| + cred = await navigator.credentials.create({ publicKey }); |
| + } catch (e) { e.phase = "create"; throw e; } |
| const ext = cred.getClientExtensionResults(); |
| if (!ext.prf?.enabled && !ext.prf?.results) { |
| throw new Error("no-prf"); // caller shows the "manager can't protect a backup" copy |
| @@ -71,14 +89,17 @@ export async function runRegister() { |
| if (!prf) { |
| // Enabled but not evaluated at create time — one assertion gets the output. |
| // The challenge is local: PRF eval happens on the authenticator, no server check. |
| - const assertion = await navigator.credentials.get({ |
| - publicKey: { |
| - challenge: crypto.getRandomValues(new Uint8Array(32)), |
| - allowCredentials: [{ type: "public-key", id: cred.rawId }], |
| - userVerification: "required", |
| - extensions: { prf: { eval: { first: prfEvalInput() } } }, |
| - }, |
| - }); |
| + let assertion; |
| + try { |
| + assertion = await navigator.credentials.get({ |
| + publicKey: { |
| + challenge: crypto.getRandomValues(new Uint8Array(32)), |
| + allowCredentials: [{ type: "public-key", id: cred.rawId }], |
| + userVerification: "required", |
| + extensions: { prf: { eval: { first: prfEvalInput() } } }, |
| + }, |
| + }); |
| + } catch (e) { e.phase = "prf-assertion"; throw e; } |
| prf = prfOutputOf(assertion); |
| } |
| if (!prf) throw new Error("no PRF output from this passkey"); |
| @@ -86,10 +107,12 @@ export async function runRegister() { |
| } |
| // runLogin runs a full assertion (discoverable credential) with PRF eval and |
| -// returns { token, credId, prf, proof }. proof is a fresh ceremony proof for |
| -// destructive ops; prf decrypts the blob. The server never sees prf. |
| -export async function runLogin() { |
| - const { session, options } = await apiJSON("/api/login/begin", { method: "POST" }); |
| +// returns { token, credId, prf, proof }. Pass pre-fetched beginLogin() options |
| +// so get() fires within the click gesture (Safari activation — see above). |
| +// proof is a fresh ceremony proof for destructive ops; prf decrypts the blob. |
| +// The server never sees prf. |
| +export async function runLogin(prefetched) { |
| + const { session, options } = prefetched || await apiJSON("/api/login/begin", { method: "POST" }); |
| const publicKey = decodePublicKeyOptions(options.publicKey); |
| publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } }; |
| const cred = await navigator.credentials.get({ publicKey }); |
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js| index a5531df..55a50a4 100644 |
| --- a/home/web/static/enroll.js |
| +++ b/home/web/static/enroll.js |
| @@ -1,21 +1,23 @@ |
| -// The enrolment popup. An instance page (e.g. family.luan.chat) opens this as a |
| -// popup to add itself to the user's passkey-protected backup. Passkeys are |
| -// bound to THIS origin, so the instance can't run the ceremony — it hands us the |
| +// The enrolment page. An instance (e.g. family.luan.chat) navigates here (same |
| +// tab) to add itself to the user's passkey-protected backup. Passkeys are bound |
| +// to THIS origin, so the instance can't run the ceremony — it hands us the |
| // server entry (its URL + the user's session) in our URL FRAGMENT, which never |
| // reaches any server (same model as invite secrets). We show exactly what's |
| -// being added, run the passkey ceremony here, merge it into the encrypted blob, |
| -// and post only a boolean result back to the opener. |
| +// being added, run the passkey ceremony here (a plain top-level page — Safari |
| +// blocks WebAuthn in a cross-origin-opened popup, which is why this is a |
| +// navigation, not window.open), merge it into the encrypted blob, and redirect |
| +// back to the instance with a one-time nonce it can verify. |
| // |
| -// Deliberately NO inbound postMessage listener: the handoff is one-way via the |
| -// fragment, so there's no cross-origin receiver to spoof (see the removed stub |
| -// in home.js history + the design doc). The single trust control is the |
| -// confirmation card below — the user sees the entry before anything is written. |
| +// No inbound postMessage listener and no secret ever leaves here — the handoff |
| +// in is one-way via the fragment, the handoff back is a redirect carrying only |
| +// the nonce. The single trust control on the entry is the confirmation card |
| +// below — the user sees what's being added before anything is written. |
| import { |
| b64urlToBytes, kekFromPRF, generateDEK, wrapDEK, unwrapDEK, |
| sealBlob, unpackBlob, openBlob, |
| } from "/static/crypto.js"; |
| -import { runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js"; |
| +import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js"; |
| const $ = (id) => document.getElementById(id); |
| function setStatus(msg, kind) { |
| @@ -33,7 +35,7 @@ function readPayload() { |
| const json = new TextDecoder().decode(b64urlToBytes(raw)); |
| const p = JSON.parse(json); |
| if (!p || !p.entry || typeof p.entry.url !== "string") return null; |
| - // The entry URL must parse — we derive the postMessage target origin from it. |
| + // The entry URL must parse — it's where we send the user back to. |
| new URL(p.entry.url); |
| return p; |
| } catch { |
| @@ -45,19 +47,17 @@ const payload = readPayload(); |
| // Drop the secret-bearing fragment from the address bar as soon as we've read it. |
| try { history.replaceState(null, "", location.pathname); } catch {} |
| -let targetOrigin = null; |
| -if (payload) targetOrigin = new URL(payload.entry.url).origin; |
| +// Where to send the user back to. The instance passes its origin as `ret`; fall |
| +// back to the entry URL's origin. Only ever a bare origin, and we append only a |
| +// non-secret nonce marker — never blob contents, tokens, or keys leave here. |
| +const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin : null; |
| -// ---- result channel: popup -> opener (the ONLY postMessage) ------------------ |
| - |
| -let resultSent = false; |
| -function postResult(ok) { |
| - if (resultSent) return; |
| - resultSent = true; |
| - // Target the entry's origin explicitly: postMessage only delivers if the |
| - // opener's origin matches, and we never broadcast to "*". We send just the |
| - // boolean — never blob contents, tokens, or keys. |
| - try { window.opener?.postMessage({ type: "eleven-enroll-result", ok: !!ok }, targetOrigin); } catch {} |
| +// goBack returns to the chat. On success we carry the one-time nonce the |
| +// instance gave us, so it can trust the redirect is genuine; on cancel, nothing. |
| +function goBack(withNonce) { |
| + if (!returnOrigin) return; |
| + const frag = withNonce && payload.nonce ? "#lchat_backup=" + encodeURIComponent(payload.nonce) : ""; |
| + location.replace(returnOrigin + "/" + frag); |
| } |
| // ---- merge one entry into the (possibly new) blob ---------------------------- |
| @@ -77,13 +77,23 @@ function credFingerprint(entry) { |
| return JSON.stringify([c.token || "", c.userPub || "", c.userPriv || ""]); |
| } |
| +// Ceremony options are pre-fetched on load (see boot) so a button click reaches |
| +// navigator.credentials.* WITHOUT an intervening network await — Safari drops |
| +// transient activation across a fetch and then rejects the ceremony. Held as |
| +// resolved values; refreshed after any failure so a retry has a fresh challenge. |
| +let regBegin = null, loginBegin = null; |
| +function prefetch() { |
| + beginRegister().then((v) => { regBegin = v; }).catch(() => { regBegin = null; }); |
| + beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); |
| +} |
| + |
| // createAndAdd: a brand-new backup whose first (and only) server is this entry. |
| async function createAndAdd() { |
| disable(); |
| setStatus("Waiting for your passkey…"); |
| let reg; |
| try { |
| - reg = await runRegister(); |
| + reg = await runRegister(regBegin); |
| } catch (e) { |
| if (e.message === "no-prf") { |
| setStatus("This passkey manager can't protect a backup (no PRF support). You can still use add-device links to move between devices.", "error"); |
| @@ -109,7 +119,7 @@ async function createAndAdd() { |
| async function addToExisting() { |
| disable(); |
| setStatus("Waiting for your passkey…"); |
| - const { token, credId, prf } = await runLogin(); |
| + const { token, credId, prf } = await runLogin(loginBegin); |
| if (!prf) { |
| setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error"); |
| enable(); |
| @@ -201,25 +211,32 @@ function confirmReplace(existing) { |
| } |
| function succeed() { |
| - postResult(true); |
| $("actions").classList.add("hidden"); |
| - setStatus("Added to your backup. You can close this window.", "ok"); |
| + setStatus("Added to your backup. Taking you back to your chat…", "ok"); |
| + goBack(true); // returns with the one-time nonce so the instance trusts it |
| } |
| const onError = (err) => { |
| - if (err.name === "NotAllowedError") { setStatus("Passkey prompt was dismissed."); enable(); return; } |
| - setStatus(err.message || String(err), "error"); |
| + try { console.error("[enrol] error", err && err.phase, err); } catch {} |
| + const phase = err && err.phase ? ` [${err.phase}]` : ""; |
| + if (err && err.name === "NotAllowedError") { |
| + // NotAllowedError is WebAuthn's catch-all — a real user cancel, or the |
| + // browser aborting the ceremony. Surface the phase so we can tell which; a |
| + // bland "dismissed" hid the real signal. |
| + setStatus(`Passkey step blocked or cancelled${phase} (NotAllowedError). If you didn't cancel, try again.`, "error"); |
| + enable(); |
| + prefetch(); // refresh options so the retry has a live challenge |
| + return; |
| + } |
| + setStatus(`${(err && err.name) || "Error"}${phase}: ${(err && err.message) || String(err)}`, "error"); |
| enable(); |
| - postResult(false); |
| + prefetch(); // a used/failed ceremony's options are spent — refresh for a retry |
| }; |
| // ---- boot --------------------------------------------------------------------- |
| if (!payload) { |
| - setStatus("This window was opened without a valid request. Close it and try again from your chat.", "error"); |
| - $("actions").classList.add("hidden"); |
| -} else if (!window.opener) { |
| - setStatus("This page is meant to open from your chat's “Back up” button.", "error"); |
| + setStatus("This page was opened without a valid request. Go back to your chat and use the “Back up” button.", "error"); |
| $("actions").classList.add("hidden"); |
| } else if (!window.PublicKeyCredential) { |
| setStatus("This browser doesn't support passkeys.", "error"); |
| @@ -228,6 +245,15 @@ if (!payload) { |
| const host = new URL(payload.entry.url).host; |
| $("entry-name").textContent = payload.entry.name || host; |
| $("entry-host").textContent = host; |
| + // Pre-fetch ceremony options now, while the user reads the card — so the click |
| + // reaches the authenticator inside the gesture (Safari activation, see prefetch). |
| + prefetch(); |
| $("create").addEventListener("click", () => createAndAdd().catch(onError)); |
| $("existing").addEventListener("click", () => addToExisting().catch(onError)); |
| + |
| + // A way back without enrolling (same-tab navigation replaced the chat). |
| + const back = document.createElement("button"); |
| + back.textContent = "Cancel"; |
| + back.addEventListener("click", () => goBack(false)); |
| + $("actions").append(back); |
| } |
diff --git a/test/backup.test.mjs b/test/backup.test.mjs| index 9166f6c..6d4d37a 100644 |
| --- a/test/backup.test.mjs |
| +++ b/test/backup.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for the passkey-backup banner visibility rule. node test/backup.test.mjs |
| import assert from "node:assert/strict"; |
| -import { backupBannerState } from "../web/static/backup.js"; |
| +import { backupBannerState, parseReturnNonce } from "../web/static/backup.js"; |
| let failures = 0; |
| async function test(name, fn) { |
| @@ -40,5 +40,21 @@ await test("enrolled wins even if the snooze has expired", () => { |
| assert.equal(backupBannerState({ enrolled: true, dismissed: false, snoozedUntil: now - DAY, now }), "hidden"); |
| }); |
| +// parseReturnNonce: pull our one-time nonce out of the /enroll return fragment. |
| +await test("return nonce parses from a lone marker", () => { |
| + assert.equal(parseReturnNonce("#lchat_backup=abc123"), "abc123"); |
| +}); |
| +await test("return nonce is URL-decoded", () => { |
| + assert.equal(parseReturnNonce("#lchat_backup=a%2Fb%2Bc"), "a/b+c"); |
| +}); |
| +await test("return nonce parses when not first in the fragment", () => { |
| + assert.equal(parseReturnNonce("#foo=1&lchat_backup=xyz"), "xyz"); |
| +}); |
| +await test("no marker → empty", () => { |
| + assert.equal(parseReturnNonce("#something-else"), ""); |
| + assert.equal(parseReturnNonce(""), ""); |
| + assert.equal(parseReturnNonce("#lchat_backupZZZ=nope"), ""); |
| +}); |
| + |
| console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/backup.js b/web/static/backup.js| index 9a9d741..e6ccf4f 100644 |
| --- a/web/static/backup.js |
| +++ b/web/static/backup.js |
| @@ -1,10 +1,12 @@ |
| -// Passkey backup enrolment (the messenger side of the cross-origin contract). |
| +// Passkey backup enrolment (the messenger side of the cross-origin handoff). |
| // |
| // Passkeys are bound to the HOME origin, so this instance can't run the WebAuthn |
| -// ceremony itself — it opens the home server's /enroll page as a user-initiated |
| -// popup, handing over the current session in the URL *fragment* (fragments never |
| -// reach any server, same model as invite secrets). The popup posts back only a |
| -// boolean result; it never returns any secret. See enroll-contract.md. |
| +// ceremony itself — it navigates (same tab) to the home server's /enroll page, |
| +// handing over the current session in the URL *fragment* (fragments never reach |
| +// any server, same model as invite secrets). /enroll runs the ceremony as a |
| +// plain top-level page — Safari blocks WebAuthn in a cross-origin-opened popup — |
| +// then redirects back here with a one-time nonce we can verify. No secret ever |
| +// returns; only the nonce marker. |
| // |
| // A small inline banner in the sidebar nudges un-backed-up users to enrol. It |
| // occupies layout space — never a floating toast (see CLAUDE.md). |
| @@ -19,6 +21,8 @@ import { bytesToB64url } from "./crypto.js"; |
| const ENROLLED_KEY = "lchat_home_enrolled"; // "1" once the home server confirms |
| const SNOOZE_KEY = "lchat_backup_snooze"; // unix-ms timestamp the snooze expires at |
| const DISMISSED_KEY = "lchat_backup_dismissed"; // "1" = opted out for good |
| +const PENDING_KEY = "lchat_home_enrol_pending"; // nonce for the in-flight enrolment |
| +const RETURN_MARK = "lchat_backup"; // return fragment key: #lchat_backup=<nonce> |
| const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; // "remind me later" = ~7 days |
| @@ -69,6 +73,9 @@ function removeBanner() { |
| // rule says to show it. opts.now lets a caller pin "now" (defaults to real time). |
| export function initBackupBanner(session, opts = {}) { |
| if (!session || !session.token) return; // nothing to back up |
| + // If we've just returned from the home server's /enroll page, record the |
| + // backup (and scrub the marker) before deciding whether to nudge. |
| + checkEnrolReturn(); |
| const el = bannerEl(); |
| if (!el) return; |
| const state = backupBannerState({ |
| @@ -96,9 +103,7 @@ function renderBanner(el, session) { |
| const cta = document.createElement("button"); |
| cta.className = "backup-banner-cta"; |
| cta.textContent = "Back up with a passkey"; |
| - cta.addEventListener("click", () => { |
| - launchEnrol(session).catch(() => {}); |
| - }); |
| + cta.addEventListener("click", () => launchEnrol(session)); |
| // Double-dismiss: the first click snoozes for a week and escalates the label to |
| // spell out the consequence; a second click on the escalated control opts out |
| @@ -123,68 +128,67 @@ function renderBanner(el, session) { |
| el.classList.remove("hidden"); |
| } |
| -// launchEnrol opens the home server's /enroll popup with the session in the URL |
| -// fragment and waits for the boolean result. Must be called from a user gesture |
| -// (the CTA click) so the popup isn't blocked. Resolves true on a confirmed backup. |
| +// launchEnrol hands off to the home server's /enroll page via a SAME-TAB |
| +// navigation, with the session in the URL fragment (never sent to a server). |
| +// |
| +// Why same-tab and not a popup: Safari blocks WebAuthn in a window opened by a |
| +// cross-origin page (the popup's create() aborts with NotAllowedError the |
| +// instant it opens). A same-tab navigation makes /enroll a plain top-level page |
| +// — the context where the ceremony works — and the home server redirects back |
| +// here on success. A one-time nonce (stored locally, echoed in the return |
| +// fragment) makes that return signal unforgeable: another site can't send you |
| +// back with a fake success marker to suppress the backup reminder. |
| export function launchEnrol(session) { |
| - return new Promise((resolve) => { |
| - const home = window.__homeOrigin; |
| - if (!home) { |
| - resolve(false); |
| - return; |
| - } |
| - const payload = { |
| - v: 1, |
| - entry: { |
| - url: location.origin, // the instance being enrolled |
| - name: groupName(), // best-effort, for the confirmation UI |
| - credentials: { |
| - token: session.token, |
| - userPub: session.userPub, |
| - userPriv: session.userPriv, |
| - name: session.name, |
| - }, |
| + const home = window.__homeOrigin; |
| + if (!home) return; |
| + const nonce = randomNonce(); |
| + localStorage.setItem(PENDING_KEY, nonce); |
| + const payload = { |
| + v: 1, |
| + nonce, // echoed back in the return fragment to prove the redirect is genuine |
| + ret: location.origin, // where /enroll sends the user back to |
| + entry: { |
| + url: location.origin, // the instance being enrolled |
| + name: groupName(), // best-effort, for the confirmation UI |
| + credentials: { |
| + token: session.token, |
| + userPub: session.userPub, |
| + userPriv: session.userPriv, |
| + name: session.name, |
| }, |
| - }; |
| - const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload))); |
| - // The payload rides in the #fragment — it never reaches the home server; the |
| - // popup reads its own location.hash. So there's no inbound secret to receive. |
| - const popup = window.open(`${home}/enroll#${encoded}`, "eleven-enroll"); |
| - if (!popup) { |
| - resolve(false); // popup blocked |
| - return; |
| - } |
| + }, |
| + }; |
| + const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload))); |
| + // The payload rides in the #fragment — it never reaches the home server. |
| + location.assign(`${home}/enroll#${encoded}`); |
| +} |
| - let done = false; |
| - const cleanup = () => { |
| - done = true; |
| - window.removeEventListener("message", onMessage); |
| - clearInterval(poll); |
| - }; |
| - // Accept a result ONLY from the home origin, from the popup we opened, of the |
| - // agreed type — the home server posts back just { ok }, never any secret. |
| - const onMessage = (e) => { |
| - if (e.origin !== home || e.source !== popup) return; |
| - const data = e.data; |
| - if (!data || data.type !== "eleven-enroll-result") return; |
| - cleanup(); |
| - if (data.ok) { |
| - localStorage.setItem(ENROLLED_KEY, "1"); |
| - removeBanner(); |
| - resolve(true); |
| - } else { |
| - resolve(false); |
| - } |
| - }; |
| - window.addEventListener("message", onMessage); |
| - // If the user just closes the popup (no result posted), tear down so we don't |
| - // leak the listener or leave this promise pending forever. |
| - const poll = setInterval(() => { |
| - if (done) return; |
| - if (popup.closed) { |
| - cleanup(); |
| - resolve(false); |
| - } |
| - }, 500); |
| - }); |
| +function randomNonce() { |
| + const b = new Uint8Array(16); |
| + crypto.getRandomValues(b); |
| + return bytesToB64url(b); |
| +} |
| + |
| +// parseReturnNonce pulls our nonce out of a return fragment like |
| +// "#lchat_backup=<nonce>" (pure, so it's unit-testable). Returns "" if absent. |
| +export function parseReturnNonce(hash) { |
| + const m = new RegExp("(?:^#|[#&])" + RETURN_MARK + "=([^&]+)").exec(hash || ""); |
| + return m ? decodeURIComponent(m[1]) : ""; |
| +} |
| + |
| +// checkEnrolReturn runs on load: if we've come back from /enroll with a nonce |
| +// that matches the one we stored before leaving, record enrolment and scrub the |
| +// marker from the URL. A mismatched/forged marker is ignored (but still scrubbed). |
| +export function checkEnrolReturn() { |
| + const got = parseReturnNonce(location.hash); |
| + if (!got) return false; |
| + const pending = localStorage.getItem(PENDING_KEY); |
| + const ok = !!pending && got === pending; |
| + if (ok) { |
| + localStorage.setItem(ENROLLED_KEY, "1"); |
| + localStorage.removeItem(PENDING_KEY); |
| + } |
| + // Drop the marker fragment either way (the return URL carries only our marker). |
| + try { history.replaceState(null, "", location.pathname + location.search); } catch {} |
| + return ok; |
| } |