Files
WRNexusJS/packages/auth/src/engine.ts
T
2026-07-29 12:51:10 +05:30

2289 lines
82 KiB
TypeScript

import { hashPassword, verifyPassword } from "@wrnexus/core";
import type { OAuthProfile, OAuthTokens } from "@wrnexus/oauth";
import {
constantTimeEqual,
fingerprint,
hashSecret,
randomDigits,
randomReadableCode,
randomToken,
} from "./crypto.ts";
import { inferIdentityType, normalizeIdentity, publicUser } from "./normalize.ts";
import { assertPasskeyProvider, MemoryPasskeyChallengeStore } from "./passkeys/index.ts";
import { evaluateAuthRisk } from "./risk.ts";
import type { AuthStore } from "./store.ts";
import { generateTotpSecret, totpUri, verifyTotp } from "./totp/index.ts";
import type {
AuthDeliveryMessage,
AuthEngineOptions,
AuthIdentity,
AuthIdentityType,
AuthResult,
AuthSecurityEvent,
AuthSession,
AuthSignedInHandler,
AuthSignedOutHandler,
AuthSuccessfulSignUpHandler,
AuthTokenPurpose,
AuthUser,
LoginInput,
OAuthAccount,
PasskeyAuthenticationOptions,
PasskeyRegistrationOptions,
RegisterInput,
TrustedDevice,
} from "./types.ts";
const MIN_SECRET_LENGTH = 32;
const DEFAULT_TOKEN_TTLS: Record<AuthTokenPurpose, number> = {
"verify-email": 24 * 60 * 60_000,
"verify-phone": 15 * 60_000,
"password-reset": 30 * 60_000,
"magic-link": 15 * 60_000,
invite: 7 * 24 * 60 * 60_000,
"change-email": 30 * 60_000,
"change-phone": 15 * 60_000,
"login-mfa": 5 * 60_000,
};
export interface AuthEngine {
readonly store: AuthStore;
readonly onSignedIn?: AuthSignedInHandler;
readonly onSignedOut?: AuthSignedOutHandler;
readonly onSuccessfulSignUp?: AuthSuccessfulSignUpHandler;
register(input: RegisterInput): Promise<AuthResult>;
login(input: LoginInput): Promise<AuthResult>;
logout(sessionId: string, reason?: string): Promise<void>;
findUserByIdentifier(identifier: string): Promise<AuthUser | undefined>;
getUser(userId: string): Promise<AuthUser | undefined>;
getPublicUser(userId: string): Promise<ReturnType<typeof publicUser> | undefined>;
createSession(
userId: string,
input?: Partial<AuthSession> & { fingerprint?: string; deviceName?: string },
): Promise<AuthSession>;
validateSession(sessionId: string): Promise<AuthSession | undefined>;
listSessions(userId: string): Promise<AuthSession[]>;
revokeSession(userId: string, sessionId: string, reason?: string): Promise<boolean>;
revokeAllSessions(userId: string, exceptSessionId?: string, reason?: string): Promise<number>;
trustDevice(
userId: string,
input: { fingerprint: string; name?: string },
): Promise<TrustedDevice>;
listTrustedDevices(userId: string): ReturnType<AuthStore["listTrustedDevices"]>;
revokeTrustedDevice(userId: string, deviceId: string): Promise<boolean>;
issueToken(
userId: string,
purpose: AuthTokenPurpose,
input?: { target?: string; metadata?: Record<string, unknown>; ttlMs?: number },
): Promise<string>;
consumeToken(token: string, purpose: AuthTokenPurpose): Promise<AuthUser | undefined>;
verifyEmail(token: string): Promise<AuthResult>;
verifyPhone(token: string): Promise<AuthResult>;
requestVerification(userId: string, type: "email" | "phone", baseUrl?: string): Promise<void>;
requestPasswordReset(identifier: string, baseUrl?: string): Promise<void>;
resetPassword(token: string, password: string): Promise<AuthResult>;
requestMagicLink(identifier: string, baseUrl?: string): Promise<void>;
createInvitation(input: {
email: string;
displayName?: string;
roles?: string[];
invitedBy?: string;
metadata?: Record<string, unknown>;
ttlMs?: number;
baseUrl?: string;
}): Promise<{ token: string; user: ReturnType<typeof publicUser> }>;
acceptInvitation(
token: string,
input: { password?: string; displayName?: string },
): Promise<AuthResult>;
consumeMagicLink(token: string, sessionInput?: Partial<AuthSession>): Promise<AuthResult>;
issueOtp(
userId: string,
method: "email-otp" | "sms-otp",
destination?: string,
purpose?: "verification" | "login" | "mfa",
): Promise<{ id: string; expiresAt: number }>;
verifyOtp(
challengeId: string,
code: string,
expectedPurpose?: "verification" | "login" | "mfa",
): Promise<AuthResult>;
requestOtpLogin(
identifier: string,
method: "email-otp" | "sms-otp",
): Promise<{ id: string; expiresAt: number }>;
completeOtpLogin(
challengeId: string,
code: string,
sessionInput?: Partial<AuthSession>,
): Promise<AuthResult>;
beginMfaOtp(
mfaToken: string,
method: "email-otp" | "sms-otp",
): Promise<{ id: string; expiresAt: number } | undefined>;
completeMfa(input: {
mfaToken: string;
method: "totp" | "recovery-code" | "email-otp" | "sms-otp";
code: string;
challengeId?: string;
session?: Partial<AuthSession>;
}): Promise<AuthResult>;
beginTotp(
userId: string,
label?: string,
): Promise<{ credentialId: string; secret: string; uri: string }>;
confirmTotp(userId: string, credentialId: string, token: string): Promise<boolean>;
verifyTotp(userId: string, token: string): Promise<boolean>;
disableTotp(userId: string, credentialId: string): Promise<boolean>;
generateRecoveryCodes(userId: string, count?: number): Promise<string[]>;
consumeRecoveryCode(userId: string, code: string): Promise<boolean>;
listRecoveryCodeStatus(userId: string): Promise<{ total: number; remaining: number }>;
linkOAuth(
userId: string,
provider: string,
profile: OAuthProfile,
tokens?: OAuthTokens,
): Promise<OAuthAccount>;
loginWithOAuth(
provider: string,
profile: OAuthProfile,
tokens?: OAuthTokens,
): Promise<AuthResult>;
unlinkOAuth(userId: string, accountId: string): Promise<boolean>;
beginPasskeyRegistration(
userId: string,
input: { rpId: string; rpName: string; origin: string },
): Promise<{ key: string; options: PasskeyRegistrationOptions }>;
finishPasskeyRegistration(
userId: string,
input: {
key: string;
response: unknown;
name?: string;
/** @deprecated Verification uses the RP ID bound to the issued challenge. */
rpId?: string;
/** @deprecated Verification uses the origin bound to the issued challenge. */
origin?: string;
},
): Promise<boolean>;
beginPasskeyAuthentication(input: {
identifier?: string;
rpId: string;
origin: string;
}): Promise<{ key: string; options: PasskeyAuthenticationOptions }>;
finishPasskeyAuthentication(input: {
key: string;
response: unknown;
/** Request metadata used only for the resulting session. */
session?: Partial<AuthSession>;
/** @deprecated Verification uses the RP ID bound to the issued challenge. */
rpId?: string;
/** @deprecated Verification uses the origin bound to the issued challenge. */
origin?: string;
}): Promise<AuthResult>;
changePassword(
userId: string,
currentPassword: string,
nextPassword: string,
): Promise<AuthResult>;
setAccountStatus(userId: string, status: AuthUser["status"], actorUserId?: string): Promise<void>;
startImpersonation(
actorUserId: string,
targetUserId: string,
input?: { reason?: string; sessionId?: string; ip?: string; userAgent?: string },
): Promise<AuthResult>;
stopImpersonation(sessionId: string): Promise<AuthResult>;
}
function defaultRandom(length: number): Uint8Array {
return crypto.getRandomValues(new Uint8Array(length));
}
function safeIdentifier(value: string): string {
const trimmed = value.trim().slice(0, 320);
return normalizeIdentity(inferIdentityType(trimmed), trimmed);
}
function normalizedOAuthProvider(value: string): string {
const provider = value.trim().toLowerCase();
if (!provider || provider.length > 64 || !/^[a-z0-9._-]+$/.test(provider)) {
throw new TypeError(
"OAuth provider must use 1 to 64 letters, numbers, dots, underscores, or hyphens",
);
}
return provider;
}
function normalizedOAuthAccountId(value: string): string {
const accountId = value.trim();
if (!accountId || accountId.length > 191) {
throw new TypeError("OAuth provider account ID must contain 1 to 191 characters");
}
return accountId;
}
function validatedIdentity(type: AuthIdentityType, value: string): string {
const raw = value.trim();
const normalized = normalizeIdentity(type, raw);
if (type === "email") {
if (raw.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)) {
throw new TypeError("Enter a valid email address");
}
} else if (type === "phone") {
if (raw.length > 24 || !/^\+?[0-9 ()-]{7,24}$/.test(raw) || !/^\+?\d{7,15}$/.test(normalized)) {
throw new TypeError("Enter a valid phone number");
}
} else if (
normalized.length < 3 ||
normalized.length > 64 ||
!/^[a-z0-9._-]+$/.test(normalized)
) {
throw new TypeError("Enter a valid username");
}
return normalized;
}
function positiveInteger(
value: number | undefined,
fallback: number,
name: string,
minimum = 1,
): number {
const resolved = value ?? fallback;
if (!Number.isSafeInteger(resolved) || resolved < minimum) {
throw new TypeError(`${name} must be a safe integer greater than or equal to ${minimum}`);
}
return resolved;
}
function positiveDuration(value: number | undefined, fallback: number, name: string): number {
const resolved = value ?? fallback;
if (!Number.isFinite(resolved) || resolved <= 0) {
throw new TypeError(`${name} must be a positive finite duration`);
}
return Math.trunc(resolved);
}
function passkeyChallengeTtl(timeout: number): number {
const fallback = 5 * 60_000;
if (!Number.isFinite(timeout)) return fallback;
return Math.min(10 * 60_000, Math.max(30_000, Math.trunc(timeout)));
}
export function createAuthEngine(options: AuthEngineOptions): AuthEngine {
if (options.secret.length < MIN_SECRET_LENGTH) {
throw new TypeError(`auth secret must be at least ${MIN_SECRET_LENGTH} characters`);
}
const store = options.store;
const now = () => {
const value = options.clock?.now() ?? Date.now();
if (!Number.isFinite(value)) throw new Error("WRN-AUTH-CLOCK: clock returned an invalid time");
return Math.trunc(value);
};
const random = (length: number) => options.random?.bytes(length) ?? defaultRandom(length);
const issuer = options.issuer?.trim() || "WRNexusJS";
const sessionTtlMs = positiveDuration(options.sessionTtlMs, 24 * 60 * 60_000, "sessionTtlMs");
const absoluteTtlMs = positiveDuration(
options.sessionAbsoluteTtlMs,
30 * 24 * 60 * 60_000,
"sessionAbsoluteTtlMs",
);
if (absoluteTtlMs < sessionTtlMs) {
throw new TypeError("sessionAbsoluteTtlMs must be greater than or equal to sessionTtlMs");
}
const trustedTtlMs = positiveDuration(
options.trustedDeviceTtlMs,
90 * 24 * 60 * 60_000,
"trustedDeviceTtlMs",
);
const otpTtlMs = positiveDuration(options.otpTtlMs, 10 * 60_000, "otpTtlMs");
const maxTokenAttempts = positiveInteger(options.maxTokenAttempts, 5, "maxTokenAttempts");
const maxOtpAttempts = positiveInteger(options.maxOtpAttempts, 5, "maxOtpAttempts");
const maxFailedLogins = positiveInteger(options.maxFailedLogins, 5, "maxFailedLogins");
const lockDurationMs = positiveDuration(options.lockDurationMs, 15 * 60_000, "lockDurationMs");
const passwordMinLength = positiveInteger(options.passwordMinLength, 12, "passwordMinLength", 8);
const passkeyChallenges = options.passkeyChallengeStore ?? new MemoryPasskeyChallengeStore(now);
let dummyPasswordHash: Promise<string> | undefined;
const protect = async (value: string, purpose: "totp" | "oauth-access" | "oauth-refresh") =>
options.secretProtector ? options.secretProtector.protect(value, purpose) : value;
const reveal = async (value: string, purpose: "totp" | "oauth-access" | "oauth-refresh") =>
options.secretProtector ? options.secretProtector.reveal(value, purpose) : value;
const id = (prefix: string) => `${prefix}_${randomToken(random, 18)}`;
const tokenTtl = (purpose: AuthTokenPurpose, override?: number) =>
positiveDuration(
override ?? options.tokenTtlMs?.[purpose],
DEFAULT_TOKEN_TTLS[purpose],
`tokenTtlMs.${purpose}`,
);
async function audit(input: Omit<AuthSecurityEvent, "id" | "createdAt">): Promise<void> {
const event: AuthSecurityEvent = { ...input, id: id("evt"), createdAt: now() };
await store.createSecurityEvent(event);
await options.audit?.(event);
}
async function deliver(message: AuthDeliveryMessage): Promise<boolean> {
if (!options.delivery) return false;
try {
await options.delivery.send(message);
return true;
} catch (error) {
await audit({
userId: message.user.id,
type: "delivery.failed",
severity: "warning",
data: {
channel: message.channel,
template: message.template,
error: error instanceof Error ? error.name : "UnknownError",
},
});
return false;
}
}
async function findUserByIdentifier(identifier: string): Promise<AuthUser | undefined> {
const type = inferIdentityType(identifier);
const identity = await store.findIdentity(type, normalizeIdentity(type, identifier));
return identity ? store.findUserById(identity.userId) : undefined;
}
async function restoreExpiredLoginLock(user: AuthUser): Promise<void> {
if (user.status !== "locked") return;
const metadata = user.metadata ?? {};
const lockedUntil = Number(metadata.lockedUntil ?? 0);
if (
metadata.lockReason !== "failed-login" ||
!Number.isFinite(lockedUntil) ||
lockedUntil <= 0 ||
lockedUntil > now()
) {
return;
}
const previous = metadata.lockedPreviousStatus === "pending" ? "pending" : "active";
const {
lockedUntil: _lockedUntil,
lockReason: _lockReason,
lockedPreviousStatus: _previous,
...rest
} = metadata;
user.status = previous;
user.metadata = Object.keys(rest).length ? rest : undefined;
user.updatedAt = now();
await store.updateUser(user);
}
function accountStatusUnavailable(user: AuthUser): AuthResult | undefined {
if (user.status === "locked") {
return {
ok: false,
code: "account-locked",
message: "This account is locked. Try again later or contact support.",
};
}
if (user.status === "disabled") {
return {
ok: false,
code: "account-disabled",
message: "This account is disabled. Contact support for help.",
};
}
if (user.status === "deleted") {
return {
ok: false,
code: "account-deleted",
message: "This account is unavailable. Contact support for help.",
};
}
return undefined;
}
function recoveryUnavailable(user: AuthUser): AuthResult | undefined {
if (user.status === "disabled" || user.status === "deleted") {
return {
ok: false,
code: `account-${user.status}`,
message: "This account cannot complete recovery",
};
}
if (user.status === "locked" && user.metadata?.lockReason !== "failed-login") {
return {
ok: false,
code: "account-locked",
message: "This account cannot complete recovery",
};
}
return undefined;
}
async function clearFailedLoginLock(user: AuthUser): Promise<void> {
if (user.status !== "locked" || user.metadata?.lockReason !== "failed-login") return;
const metadata = user.metadata ?? {};
const previous = metadata.lockedPreviousStatus === "pending" ? "pending" : "active";
const {
lockedUntil: _lockedUntil,
lockReason: _lockReason,
lockedPreviousStatus: _previous,
...rest
} = metadata;
user.status = previous;
user.metadata = Object.keys(rest).length ? rest : undefined;
user.updatedAt = now();
await store.updateUser(user);
}
function verificationUnavailable(user: AuthUser): AuthResult | undefined {
if (options.requireVerifiedEmail && !user.emailVerified) {
return {
ok: false,
code: "email-unverified",
message: "Verify your email address before signing in.",
user: publicUser(user),
requires: { emailVerification: true },
};
}
if (options.requireVerifiedPhone && !user.phoneVerified) {
return {
ok: false,
code: "phone-unverified",
message: "Verify your phone number before signing in.",
user: publicUser(user),
requires: { phoneVerification: true },
};
}
return undefined;
}
async function mfaRequirement(
user: AuthUser,
metadata: Record<string, unknown> = {},
): Promise<AuthResult | undefined> {
if (!(await hasMfa(user.id))) return undefined;
const methods = await availableMfaMethods(user.id);
if (!methods.length) {
return {
ok: false,
code: "mfa-unavailable",
message: "Additional verification is required but no verification method is available",
};
}
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata,
});
return {
ok: false,
code: "mfa-required",
message: "Complete two-step verification to finish signing in.",
user: publicUser(user),
mfaToken,
requires: { mfa: methods },
};
}
async function assertPasswordPolicy(password: string): Promise<void> {
if (password.length < passwordMinLength) {
throw new Error(`Password must be at least ${passwordMinLength} characters`);
}
if (!/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password)) {
throw new Error("Password must include uppercase, lowercase, and a number");
}
if (await options.breachProvider?.isBreached(password)) {
throw new Error("This password appears in a known breach");
}
}
async function consumeUnknownPasswordAttempt(password: string): Promise<void> {
dummyPasswordHash ??= hashPassword(`WRNexusJS-Dummy-${randomToken(random, 24)}-Aa1`);
await verifyPassword(password, await dummyPasswordHash);
}
async function createIdentity(
userId: string,
type: AuthIdentityType,
value: string,
primary: boolean,
): Promise<AuthIdentity> {
const normalizedValue = validatedIdentity(type, value);
if (await store.findIdentity(type, normalizedValue)) {
throw new Error(`${type} is already in use`);
}
const timestamp = now();
const identity: AuthIdentity = {
id: id("idn"),
userId,
type,
value: value.trim(),
normalizedValue,
primary,
createdAt: timestamp,
updatedAt: timestamp,
};
await store.createIdentity(identity);
return identity;
}
async function issueToken(
userId: string,
purpose: AuthTokenPurpose,
input: { target?: string; metadata?: Record<string, unknown>; ttlMs?: number } = {},
): Promise<string> {
if (!(await store.findUserById(userId))) throw new Error("User not found");
const raw = randomToken(random, 32);
const timestamp = now();
await store.createToken({
id: id("tok"),
userId,
purpose,
tokenHash: await hashSecret(raw, options.secret),
target: input.target,
createdAt: timestamp,
expiresAt: timestamp + tokenTtl(purpose, input.ttlMs),
attempts: 0,
maxAttempts: maxTokenAttempts,
metadata: input.metadata,
});
return raw;
}
async function consumeTokenRecord(
raw: string,
purpose: AuthTokenPurpose,
): Promise<{ user: AuthUser; token: import("./types.ts").OneTimeToken } | undefined> {
const token = await store.findTokenByHash(await hashSecret(raw, options.secret));
if (!token || token.purpose !== purpose || token.usedAt || token.expiresAt <= now())
return undefined;
token.attempts += 1;
if (token.attempts > token.maxAttempts) {
await store.updateToken(token);
return undefined;
}
const user = await store.findUserById(token.userId);
if (!user) return undefined;
token.usedAt = now();
await store.updateToken(token);
return { user, token };
}
async function consumeToken(
raw: string,
purpose: AuthTokenPurpose,
): Promise<AuthUser | undefined> {
return (await consumeTokenRecord(raw, purpose))?.user;
}
async function inspectToken(raw: string, purpose: AuthTokenPurpose) {
const token = await store.findTokenByHash(await hashSecret(raw, options.secret));
if (!token || token.purpose !== purpose || token.usedAt || token.expiresAt <= now())
return undefined;
return token;
}
async function consumeInspectedToken(token: import("./types.ts").OneTimeToken): Promise<void> {
token.attempts += 1;
token.usedAt = now();
await store.updateToken(token);
}
function actionUrl(
user: AuthUser,
purpose: AuthTokenPurpose,
token: string,
destination: string,
baseUrl?: string,
): string | undefined {
if (options.tokenUrl) {
return options.tokenUrl({
purpose,
token,
baseUrl,
destination,
user: publicUser(user),
});
}
if (!baseUrl) return undefined;
if (purpose === "invite") {
return new URL(`/join/${encodeURIComponent(token)}`, baseUrl).toString();
}
return new URL(`/${purpose}?token=${encodeURIComponent(token)}`, baseUrl).toString();
}
async function sendToken(
user: AuthUser,
purpose: AuthTokenPurpose,
destination: string,
baseUrl?: string,
): Promise<string> {
const token = await issueToken(user.id, purpose, { target: destination });
const url = actionUrl(user, purpose, token, destination, baseUrl);
await deliver({
channel: destination.includes("@") ? "email" : "sms",
template:
purpose === "password-reset"
? "password-reset"
: purpose === "magic-link"
? "magic-link"
: purpose === "verify-email"
? "verify-email"
: purpose === "invite"
? "invitation"
: "verify-phone",
destination,
token,
url,
user: publicUser(user),
expiresAt: now() + tokenTtl(purpose),
});
return token;
}
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,
},
});
}
async function createSession(
userId: string,
input: Partial<AuthSession> & { fingerprint?: string; deviceName?: string } = {},
): Promise<AuthSession> {
if (!(await store.findUserById(userId))) throw new Error("User not found");
const timestamp = now();
let trusted = Boolean(input.trusted);
let deviceId = input.deviceId ?? id("dev");
if (input.fingerprint) {
const hash = await fingerprint(input.fingerprint);
const existing = await store.findTrustedDeviceByFingerprint(userId, hash);
if (existing && !existing.revokedAt && existing.expiresAt > timestamp) {
trusted = true;
deviceId = existing.id;
existing.lastSeenAt = timestamp;
await store.updateTrustedDevice(existing);
}
}
const session: AuthSession = {
id: input.id ?? id("ses"),
userId,
deviceId,
createdAt: timestamp,
lastSeenAt: timestamp,
expiresAt: timestamp + sessionTtlMs,
absoluteExpiresAt: timestamp + absoluteTtlMs,
ip: input.ip,
userAgent: input.userAgent,
trusted,
metadata: input.metadata,
};
await store.createSession(session);
return session;
}
async function validateSession(sessionId: string): Promise<AuthSession | undefined> {
const session = await store.findSession(sessionId);
const timestamp = now();
if (
!session ||
session.revokedAt ||
session.expiresAt <= timestamp ||
session.absoluteExpiresAt <= timestamp
) {
if (session) await store.deleteSession(session.id);
return undefined;
}
session.lastSeenAt = timestamp;
session.expiresAt = Math.min(timestamp + sessionTtlMs, session.absoluteExpiresAt);
await store.updateSession(session);
return session;
}
async function markVerified(
user: AuthUser,
type: "email" | "phone",
targetValue?: string,
): Promise<boolean> {
const identities = await store.listIdentities(user.id);
const normalizedTarget = targetValue ? normalizeIdentity(type, targetValue) : undefined;
const target = normalizedTarget
? identities.find((item) => item.type === type && item.normalizedValue === normalizedTarget)
: (identities.find((item) => item.type === type && item.primary) ??
identities.find((item) => item.type === type));
if (!target) return false;
target.verifiedAt = now();
target.updatedAt = now();
await store.updateIdentity(target);
if (type === "email") user.emailVerified = true;
else user.phoneVerified = true;
if (user.status === "pending") user.status = "active";
user.updatedAt = now();
await store.updateUser(user);
return true;
}
async function issueOtp(
userId: string,
method: "email-otp" | "sms-otp",
destination?: string,
purpose: "verification" | "login" | "mfa" = "verification",
) {
const user = await store.findUserById(userId);
if (!user) throw new Error("User not found");
const identities = await store.listIdentities(userId);
const type = method === "email-otp" ? "email" : "phone";
const matchingIdentities = identities.filter(
(item) => item.type === type && (purpose !== "mfa" || Boolean(item.verifiedAt)),
);
const normalizedDestination = destination ? normalizeIdentity(type, destination) : undefined;
const requestedIdentity = normalizedDestination
? matchingIdentities.find((item) => item.normalizedValue === normalizedDestination)
: undefined;
if (destination && !requestedIdentity) {
throw new Error(`OTP destination must be a linked ${type} identity`);
}
const resolved =
requestedIdentity?.value ??
matchingIdentities.find((item) => item.primary)?.value ??
matchingIdentities[0]?.value;
if (!resolved) throw new Error(`No ${type} identity is available`);
const code = randomDigits(random, 6);
const timestamp = now();
const challenge = {
id: id("otp"),
userId,
method,
purpose,
destination: resolved,
codeHash: await hashSecret(code, options.secret),
createdAt: timestamp,
expiresAt: timestamp + otpTtlMs,
attempts: 0,
maxAttempts: maxOtpAttempts,
} as const;
await store.createOtp(challenge);
await deliver({
channel: method === "email-otp" ? "email" : "sms",
template: method,
destination: resolved,
code,
user: publicUser(user),
expiresAt: challenge.expiresAt,
});
return { id: challenge.id, expiresAt: challenge.expiresAt };
}
async function oauthEmailVerified(provider: string, profile: OAuthProfile): Promise<boolean> {
if (!profile.email) return false;
if (options.isOAuthEmailVerified) return options.isOAuthEmailVerified(provider, profile);
const raw = profile.raw;
return (
raw.email_verified === true ||
raw.verified_email === true ||
raw.emailVerified === true ||
raw.verified === true
);
}
async function hasMfa(userId: string): Promise<boolean> {
const [totp, recovery] = await Promise.all([
store.listTotp(userId),
store.listRecoveryCodes(userId),
]);
return totp.some((item) => item.verifiedAt) || recovery.some((item) => !item.usedAt);
}
async function availableMfaMethods(
userId: string,
): Promise<Array<"email-otp" | "sms-otp" | "totp" | "recovery-code">> {
const [identities, totp, recovery] = await Promise.all([
store.listIdentities(userId),
store.listTotp(userId),
store.listRecoveryCodes(userId),
]);
const methods: Array<"email-otp" | "sms-otp" | "totp" | "recovery-code"> = [];
if (identities.some((item) => item.type === "email" && item.verifiedAt)) {
methods.push("email-otp");
}
if (identities.some((item) => item.type === "phone" && item.verifiedAt)) {
methods.push("sms-otp");
}
if (totp.some((item) => item.verifiedAt)) methods.push("totp");
if (recovery.some((item) => !item.usedAt)) methods.push("recovery-code");
return methods;
}
const engine: AuthEngine = {
store,
onSignedIn: options.onSignedIn,
onSignedOut: options.onSignedOut,
onSuccessfulSignUp: options.onSuccessfulSignUp ?? options.onSuccessfullSignUp,
async register(input) {
try {
await assertPasswordPolicy(input.password);
if (!input.email && !input.phone && !input.username)
throw new Error("Email, phone, or username is required");
for (const [type, value] of [
["email", input.email],
["phone", input.phone],
["username", input.username],
] as const) {
if (value) {
const normalized = validatedIdentity(type, value);
if (await store.findIdentity(type, normalized)) {
throw new Error(`${type} is already in use`);
}
}
}
const timestamp = now();
const user: AuthUser = {
id: id("usr"),
username: input.username?.trim(),
displayName: input.displayName?.trim(),
status: "pending",
roles: ["user"],
emailVerified: false,
phoneVerified: false,
mfaEnabled: false,
locale: input.locale,
timezone: input.timezone,
createdAt: timestamp,
updatedAt: timestamp,
metadata: input.metadata,
};
await store.createUser(user);
if (input.email) await createIdentity(user.id, "email", input.email, true);
if (input.phone) await createIdentity(user.id, "phone", input.phone, !input.email);
if (input.username)
await createIdentity(user.id, "username", input.username, !input.email && !input.phone);
await store.setPassword({
userId: user.id,
passwordHash: await hashPassword(input.password),
passwordVersion: 1,
changedAt: timestamp,
mustChange: false,
});
const identities = await store.listIdentities(user.id);
const email = identities.find((item) => item.type === "email");
const phone = identities.find((item) => item.type === "phone");
if (email) await sendToken(user, "verify-email", email.value);
if (phone) await sendToken(user, "verify-phone", phone.value);
await audit({ userId: user.id, type: "account.registered", severity: "info" });
return {
ok: true,
user: publicUser(user),
requires: {
emailVerification: Boolean(email),
phoneVerification: Boolean(phone),
},
};
} catch (error) {
return {
ok: false,
code: "registration-failed",
message: error instanceof Error ? error.message : "Registration failed",
};
}
},
async login(input) {
const identifier = safeIdentifier(input.identifier);
const user = await findUserByIdentifier(identifier);
if (user) await restoreExpiredLoginLock(user);
const recent = await store.listRecentLoginAttempts(identifier, now() - lockDurationMs);
const failedAttempts = recent.filter((attempt) => !attempt.success).length;
let breachedPassword = false;
if (options.breachProvider)
breachedPassword = await options.breachProvider.isBreached(input.password);
const risk = evaluateAuthRisk(
{
...input.signals,
failedAttempts,
breachedPassword,
accountLocked: user?.status === "locked",
},
{
captchaThreshold: options.captchaThreshold ?? 35,
mfaThreshold: options.mfaThreshold ?? 60,
blockThreshold: options.blockThreshold ?? 90,
},
);
if (!input.captchaVerified && risk.requireCaptcha && !risk.block) {
await store.createLoginAttempt({
id: id("log"),
identifier,
userId: user?.id,
success: false,
reason: "captcha-required",
ip: input.ip,
userAgent: input.userAgent,
createdAt: now(),
riskScore: risk.score,
riskLevel: risk.level,
});
return {
ok: false,
code: "captcha-required",
message: "Complete the security check shown on the sign-in form, then try again.",
risk,
requires: { captcha: true },
};
}
if (!user || risk.block) {
await consumeUnknownPasswordAttempt(input.password);
await store.createLoginAttempt({
id: id("log"),
identifier,
userId: user?.id,
success: false,
reason: risk.block ? "risk-blocked" : "unknown-user",
ip: input.ip,
userAgent: input.userAgent,
createdAt: now(),
riskScore: risk.score,
riskLevel: risk.level,
});
return {
ok: false,
code: risk.block ? "risk-blocked" : "invalid-credentials",
message: risk.block
? "Sign-in was blocked because of unusual activity. Wait a few minutes and try again."
: "The email, phone, username, or password you entered is incorrect.",
risk,
requires: { captcha: risk.requireCaptcha },
};
}
const credential = await store.getPassword(user.id);
const valid = credential
? await verifyPassword(input.password, credential.passwordHash)
: (await consumeUnknownPasswordAttempt(input.password), false);
await store.createLoginAttempt({
id: id("log"),
identifier,
userId: user.id,
success: valid,
reason: valid ? undefined : "invalid-password",
ip: input.ip,
userAgent: input.userAgent,
createdAt: now(),
riskScore: risk.score,
riskLevel: risk.level,
});
if (!valid) {
await audit({
userId: user.id,
type: "login.failed",
severity: "warning",
ip: input.ip,
userAgent: input.userAgent,
data: { risk },
});
if (failedAttempts + 1 >= maxFailedLogins) {
const previousStatus = user.status;
user.status = "locked";
user.updatedAt = now();
user.metadata = {
...user.metadata,
lockedUntil: now() + lockDurationMs,
lockReason: "failed-login",
lockedPreviousStatus: previousStatus,
};
await store.updateUser(user);
}
return {
ok: false,
code: "invalid-credentials",
message: "The email, phone, username, or password you entered is incorrect.",
risk,
requires: { captcha: risk.requireCaptcha },
};
}
// Check account state only after proving the password so public login
// responses do not reveal whether a known identifier is disabled or locked.
const accountUnavailable = accountStatusUnavailable(user);
if (accountUnavailable) return { ...accountUnavailable, risk };
const verification = verificationUnavailable(user);
if (verification) return { ...verification, risk };
const mfaEnabled = await hasMfa(user.id);
const trustedDevice = input.fingerprint
? await store.findTrustedDeviceByFingerprint(user.id, await fingerprint(input.fingerprint))
: undefined;
const trusted = Boolean(
trustedDevice && !trustedDevice.revokedAt && trustedDevice.expiresAt > now(),
);
user.mfaEnabled = mfaEnabled;
if (
(mfaEnabled && !((options.skipMfaForTrustedDevices ?? true) && trusted)) ||
risk.requireMfa
) {
await store.updateUser(user);
const methods = await availableMfaMethods(user.id);
if (!methods.length) {
return {
ok: false,
code: "mfa-unavailable",
message: "Additional verification is required but no verification method is available",
risk,
};
}
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata: {
ip: input.ip,
userAgent: input.userAgent,
deviceId: input.deviceId,
deviceName: input.deviceName,
fingerprint: input.fingerprint,
rememberDevice: input.rememberDevice,
},
});
return {
ok: false,
code: "mfa-required",
message: "Complete two-step verification to finish signing in.",
user: publicUser(user),
risk,
mfaToken,
requires: { captcha: risk.requireCaptcha, mfa: methods },
};
}
const rememberedDevice =
input.rememberDevice && input.fingerprint
? await engine.trustDevice(user.id, {
fingerprint: input.fingerprint,
name: input.deviceName,
})
: undefined;
const session = await createSession(user.id, {
ip: input.ip,
userAgent: input.userAgent,
deviceId: rememberedDevice?.id ?? input.deviceId,
trusted: Boolean(rememberedDevice),
fingerprint: input.fingerprint,
deviceName: input.deviceName,
});
user.status = "active";
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
await audit({
userId: user.id,
sessionId: session.id,
type: "login.succeeded",
severity: "info",
ip: input.ip,
userAgent: input.userAgent,
data: { risk, trustedDevice: session.trusted },
});
await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session, risk };
},
async logout(sessionId, reason = "user-logout") {
const session = await store.findSession(sessionId);
if (!session) return;
session.revokedAt = now();
session.revokeReason = reason;
await store.updateSession(session);
await audit({
userId: session.userId,
sessionId,
type: "session.revoked",
severity: "info",
data: { reason },
});
},
findUserByIdentifier,
getUser: (userId) => store.findUserById(userId),
async getPublicUser(userId) {
const user = await store.findUserById(userId);
return user ? publicUser(user) : undefined;
},
createSession,
validateSession,
async listSessions(userId) {
const timestamp = now();
const sessions = await store.listSessions(userId);
const active: AuthSession[] = [];
for (const session of sessions) {
if (session.revokedAt) continue;
if (session.expiresAt <= timestamp || session.absoluteExpiresAt <= timestamp) {
await store.deleteSession(session.id);
continue;
}
active.push(session);
}
return active.sort((left, right) => right.lastSeenAt - left.lastSeenAt);
},
async revokeSession(userId, sessionId, reason = "user-revoked") {
const session = await store.findSession(sessionId);
if (!session || session.userId !== userId) return false;
session.revokedAt = now();
session.revokeReason = reason;
await store.updateSession(session);
await audit({
userId,
sessionId,
type: "session.revoked",
severity: "warning",
data: { reason },
});
return true;
},
async revokeAllSessions(userId, exceptSessionId, reason = "all-sessions-revoked") {
const sessions = await store.listSessions(userId);
let count = 0;
for (const session of sessions) {
if (session.id === exceptSessionId || session.revokedAt) continue;
session.revokedAt = now();
session.revokeReason = reason;
await store.updateSession(session);
count += 1;
}
if (count)
await audit({
userId,
type: "session.revoked-all",
severity: "warning",
data: { count, exceptSessionId, reason },
});
return count;
},
async trustDevice(userId, input) {
if (!(await store.findUserById(userId))) throw new Error("User not found");
const timestamp = now();
const hash = await fingerprint(input.fingerprint);
const existing = await store.findTrustedDeviceByFingerprint(userId, hash);
if (existing) {
existing.name = input.name ?? existing.name;
existing.lastSeenAt = timestamp;
existing.expiresAt = timestamp + trustedTtlMs;
existing.revokedAt = undefined;
await store.updateTrustedDevice(existing);
return existing;
}
const device: TrustedDevice = {
id: id("dev"),
userId,
name: input.name ?? "Trusted device",
fingerprintHash: hash,
createdAt: timestamp,
lastSeenAt: timestamp,
expiresAt: timestamp + trustedTtlMs,
};
await store.createTrustedDevice(device);
await audit({
userId,
type: "device.trusted",
severity: "info",
data: { deviceId: device.id, name: device.name },
});
return device;
},
listTrustedDevices: (userId) => store.listTrustedDevices(userId),
async revokeTrustedDevice(userId, deviceId) {
const device = (await store.listTrustedDevices(userId)).find((item) => item.id === deviceId);
if (!device) return false;
device.revokedAt = now();
await store.updateTrustedDevice(device);
await audit({ userId, type: "device.revoked", severity: "warning", data: { deviceId } });
return true;
},
issueToken,
consumeToken,
async verifyEmail(token) {
const record = await inspectToken(token, "verify-email");
if (!record)
return {
ok: false,
code: "invalid-token",
message: "Verification link is invalid or expired",
};
const user = await store.findUserById(record.userId);
if (!user)
return {
ok: false,
code: "invalid-token",
message: "Verification link is invalid or expired",
};
const unavailable = recoveryUnavailable(user);
if (unavailable) return unavailable;
if (!(await markVerified(user, "email", record.target))) {
return { ok: false, code: "identity-missing", message: "Email identity is unavailable" };
}
await consumeInspectedToken(record);
await audit({ userId: user.id, type: "identity.email-verified", severity: "info" });
return { ok: true, user: publicUser(user) };
},
async verifyPhone(token) {
const record = await inspectToken(token, "verify-phone");
if (!record)
return {
ok: false,
code: "invalid-token",
message: "Verification code is invalid or expired",
};
const user = await store.findUserById(record.userId);
if (!user)
return {
ok: false,
code: "invalid-token",
message: "Verification code is invalid or expired",
};
const unavailable = recoveryUnavailable(user);
if (unavailable) return unavailable;
if (!(await markVerified(user, "phone", record.target))) {
return { ok: false, code: "identity-missing", message: "Phone identity is unavailable" };
}
await consumeInspectedToken(record);
await audit({ userId: user.id, type: "identity.phone-verified", severity: "info" });
return { ok: true, user: publicUser(user) };
},
async requestVerification(userId, type, baseUrl) {
const user = await store.findUserById(userId);
if (!user || user.status === "disabled" || user.status === "deleted") return;
const identities = await store.listIdentities(userId);
const identity =
identities.find((item) => item.type === type && item.primary) ??
identities.find((item) => item.type === type);
if (!identity || identity.verifiedAt) return;
await sendToken(
user,
type === "email" ? "verify-email" : "verify-phone",
identity.value,
baseUrl,
);
await audit({ userId, type: `identity.${type}-verification-requested`, severity: "info" });
},
async requestPasswordReset(identifier, baseUrl) {
const user = await findUserByIdentifier(identifier);
if (!user || user.status === "disabled" || user.status === "deleted") return;
const identities = await store.listIdentities(user.id);
const target =
identities.find((item) => item.type === "email" && item.primary)?.value ??
identities.find((item) => item.type === "email")?.value;
if (target) await sendToken(user, "password-reset", target, baseUrl);
await audit({ userId: user.id, type: "password.reset-requested", severity: "info" });
},
async resetPassword(token, password) {
try {
await assertPasswordPolicy(password);
} catch (error) {
return {
ok: false,
code: "password-policy",
message: error instanceof Error ? error.message : "Password does not meet policy",
};
}
const record = await inspectToken(token, "password-reset");
if (!record)
return { ok: false, code: "invalid-token", message: "Reset link is invalid or expired" };
const user = await store.findUserById(record.userId);
if (!user)
return { ok: false, code: "invalid-token", message: "Reset link is invalid or expired" };
const unavailable = recoveryUnavailable(user);
if (unavailable) return unavailable;
await consumeInspectedToken(record);
const current = await store.getPassword(user.id);
await store.setPassword({
userId: user.id,
passwordHash: await hashPassword(password),
passwordVersion: (current?.passwordVersion ?? 0) + 1,
changedAt: now(),
mustChange: false,
});
await clearFailedLoginLock(user);
await engine.revokeAllSessions(user.id, undefined, "password-reset");
await audit({ userId: user.id, type: "password.reset", severity: "warning" });
return { ok: true, user: publicUser(user) };
},
async createInvitation(input) {
const normalized = validatedIdentity("email", input.email);
const existingIdentity = await store.findIdentity("email", normalized);
let user = existingIdentity ? await store.findUserById(existingIdentity.userId) : undefined;
if (!user) {
const timestamp = now();
user = {
id: id("usr"),
displayName: input.displayName?.trim(),
status: "pending",
roles: input.roles?.length ? [...new Set(input.roles)] : ["user"],
emailVerified: false,
phoneVerified: false,
mfaEnabled: false,
createdAt: timestamp,
updatedAt: timestamp,
metadata: input.metadata,
};
await store.createUser(user);
await createIdentity(user.id, "email", input.email, true);
}
const token = await issueToken(user.id, "invite", {
target: input.email,
ttlMs: input.ttlMs,
metadata: { invitedBy: input.invitedBy, roles: input.roles, ...input.metadata },
});
const url = actionUrl(user, "invite", token, input.email, input.baseUrl);
await deliver({
channel: "email",
template: "invitation",
destination: input.email,
token,
url,
user: publicUser(user),
expiresAt: now() + tokenTtl("invite", input.ttlMs),
data: { invitedBy: input.invitedBy },
});
await audit({
userId: user.id,
actorUserId: input.invitedBy,
type: "invitation.created",
severity: "info",
});
return { token, user: publicUser(user) };
},
async acceptInvitation(raw, input) {
const token = await inspectToken(raw, "invite");
if (!token)
return {
ok: false,
code: "invalid-invitation",
message: "Invitation is invalid or expired",
};
const user = await store.findUserById(token.userId);
if (!user || user.status === "deleted" || user.status === "disabled") {
return { ok: false, code: "account-unavailable" };
}
const credential = await store.getPassword(user.id);
if (!credential) {
if (!input.password)
return {
ok: false,
code: "password-required",
message: "Create a password to accept this invitation",
};
try {
await assertPasswordPolicy(input.password);
} catch (error) {
return {
ok: false,
code: "password-policy",
message: error instanceof Error ? error.message : "Password does not meet policy",
};
}
await store.setPassword({
userId: user.id,
passwordHash: await hashPassword(input.password),
passwordVersion: 1,
changedAt: now(),
mustChange: false,
});
}
if (input.displayName?.trim()) user.displayName = input.displayName.trim();
const roles = Array.isArray(token.metadata?.roles)
? token.metadata.roles.filter((role): role is string => typeof role === "string")
: [];
if (roles.length) user.roles = [...new Set([...user.roles, ...roles])];
if (!(await markVerified(user, "email", token.target))) {
return { ok: false, code: "invitation-identity-missing" };
}
user.status = "active";
user.updatedAt = now();
await store.updateUser(user);
await consumeInspectedToken(token);
await audit({
userId: user.id,
actorUserId:
typeof token.metadata?.invitedBy === "string" ? token.metadata.invitedBy : undefined,
type: "invitation.accepted",
severity: "info",
});
return { ok: true, user: publicUser(user) };
},
async requestMagicLink(identifier, baseUrl) {
const user = await findUserByIdentifier(identifier);
if (!user) return;
await restoreExpiredLoginLock(user);
if (accountStatusUnavailable(user)?.code?.startsWith("account-")) return;
const identities = await store.listIdentities(user.id);
const target =
identities.find((item) => item.type === "email" && item.primary)?.value ??
identities.find((item) => item.type === "email")?.value;
if (target) await sendToken(user, "magic-link", target, baseUrl);
},
async consumeMagicLink(token, sessionInput = {}) {
const consumed = await consumeTokenRecord(token, "magic-link");
if (!consumed)
return { ok: false, code: "invalid-token", message: "Magic link is invalid or expired" };
const { user, token: record } = consumed;
await restoreExpiredLoginLock(user);
const blocked = accountStatusUnavailable(user);
if (blocked?.code?.startsWith("account-")) return blocked;
if (!(await markVerified(user, "email", record.target))) {
return { ok: false, code: "identity-missing", message: "Email identity is unavailable" };
}
const verification = verificationUnavailable(user);
if (verification) return verification;
const mfa = await mfaRequirement(user, {
ip: sessionInput.ip,
userAgent: sessionInput.userAgent,
deviceId: sessionInput.deviceId,
});
if (mfa) return mfa;
const session = await createSession(user.id, sessionInput);
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
await audit({
userId: user.id,
sessionId: session.id,
type: "login.magic-link",
severity: "info",
});
await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session };
},
issueOtp,
async verifyOtp(challengeId, code, expectedPurpose = "verification") {
const challenge = await store.findOtp(challengeId);
if (
!challenge ||
challenge.purpose !== expectedPurpose ||
challenge.usedAt ||
challenge.expiresAt <= now()
)
return { ok: false, code: "invalid-otp", message: "Code is invalid or expired" };
challenge.attempts += 1;
if (challenge.attempts > challenge.maxAttempts) {
await store.updateOtp(challenge);
return { ok: false, code: "attempts-exhausted", message: "Too many attempts" };
}
const normalizedCode = code.trim();
const validShape = /^\d{6}$/.test(normalizedCode);
const actual = await hashSecret(validShape ? normalizedCode : "000000", options.secret);
if (!validShape || !(await constantTimeEqual(actual, challenge.codeHash))) {
await store.updateOtp(challenge);
return { ok: false, code: "invalid-otp", message: "Code is incorrect" };
}
challenge.usedAt = now();
await store.updateOtp(challenge);
const user = await store.findUserById(challenge.userId);
if (!user) return { ok: false, code: "user-missing" };
const identityVerified =
challenge.method === "email-otp"
? await markVerified(user, "email", challenge.destination)
: await markVerified(user, "phone", challenge.destination);
if (!identityVerified) {
return { ok: false, code: "identity-missing", message: "OTP identity is unavailable" };
}
await audit({
userId: user.id,
type:
expectedPurpose === "mfa"
? "mfa.otp-verified"
: expectedPurpose === "login"
? "login.otp-verified"
: "identity.otp-verified",
severity: "info",
data: { method: challenge.method },
});
return { ok: true, user: publicUser(user) };
},
async requestOtpLogin(identifier, method) {
const user = await findUserByIdentifier(safeIdentifier(identifier));
if (!user || !["active", "pending"].includes(user.status)) {
// Keep the public response shape and timing independent of account
// existence. The fake challenge is deliberately not persisted, so any
// completion attempt fails with the same generic invalid-code result.
return {
id: id("otp"),
expiresAt: now() + otpTtlMs,
};
}
try {
return await issueOtp(user.id, method, undefined, "login");
} catch {
return {
id: id("otp"),
expiresAt: now() + otpTtlMs,
};
}
},
async completeOtpLogin(challengeId, code, sessionInput = {}) {
const challenge = await store.findOtp(challengeId);
if (!challenge)
return { ok: false, code: "invalid-otp", message: "Code is invalid or expired" };
if (challenge.purpose !== "login") {
return { ok: false, code: "invalid-otp", message: "Code is invalid or expired" };
}
const verified = await engine.verifyOtp(challengeId, code, "login");
if (!verified.ok || !verified.user) return verified;
const user = await store.findUserById(challenge.userId);
if (!user)
return { ok: false, code: "account-unavailable", message: "This account cannot sign in" };
await restoreExpiredLoginLock(user);
const unavailable = accountStatusUnavailable(user);
if (unavailable) return unavailable;
const verification = verificationUnavailable(user);
if (verification) return verification;
const mfa = await mfaRequirement(user, {
ip: sessionInput.ip,
userAgent: sessionInput.userAgent,
deviceId: sessionInput.deviceId,
});
if (mfa) return mfa;
const session = await createSession(user.id, sessionInput);
user.status = "active";
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
await audit({
userId: user.id,
sessionId: session.id,
type: "login.otp",
severity: "info",
data: { method: challenge.method },
});
await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session };
},
async beginMfaOtp(mfaToken, method) {
const token = await inspectToken(mfaToken, "login-mfa");
if (!token) return undefined;
const user = await store.findUserById(token.userId);
if (!user) return undefined;
await restoreExpiredLoginLock(user);
if (accountStatusUnavailable(user) || verificationUnavailable(user)) return undefined;
try {
return await issueOtp(token.userId, method, undefined, "mfa");
} catch {
return undefined;
}
},
async completeMfa(input) {
const transaction = await inspectToken(input.mfaToken, "login-mfa");
if (!transaction)
return {
ok: false,
code: "mfa-transaction-expired",
message: "Sign-in verification expired",
};
const transactionUser = await store.findUserById(transaction.userId);
if (!transactionUser) return { ok: false, code: "user-missing" };
await restoreExpiredLoginLock(transactionUser);
const unavailable = accountStatusUnavailable(transactionUser);
if (unavailable) return unavailable;
const verification = verificationUnavailable(transactionUser);
if (verification) return verification;
let verified = false;
if (input.method === "totp")
verified = await engine.verifyTotp(transaction.userId, input.code);
else if (input.method === "recovery-code")
verified = await engine.consumeRecoveryCode(transaction.userId, input.code);
else if (input.challengeId) {
const otp = await store.findOtp(input.challengeId);
if (
otp?.userId === transaction.userId &&
otp.method === input.method &&
otp.purpose === "mfa"
) {
verified = (await engine.verifyOtp(input.challengeId, input.code, "mfa")).ok;
}
}
if (!verified) {
transaction.attempts += 1;
if (transaction.attempts >= transaction.maxAttempts) transaction.usedAt = now();
await store.updateToken(transaction);
await audit({
userId: transaction.userId,
type: "mfa.failed",
severity: "warning",
data: { method: input.method },
});
return { ok: false, code: "mfa-invalid", message: "Verification failed" };
}
await consumeInspectedToken(transaction);
const user = transactionUser;
const metadata = transaction.metadata ?? {};
const rememberedDevice =
metadata.rememberDevice === true && typeof metadata.fingerprint === "string"
? await engine.trustDevice(user.id, {
fingerprint: metadata.fingerprint,
name: typeof metadata.deviceName === "string" ? metadata.deviceName : undefined,
})
: undefined;
const session = await createSession(user.id, {
...input.session,
ip: input.session?.ip ?? (typeof metadata.ip === "string" ? metadata.ip : undefined),
userAgent:
input.session?.userAgent ??
(typeof metadata.userAgent === "string" ? metadata.userAgent : undefined),
deviceId:
rememberedDevice?.id ??
input.session?.deviceId ??
(typeof metadata.deviceId === "string" ? metadata.deviceId : undefined),
trusted: Boolean(rememberedDevice) || input.session?.trusted === true,
fingerprint: typeof metadata.fingerprint === "string" ? metadata.fingerprint : undefined,
});
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
await audit({
userId: user.id,
sessionId: session.id,
type: "mfa.succeeded",
severity: "info",
data: { method: input.method },
});
return { ok: true, user: publicUser(user), session };
},
async beginTotp(userId, label = "Authenticator") {
const user = await store.findUserById(userId);
if (!user) throw new Error("User not found");
for (const existing of await store.listTotp(userId)) {
if (!existing.verifiedAt) await store.deleteTotp(existing.id);
}
const identities = await store.listIdentities(userId);
const accountName =
identities.find((item) => item.type === "email")?.value ?? user.username ?? user.id;
const secret = generateTotpSecret(random);
const credentialId = id("totp");
await store.createTotp({
id: credentialId,
userId,
label,
secret: await protect(secret, "totp"),
createdAt: now(),
});
return { credentialId, secret, uri: totpUri({ issuer, accountName, secret }) };
},
async confirmTotp(userId, credentialId, token) {
const credential = (await store.listTotp(userId)).find((item) => item.id === credentialId);
if (!credential || credential.verifiedAt) return false;
const result = await verifyTotp(await reveal(credential.secret, "totp"), token, {
timestamp: now(),
});
if (!result.valid) return false;
credential.verifiedAt = now();
credential.lastCounter = result.counter;
await store.updateTotp(credential);
const user = await store.findUserById(userId);
if (user) {
user.mfaEnabled = true;
user.updatedAt = now();
await store.updateUser(user);
}
await audit({ userId, type: "mfa.totp-enabled", severity: "info", data: { credentialId } });
return true;
},
async verifyTotp(userId, token) {
for (const credential of await store.listTotp(userId)) {
if (!credential.verifiedAt) continue;
const result = await verifyTotp(await reveal(credential.secret, "totp"), token, {
timestamp: now(),
lastCounter: credential.lastCounter,
});
if (!result.valid) continue;
credential.lastCounter = result.counter;
await store.updateTotp(credential);
await audit({
userId,
type: "mfa.totp-verified",
severity: "info",
data: { credentialId: credential.id },
});
return true;
}
return false;
},
async disableTotp(userId, credentialId) {
const credential = (await store.listTotp(userId)).find((item) => item.id === credentialId);
if (!credential) return false;
await store.deleteTotp(credentialId);
const user = await store.findUserById(userId);
if (user) {
user.mfaEnabled = await hasMfa(userId);
user.updatedAt = now();
await store.updateUser(user);
}
await audit({
userId,
type: "mfa.totp-disabled",
severity: "warning",
data: { credentialId },
});
return true;
},
async generateRecoveryCodes(userId, count = 10) {
if (!Number.isInteger(count) || count < 1 || count > 50)
throw new RangeError("Recovery code count must be between 1 and 50");
const user = await store.findUserById(userId);
if (!user) throw new Error("User not found");
const timestamp = now();
const plain: string[] = [];
const records = [];
for (let index = 0; index < count; index += 1) {
const raw = `${randomReadableCode(random, 5)}-${randomReadableCode(random, 5)}`;
plain.push(raw);
records.push({
id: id("rcv"),
userId,
codeHash: await hashSecret(raw, options.secret),
createdAt: timestamp,
});
}
// Regeneration invalidates every previously issued code. Keeping old
// unused codes valid would make the security action misleading.
await store.deleteRecoveryCodes(userId);
await store.createRecoveryCodes(records);
user.mfaEnabled = true;
user.updatedAt = timestamp;
await store.updateUser(user);
await audit({
userId,
type: "mfa.recovery-codes-generated",
severity: "warning",
data: { count },
});
return plain;
},
async consumeRecoveryCode(userId, code) {
const hash = await hashSecret(code.trim().toUpperCase(), options.secret);
for (const record of await store.listRecoveryCodes(userId)) {
if (record.usedAt || !(await constantTimeEqual(record.codeHash, hash))) continue;
record.usedAt = now();
await store.updateRecoveryCode(record);
const user = await store.findUserById(userId);
if (user) {
user.mfaEnabled = await hasMfa(userId);
user.updatedAt = now();
await store.updateUser(user);
}
await audit({
userId,
type: "mfa.recovery-code-used",
severity: "warning",
data: { codeId: record.id },
});
return true;
}
return false;
},
async listRecoveryCodeStatus(userId) {
const records = await store.listRecoveryCodes(userId);
return { total: records.length, remaining: records.filter((item) => !item.usedAt).length };
},
async linkOAuth(userId, provider, profile, tokens) {
if (!(await store.findUserById(userId))) throw new Error("User not found");
const providerName = normalizedOAuthProvider(provider);
const providerAccountId = normalizedOAuthAccountId(profile.id);
const existing = await store.findOAuthAccount(providerName, providerAccountId);
if (existing && existing.userId !== userId)
throw new Error("OAuth account is already linked");
const timestamp = now();
const account: OAuthAccount = existing ?? {
id: id("oauth"),
userId,
provider: providerName,
providerAccountId,
createdAt: timestamp,
updatedAt: timestamp,
};
if (profile.email !== undefined) account.email = profile.email;
if (tokens && "access_token" in tokens) {
account.accessToken = tokens.access_token
? await protect(tokens.access_token, "oauth-access")
: undefined;
}
if (tokens && "refresh_token" in tokens) {
account.refreshToken = tokens.refresh_token
? await protect(tokens.refresh_token, "oauth-refresh")
: undefined;
}
if (tokens && "expires_in" in tokens) {
const expiresIn = Number(tokens.expires_in);
if (Number.isFinite(expiresIn) && expiresIn > 0) {
account.tokenExpiresAt = timestamp + Math.trunc(expiresIn * 1000);
} else if (tokens.expires_in === 0 || tokens.expires_in == null) {
account.tokenExpiresAt = undefined;
}
}
if (tokens && "scope" in tokens) account.scope = tokens.scope;
account.updatedAt = timestamp;
if (existing) await store.updateOAuthAccount(account);
else await store.createOAuthAccount(account);
await audit({
userId,
type: "oauth.linked",
severity: "info",
data: { provider: providerName, accountId: account.id },
});
return account;
},
async loginWithOAuth(provider, profile, tokens) {
const providerName = normalizedOAuthProvider(provider);
const providerAccountId = normalizedOAuthAccountId(profile.id);
let account = await store.findOAuthAccount(providerName, providerAccountId);
let user = account ? await store.findUserById(account.userId) : undefined;
const normalizedProfile = { ...profile, id: providerAccountId };
const verifiedEmail = await oauthEmailVerified(providerName, normalizedProfile);
if (!user && profile.email) {
const identity = await store.findIdentity(
"email",
normalizeIdentity("email", profile.email),
);
if (identity) {
if (!verifiedEmail || options.linkVerifiedOAuthEmails === false) {
return {
ok: false,
code: "oauth-link-required",
message: "Sign in to the existing account before linking this provider",
};
}
user = await store.findUserById(identity.userId);
if (user && verifiedEmail) await markVerified(user, "email", profile.email);
}
}
if (!user) {
const timestamp = now();
user = {
id: id("usr"),
displayName: profile.name,
avatarUrl: profile.avatar,
status: verifiedEmail || !profile.email ? "active" : "pending",
roles: ["user"],
emailVerified: verifiedEmail,
phoneVerified: false,
mfaEnabled: false,
createdAt: timestamp,
updatedAt: timestamp,
};
await store.createUser(user);
if (profile.email) {
const identity = await createIdentity(user.id, "email", profile.email, true);
if (verifiedEmail) {
identity.verifiedAt = timestamp;
await store.updateIdentity(identity);
}
}
}
await restoreExpiredLoginLock(user);
const unavailable = accountStatusUnavailable(user);
if (unavailable) return unavailable;
const verification = verificationUnavailable(user);
if (verification) return verification;
account = await engine.linkOAuth(user.id, providerName, normalizedProfile, tokens);
if (await hasMfa(user.id)) {
const methods = await availableMfaMethods(user.id);
const mfaToken = await issueToken(user.id, "login-mfa", {
ttlMs: 5 * 60_000,
metadata: { oauthProvider: providerName },
});
return {
ok: false,
code: "mfa-required",
user: publicUser(user),
mfaToken,
requires: { mfa: methods },
};
}
const session = await createSession(user.id);
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
await audit({
userId: user.id,
sessionId: session.id,
type: "login.oauth",
severity: "info",
data: { provider: providerName, accountId: account.id },
});
await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session };
},
async unlinkOAuth(userId, accountId) {
const account = (await store.listOAuthAccounts(userId)).find((item) => item.id === accountId);
if (!account) return false;
const [password, accounts, passkeys, identities] = await Promise.all([
store.getPassword(userId),
store.listOAuthAccounts(userId),
store.listPasskeys(userId),
store.listIdentities(userId),
]);
const canStillSignIn = Boolean(
password ||
accounts.some((item) => item.id !== accountId) ||
passkeys.length ||
(Boolean(options.delivery) &&
identities.some((item) => item.type === "email" || item.type === "phone")),
);
if (!canStillSignIn) return false;
await store.deleteOAuthAccount(accountId);
await audit({
userId,
type: "oauth.unlinked",
severity: "warning",
data: { provider: account.provider, accountId },
});
return true;
},
async beginPasskeyRegistration(userId, input) {
const provider = assertPasskeyProvider(options.passkeys);
const user = await store.findUserById(userId);
if (!user) throw new Error("User not found");
const identities = await store.listIdentities(userId);
const credentials = await store.listPasskeys(userId);
const registration = await provider.registrationOptions({
user,
identities,
credentials,
...input,
});
if (!registration.challenge) {
throw new Error("WRN-AUTH-PASSKEY-PROVIDER: registration options omitted challenge");
}
const timeout = passkeyChallengeTtl(registration.timeout);
const key = id("pkc");
await passkeyChallenges.set(key, {
challenge: registration.challenge,
kind: "registration",
userId,
rpId: input.rpId,
origin: input.origin,
expiresAt: now() + timeout,
});
return { key, options: { ...registration, timeout } };
},
async finishPasskeyRegistration(userId, input) {
const provider = assertPasskeyProvider(options.passkeys);
const saved = await passkeyChallenges.consume(input.key);
if (
!saved ||
saved.kind !== "registration" ||
saved.userId !== userId ||
saved.expiresAt <= now()
)
return false;
const user = await store.findUserById(userId);
if (!user) return false;
const result = await provider.verifyRegistration({
user,
response: input.response,
expectedChallenge: saved.challenge,
expectedOrigin: saved.origin,
expectedRpId: saved.rpId,
});
if (!result.verified || !result.credential) return false;
const providedCredential = result.credential;
if (
!providedCredential.credentialId ||
!providedCredential.publicKey ||
!Number.isSafeInteger(providedCredential.counter) ||
providedCredential.counter < 0
) {
return false;
}
if (await store.findPasskeyByCredentialId(providedCredential.credentialId)) return false;
try {
await store.createPasskey({
...providedCredential,
id: id("pky"),
userId,
name: input.name ?? result.credential.name ?? "Passkey",
createdAt: now(),
});
} catch {
return false;
}
user.mfaEnabled = await hasMfa(userId);
user.updatedAt = now();
await store.updateUser(user);
await audit({ userId, type: "passkey.registered", severity: "info" });
return true;
},
async beginPasskeyAuthentication(input) {
const provider = assertPasskeyProvider(options.passkeys);
const user = input.identifier ? await findUserByIdentifier(input.identifier) : undefined;
const credentials = user ? await store.listPasskeys(user.id) : [];
const authentication = await provider.authenticationOptions({
user,
credentials,
rpId: input.rpId,
origin: input.origin,
});
if (!authentication.challenge) {
throw new Error("WRN-AUTH-PASSKEY-PROVIDER: authentication options omitted challenge");
}
const timeout = passkeyChallengeTtl(authentication.timeout);
const key = id("pkc");
await passkeyChallenges.set(key, {
challenge: authentication.challenge,
kind: "authentication",
userId: user?.id,
rpId: input.rpId,
origin: input.origin,
expiresAt: now() + timeout,
});
return { key, options: { ...authentication, timeout } };
},
async finishPasskeyAuthentication(input) {
const provider = assertPasskeyProvider(options.passkeys);
const saved = await passkeyChallenges.consume(input.key);
if (!saved || saved.kind !== "authentication" || saved.expiresAt <= now())
return { ok: false, code: "passkey-challenge-expired" };
const credentialId =
typeof input.response === "object" && input.response
? String((input.response as Record<string, unknown>).id ?? "")
: "";
let credential = credentialId
? await store.findPasskeyByCredentialId(credentialId)
: undefined;
const result = await provider.verifyAuthentication({
response: input.response,
credential,
expectedChallenge: saved.challenge,
expectedOrigin: saved.origin,
expectedRpId: saved.rpId,
});
if (!credential && result.credentialId) {
credential = await store.findPasskeyByCredentialId(result.credentialId);
}
const userId = result.userId ?? credential?.userId ?? saved.userId;
if (!result.verified || !userId)
return { ok: false, code: "passkey-verification-failed", message: result.error };
if (saved.userId && userId !== saved.userId) {
return { ok: false, code: "passkey-user-mismatch" };
}
if (credential && credential.userId !== userId) {
return { ok: false, code: "passkey-user-mismatch" };
}
const user = await store.findUserById(userId);
if (!user) return { ok: false, code: "user-missing" };
await restoreExpiredLoginLock(user);
const unavailable = accountStatusUnavailable(user);
if (unavailable) return unavailable;
const verification = verificationUnavailable(user);
if (verification) return verification;
if (credential) {
if (result.newCounter !== undefined) {
if (
!Number.isSafeInteger(result.newCounter) ||
result.newCounter < 0 ||
(credential.counter > 0 && result.newCounter <= credential.counter)
) {
return { ok: false, code: "passkey-counter-regression" };
}
credential.counter = result.newCounter;
}
credential.lastUsedAt = now();
await store.updatePasskey(credential);
}
const session = await createSession(user.id, input.session);
user.lastLoginAt = now();
user.updatedAt = now();
await store.updateUser(user);
await audit({
userId: user.id,
sessionId: session.id,
type: "login.passkey",
severity: "info",
});
await sendLoginAlert(user, session);
return { ok: true, user: publicUser(user), session };
},
async changePassword(userId, currentPassword, nextPassword) {
const user = await store.findUserById(userId);
if (!user || user.status === "disabled" || user.status === "deleted") {
return {
ok: false,
code: "account-unavailable",
message: "Password change is unavailable",
};
}
const credential = await store.getPassword(userId);
if (!credential || !(await verifyPassword(currentPassword, credential.passwordHash))) {
return {
ok: false,
code: "invalid-current-password",
message: "Current password is incorrect",
};
}
try {
await assertPasswordPolicy(nextPassword);
} catch (error) {
return {
ok: false,
code: "password-policy",
message: error instanceof Error ? error.message : "Password does not meet policy",
};
}
credential.passwordHash = await hashPassword(nextPassword);
credential.passwordVersion += 1;
credential.changedAt = now();
credential.mustChange = false;
await store.setPassword(credential);
await engine.revokeAllSessions(userId, undefined, "password-changed");
await audit({ userId, type: "password.changed", severity: "warning" });
return { ok: true, user: publicUser(user) };
},
async setAccountStatus(userId, status, actorUserId) {
const user = await store.findUserById(userId);
if (!user) throw new Error("User not found");
const previous = user.status;
user.status = status;
user.updatedAt = now();
await store.updateUser(user);
if (status !== "active")
await engine.revokeAllSessions(userId, undefined, `account-${status}`);
await audit({
userId,
actorUserId,
type: "account.status-changed",
severity: status === "active" ? "info" : "warning",
data: { previous, status },
});
},
async startImpersonation(actorUserId, targetUserId, input = {}) {
const [actor, target] = await Promise.all([
store.findUserById(actorUserId),
store.findUserById(targetUserId),
]);
if (!actor || !target || target.status !== "active") {
return {
ok: false,
code: "impersonation-unavailable",
message: "Impersonation is unavailable",
};
}
const rawDecision = await options.authorizeImpersonation?.({
actor,
target,
reason: input.reason,
});
const decision =
typeof rawDecision === "boolean"
? { allowed: rawDecision }
: (rawDecision ?? { allowed: false, reason: "No impersonation policy is configured" });
if (!decision.allowed) {
await audit({
userId: target.id,
actorUserId: actor.id,
type: "impersonation.denied",
severity: "warning",
data: { reason: input.reason, policyReason: decision.reason },
});
return {
ok: false,
code: "impersonation-denied",
message: decision.reason ?? "Impersonation was denied",
};
}
const session = await createSession(target.id, {
ip: input.ip,
userAgent: input.userAgent,
metadata: {
impersonated: true,
actorUserId: actor.id,
actorSessionId: input.sessionId,
reason: input.reason,
},
});
await audit({
userId: target.id,
actorUserId: actor.id,
sessionId: session.id,
type: "impersonation.started",
severity: "critical",
data: { reason: input.reason, actorSessionId: input.sessionId },
});
return { ok: true, user: publicUser(target), session };
},
async stopImpersonation(sessionId) {
const impersonated = await store.findSession(sessionId);
const actorUserId =
typeof impersonated?.metadata?.actorUserId === "string"
? impersonated.metadata.actorUserId
: undefined;
if (!impersonated || impersonated.metadata?.impersonated !== true || !actorUserId) {
return {
ok: false,
code: "not-impersonating",
message: "This session is not impersonating another account",
};
}
impersonated.revokedAt = now();
impersonated.revokeReason = "impersonation-ended";
await store.updateSession(impersonated);
const actor = await store.findUserById(actorUserId);
if (!actor || actor.status !== "active") return { ok: false, code: "actor-unavailable" };
const actorSessionId =
typeof impersonated.metadata.actorSessionId === "string"
? impersonated.metadata.actorSessionId
: undefined;
let session = actorSessionId ? await validateSession(actorSessionId) : undefined;
session ??= await createSession(actor.id);
await audit({
userId: impersonated.userId,
actorUserId: actor.id,
sessionId,
type: "impersonation.ended",
severity: "critical",
});
return { ok: true, user: publicUser(actor), session };
},
};
return engine;
}