feat(auth): require verified new-browser trust with fixed expiry
This commit is contained in:
1 parent
bef67b0a24
commit
61a4f01321
9 files changed
+418
-33
No files matched your search
@@ -235,7 +235,7 @@
|
||||
},
|
||||
"packages/auth": {
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.25",
|
||||
"version": "0.8.26",
|
||||
"dependencies": {
|
||||
"@wrnexus/authz": "workspace:*",
|
||||
"@wrnexus/captcha": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @wrnexus/auth
|
||||
|
||||
### New-browser verification and device trust
|
||||
|
||||
Enable `requireTrustedDeviceVerification: true` to require a verified email or
|
||||
SMS challenge before an untrusted browser receives a sign-in session. The HTTP
|
||||
handlers use a host-only HttpOnly random cookie, ignoring posted fingerprints.
|
||||
Direct engine callers must supply a trusted server-side browser fingerprint.
|
||||
`trustSignupDevice: true` grants the registration browser one-time trust after
|
||||
required account verification, at its first successful sign-in.
|
||||
|
||||
Use `trustedDeviceTtlMs: 30 * 86400_000` for 30-day trust. Ordinary logins do not
|
||||
renew it. The verification form offers explicit trust consent; expiry/revocation
|
||||
requires a new email/SMS challenge. Successful login alerts use both verified
|
||||
contact channels when configured. Delivery still requires an application adapter.
|
||||
|
||||
### Signup business logic and verification-only sessions
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Input, PinInput, Radio } from "@wrnexus/ui"
|
||||
import { Button, Input, PinInput, Radio, Checkbox } from "@wrnexus/ui"
|
||||
|
||||
component TwoFactorChallenge {
|
||||
props {
|
||||
@@ -45,6 +45,7 @@ component TwoFactorChallenge {
|
||||
<div data-auth-mfa-pin><PinInput label="Verification code" name="otpCode" length="6" inputMode="numeric" autocomplete="one-time-code" color="{color}" size="{size}" /></div>
|
||||
<div data-auth-mfa-recovery hidden><Input label="Recovery code" name="recoveryCode" autocomplete="off" maxlength="32" color="{color}" size="{size}" /></div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/auth",
|
||||
"version": "0.8.25",
|
||||
"version": "0.8.26",
|
||||
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
+142
-30
@@ -49,6 +49,7 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
|
||||
};
|
||||
|
||||
export interface AuthEngine {
|
||||
readonly deviceTrustPolicyEnabled: boolean;
|
||||
readonly store: AuthStore;
|
||||
readonly onSignedIn?: AuthSignedInHandler;
|
||||
readonly onSignedOut?: AuthSignedOutHandler;
|
||||
@@ -101,7 +102,10 @@ export interface AuthEngine {
|
||||
token: string,
|
||||
input: { password?: string; displayName?: string },
|
||||
): Promise<AuthResult>;
|
||||
consumeMagicLink(token: string, sessionInput?: Partial<AuthSession>): Promise<AuthResult>;
|
||||
consumeMagicLink(
|
||||
token: string,
|
||||
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||
): Promise<AuthResult>;
|
||||
issueOtp(
|
||||
userId: string,
|
||||
method: "email-otp" | "sms-otp",
|
||||
@@ -120,18 +124,20 @@ export interface AuthEngine {
|
||||
completeOtpLogin(
|
||||
challengeId: string,
|
||||
code: string,
|
||||
sessionInput?: Partial<AuthSession>,
|
||||
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||
): Promise<AuthResult>;
|
||||
beginMfaOtp(
|
||||
mfaToken: string,
|
||||
method: "email-otp" | "sms-otp",
|
||||
): Promise<{ id: string; expiresAt: number } | undefined>;
|
||||
completeMfa(input: {
|
||||
rememberDevice?: boolean;
|
||||
fingerprint?: string;
|
||||
mfaToken: string;
|
||||
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
|
||||
code: string;
|
||||
challengeId?: string;
|
||||
session?: Partial<AuthSession>;
|
||||
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
|
||||
}): Promise<AuthResult>;
|
||||
beginTotp(
|
||||
userId: string,
|
||||
@@ -153,6 +159,7 @@ export interface AuthEngine {
|
||||
provider: string,
|
||||
profile: OAuthProfile,
|
||||
tokens?: OAuthTokens,
|
||||
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||
): Promise<AuthResult>;
|
||||
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
|
||||
beginPasskeyRegistration(
|
||||
@@ -176,7 +183,7 @@ export interface AuthEngine {
|
||||
key: string;
|
||||
response: unknown;
|
||||
/** Request metadata used only for the resulting session. */
|
||||
session?: Partial<AuthSession>;
|
||||
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
|
||||
}): Promise<AuthResult>;
|
||||
changePassword(
|
||||
userId: string,
|
||||
@@ -481,6 +488,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
user: AuthUser,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<AuthResult | undefined> {
|
||||
const deviceCheck = await newDeviceRequirement(user, metadata);
|
||||
if (deviceCheck) return deviceCheck;
|
||||
if (!(await hasMfa(user.id))) return undefined;
|
||||
const methods = await availableMfaMethods(user.id);
|
||||
if (!methods.length) {
|
||||
@@ -506,6 +515,54 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
};
|
||||
}
|
||||
|
||||
async function newDeviceRequirement(
|
||||
user: AuthUser,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): Promise<AuthResult | undefined> {
|
||||
if (!options.requireTrustedDeviceVerification) return;
|
||||
const browser = typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined;
|
||||
if (browser) {
|
||||
const hash = await fingerprint(browser);
|
||||
const existing = await store.findTrustedDeviceByFingerprint(user.id, hash);
|
||||
if (existing && !existing.revokedAt && existing.expiresAt > now()) return;
|
||||
// Only the original registration browser can claim this one-time trust.
|
||||
// Account-state / required-contact checks run before this helper.
|
||||
if (options.trustSignupDevice && user.metadata?.signupDeviceHash === hash) {
|
||||
const { signupDeviceHash: _signup, ...rest } = user.metadata;
|
||||
user.metadata = rest;
|
||||
await store.updateUser(user);
|
||||
await engine.trustDevice(user.id, {
|
||||
fingerprint: browser,
|
||||
name: String(metadata.deviceName || "Signup browser"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const methods = (await availableMfaMethods(user.id)).filter(
|
||||
(method) => method === "email-otp" || method === "sms-otp",
|
||||
);
|
||||
if (!methods.length)
|
||||
return {
|
||||
ok: false,
|
||||
code: "device-verification-unavailable",
|
||||
message:
|
||||
"A verified email address or mobile number is required to verify this device. Use account recovery.",
|
||||
};
|
||||
const mfaExpiresAt = now() + 5 * 60_000;
|
||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||
ttlMs: 5 * 60_000,
|
||||
metadata: { ...metadata, deviceVerification: true, rememberDevice: false },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
code: "mfa-required",
|
||||
message: "Verify this new device using email or SMS. You can then trust it for 30 days.",
|
||||
mfaToken,
|
||||
mfaExpiresAt,
|
||||
requires: { mfa: methods },
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPasswordPolicy(password: string): Promise<void> {
|
||||
if (password.length < passwordMinLength) {
|
||||
throw new Error(`Password must be at least ${passwordMinLength} characters`);
|
||||
@@ -665,24 +722,26 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
async function sendLoginAlert(user: AuthUser, session: AuthSession): Promise<void> {
|
||||
if (!options.sendLoginAlerts || !options.delivery) return;
|
||||
const identities = await store.listIdentities(user.id);
|
||||
const destination =
|
||||
identities.find((item) => item.type === "email" && item.primary)?.value ??
|
||||
identities.find((item) => item.type === "email")?.value;
|
||||
if (!destination) return;
|
||||
await deliver({
|
||||
channel: "email",
|
||||
template: "login-alert",
|
||||
destination,
|
||||
user: publicUser(user),
|
||||
expiresAt: session.expiresAt,
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
deviceId: session.deviceId,
|
||||
ip: session.ip,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
});
|
||||
for (const type of ["email", "phone"] as const) {
|
||||
const destination =
|
||||
identities.find((item) => item.type === type && item.verifiedAt && item.primary)?.value ??
|
||||
identities.find((item) => item.type === type && item.verifiedAt)?.value;
|
||||
if (!destination) continue;
|
||||
await deliver({
|
||||
channel: type === "phone" ? "sms" : "email",
|
||||
template: "login-alert",
|
||||
destination,
|
||||
user: publicUser(user),
|
||||
expiresAt: session.expiresAt,
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
deviceId: session.deviceId,
|
||||
ip: session.ip,
|
||||
userAgent: session.userAgent,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(
|
||||
@@ -853,6 +912,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
}
|
||||
|
||||
const engine: AuthEngine = {
|
||||
deviceTrustPolicyEnabled: options.requireTrustedDeviceVerification === true,
|
||||
store,
|
||||
onSignedIn: options.onSignedIn,
|
||||
onSignedOut: options.onSignedOut,
|
||||
@@ -906,7 +966,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
timezone: input.timezone,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
metadata: input.metadata,
|
||||
metadata: {
|
||||
...input.metadata,
|
||||
...(options.trustSignupDevice && input.fingerprint
|
||||
? { signupDeviceHash: await fingerprint(input.fingerprint) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
await store.createUser(user);
|
||||
if (input.email) await createIdentity(user.id, "email", input.email, true);
|
||||
@@ -1062,6 +1127,13 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return { ...verification, risk };
|
||||
const mfaEnabled = await hasMfa(user.id);
|
||||
const deviceCheck = await newDeviceRequirement(user, {
|
||||
fingerprint: input.fingerprint,
|
||||
deviceName: input.deviceName,
|
||||
ip: input.ip,
|
||||
userAgent: input.userAgent,
|
||||
});
|
||||
if (deviceCheck) return deviceCheck;
|
||||
const trustedDevice = input.fingerprint
|
||||
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
|
||||
: undefined;
|
||||
@@ -1107,7 +1179,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
};
|
||||
}
|
||||
const rememberedDevice =
|
||||
input.rememberDevice && input.fingerprint
|
||||
input.rememberDevice && input.fingerprint && !trusted
|
||||
? await engine.trustDevice(user.id, {
|
||||
fingerprint: input.fingerprint,
|
||||
name: input.deviceName,
|
||||
@@ -1496,6 +1568,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return verification;
|
||||
const mfa = await mfaRequirement(user, {
|
||||
fingerprint: sessionInput.fingerprint,
|
||||
deviceName: sessionInput.deviceName,
|
||||
ip: sessionInput.ip,
|
||||
userAgent: sessionInput.userAgent,
|
||||
deviceId: sessionInput.deviceId,
|
||||
@@ -1601,6 +1675,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const verification = verificationUnavailable(user);
|
||||
if (verification) return verification;
|
||||
const mfa = await mfaRequirement(user, {
|
||||
fingerprint: sessionInput.fingerprint,
|
||||
deviceName: sessionInput.deviceName,
|
||||
ip: sessionInput.ip,
|
||||
userAgent: sessionInput.userAgent,
|
||||
deviceId: sessionInput.deviceId,
|
||||
@@ -1630,7 +1706,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
await restoreExpiredLoginLock(user);
|
||||
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
|
||||
try {
|
||||
return await issueOtp(token.userId, method, undefined, "mfa");
|
||||
if (!(await availableMfaMethods(user.id)).includes(method)) return undefined;
|
||||
const key = "otp:" + method;
|
||||
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 };
|
||||
}
|
||||
const challenge = await issueOtp(token.userId, method, undefined, "mfa");
|
||||
token.metadata = { ...token.metadata, [key]: challenge.id };
|
||||
await store.updateToken(token);
|
||||
return challenge;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1651,6 +1738,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
if (unavailable) return unavailable;
|
||||
const verification = verificationUnavailable(transactionUser);
|
||||
if (verification) return verification;
|
||||
if (
|
||||
transaction.metadata?.deviceVerification === true &&
|
||||
(input.fingerprint !== transaction.metadata.fingerprint ||
|
||||
!input.fingerprint ||
|
||||
!["email-otp", "sms-otp"].includes(input.method))
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "mfa-invalid",
|
||||
message: "Verify this device using email or SMS in the browser that started sign-in.",
|
||||
};
|
||||
}
|
||||
let verified = false;
|
||||
if (input.method === "totp")
|
||||
verified = await engine.verifyTotp(transaction.userId, input.code);
|
||||
@@ -1661,7 +1760,9 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
if (
|
||||
otp?.userId === transaction.userId &&
|
||||
otp.method === input.method &&
|
||||
otp.purpose === "mfa"
|
||||
otp.purpose === "mfa" &&
|
||||
(transaction.metadata?.deviceVerification !== true ||
|
||||
transaction.metadata?.["otp:" + input.method] === input.challengeId)
|
||||
) {
|
||||
verified = (await engine.verifyOtp(input.challengeId, input.code, "mfa")).ok;
|
||||
}
|
||||
@@ -1682,7 +1783,10 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
const user = transactionUser;
|
||||
const metadata = transaction.metadata ?? {};
|
||||
const rememberedDevice =
|
||||
metadata.rememberDevice === true && typeof metadata.fingerprint === "string"
|
||||
(metadata.deviceVerification === true
|
||||
? input.rememberDevice === true
|
||||
: metadata.rememberDevice === true || input.rememberDevice === true) &&
|
||||
typeof metadata.fingerprint === "string"
|
||||
? await engine.trustDevice(user.id, {
|
||||
fingerprint: metadata.fingerprint,
|
||||
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
|
||||
@@ -1716,6 +1820,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
severity: "info",
|
||||
data: { method: input.method },
|
||||
});
|
||||
await sendLoginAlert(user, session);
|
||||
return { ok: true, user: publicUser(user), session };
|
||||
},
|
||||
|
||||
@@ -1910,7 +2015,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
return account;
|
||||
},
|
||||
|
||||
async loginWithOAuth(provider, profile, tokens) {
|
||||
async loginWithOAuth(provider, profile, tokens, sessionInput = {}) {
|
||||
const providerName = normalizedOAuthProvider(provider);
|
||||
const providerAccountId = normalizedOAuthAccountId(profile.id);
|
||||
let account = await store.findOAuthAccount(providerName, providerAccountId);
|
||||
@@ -1963,12 +2068,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 deviceCheck = await newDeviceRequirement(user, {
|
||||
...sessionInput,
|
||||
oauthProvider: providerName,
|
||||
});
|
||||
if (deviceCheck) return deviceCheck;
|
||||
if (await hasMfa(user.id)) {
|
||||
const methods = await availableMfaMethods(user.id);
|
||||
const mfaExpiresAt = now() + 5 * 60_000;
|
||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||
ttlMs: 5 * 60_000,
|
||||
metadata: { oauthProvider: providerName },
|
||||
metadata: { ...sessionInput, oauthProvider: providerName },
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1979,7 +2089,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
requires: { mfa: methods },
|
||||
};
|
||||
}
|
||||
const session = await createSession(user.id);
|
||||
const session = await createSession(user.id, sessionInput);
|
||||
user.lastLoginAt = now();
|
||||
user.updatedAt = now();
|
||||
await store.updateUser(user);
|
||||
@@ -2175,6 +2285,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
||||
credential.lastUsedAt = now();
|
||||
await store.updatePasskey(credential);
|
||||
}
|
||||
const deviceCheck = await newDeviceRequirement(user, { ...input.session });
|
||||
if (deviceCheck) return deviceCheck;
|
||||
const session = await createSession(user.id, input.session);
|
||||
user.lastLoginAt = now();
|
||||
user.updatedAt = now();
|
||||
|
||||
@@ -64,6 +64,26 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
const onSignedOut = engine.onSignedOut;
|
||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
||||
|
||||
function browserDevice(ctx: Context) {
|
||||
if (!engine.deviceTrustPolicyEnabled) return {};
|
||||
const cookie = "wrnexus.auth.device";
|
||||
let value = ctx.cookies.get(cookie);
|
||||
if (!value || !/^[a-f0-9-]{36}$/.test(value)) {
|
||||
value = crypto.randomUUID();
|
||||
ctx.cookies.set(cookie, value, {
|
||||
httpOnly: true,
|
||||
secure: ctx.url.protocol === "https:",
|
||||
sameSite: "Lax",
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
return {
|
||||
fingerprint: value,
|
||||
deviceName: (ctx.req.headers.get("user-agent") || "Browser").slice(0, 120),
|
||||
};
|
||||
}
|
||||
|
||||
function oauthProvider(ctx: Context): OAuthProvider | undefined {
|
||||
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
|
||||
}
|
||||
@@ -148,6 +168,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
provider.name,
|
||||
completed.profile,
|
||||
completed.tokens,
|
||||
browserDevice(ctx),
|
||||
);
|
||||
if (result.code === "mfa-required" && result.mfaToken) {
|
||||
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
|
||||
@@ -181,6 +202,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const result = await engine.register({
|
||||
...browserDevice(ctx),
|
||||
baseUrl: options.baseUrl ?? ctx.url.origin,
|
||||
email: text(input.email) || undefined,
|
||||
phone: text(input.phone) || undefined,
|
||||
@@ -200,6 +222,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (action?.autoSignIn) {
|
||||
const identifier = text(input.email) || text(input.phone) || text(input.username);
|
||||
const loginResult = await engine.login({
|
||||
...browserDevice(ctx),
|
||||
identifier,
|
||||
password: text(input.password),
|
||||
ip: ctx.ip,
|
||||
@@ -229,6 +252,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
fingerprint: text(input.deviceFingerprint) || undefined,
|
||||
deviceName: text(input.deviceName) || undefined,
|
||||
rememberDevice: boolean(input.rememberDevice),
|
||||
...browserDevice(ctx),
|
||||
captchaVerified:
|
||||
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
|
||||
ctx.locals.captchaVerified === true,
|
||||
@@ -340,6 +364,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const result = await engine.consumeMagicLink(text(input.token), {
|
||||
...browserDevice(ctx),
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
@@ -366,6 +391,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
if (!validation.ok) return validation.response;
|
||||
const input = validation.value;
|
||||
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
|
||||
...browserDevice(ctx),
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||
});
|
||||
@@ -500,6 +526,8 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
|
||||
: "totp";
|
||||
const result = await engine.completeMfa({
|
||||
...browserDevice(ctx),
|
||||
rememberDevice: boolean(input.rememberDevice),
|
||||
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
|
||||
method,
|
||||
code: text(input.code),
|
||||
@@ -618,6 +646,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
||||
key: text(input.key),
|
||||
response: input.response,
|
||||
session: {
|
||||
...browserDevice(ctx),
|
||||
ip: ctx.ip,
|
||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||
},
|
||||
|
||||
@@ -438,6 +438,10 @@ export interface AuthEngineOptions {
|
||||
sessionTtlMs?: number;
|
||||
sessionAbsoluteTtlMs?: number;
|
||||
trustedDeviceTtlMs?: number;
|
||||
/** Require verified email/SMS confirmation before a new browser receives a session. */
|
||||
requireTrustedDeviceVerification?: boolean;
|
||||
/** Remember the registration browser after required account verification succeeds. */
|
||||
trustSignupDevice?: boolean;
|
||||
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
|
||||
/**
|
||||
* Customize action links delivered with one-time tokens. Returning undefined
|
||||
@@ -472,6 +476,8 @@ export interface AuthEngineOptions {
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
fingerprint?: string;
|
||||
deviceName?: string;
|
||||
/** Trusted server-side origin for verification links; never copied from request data. */
|
||||
baseUrl?: string;
|
||||
email?: string;
|
||||
|
||||
@@ -160,6 +160,7 @@ export const mfaOtpRequestSchema = v.object({
|
||||
});
|
||||
|
||||
export const mfaSchema = v.object({
|
||||
rememberDevice: v.boolean().optional(),
|
||||
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
|
||||
method: v
|
||||
.string()
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createAuthEngine } from "../src/engine.ts";
|
||||
import { MemoryAuthStore } from "../src/stores/memory.ts";
|
||||
import { createAuthHttpHandlers } from "../src/http/index.ts";
|
||||
import { createContext } from "@wrnexus/core";
|
||||
import type { AuthDeliveryMessage } from "../src/types.ts";
|
||||
|
||||
test("magic-link, OTP, and passkey sign-in cannot bypass new-device verification", async () => {
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
let userId = "";
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "all-flows-trust-secret-at-least-32-characters",
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
passkeys: {
|
||||
async registrationOptions() {
|
||||
throw new Error("Not used");
|
||||
},
|
||||
async verifyRegistration() {
|
||||
throw new Error("Not used");
|
||||
},
|
||||
async authenticationOptions() {
|
||||
return {
|
||||
challenge: "AQID",
|
||||
rpId: "example.test",
|
||||
timeout: 60000,
|
||||
userVerification: "required" as const,
|
||||
};
|
||||
},
|
||||
async verifyAuthentication() {
|
||||
return { verified: true, userId };
|
||||
},
|
||||
},
|
||||
});
|
||||
userId = (await engine.register({ email: "flows@example.test", password: "StrongPassword123!" }))
|
||||
.user!.id;
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
await engine.requestMagicLink("flows@example.test");
|
||||
const magic = messages.find((m) => m.template === "magic-link")!.token!;
|
||||
expect((await engine.consumeMagicLink(magic, { fingerprint: "browser" })).code).toBe(
|
||||
"mfa-required",
|
||||
);
|
||||
const otp = await engine.requestOtpLogin("flows@example.test", "email-otp");
|
||||
expect(
|
||||
(
|
||||
await engine.completeOtpLogin(
|
||||
otp.id,
|
||||
messages.filter((m) => m.template === "email-otp").at(-1)!.code!,
|
||||
{ fingerprint: "browser" },
|
||||
)
|
||||
).code,
|
||||
).toBe("mfa-required");
|
||||
const passkey = await engine.beginPasskeyAuthentication({
|
||||
identifier: "flows@example.test",
|
||||
rpId: "example.test",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await engine.finishPasskeyAuthentication({
|
||||
key: passkey.key,
|
||||
response: {},
|
||||
session: { fingerprint: "browser" },
|
||||
})
|
||||
).code,
|
||||
).toBe("mfa-required");
|
||||
expect(await engine.listSessions(userId)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("new browsers require bound email/SMS verification; signup trust expires without sliding", async () => {
|
||||
let now = 1_780_000_000_000;
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "test-browser-trust-secret-at-least-32-characters",
|
||||
clock: { now: () => now },
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
trustSignupDevice: true,
|
||||
trustedDeviceTtlMs: 30 * 86400_000,
|
||||
sendLoginAlerts: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
const registered = await engine.register({
|
||||
email: "trust@example.test",
|
||||
phone: "+15551234567",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "signup-browser",
|
||||
});
|
||||
const userId = registered.user!.id;
|
||||
expect(
|
||||
(
|
||||
await engine.login({
|
||||
identifier: "trust@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "signup-browser",
|
||||
})
|
||||
).code,
|
||||
).toBe("email-unverified");
|
||||
expect(await engine.listTrustedDevices(userId)).toHaveLength(0);
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
await engine.verifyPhone(messages.find((m) => m.template === "verify-phone")!.token!);
|
||||
const login = (fingerprint: string, rememberDevice = false) =>
|
||||
engine.login({
|
||||
identifier: "trust@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint,
|
||||
rememberDevice,
|
||||
});
|
||||
expect((await login("signup-browser")).session?.trusted).toBe(true);
|
||||
const firstExpiry = (await engine.listTrustedDevices(userId))[0]!.expiresAt;
|
||||
now += 86400_000;
|
||||
expect((await login("signup-browser", true)).ok).toBe(true);
|
||||
expect((await engine.listTrustedDevices(userId))[0]!.expiresAt).toBe(firstExpiry);
|
||||
const before = (await engine.listSessions(userId)).length;
|
||||
const pending = await login("new-browser", true);
|
||||
expect(pending.code).toBe("mfa-required");
|
||||
expect(pending.requires?.mfa).toEqual(["email-otp", "sms-otp"]);
|
||||
expect((await engine.listSessions(userId)).length).toBe(before);
|
||||
const challenge = await engine.beginMfaOtp(pending.mfaToken!, "email-otp");
|
||||
const count = messages.length;
|
||||
expect(await engine.beginMfaOtp(pending.mfaToken!, "email-otp")).toEqual(challenge);
|
||||
expect(messages.length).toBe(count);
|
||||
const code = messages.filter((m) => m.template === "email-otp").at(-1)!.code!;
|
||||
const complete = {
|
||||
mfaToken: pending.mfaToken!,
|
||||
method: "email-otp" as const,
|
||||
challengeId: challenge!.id,
|
||||
code,
|
||||
fingerprint: "new-browser",
|
||||
};
|
||||
expect((await engine.completeMfa({ ...complete, fingerprint: "other-browser" })).ok).toBe(false);
|
||||
expect((await engine.completeMfa({ ...complete, method: "totp" })).ok).toBe(false);
|
||||
expect((await engine.completeMfa(complete)).ok).toBe(true);
|
||||
expect((await engine.listTrustedDevices(userId)).length).toBe(1); // Explicit opt-in only.
|
||||
expect((await engine.completeMfa(complete)).ok).toBe(false);
|
||||
const again = await login("new-browser");
|
||||
const sms = await engine.beginMfaOtp(again.mfaToken!, "sms-otp");
|
||||
const smsCode = messages.filter((m) => m.template === "sms-otp").at(-1)!.code!;
|
||||
expect(
|
||||
(
|
||||
await engine.completeMfa({
|
||||
mfaToken: again.mfaToken!,
|
||||
method: "sms-otp",
|
||||
challengeId: sms!.id,
|
||||
code: smsCode,
|
||||
fingerprint: "new-browser",
|
||||
rememberDevice: true,
|
||||
})
|
||||
).session?.trusted,
|
||||
).toBe(true);
|
||||
expect(
|
||||
messages
|
||||
.filter((m) => m.template === "login-alert")
|
||||
.slice(-2)
|
||||
.map((m) => m.channel),
|
||||
).toEqual(["email", "sms"]);
|
||||
const second = (await engine.listTrustedDevices(userId)).find(
|
||||
(d) => d.expiresAt !== firstExpiry,
|
||||
)!;
|
||||
await engine.revokeTrustedDevice(userId, second.id);
|
||||
expect((await login("new-browser")).code).toBe("mfa-required");
|
||||
now = firstExpiry + 1;
|
||||
expect((await login("signup-browser")).code).toBe("mfa-required");
|
||||
});
|
||||
|
||||
test("HTTP signup binds trust to an HttpOnly cookie, not a supplied device fingerprint", async () => {
|
||||
const messages: AuthDeliveryMessage[] = [];
|
||||
const engine = createAuthEngine({
|
||||
store: new MemoryAuthStore(),
|
||||
secret: "http-device-trust-secret-at-least-32-characters",
|
||||
requireVerifiedEmail: true,
|
||||
requireTrustedDeviceVerification: true,
|
||||
trustSignupDevice: true,
|
||||
delivery: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
const url = new URL("https://sso.example.test/api/auth/register");
|
||||
const ctx = createContext(
|
||||
new Request(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
displayName: "Trust Test",
|
||||
consent: true,
|
||||
email: "http@example.test",
|
||||
password: "StrongPassword123!",
|
||||
deviceFingerprint: "attacker",
|
||||
}),
|
||||
}),
|
||||
url,
|
||||
);
|
||||
await createAuthHttpHandlers({ engine }).register(ctx);
|
||||
const cookie = ctx.cookies.headers().find((c) => c.startsWith("wrnexus.auth.device="))!;
|
||||
expect(cookie).toContain("HttpOnly");
|
||||
expect(cookie).toContain("Secure");
|
||||
expect(cookie).toContain("Max-Age=2592000");
|
||||
expect(cookie).not.toContain("attacker");
|
||||
await engine.verifyEmail(messages.find((m) => m.template === "verify-email")!.token!);
|
||||
expect(
|
||||
(
|
||||
await engine.login({
|
||||
identifier: "http@example.test",
|
||||
password: "StrongPassword123!",
|
||||
fingerprint: "attacker",
|
||||
})
|
||||
).code,
|
||||
).toBe("mfa-required");
|
||||
});
|
||||
Reference in new issue
Block a user