Skip to content

UserService

UserService<T> is the single orchestrator for everything credential-related. Every higher-level package in aoothjs (auth, auth-moost) ultimately delegates to a UserService. This page is the reference for its constructor, config, and every public method.

Constructor

ts
import { UserService, UserStoreMemory } from "@aooth/user"

const users = new UserService<T extends object = object>(
  store: UserStore<T>,
  config?: UserServiceConfig,
)

The generic T extends the base UserCredentials with custom columns of your choice (e.g. tenantId, displayName, and your own email/phone login handles). id and username are the only handle-bearing fields on the base — T adds every extra column, including any secondary login handle. Those flow through LoginResult.user, the createUser(extras) parameter, and update(patch).

Source: user-service.ts:74.

Identity model — id vs. handle

The stable surrogate id is the token subject (useAuth().getUserId()) and the key for every read-by-identity and every write on this service (getUser, update, setPassword, the MFA/lock/trusted-device methods, …). The one exception is login(handle, …), which resolves a handle via UserStore.findByHandleusername first, then whatever secondary handle fields your model declares (e.g. email, then phone; see Credentials Model). Two passthrough reads cover the rest: findByHandle (deterministic login resolution) and findByIdentifier (permissive idusername→ the configured handles, for admin/recovery). See Stores.

Generic example — custom user shape

ts
interface AppUser {
  tenantId: string;
}

const users = new UserService<AppUser>(store, {
  password: { pepper: process.env.PEPPER },
});

// `id` is minted automatically; `username` is the 1st arg.
const u = await users.createUser("alice", "p4ssw0rd!", { tenantId: "acme" });

const { user } = await users.login("alice", "p4ssw0rd!"); // handle = username or a configured handle field
user.tenantId; // ← typed as string
user.id; // ← base field, the token subject

Config reference

UserServiceConfig is a plain object. All fields are optional.

FieldTypeDefaultEffect
password.pepperstring""Static prefix prepended to every password before scrypt. Store in env / secret manager — never in the DB.
password.historyLengthnumber0Number of previous hashes to keep in password.history[]. 0 disables history checks.
password.scryptNnumber16384scrypt cost parameter (CPU/memory).
password.scryptRnumber8scrypt block size.
password.scryptPnumber1scrypt parallelization.
password.keyLengthnumber64Derived key length in bytes.
password.policiesPasswordPolicyDef[][]Password rules enforced by changePassword/setPassword. See Policies.
lockout.thresholdnumber0Failed-login attempts before auto-locking. 0 disables lockout.
lockout.durationnumber0Lock duration in ms. 0 ⇒ permanent.
deviceTrust.secretstringHMAC key for trusted-device tokens. Required to call any *TrustedDevice method.
emailFieldstringName of the consumer-declared @aooth.user.email column — level 1 of getCorrespondenceEmail's chain.
clock() => numberDate.nowTime source. Override in tests for deterministic lockout/expiry.

Pepper is irrecoverable

Losing the pepper invalidates every stored hash. Treat it like a database master key: rotate via app-level dual-write, store in a secrets manager, and never commit it.

Login flow

UserService.login(handle, password)handle is a username or one of your model's configured secondary handle fields, resolved via UserStore.findByHandle (username first, then the configured handle fields in order — e.g. email, then phone; never a permissive $or):

  1. Look up the user by handle — throws NOT_FOUND if missing.
  2. Reject if account.active === false — throws INACTIVE.
  3. Reject if locked (auto-unlocks when lockEnds is past) — throws LOCKED.
  4. Verify the password:
    • On success: resets failedLoginAttempts, stamps lastLogin, returns { user, mfaRequired } where mfaRequired is true iff at least one confirmed MFA method exists.
    • On failure: increments failedLoginAttempts; if the configured threshold is hit, sets locked/lockReason/lockEnds; throws INVALID_CREDENTIALS (with details.lockEnds when the lock fired on this attempt).

verifyMfa shares this exact lockout counter — total tries across login + verifyMfa is threshold, not 2 × threshold.

Methods — overview

All methods are async unless explicitly marked (sync).

