fix(auth): follow server redirect after passkey sign-in
This commit is contained in:
1 parent
6123a6865e
commit
bef67b0a24
5 files changed
+63
-6
No files matched your search
@@ -235,7 +235,7 @@
|
|||||||
},
|
},
|
||||||
"packages/auth": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.24",
|
"version": "0.8.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
|
|||||||
@@ -137,10 +137,15 @@
|
|||||||
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
element.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(target) {
|
function navigate(target, allowCrossOrigin = false) {
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
const url = new URL(target, location.href);
|
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) {
|
if (window.__wrnexusNavigate) {
|
||||||
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
|
window.__wrnexusNavigate(url.pathname + url.search + url.hash);
|
||||||
} else {
|
} else {
|
||||||
@@ -262,6 +267,9 @@
|
|||||||
);
|
);
|
||||||
setState(element, "success", element.dataset.successMessage || "Signed in.");
|
setState(element, "success", element.dataset.successMessage || "Signed in.");
|
||||||
dispatch(element, "auth:passkey-authenticated", result);
|
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);
|
if (element.dataset.redirect) navigate(element.dataset.redirect);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.name === "AbortError") return;
|
if (error && error.name === "AbortError") return;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"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.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@@ -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");
|
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");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user