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 = { "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; login(input: LoginInput): Promise; logout(sessionId: string, reason?: string): Promise; findUserByIdentifier(identifier: string): Promise; getUser(userId: string): Promise; getPublicUser(userId: string): Promise | undefined>; createSession( userId: string, input?: Partial & { fingerprint?: string; deviceName?: string }, ): Promise; validateSession(sessionId: string): Promise; listSessions(userId: string): Promise; revokeSession(userId: string, sessionId: string, reason?: string): Promise; revokeAllSessions(userId: string, exceptSessionId?: string, reason?: string): Promise; trustDevice( userId: string, input: { fingerprint: string; name?: string }, ): Promise; listTrustedDevices(userId: string): ReturnType; revokeTrustedDevice(userId: string, deviceId: string): Promise; issueToken( userId: string, purpose: AuthTokenPurpose, input?: { target?: string; metadata?: Record; ttlMs?: number }, ): Promise; consumeToken(token: string, purpose: AuthTokenPurpose): Promise; verifyEmail(token: string): Promise; verifyPhone(token: string): Promise; requestVerification(userId: string, type: "email" | "phone", baseUrl?: string): Promise; requestPasswordReset(identifier: string, baseUrl?: string): Promise; resetPassword(token: string, password: string): Promise; requestMagicLink(identifier: string, baseUrl?: string): Promise; createInvitation(input: { email: string; displayName?: string; roles?: string[]; invitedBy?: string; metadata?: Record; ttlMs?: number; baseUrl?: string; }): Promise<{ token: string; user: ReturnType }>; acceptInvitation( token: string, input: { password?: string; displayName?: string }, ): Promise; consumeMagicLink(token: string, sessionInput?: Partial): Promise; 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; requestOtpLogin( identifier: string, method: "email-otp" | "sms-otp", ): Promise<{ id: string; expiresAt: number }>; completeOtpLogin( challengeId: string, code: string, sessionInput?: Partial, ): Promise; 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; }): Promise; beginTotp( userId: string, label?: string, ): Promise<{ credentialId: string; secret: string; uri: string }>; confirmTotp(userId: string, credentialId: string, token: string): Promise; verifyTotp(userId: string, token: string): Promise; disableTotp(userId: string, credentialId: string): Promise; generateRecoveryCodes(userId: string, count?: number): Promise; consumeRecoveryCode(userId: string, code: string): Promise; listRecoveryCodeStatus(userId: string): Promise<{ total: number; remaining: number }>; linkOAuth( userId: string, provider: string, profile: OAuthProfile, tokens?: OAuthTokens, ): Promise; loginWithOAuth( provider: string, profile: OAuthProfile, tokens?: OAuthTokens, ): Promise; unlinkOAuth(userId: string, accountId: string): Promise; 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; 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; /** @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; changePassword( userId: string, currentPassword: string, nextPassword: string, ): Promise; setAccountStatus(userId: string, status: AuthUser["status"], actorUserId?: string): Promise; startImpersonation( actorUserId: string, targetUserId: string, input?: { reason?: string; sessionId?: string; ip?: string; userAgent?: string }, ): Promise; stopImpersonation(sessionId: string): Promise; } 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 | 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): Promise { const event: AuthSecurityEvent = { ...input, id: id("evt"), createdAt: now() }; await store.createSecurityEvent(event); await options.audit?.(event); } async function deliver(message: AuthDeliveryMessage): Promise { 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 { 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 { 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 { 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 = {}, ): Promise { 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 { 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 { 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 { 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; ttlMs?: number } = {}, ): Promise { 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 { 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 { 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 { 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 { 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 & { fingerprint?: string; deviceName?: string } = {}, ): Promise { 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 { 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 { 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 { 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 { 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> { 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).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; }