feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.34
|
||||
|
||||
- Add a Resend code control to email and SMS MFA challenges with a 60-second cooldown.
|
||||
- Explicit resend requests create a fresh challenge and invalidate the previous code while retaining sign-in expiry and device binding.
|
||||
- Clear entered digits after resend and keep resend hidden for authenticator and recovery-code methods.
|
||||
|
||||
## 0.8.19
|
||||
|
||||
- Pass the trusted HTTP origin into registration delivery, so signup verification links are populated without custom URL callbacks.
|
||||
|
||||
@@ -415,6 +415,10 @@
|
||||
const challengeInput = form?.querySelector("[name=challengeId]");
|
||||
const radios = Array.from(form?.querySelectorAll("input[type=radio][name=method]") || []);
|
||||
const status = form?.querySelector("[data-auth-mfa-status]");
|
||||
const resendGroup = form?.querySelector("[data-auth-mfa-resend-group]");
|
||||
const resendButton = form?.querySelector("button[data-auth-mfa-resend]");
|
||||
const resendDeadlines = new Map();
|
||||
let resendTimer;
|
||||
const saved = readMfaState();
|
||||
const allowed = Array.isArray(saved?.requires?.mfa) ? saved.requires.mfa : [];
|
||||
const challenges = new Map();
|
||||
@@ -457,6 +461,19 @@
|
||||
};
|
||||
const method = () => radios.find((radio) => radio.checked && !radio.disabled)?.value;
|
||||
const isOtp = (value) => ["email-otp", "sms-otp"].includes(value);
|
||||
const updateResend = () => {
|
||||
window.clearTimeout(resendTimer);
|
||||
const selected = method();
|
||||
if (resendGroup) resendGroup.hidden = !isOtp(selected) || expired;
|
||||
if (!resendButton || !isOtp(selected) || expired || disposed) return;
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.ceil(((resendDeadlines.get(selected) || 0) - Date.now()) / 1000),
|
||||
);
|
||||
resendButton.disabled = pending.has(selected) || seconds > 0;
|
||||
resendButton.textContent = seconds > 0 ? "Resend code in " + seconds + "s" : "Resend code";
|
||||
if (seconds > 0) resendTimer = window.setTimeout(updateResend, 1000);
|
||||
};
|
||||
for (const radio of radios) {
|
||||
radio.disabled = allowed.length > 0 && !allowed.includes(radio.value);
|
||||
const label = radio.closest("label");
|
||||
@@ -468,8 +485,10 @@
|
||||
for (const radio of radios) radio.checked = radio === initial;
|
||||
restoreToken();
|
||||
|
||||
const sendCode = async (selected) => {
|
||||
const sendCode = async (selected, resend = false) => {
|
||||
if (disposed || expired || !isOtp(selected)) return;
|
||||
if (resend && (pending.has(selected) || (resendDeadlines.get(selected) || 0) > Date.now()))
|
||||
return;
|
||||
restoreToken();
|
||||
if (expired) return;
|
||||
if (!tokenInput?.value) {
|
||||
@@ -481,12 +500,13 @@
|
||||
const previous = challenges.get(selected);
|
||||
if (previous?.expiresAt && Number(previous.expiresAt) <= Date.now())
|
||||
challenges.delete(selected);
|
||||
if (!pending.has(selected) && !challenges.has(selected)) {
|
||||
if (!pending.has(selected) && (resend || !challenges.has(selected))) {
|
||||
pending.set(
|
||||
selected,
|
||||
json(element.dataset.mfaOtpAction || "/api/auth/mfa/otp", {
|
||||
mfaToken: tokenInput.value,
|
||||
method: selected,
|
||||
resend,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -495,16 +515,27 @@
|
||||
setMessage(status, "Sending a one-time code…");
|
||||
}
|
||||
try {
|
||||
const result = challenges.get(selected) || (await pending.get(selected));
|
||||
const result = (!resend && challenges.get(selected)) || (await pending.get(selected));
|
||||
if (disposed || expired) return;
|
||||
challenges.set(selected, result);
|
||||
if (!resendDeadlines.has(selected) || resend)
|
||||
resendDeadlines.set(selected, Date.now() + (Number(result.retryAfter) || 60) * 1000);
|
||||
if (method() !== selected) return;
|
||||
if (challengeInput) challengeInput.value = String(result.id || "");
|
||||
if (resend) {
|
||||
form?.querySelectorAll("[data-auth-mfa-pin] input, [name=code]").forEach((input) => {
|
||||
input.value = "";
|
||||
input.dispatchEvent(new window.Event("input", { bubbles: true }));
|
||||
});
|
||||
form?.querySelectorAll("[data-error]").forEach((error) => {
|
||||
error.textContent = "";
|
||||
});
|
||||
}
|
||||
setMessage(
|
||||
status,
|
||||
selected === "email-otp"
|
||||
? "A code has been sent to your email."
|
||||
: "A code has been sent to your mobile number.",
|
||||
? "An email code has been requested. Check your Inbox and Spam folder."
|
||||
: "An SMS code has been requested. It may take a moment to arrive.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (!disposed && !expired && method() === selected)
|
||||
@@ -516,6 +547,7 @@
|
||||
} finally {
|
||||
pending.delete(selected);
|
||||
if (!disposed && !expired && method() === selected) setFormBusy(form, false);
|
||||
updateResend();
|
||||
}
|
||||
};
|
||||
const updateMethod = () => {
|
||||
@@ -528,6 +560,7 @@
|
||||
const error = form?.querySelector("[data-error=code]");
|
||||
if (error) error.textContent = "";
|
||||
setMessage(status, "");
|
||||
updateResend();
|
||||
for (const [selector, active] of [
|
||||
["[data-auth-mfa-pin]", selected !== "recovery-code"],
|
||||
["[data-auth-mfa-recovery]", selected === "recovery-code"],
|
||||
@@ -546,6 +579,10 @@
|
||||
const onMethodChange = (event) => {
|
||||
if (radios.includes(event.target)) updateMethod();
|
||||
};
|
||||
const onResend = (event) => {
|
||||
event.preventDefault();
|
||||
void sendCode(method(), true);
|
||||
};
|
||||
const onSubmit = (event) => {
|
||||
restoreToken();
|
||||
const code = form?.querySelector("[name=code]");
|
||||
@@ -573,11 +610,14 @@
|
||||
// Capture native change before the shared Radio emits its component output.
|
||||
form?.addEventListener("change", onMethodChange, true);
|
||||
form?.addEventListener("submit", onSubmit, true);
|
||||
resendButton?.addEventListener("click", onResend);
|
||||
element.addEventListener("wrn:success", onSuccess);
|
||||
element.addEventListener("wrn:error", onError);
|
||||
flowCleanups.set(element, () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(expiryTimer);
|
||||
window.clearTimeout(resendTimer);
|
||||
resendButton?.removeEventListener("click", onResend);
|
||||
form?.removeEventListener("change", onMethodChange, true);
|
||||
form?.removeEventListener("submit", onSubmit, true);
|
||||
element.removeEventListener("wrn:success", onSuccess);
|
||||
|
||||
@@ -47,6 +47,10 @@ component TwoFactorChallenge {
|
||||
<p data-error="code" class="m-0 text-sm text-[var(--wrn-color-danger)]" aria-live="polite"></p>
|
||||
<Checkbox name="rememberDevice" label="Trust this device for 30 days" value="true" />
|
||||
<p data-auth-mfa-status role="status" hidden class="m-0 rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-primary)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-primary)]"></p>
|
||||
<div data-auth-mfa-resend-group hidden class="space-y-2">
|
||||
<Button type="button" data-auth-mfa-resend label="Resend code" variant="outline" color="{color}" size="{size}" />
|
||||
<p class="m-0 text-xs text-[var(--wrn-color-muted)]">Check your Spam or Junk folder. After requesting a new code, use the latest email.</p>
|
||||
</div>
|
||||
<p data-error="mfaToken" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||
<p data-error="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||
<p data-error="_form" role="alert" class="m-0 hidden rounded-[var(--wrn-radius-sm)] bg-[color-mix(in_srgb,var(--wrn-color-danger)_10%,transparent)] p-3 text-sm text-[var(--wrn-color-danger)]"></p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.33",
|
||||
"version": "0.8.36",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface AuthEngine {
|
||||
beginMfaOtp(
|
||||
mfaToken: string,
|
||||
method: "email-otp" | "sms-otp",
|
||||
options?: { resend?: boolean },
|
||||
): Promise<{ id: string; expiresAt: number } | undefined>;
|
||||
completeMfa(input: {
|
||||
rememberDevice?: boolean;
|
||||
@@ -1711,7 +1712,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
return { ok: true, user: publicUser(user), session };
|
||||
},
|
||||
|
||||
async beginMfaOtp(mfaToken, method) {
|
||||
async beginMfaOtp(mfaToken, method, request = {}) {
|
||||
const token = await inspectToken(mfaToken, "login-mfa");
|
||||
if (!token) return undefined;
|
||||
const user = await store.findUserById(token.userId);
|
||||
@@ -1724,12 +1725,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const previousId = token.metadata?.[key];
|
||||
if (typeof previousId === "string") {
|
||||
const previous = await store.findOtp(previousId);
|
||||
if (previous && !previous.usedAt && previous.expiresAt > now())
|
||||
return { id: previous.id, expiresAt: previous.expiresAt };
|
||||
if (request.resend && previous && now() - previous.createdAt < 60_000) return undefined;
|
||||
if (previous && !previous.usedAt && previous.expiresAt > now()) {
|
||||
if (!request.resend) return { id: previous.id, expiresAt: previous.expiresAt };
|
||||
}
|
||||
}
|
||||
const challenge = await issueOtp(token.userId, method, undefined, "mfa");
|
||||
token.metadata = { ...token.metadata, [key]: challenge.id };
|
||||
await store.updateToken(token);
|
||||
if (request.resend && typeof previousId === "string") {
|
||||
const previous = await store.findOtp(previousId);
|
||||
if (previous && !previous.usedAt) await store.updateOtp({ ...previous, usedAt: now() });
|
||||
}
|
||||
return challenge;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -2081,12 +2088,17 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return verification;
|
||||
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
|
||||
const providerVerifiesDevice = verifiedEmail &&
|
||||
options.verifiedOAuthDeviceProviders?.some((name) => normalizedOAuthProvider(name) === providerName);
|
||||
const deviceCheck = providerVerifiesDevice ? undefined : await newDeviceRequirement(user, {
|
||||
...sessionInput,
|
||||
oauthProvider: providerName,
|
||||
});
|
||||
const providerVerifiesDevice =
|
||||
verifiedEmail &&
|
||||
options.verifiedOAuthDeviceProviders?.some(
|
||||
(name) => normalizedOAuthProvider(name) === providerName,
|
||||
);
|
||||
const deviceCheck = providerVerifiesDevice
|
||||
? undefined
|
||||
: await newDeviceRequirement(user, {
|
||||
...sessionInput,
|
||||
oauthProvider: providerName,
|
||||
});
|
||||
if (deviceCheck) return deviceCheck;
|
||||
if (await hasMfa(user.id)) {
|
||||
const methods = await availableMfaMethods(user.id);
|
||||
|
||||
@@ -505,10 +505,19 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const method = text(input.method) === "sms-otp" ? "sms-otp" : "email-otp";
|
||||
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method);
|
||||
const challenge = await engine.beginMfaOtp(text(input.mfaToken), method, {
|
||||
resend: input.resend === true,
|
||||
});
|
||||
return challenge
|
||||
? json({ ok: true, ...challenge })
|
||||
: json({ ok: false, error: "MFA transaction is invalid or expired" }, 400);
|
||||
? json({ ok: true, ...challenge, retryAfter: 60 })
|
||||
: json(
|
||||
{
|
||||
ok: false,
|
||||
error:
|
||||
"Unable to request a code. Wait 60 seconds before retrying, or sign in again if this request has expired.",
|
||||
},
|
||||
400,
|
||||
);
|
||||
},
|
||||
|
||||
async completeMfa(ctx: Context): Promise<Response> {
|
||||
|
||||
@@ -148,6 +148,7 @@ export const otpSchema = v.object({
|
||||
});
|
||||
|
||||
export const mfaOtpRequestSchema = v.object({
|
||||
resend: v.boolean().optional(),
|
||||
mfaToken: v
|
||||
.string()
|
||||
.required("MFA transaction is missing")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import type { AuthDeliveryMessage } from "../src/types.ts";
|
||||
|
||||
test("MFA resend creates a fresh code after cooldown and invalidates the previous code", async () => {
|
||||
let now = 1_790_000_000_000;
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "mfa-resend-test-secret-longer-than-thirty-two-characters",
|
||||
clock: { now: () => now },
|
||||
requireTrustedDeviceVerification: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
await engine.register({ email: "resend@example.test", password: "StrongPassword123!" });
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
const login = await engine.login({
|
||||
identifier: "resend@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "browser",
|
||||
});
|
||||
const handlers = createAuthHttpHandlers({ engine });
|
||||
const request = (resend: boolean) => {
|
||||
const req = new Request("https://sso.example.test/api/auth/mfa/otp", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mfaToken: login.mfaToken, method: "email-otp", resend }),
|
||||
});
|
||||
return handlers.beginMfaOtp(createContext(req, new URL(req.url)));
|
||||
};
|
||||
const first = await (await request(false)).json();
|
||||
const firstCode = messages.at(-1)!.code!;
|
||||
const count = messages.length;
|
||||
expect((await request(true)).status).toBe(400);
|
||||
expect(messages.length).toBe(count);
|
||||
expect((await (await request(false)).json()).id).toBe(first.id);
|
||||
now += 60_000;
|
||||
const next = await (await request(true)).json();
|
||||
expect(next.ok).toBe(true);
|
||||
expect(next.id).not.toBe(first.id);
|
||||
expect(next.retryAfter).toBe(60);
|
||||
expect(messages.length).toBe(count + 1);
|
||||
expect((await engine.verifyOtp(first.id, firstCode, "mfa")).ok).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await engine.completeMfa({
|
||||
mfaToken: login.mfaToken!,
|
||||
method: "email-otp",
|
||||
challengeId: next.id,
|
||||
code: messages.at(-1)!.code!,
|
||||
fingerprint: "browser",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
now += 60_000;
|
||||
expect((await request(true)).status).toBe(400);
|
||||
});
|
||||
@@ -1,6 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Window, type HTMLInputElement, type HTMLElement } from "happy-dom";
|
||||
import { Window, type HTMLInputElement, type HTMLButtonElement, type HTMLElement } from "happy-dom";
|
||||
|
||||
test("MFA resend waits for cooldown, clears old code and replaces the active challenge", async () => {
|
||||
const window = new Window({ url: "https://sso.example.test/mfa" });
|
||||
try {
|
||||
let now = Date.now();
|
||||
window.Date.now = () => now;
|
||||
window.sessionStorage.setItem(
|
||||
"wrnexus.auth.mfa",
|
||||
JSON.stringify({
|
||||
mfaToken: "synthetic-test-challenge",
|
||||
mfaExpiresAt: now + 300_000,
|
||||
requires: { mfa: ["email-otp"] },
|
||||
}),
|
||||
);
|
||||
window.document.body.innerHTML = `<section data-auth-mfa-challenge><form data-auth-mfa-form>
|
||||
<input name="mfaToken"><input name="challengeId"><input name="code"><input type="radio" name="method" value="email-otp">
|
||||
<div data-auth-mfa-pin><input name="otpCode"></div><p data-auth-mfa-status></p><p data-error="_form">Verification failed</p>
|
||||
<div data-auth-mfa-resend-group hidden><button type="button" data-auth-mfa-resend>Resend code</button></div><button type="submit">Verify</button>
|
||||
</form></section>`;
|
||||
const requests: any[] = [];
|
||||
window.fetch = (async (_: unknown, init: RequestInit) => {
|
||||
requests.push(JSON.parse(String(init.body)));
|
||||
return Response.json({
|
||||
id: "challenge-" + requests.length,
|
||||
retryAfter: 60,
|
||||
expiresAt: now + 300_000,
|
||||
});
|
||||
}) as any;
|
||||
window.eval(readFileSync(new URL("../assets/client/auth.js", import.meta.url), "utf8"));
|
||||
(window as any).WRNexusAuth.mount(window.document);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const button = window.document.querySelector<HTMLButtonElement>("[data-auth-mfa-resend]")!;
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(button.textContent).toContain("60s");
|
||||
button.dispatchEvent(new window.Event("click"));
|
||||
expect(requests.length).toBe(1);
|
||||
const code = window.document.querySelector<HTMLInputElement>("[name=otpCode]")!;
|
||||
code.value = "123456";
|
||||
now += 60_001;
|
||||
button.dispatchEvent(new window.Event("click"));
|
||||
button.dispatchEvent(new window.Event("click"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(requests.length).toBe(2);
|
||||
expect(requests[1].resend).toBe(true);
|
||||
expect(window.document.querySelector<HTMLInputElement>("[name=challengeId]")!.value).toBe(
|
||||
"challenge-2",
|
||||
);
|
||||
expect(code.value).toBe("");
|
||||
expect(window.document.querySelector("[data-error=_form]")!.textContent).toBe("");
|
||||
expect(button.disabled).toBe(true);
|
||||
} finally {
|
||||
await window.happyDOM.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("expired and legacy MFA requests are rejected on mount before entering a code", async () => {
|
||||
for (const deadline of [undefined, Date.now() - 1]) {
|
||||
|
||||
@@ -5,8 +5,11 @@ import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
const profile = { id: "google-user", email: "google@example.test", raw: { email_verified: true } };
|
||||
function setup(providers?: string[]) {
|
||||
const store = new MemoryAuthStore();
|
||||
const engine = createAuthEngine({ store, secret: "oauth-device-policy-test-at-least-32-characters",
|
||||
requireVerifiedEmail: true, requireTrustedDeviceVerification: true,
|
||||
const engine = createAuthEngine({
|
||||
store,
|
||||
secret: "oauth-device-policy-test-at-least-32-characters",
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
verifiedOAuthDeviceProviders: providers,
|
||||
isOAuthEmailVerified: (_provider, value) => value.raw.email_verified === true,
|
||||
});
|
||||
@@ -14,18 +17,25 @@ function setup(providers?: string[]) {
|
||||
}
|
||||
test("explicit verified Google policy satisfies the device check without trusting future password logins", async () => {
|
||||
const { engine } = setup(["google"]);
|
||||
const result = await engine.loginWithOAuth("google", profile, undefined, { fingerprint: "new-browser" });
|
||||
const result = await engine.loginWithOAuth("google", profile, undefined, {
|
||||
fingerprint: "new-browser",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.session).toBeDefined();
|
||||
expect(await engine.listTrustedDevices(result.user!.id)).toHaveLength(0);
|
||||
});
|
||||
test("device verification remains the default and applies to other providers", async () => {
|
||||
expect((await setup().engine.loginWithOAuth("google", profile)).code).toBe("mfa-required");
|
||||
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe("mfa-required");
|
||||
expect((await setup(["google"]).engine.loginWithOAuth("other", profile)).code).toBe(
|
||||
"mfa-required",
|
||||
);
|
||||
});
|
||||
test("unverified provider emails cannot satisfy verification", async () => {
|
||||
const { engine } = setup(["google"]);
|
||||
const result = await engine.loginWithOAuth("google", { ...profile, raw: { email_verified: false } });
|
||||
const result = await engine.loginWithOAuth("google", {
|
||||
...profile,
|
||||
raw: { email_verified: false },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.session).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -3,39 +3,76 @@ import { createContext } from "@wrnexus/core";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { configureAuthRuntime } from "../src/plugin.ts";
|
||||
import { clearDefaultAuthEngine, getDefaultAuthRouteOptions, setDefaultAuthRouteOptions } from "../src/runtime.ts";
|
||||
import {
|
||||
clearDefaultAuthEngine,
|
||||
getDefaultAuthRouteOptions,
|
||||
setDefaultAuthRouteOptions,
|
||||
} from "../src/runtime.ts";
|
||||
import { invokeAuthHandler } from "../src/routes/api.ts";
|
||||
|
||||
afterEach(() => { clearDefaultAuthEngine(); setDefaultAuthRouteOptions({}); });
|
||||
afterEach(() => {
|
||||
clearDefaultAuthEngine();
|
||||
setDefaultAuthRouteOptions({});
|
||||
});
|
||||
|
||||
test("production startup restores runtime-only providers and passkey settings for generated routes", async () => {
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(), secret: "production-runtime-test-secret-at-least-32-characters",
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "production-runtime-test-secret-at-least-32-characters",
|
||||
passkeys: {
|
||||
async authenticationOptions({ rpId }) { return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" }; },
|
||||
async registrationOptions() { throw new Error("not used"); },
|
||||
async verifyRegistration() { return { verified: false }; },
|
||||
async verifyAuthentication() { return { verified: false }; },
|
||||
async authenticationOptions({ rpId }) {
|
||||
return { challenge: "test-challenge", rpId, timeout: 60000, userVerification: "required" };
|
||||
},
|
||||
async registrationOptions() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async verifyRegistration() {
|
||||
return { verified: false };
|
||||
},
|
||||
async verifyAuthentication() {
|
||||
return { verified: false };
|
||||
},
|
||||
},
|
||||
});
|
||||
// Build-time credentials absent, followed by server-startup runtime secrets.
|
||||
configureAuthRuntime({ engine, oauth: {} });
|
||||
const ctx = createContext(new Request("https://sso.example/api/auth/oauth/google"), new URL("https://sso.example/api/auth/oauth/google"));
|
||||
const ctx = createContext(
|
||||
new Request("https://sso.example/api/auth/oauth/google"),
|
||||
new URL("https://sso.example/api/auth/oauth/google"),
|
||||
);
|
||||
ctx.params = { provider: "google" };
|
||||
expect((await invokeAuthHandler("startOAuth", ctx)).status).toBe(404);
|
||||
configureAuthRuntime({ engine, baseUrl: "https://sso.example", sessionCookieName: "custom_session",
|
||||
configureAuthRuntime({
|
||||
engine,
|
||||
baseUrl: "https://sso.example",
|
||||
sessionCookieName: "custom_session",
|
||||
oauth: { google: { clientId: "runtime-client", clientSecret: "runtime-secret" } },
|
||||
passkey: { rpId: "sso.example", origin: "https://sso.example" }, oauthMfaPath: "/mfa" });
|
||||
passkey: { rpId: "sso.example", origin: "https://sso.example" },
|
||||
oauthMfaPath: "/mfa",
|
||||
});
|
||||
const response = await invokeAuthHandler("startOAuth", ctx);
|
||||
expect(response.status).toBe(302);
|
||||
const location = new URL(response.headers.get("location")!);
|
||||
expect(location.hostname).toBe("accounts.google.com");
|
||||
expect(location.searchParams.get("client_id")).toBe("runtime-client");
|
||||
expect(location.searchParams.get("redirect_uri")).toBe("https://sso.example/api/auth/oauth/google/callback");
|
||||
expect(location.searchParams.get("redirect_uri")).toBe(
|
||||
"https://sso.example/api/auth/oauth/google/callback",
|
||||
);
|
||||
expect(getDefaultAuthRouteOptions().sessionCookieName).toBe("custom_session");
|
||||
const request = new Request("https://sso.example/api/auth/passkeys/login/options", { method: "POST",
|
||||
headers: { "content-type": "application/json", origin: "https://sso.example", cookie: "wrn-csrf=runtime-test-csrf", "x-csrf-token": "runtime-test-csrf" }, body: "{}" });
|
||||
const result = await invokeAuthHandler("passkeyAuthenticationOptions", createContext(request, new URL(request.url)));
|
||||
const request = new Request("https://sso.example/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: "https://sso.example",
|
||||
cookie: "wrn-csrf=runtime-test-csrf",
|
||||
"x-csrf-token": "runtime-test-csrf",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
const result = await invokeAuthHandler(
|
||||
"passkeyAuthenticationOptions",
|
||||
createContext(request, new URL(request.url)),
|
||||
);
|
||||
expect(result.status).toBe(200);
|
||||
expect((await result.json()).options.rpId).toBe("sso.example");
|
||||
configureAuthRuntime({ enabled: false, engine });
|
||||
|
||||
Reference in new issue
Block a user