MethodReturnsThrowsNotes
createUserUserCredentials & TALREADY_EXISTSMints id (randomUUID); extras.id overrides. ALREADY_EXISTS on duplicate username or any configured handle field.
getUserUserCredentials & TNOT_FOUNDBy id.
findByHandle(UserCredentials & T) | nullLogin resolver: username then the configured handle fields.
findByIdentifier(UserCredentials & T) | nullPermissive: idusername → the configured handle fields.
loginLoginResult<T>NOT_FOUND / INACTIVE / LOCKED / INVALID_CREDENTIALSArg is a handle (username or a configured handle field).
verifyPasswordbooleanNOT_FOUNDNo side effects, bypasses lockout.
changePasswordvoidINVALID_CREDENTIALS / PASSWORDS_MISMATCH / POLICY_VIOLATION / PASSWORD_IN_HISTORY
setPasswordvoidPOLICY_VIOLATION / PASSWORD_IN_HISTORYAdmin-style — no current password required.
deleteUservoidNOT_FOUND
updateUserCredentials & TNOT_FOUNDDeep-merge patch.
activateAccount / deactivateAccountvoidNOT_FOUND
lockAccountvoidNOT_FOUNDduration=0 ⇒ permanent.
unlockAccountvoidNOT_FOUNDResets failedLoginAttempts too.
getLockStatus (sync)LockStatus
checkPoliciesPolicyCheckResult
getTransferablePolicies (sync)TransferablePolicy[]String-rule policies only.
addMfaMethod / confirmMfaMethod / removeMfaMethodvoidNOT_FOUND / MFA_NOT_CONFIGURED
setDefaultMfaMethodvoidMFA_NOT_CONFIGUREDEmpty name clears the default.
setMfaAutoSendvoidNOT_FOUND
getAvailableMfaMethods (sync)MfaMethodInfo[]Masks value.
verifyTotpSetupCodevoidNOT_FOUND / MFA_NOT_CONFIGURED / MFA_INVALIDVerify first code + flip method to confirmed.
verifyMfavoidNOT_FOUND / INACTIVE / LOCKED / MFA_INVALID / MFA_NOT_CONFIGURED4th arg lockoutOverride?.
isPasswordExpired (sync)booleanAgainst config.password expiry policy.
issueTrustedDevice (sync)TrustedDeviceRecordplain Error if deviceTrust.secret unset
addTrustedDevice / verifyTrustedDevice / revokeTrustedDevice / listTrustedDevicesvariesNOT_FOUND
getPasswordHasher (sync)PasswordHasherEscape hatch.
getConfig (sync)Readonly<ResolvedConfig>

Methods — reference

createUser

Creates a fresh user record. If password is omitted, a random password is generated and password.isInitial is set true.

ts
createUser(
  username: string,
  password?: string,
  extras?: Partial<T>,
): Promise<UserCredentials & T>
ts
// minimal
await users.createUser("alice", "S3cret!");

// generated initial password (e.g. invitation flow)
const u = await users.createUser("bob");
u.password.isInitial; // → true

// custom columns
await users.createUser("carol", "p4ss", { tenantId: "acme", email: "c@x.dev" });

Top-level extras keys replace

extras is shallow-merged AFTER the base record is constructed. If you pass extras.account, you replace the entire base account sub-object — you don't merge into it.

account.active defaults to false

AuthWorkflow's invite accept phase relies on this — pending invitees stay inactive until accept. For seed scripts, admin-create flows, or tests that don't go through invite, call activateAccount(id) after or login() throws UserAuthError("INACTIVE"). The login workflow deliberately re-maps INACTIVE to "Invalid credentials" (anti-enumeration), so the client-side failure looks identical to a wrong password.

ts
const u = await users.createUser("alice", "S3cret!");
await users.activateAccount(u.id); // ← required outside the invite flow

getUser / findByHandle / findByIdentifier

ts
getUser(id: string): Promise<UserCredentials & T> // throws NOT_FOUND
findByHandle(handle: string): Promise<(UserCredentials & T) | null>
findByIdentifier(value: string): Promise<(UserCredentials & T) | null>

