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": {
|
"packages/auth": {
|
||||||
"name": "@wrnexus/auth",
|
"name": "@wrnexus/auth",
|
||||||
"version": "0.8.25",
|
"version": "0.8.26",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@wrnexus/authz": "workspace:*",
|
"@wrnexus/authz": "workspace:*",
|
||||||
"@wrnexus/captcha": "workspace:*",
|
"@wrnexus/captcha": "workspace:*",
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# @wrnexus/auth
|
# @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
|
### Signup business logic and verification-only sessions
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Input, PinInput, Radio } from "@wrnexus/ui"
|
import { Button, Input, PinInput, Radio, Checkbox } from "@wrnexus/ui"
|
||||||
|
|
||||||
component TwoFactorChallenge {
|
component TwoFactorChallenge {
|
||||||
props {
|
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-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>
|
<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>
|
<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-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="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="challengeId" class="m-0 min-h-4 text-xs text-[var(--wrn-color-danger)]"></p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/auth",
|
"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.",
|
"description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
+142
-30
@@ -49,6 +49,7 @@ const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface AuthEngine {
|
export interface AuthEngine {
|
||||||
|
readonly deviceTrustPolicyEnabled: boolean;
|
||||||
readonly store: AuthStore;
|
readonly store: AuthStore;
|
||||||
readonly onSignedIn?: AuthSignedInHandler;
|
readonly onSignedIn?: AuthSignedInHandler;
|
||||||
readonly onSignedOut?: AuthSignedOutHandler;
|
readonly onSignedOut?: AuthSignedOutHandler;
|
||||||
@@ -101,7 +102,10 @@ export interface AuthEngine {
|
|||||||
token: string,
|
token: string,
|
||||||
input: { password?: string; displayName?: string },
|
input: { password?: string; displayName?: string },
|
||||||
): Promise<AuthResult>;
|
): Promise<AuthResult>;
|
||||||
consumeMagicLink(token: string, sessionInput?: Partial<AuthSession>): Promise<AuthResult>;
|
consumeMagicLink(
|
||||||
|
token: string,
|
||||||
|
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||||
|
): Promise<AuthResult>;
|
||||||
issueOtp(
|
issueOtp(
|
||||||
userId: string,
|
userId: string,
|
||||||
method: "email-otp" | "sms-otp",
|
method: "email-otp" | "sms-otp",
|
||||||
@@ -120,18 +124,20 @@ export interface AuthEngine {
|
|||||||
completeOtpLogin(
|
completeOtpLogin(
|
||||||
challengeId: string,
|
challengeId: string,
|
||||||
code: string,
|
code: string,
|
||||||
sessionInput?: Partial<AuthSession>,
|
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||||
): Promise<AuthResult>;
|
): Promise<AuthResult>;
|
||||||
beginMfaOtp(
|
beginMfaOtp(
|
||||||
mfaToken: string,
|
mfaToken: string,
|
||||||
method: "email-otp" | "sms-otp",
|
method: "email-otp" | "sms-otp",
|
||||||
): Promise<{ id: string; expiresAt: number } | undefined>;
|
): Promise<{ id: string; expiresAt: number } | undefined>;
|
||||||
completeMfa(input: {
|
completeMfa(input: {
|
||||||
|
rememberDevice?: boolean;
|
||||||
|
fingerprint?: string;
|
||||||
mfaToken: string;
|
mfaToken: string;
|
||||||
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
|
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
|
||||||
code: string;
|
code: string;
|
||||||
challengeId?: string;
|
challengeId?: string;
|
||||||
session?: Partial<AuthSession>;
|
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
|
||||||
}): Promise<AuthResult>;
|
}): Promise<AuthResult>;
|
||||||
beginTotp(
|
beginTotp(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -153,6 +159,7 @@ export interface AuthEngine {
|
|||||||
provider: string,
|
provider: string,
|
||||||
profile: OAuthProfile,
|
profile: OAuthProfile,
|
||||||
tokens?: OAuthTokens,
|
tokens?: OAuthTokens,
|
||||||
|
sessionInput?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
|
||||||
): Promise<AuthResult>;
|
): Promise<AuthResult>;
|
||||||
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
|
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
|
||||||
beginPasskeyRegistration(
|
beginPasskeyRegistration(
|
||||||
@@ -176,7 +183,7 @@ export interface AuthEngine {
|
|||||||
key: string;
|
key: string;
|
||||||
response: unknown;
|
response: unknown;
|
||||||
/** Request metadata used only for the resulting session. */
|
/** Request metadata used only for the resulting session. */
|
||||||
session?: Partial<AuthSession>;
|
session?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string };
|
||||||
}): Promise<AuthResult>;
|
}): Promise<AuthResult>;
|
||||||
changePassword(
|
changePassword(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -481,6 +488,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
metadata: Record<string, unknown> = {},
|
metadata: Record<string, unknown> = {},
|
||||||
): Promise<AuthResult | undefined> {
|
): Promise<AuthResult | undefined> {
|
||||||
|
const deviceCheck = await newDeviceRequirement(user, metadata);
|
||||||
|
if (deviceCheck) return deviceCheck;
|
||||||
if (!(await hasMfa(user.id))) return undefined;
|
if (!(await hasMfa(user.id))) return undefined;
|
||||||
const methods = await availableMfaMethods(user.id);
|
const methods = await availableMfaMethods(user.id);
|
||||||
if (!methods.length) {
|
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> {
|
async function assertPasswordPolicy(password: string): Promise<void> {
|
||||||
if (password.length < passwordMinLength) {
|
if (password.length < passwordMinLength) {
|
||||||
throw new Error(`Password must be at least ${passwordMinLength} characters`);
|
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> {
|
async function sendLoginAlert(user: AuthUser, session: AuthSession): Promise<void> {
|
||||||
if (!options.sendLoginAlerts || !options.delivery) return;
|
if (!options.sendLoginAlerts || !options.delivery) return;
|
||||||
const identities = await store.listIdentities(user.id);
|
const identities = await store.listIdentities(user.id);
|
||||||
const destination =
|
for (const type of ["email", "phone"] as const) {
|
||||||
identities.find((item) => item.type === "email" && item.primary)?.value ??
|
const destination =
|
||||||
identities.find((item) => item.type === "email")?.value;
|
identities.find((item) => item.type === type && item.verifiedAt && item.primary)?.value ??
|
||||||
if (!destination) return;
|
identities.find((item) => item.type === type && item.verifiedAt)?.value;
|
||||||
await deliver({
|
if (!destination) continue;
|
||||||
channel: "email",
|
await deliver({
|
||||||
template: "login-alert",
|
channel: type === "phone" ? "sms" : "email",
|
||||||
destination,
|
template: "login-alert",
|
||||||
user: publicUser(user),
|
destination,
|
||||||
expiresAt: session.expiresAt,
|
user: publicUser(user),
|
||||||
data: {
|
expiresAt: session.expiresAt,
|
||||||
sessionId: session.id,
|
data: {
|
||||||
deviceId: session.deviceId,
|
sessionId: session.id,
|
||||||
ip: session.ip,
|
deviceId: session.deviceId,
|
||||||
userAgent: session.userAgent,
|
ip: session.ip,
|
||||||
createdAt: session.createdAt,
|
userAgent: session.userAgent,
|
||||||
},
|
createdAt: session.createdAt,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSession(
|
async function createSession(
|
||||||
@@ -853,6 +912,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const engine: AuthEngine = {
|
const engine: AuthEngine = {
|
||||||
|
deviceTrustPolicyEnabled: options.requireTrustedDeviceVerification === true,
|
||||||
store,
|
store,
|
||||||
onSignedIn: options.onSignedIn,
|
onSignedIn: options.onSignedIn,
|
||||||
onSignedOut: options.onSignedOut,
|
onSignedOut: options.onSignedOut,
|
||||||
@@ -906,7 +966,12 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
timezone: input.timezone,
|
timezone: input.timezone,
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
metadata: input.metadata,
|
metadata: {
|
||||||
|
...input.metadata,
|
||||||
|
...(options.trustSignupDevice && input.fingerprint
|
||||||
|
? { signupDeviceHash: await fingerprint(input.fingerprint) }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
await store.createUser(user);
|
await store.createUser(user);
|
||||||
if (input.email) await createIdentity(user.id, "email", input.email, true);
|
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);
|
const verification = verificationUnavailable(user);
|
||||||
if (verification) return { ...verification, risk };
|
if (verification) return { ...verification, risk };
|
||||||
const mfaEnabled = await hasMfa(user.id);
|
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
|
const trustedDevice = input.fingerprint
|
||||||
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
|
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -1107,7 +1179,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rememberedDevice =
|
const rememberedDevice =
|
||||||
input.rememberDevice && input.fingerprint
|
input.rememberDevice && input.fingerprint && !trusted
|
||||||
? await engine.trustDevice(user.id, {
|
? await engine.trustDevice(user.id, {
|
||||||
fingerprint: input.fingerprint,
|
fingerprint: input.fingerprint,
|
||||||
name: input.deviceName,
|
name: input.deviceName,
|
||||||
@@ -1496,6 +1568,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
const verification = verificationUnavailable(user);
|
const verification = verificationUnavailable(user);
|
||||||
if (verification) return verification;
|
if (verification) return verification;
|
||||||
const mfa = await mfaRequirement(user, {
|
const mfa = await mfaRequirement(user, {
|
||||||
|
fingerprint: sessionInput.fingerprint,
|
||||||
|
deviceName: sessionInput.deviceName,
|
||||||
ip: sessionInput.ip,
|
ip: sessionInput.ip,
|
||||||
userAgent: sessionInput.userAgent,
|
userAgent: sessionInput.userAgent,
|
||||||
deviceId: sessionInput.deviceId,
|
deviceId: sessionInput.deviceId,
|
||||||
@@ -1601,6 +1675,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
const verification = verificationUnavailable(user);
|
const verification = verificationUnavailable(user);
|
||||||
if (verification) return verification;
|
if (verification) return verification;
|
||||||
const mfa = await mfaRequirement(user, {
|
const mfa = await mfaRequirement(user, {
|
||||||
|
fingerprint: sessionInput.fingerprint,
|
||||||
|
deviceName: sessionInput.deviceName,
|
||||||
ip: sessionInput.ip,
|
ip: sessionInput.ip,
|
||||||
userAgent: sessionInput.userAgent,
|
userAgent: sessionInput.userAgent,
|
||||||
deviceId: sessionInput.deviceId,
|
deviceId: sessionInput.deviceId,
|
||||||
@@ -1630,7 +1706,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
await restoreExpiredLoginLock(user);
|
await restoreExpiredLoginLock(user);
|
||||||
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
|
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -1651,6 +1738,18 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
if (unavailable) return unavailable;
|
if (unavailable) return unavailable;
|
||||||
const verification = verificationUnavailable(transactionUser);
|
const verification = verificationUnavailable(transactionUser);
|
||||||
if (verification) return verification;
|
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;
|
let verified = false;
|
||||||
if (input.method === "totp")
|
if (input.method === "totp")
|
||||||
verified = await engine.verifyTotp(transaction.userId, input.code);
|
verified = await engine.verifyTotp(transaction.userId, input.code);
|
||||||
@@ -1661,7 +1760,9 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
if (
|
if (
|
||||||
otp?.userId === transaction.userId &&
|
otp?.userId === transaction.userId &&
|
||||||
otp.method === input.method &&
|
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;
|
verified = (await engine.verifyOtp(input.challengeId, input.code, "mfa")).ok;
|
||||||
}
|
}
|
||||||
@@ -1682,7 +1783,10 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
const user = transactionUser;
|
const user = transactionUser;
|
||||||
const metadata = transaction.metadata ?? {};
|
const metadata = transaction.metadata ?? {};
|
||||||
const rememberedDevice =
|
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, {
|
? await engine.trustDevice(user.id, {
|
||||||
fingerprint: metadata.fingerprint,
|
fingerprint: metadata.fingerprint,
|
||||||
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
|
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
|
||||||
@@ -1716,6 +1820,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
severity: "info",
|
severity: "info",
|
||||||
data: { method: input.method },
|
data: { method: input.method },
|
||||||
});
|
});
|
||||||
|
await sendLoginAlert(user, session);
|
||||||
return { ok: true, user: publicUser(user), session };
|
return { ok: true, user: publicUser(user), session };
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1910,7 +2015,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
return account;
|
return account;
|
||||||
},
|
},
|
||||||
|
|
||||||
async loginWithOAuth(provider, profile, tokens) {
|
async loginWithOAuth(provider, profile, tokens, sessionInput = {}) {
|
||||||
const providerName = normalizedOAuthProvider(provider);
|
const providerName = normalizedOAuthProvider(provider);
|
||||||
const providerAccountId = normalizedOAuthAccountId(profile.id);
|
const providerAccountId = normalizedOAuthAccountId(profile.id);
|
||||||
let account = await store.findOAuthAccount(providerName, providerAccountId);
|
let account = await store.findOAuthAccount(providerName, providerAccountId);
|
||||||
@@ -1963,12 +2068,17 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
const verification = verificationUnavailable(user);
|
const verification = verificationUnavailable(user);
|
||||||
if (verification) return verification;
|
if (verification) return verification;
|
||||||
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
|
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)) {
|
if (await hasMfa(user.id)) {
|
||||||
const methods = await availableMfaMethods(user.id);
|
const methods = await availableMfaMethods(user.id);
|
||||||
const mfaExpiresAt = now() + 5 * 60_000;
|
const mfaExpiresAt = now() + 5 * 60_000;
|
||||||
const mfaToken = await issueToken(user.id, "login-mfa", {
|
const mfaToken = await issueToken(user.id, "login-mfa", {
|
||||||
ttlMs: 5 * 60_000,
|
ttlMs: 5 * 60_000,
|
||||||
metadata: { oauthProvider: providerName },
|
metadata: { ...sessionInput, oauthProvider: providerName },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -1979,7 +2089,7 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
requires: { mfa: methods },
|
requires: { mfa: methods },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const session = await createSession(user.id);
|
const session = await createSession(user.id, sessionInput);
|
||||||
user.lastLoginAt = now();
|
user.lastLoginAt = now();
|
||||||
user.updatedAt = now();
|
user.updatedAt = now();
|
||||||
await store.updateUser(user);
|
await store.updateUser(user);
|
||||||
@@ -2175,6 +2285,8 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
|
|||||||
credential.lastUsedAt = now();
|
credential.lastUsedAt = now();
|
||||||
await store.updatePasskey(credential);
|
await store.updatePasskey(credential);
|
||||||
}
|
}
|
||||||
|
const deviceCheck = await newDeviceRequirement(user, { ...input.session });
|
||||||
|
if (deviceCheck) return deviceCheck;
|
||||||
const session = await createSession(user.id, input.session);
|
const session = await createSession(user.id, input.session);
|
||||||
user.lastLoginAt = now();
|
user.lastLoginAt = now();
|
||||||
user.updatedAt = now();
|
user.updatedAt = now();
|
||||||
|
|||||||
@@ -64,6 +64,26 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
const onSignedOut = engine.onSignedOut;
|
const onSignedOut = engine.onSignedOut;
|
||||||
const onSuccessfulSignUp = engine.onSuccessfulSignUp;
|
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 {
|
function oauthProvider(ctx: Context): OAuthProvider | undefined {
|
||||||
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
|
return options.oauth?.[String(ctx.params.provider ?? "").toLowerCase()];
|
||||||
}
|
}
|
||||||
@@ -148,6 +168,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
provider.name,
|
provider.name,
|
||||||
completed.profile,
|
completed.profile,
|
||||||
completed.tokens,
|
completed.tokens,
|
||||||
|
browserDevice(ctx),
|
||||||
);
|
);
|
||||||
if (result.code === "mfa-required" && result.mfaToken) {
|
if (result.code === "mfa-required" && result.mfaToken) {
|
||||||
ctx.cookies.transaction("wrnexus.auth.mfa", { sameSite: "Lax", maxAge: 300 }).set({
|
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;
|
if (!validation.ok) return validation.response;
|
||||||
const input = validation.value;
|
const input = validation.value;
|
||||||
const result = await engine.register({
|
const result = await engine.register({
|
||||||
|
...browserDevice(ctx),
|
||||||
baseUrl: options.baseUrl ?? ctx.url.origin,
|
baseUrl: options.baseUrl ?? ctx.url.origin,
|
||||||
email: text(input.email) || undefined,
|
email: text(input.email) || undefined,
|
||||||
phone: text(input.phone) || undefined,
|
phone: text(input.phone) || undefined,
|
||||||
@@ -200,6 +222,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
if (action?.autoSignIn) {
|
if (action?.autoSignIn) {
|
||||||
const identifier = text(input.email) || text(input.phone) || text(input.username);
|
const identifier = text(input.email) || text(input.phone) || text(input.username);
|
||||||
const loginResult = await engine.login({
|
const loginResult = await engine.login({
|
||||||
|
...browserDevice(ctx),
|
||||||
identifier,
|
identifier,
|
||||||
password: text(input.password),
|
password: text(input.password),
|
||||||
ip: ctx.ip,
|
ip: ctx.ip,
|
||||||
@@ -229,6 +252,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
fingerprint: text(input.deviceFingerprint) || undefined,
|
fingerprint: text(input.deviceFingerprint) || undefined,
|
||||||
deviceName: text(input.deviceName) || undefined,
|
deviceName: text(input.deviceName) || undefined,
|
||||||
rememberDevice: boolean(input.rememberDevice),
|
rememberDevice: boolean(input.rememberDevice),
|
||||||
|
...browserDevice(ctx),
|
||||||
captchaVerified:
|
captchaVerified:
|
||||||
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
|
Boolean((ctx.locals.captcha as { success?: boolean } | undefined)?.success) ||
|
||||||
ctx.locals.captchaVerified === true,
|
ctx.locals.captchaVerified === true,
|
||||||
@@ -340,6 +364,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
if (!validation.ok) return validation.response;
|
if (!validation.ok) return validation.response;
|
||||||
const input = validation.value;
|
const input = validation.value;
|
||||||
const result = await engine.consumeMagicLink(text(input.token), {
|
const result = await engine.consumeMagicLink(text(input.token), {
|
||||||
|
...browserDevice(ctx),
|
||||||
ip: ctx.ip,
|
ip: ctx.ip,
|
||||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -366,6 +391,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
if (!validation.ok) return validation.response;
|
if (!validation.ok) return validation.response;
|
||||||
const input = validation.value;
|
const input = validation.value;
|
||||||
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
|
const result = await engine.completeOtpLogin(text(input.challengeId), text(input.code), {
|
||||||
|
...browserDevice(ctx),
|
||||||
ip: ctx.ip,
|
ip: ctx.ip,
|
||||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
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")
|
? (methodValue as "totp" | "recovery-code" | "email-otp" | "sms-otp")
|
||||||
: "totp";
|
: "totp";
|
||||||
const result = await engine.completeMfa({
|
const result = await engine.completeMfa({
|
||||||
|
...browserDevice(ctx),
|
||||||
|
rememberDevice: boolean(input.rememberDevice),
|
||||||
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
|
mfaToken: text(input.mfaToken) || continuation?.mfaToken || "",
|
||||||
method,
|
method,
|
||||||
code: text(input.code),
|
code: text(input.code),
|
||||||
@@ -618,6 +646,7 @@ export function createAuthHttpHandlers(options: AuthHttpOptions) {
|
|||||||
key: text(input.key),
|
key: text(input.key),
|
||||||
response: input.response,
|
response: input.response,
|
||||||
session: {
|
session: {
|
||||||
|
...browserDevice(ctx),
|
||||||
ip: ctx.ip,
|
ip: ctx.ip,
|
||||||
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
userAgent: ctx.req.headers.get("user-agent") ?? undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -438,6 +438,10 @@ export interface AuthEngineOptions {
|
|||||||
sessionTtlMs?: number;
|
sessionTtlMs?: number;
|
||||||
sessionAbsoluteTtlMs?: number;
|
sessionAbsoluteTtlMs?: number;
|
||||||
trustedDeviceTtlMs?: 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>>;
|
tokenTtlMs?: Partial<Record<AuthTokenPurpose, number>>;
|
||||||
/**
|
/**
|
||||||
* Customize action links delivered with one-time tokens. Returning undefined
|
* Customize action links delivered with one-time tokens. Returning undefined
|
||||||
@@ -472,6 +476,8 @@ export interface AuthEngineOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterInput {
|
export interface RegisterInput {
|
||||||
|
fingerprint?: string;
|
||||||
|
deviceName?: string;
|
||||||
/** Trusted server-side origin for verification links; never copied from request data. */
|
/** Trusted server-side origin for verification links; never copied from request data. */
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export const mfaOtpRequestSchema = v.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const mfaSchema = v.object({
|
export const mfaSchema = v.object({
|
||||||
|
rememberDevice: v.boolean().optional(),
|
||||||
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
|
mfaToken: v.string().min(20, "MFA transaction is invalid").max(512).optional(),
|
||||||
method: v
|
method: v
|
||||||
.string()
|
.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