feat(auth): require verified new-browser trust with fixed expiry

This commit is contained in:
Clintchiz committed 2026-08-26 19:59:22 +05:30
1 parent bef67b0a24
commit 61a4f01321
9 files changed
+418 -33

No files matched your search

+142 -30
View File
@@ -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();
+29
View File
@@ -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,
},
+6
View File
@@ -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;
+1
View File
@@ -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()