getUser is the strict identity read by id (the token subject) — it throws NOT_FOUND when there's no row. The two find* passthroughs return null instead of throwing:

  • findByHandle — the deterministic login resolver: matches username exactly, then each configured secondary handle field exactly, in order (e.g. email, then phone — whatever your model declares via @aooth.user.*; see Credentials Model). Use it anywhere you have a user-supplied login handle (the login workflow's identity-resolution default delegates here).
  • findByIdentifier — permissive admin/recovery lookup: id, then username, then the configured handle fields (first match). Never use it for login — a value that is one user's username and another's handle would resolve ambiguously.

login

ts
login(handle: string, password: string, lockoutOverride?: Partial<LockoutConfig>): Promise<LoginResult<T>>
// handle = username OR a configured handle field (resolved via findByHandle)
// LoginResult<T> = { user: UserCredentials & T; mfaRequired: boolean }

mfaRequired is true iff the user has at least one confirmed MFA method. Source: user-service.ts.

ts
try {
  const { user, mfaRequired } = await users.login("alice", input);
  if (mfaRequired) return startMfaChallenge(user);
  return issueSession(user);
} catch (e) {
  if (e instanceof UserAuthError && e.type === "LOCKED") {
    return tooManyAttempts(e.details?.lockEnds);
  }
  throw e;
}

verifyPassword

Side-effect-free password check. Does not consume a lockout attempt.

ts
verifyPassword(id: string, password: string): Promise<boolean>

Use this for confirm-your-password gates (delete account, change email).

changePassword

ts
changePassword(
  id: string,
  currentPassword: string,
  newPassword: string,
  repeatPassword?: string,
): Promise<void>

Order of checks: PASSWORDS_MISMATCHINVALID_CREDENTIALS → policies → history. History is checked in parallel against the current hash plus every entry in password.history[].

setPassword

Admin path — skips current-password verification, still enforces policies and history.

ts
setPassword(id: string, newPassword: string): Promise<void>

update

ts
update(id: string, patch: Partial<UserCredentials & T>): Promise<UserCredentials & T>

Deep-merges the patch via store.update({ set }) and returns the re-read record.

lockAccount / unlockAccount

ts
lockAccount(id: string, reason: string, duration?: number): Promise<void>
unlockAccount(id: string): Promise<void>

duration is milliseconds. 0 or undefined ⇒ permanent.

lockEnds: 0 is permanent

The expiry check is lockEnds > 0 && lockEnds < now. 0 is "no expiry", not "no lock".

getLockStatus (sync)

ts
getLockStatus(account: AccountData): LockStatus
// LockStatus = { locked: boolean; expired: boolean; reason: string; lockEnds: number }

expired is true only when lockEnds > 0 && lockEnds < now. Callers use that to auto-unlock at the boundary.

checkPolicies

ts
checkPolicies(
  password: string,
  passwordData?: PasswordData,
): Promise<PolicyCheckResult>
// PolicyCheckResult = { passed: boolean; policies: { description: string, passed: boolean }[]; errors: string[] }

Passing passwordData lets policies reason about lastChanged / history.

getTransferablePolicies (sync)

ts
getTransferablePolicies(): TransferablePolicy[]
// TransferablePolicy = { rule: string; description?: string; errorMessage?: string }

Returns only policies whose rule is a string — those can be evaluated on the client in the same v / context namespace. See Policies.

MFA — addMfaMethod / confirmMfaMethod / removeMfaMethod

ts
addMfaMethod(id: string, method: MfaMethod): Promise<void>
confirmMfaMethod(id: string, name: string): Promise<void>
removeMfaMethod(id: string, name: string): Promise<void>

addMfaMethod upserts by name. Removing the current defaultMethod clears it.

verifyMfa

ts
verifyMfa(
  id: string,
  code: string,
  config?: TotpConfig,
  lockoutOverride?: Partial<LockoutConfig>,
): Promise<void>

TOTP-only path. Increments the same failedLoginAttempts counter as login. Pass lockoutOverride to apply a per-call lockout posture (e.g. a stricter threshold for privileged accounts). Throws MFA_INVALID (with details.lockEnds when the failure tripped a lock) or MFA_NOT_CONFIGURED when no confirmed totp method exists.

verifyTotpSetupCode

ts
verifyTotpSetupCode(id: string, code: string, config?: TotpConfig): Promise<void>

Enrollment-confirm helper: verifies code against the user's unconfirmed totp method and flips it to confirmed: true in one call. Throws MFA_INVALID on a wrong code, MFA_NOT_CONFIGURED when there's no pending totp method. Use it instead of a manual verifyTotpCode + confirmMfaMethod pair.

isPasswordExpired

ts
isPasswordExpired(user: UserCredentials & T, now?: number): boolean

Sync check against the configured password-expiry policy. The now arg defaults to the injected clock — useful for deterministic tests.

There are no generateBackupCodes / consumeBackupCode methods — backup codes are not bundled. See MFA Primitives — Backup codes.

Trusted devices

ts
issueTrustedDevice(userId: string, opts: { ip?: string; ttlMs: number; name?: string }): TrustedDeviceRecord
addTrustedDevice(id: string, record: TrustedDeviceRecord): Promise<void>
verifyTrustedDevice(userId: string, token: string, ip?: string): Promise<boolean>
revokeTrustedDevice(id: string, token: string): Promise<void>
listTrustedDevices(id: string): Promise<TrustedDeviceRecord[]>

The token is an opaque HMAC-signed string bound to the user (and optionally their IP). verifyTrustedDevice validates the signature, checks expiry, and (when issued with an ip) requires the same IP.

Requires deviceTrust.secret

Any trusted-device call throws a plain Error if config.deviceTrust.secret was not set on the service.

Clock injection for tests

Every place that reads "now" goes through config.clock. That makes lockout-expiry, MFA window, and trusted-device TTL tests deterministic.

ts
let now = 1_700_000_000_000;
const users = new UserService(store, {
  clock: () => now,
  lockout: { threshold: 3, duration: 60_000 },
});

// trip the lock
for (let i = 0; i < 3; i++) {
  await users.login("alice", "wrong").catch(() => {});
}

// fast-forward past the lock
now += 60_001;
await users.login("alice", "correct"); // ✓ auto-unlock

Escape hatches

ts
users.getPasswordHasher(); // → PasswordHasher (hash/verify/generatePassword)
users.getConfig(); // → Readonly<ResolvedConfig>

Use sparingly — anything that should be reusable across consumers belongs as a first-class method on UserService.

Testing

For unit tests, lower the scrypt cost so each hash takes single-digit ms instead of the production ~100 ms target:

ts
const FAST_SCRYPT = { scryptN: 1024, scryptR: 1, scryptP: 1, keyLength: 32 };
const users = new UserService(store, { password: FAST_SCRYPT });

Hashes are self-describing, so production and test hashes coexist without breaking verification — useful when seeding fixtures from a snapshot.

Released under the MIT License.