2289 lines
82 KiB
TypeScript
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;
|
|
}
|