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
+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();
|
||||
|
||||
Reference in new issue
Block a user