diff --git a/bun.lock b/bun.lock index becaf248..abb7dabe 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.24", + "version": "0.8.25", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", diff --git a/packages/auth/assets/client/auth.js b/packages/auth/assets/client/auth.js index 0643987e..c03d9dd0 100644 --- a/packages/auth/assets/client/auth.js +++ b/packages/auth/assets/client/auth.js @@ -137,10 +137,15 @@ element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail })); } - function navigate(target) { + function navigate(target, allowCrossOrigin = false) { if (!target) return false; const url = new URL(target, location.href); - if (url.origin !== location.origin) return false; + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return false; + if (url.origin !== location.origin) { + if (!allowCrossOrigin) return false; + location.assign(url.href); + return true; + } if (window.__wrnexusNavigate) { window.__wrnexusNavigate(url.pathname + url.search + url.hash); } else { @@ -262,6 +267,9 @@ ); setState(element, "success", element.dataset.successMessage || "Signed in."); dispatch(element, "auth:passkey-authenticated", result); + // The same-origin verification endpoint resolves and validates the final + // destination, including cross-app SSO handoffs. Never SPA-route those. + if (typeof result.redirect === "string" && navigate(result.redirect, true)) return; if (element.dataset.redirect) navigate(element.dataset.redirect); } catch (error) { if (error && error.name === "AbortError") return; diff --git a/packages/auth/package.json b/packages/auth/package.json index 45f9f668..7095b9e8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.24", + "version": "0.8.25", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "type": "module", "sideEffects": false, diff --git a/packages/auth/test/components.test.ts b/packages/auth/test/components.test.ts index 638cc377..3345114e 100644 --- a/packages/auth/test/components.test.ts +++ b/packages/auth/test/components.test.ts @@ -114,8 +114,9 @@ test("verification components can resend without application-owned schema files" } }); -test("auth browser runtime refuses cross-origin navigation targets", () => { +test("auth browser runtime refuses cross-origin navigation unless explicitly server-approved", () => { const source = readFileSync(join(directory, "..", "assets", "client", "auth.js"), "utf8"); - expect(source).toContain("if (url.origin !== location.origin) return false"); + expect(source).toContain("function navigate(target, allowCrossOrigin = false)"); + expect(source).toContain("if (!allowCrossOrigin) return false"); expect(source).toContain("!window.AbortController"); }); diff --git a/packages/auth/test/passkey-runtime.test.ts b/packages/auth/test/passkey-runtime.test.ts new file mode 100644 index 00000000..412a18d9 --- /dev/null +++ b/packages/auth/test/passkey-runtime.test.ts @@ -0,0 +1,48 @@ +import { expect, test, spyOn } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; + +test("passkey sign-in follows the server handoff, including cross-app destinations", async () => { + for (const redirect of [ + "https://account.example.test/?sso_code=synthetic-code", + "/signed-in", + "javascript:alert(1)", + ]) { + const window = new Window({ url: "https://sso.example.test/login" }); + const assign = spyOn(window.location, "assign").mockImplementation(() => {}); + try { + window.document.body.innerHTML = `