fix(auth): follow server redirect after passkey sign-in

This commit is contained in:
Clintchiz committed 2026-08-26 19:40:34 +05:30
1 parent 6123a6865e
commit bef67b0a24
5 files changed
+63 -6

No files matched your search

+10 -2
View File
@@ -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;
+1 -1
View File
@@ -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,
+3 -2
View File
@@ -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");
});
@@ -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 = `<div data-auth-passkey><button>Sign in</button><p data-auth-status></p></div>`;
Object.defineProperty(window, "PublicKeyCredential", { value: class {} });
Object.defineProperty(window.navigator, "credentials", {
value: {
get: async () => ({
id: "synthetic-credential",
type: "public-key",
rawId: new Uint8Array([1]),
response: {
clientDataJSON: new Uint8Array([1]),
authenticatorData: new Uint8Array([2]),
signature: new Uint8Array([3]),
},
}),
},
});
window.fetch = (async (url: string) =>
Response.json(
url.endsWith("/options")
? { ok: true, key: "test-key", options: { challenge: "AQID" } }
: { ok: true, redirect },
)) as any;
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
await (window as any).WRNexusAuth.authenticatePasskey(window.document.querySelector("div"));
if (redirect.startsWith("javascript:")) {
expect(assign).not.toHaveBeenCalled();
} else {
expect(assign).toHaveBeenCalledWith(new URL(redirect, window.location.href).href);
}
} finally {
assign.mockRestore();
await window.happyDOM.close();
}
}
});