MFA Primitives
@aooth/user ships primitives, not a delivery system. This page documents what's in the package — TOTP secret/URI/code/verify, one-time MFA-code helpers, trusted devices — and what's deliberately out of scope.
What's here vs. what's not
| Capability | Where it lives |
|---|---|
| TOTP secret generation, otpauth URI, code generation, constant-time verify | @aooth/user (exported from the package root) |
| SHA-256 one-time-code hash + verify (for email/SMS challenges) | @aooth/user (exported from the package root) |
UserService.verifyMfa / verifyTotpSetupCode + lockout-counter sharing | @aooth/user |
| Trusted-device HMAC tokens + IP binding | @aooth/user |
| Email / SMS delivery | Your transport layer (or @aooth/auth senders) |
| Challenge state machine ("issue OTP → wait → verify within N min") | @aooth/auth + @aooth/auth-moost workflows |
| Backup / recovery codes | Not bundled — see below |
| WebAuthn / FIDO2 | Not provided |
The split is deliberate: this package keeps the primitives pure so you can compose them into any workflow (HTTP, CLI, custom).
TOTP
Sources: packages/user/src/mfa/totp.ts.
Generate a secret
import { generateTotpSecret } from "@aooth/user";
const secret = generateTotpSecret(); // 20 random bytes, base32-encoded, unpadded, uppercase
const secret32 = generateTotpSecret(32); // optional byte lengthThe output is the base32-encoded secret as plain ASCII — that's the format authenticator apps expect. Store it in MfaMethod.value.
Build the otpauth URI (for QR codes)
import { generateTotpUri } from "@aooth/user";
const uri = generateTotpUri(secret, "AcmeCorp", "alice@acme.dev", {
period: 30, // default 30 s
digits: 6, // default 6
});
// → "otpauth://totp/AcmeCorp:alice@acme.dev?secret=...&issuer=AcmeCorp&algorithm=SHA1&digits=6&period=30"Render it through any QR encoder (qrcode, qrcode-svg) on enrollment. The algorithm is fixed at SHA1 — that's what every consumer authenticator app supports universally.
Generate / verify codes
import { generateTotpCode, verifyTotpCode } from "@aooth/user";
const code = generateTotpCode(secret); // RFC-4226 HOTP at the current TOTP step
verifyTotpCode(secret, code); // → matched HOTP counter (number), or null on failure
verifyTotpCode(secret, code, { window: 1 }); // ± 1 step (≈ ±30 s) for clock drift
verifyTotpCode(secret, code, { period: 60 }); // 60 s stepsverifyTotpCode walks the full [-window..+window] range; verification is constant-time and length-checked, so a mismatched-length code returns null without leaking timing or step information. On success it returns the matched HOTP counter — UserService.verifyMfa persists it (lastUsedWindow) and rejects any code whose counter is <= the last accepted one, so a sniffed code can't be replayed inside the validity window (RFC 6238 §5.2). Check !== null, not truthiness — counter 0 is a valid match.
Wiring into a user
// Service methods key on the surrogate `id` (the token subject); `username`
// here is only the human label baked into the TOTP URI.
const { id, username } = user;
// 1. enrollment
const secret = generateTotpSecret();
const uri = generateTotpUri(secret, "AcmeCorp", username);
await users.addMfaMethod(id, { name: "totp", value: secret, confirmed: false });
// render uri as QR → user scans → enters first code
// 2. confirm — `verifyTotpSetupCode` verifies the first code against the
// *unconfirmed* stored method and flips it to confirmed in one call
// (throws UserAuthError("MFA_INVALID") on a bad code).
await users.verifyTotpSetupCode(id, submittedCode, { window: 1 });
// 3. login-time verify (uses the user's stored secret internally)
await users.verifyMfa(id, submittedCode);UserService.verifyMfa reads the stored totp method, calls verifyTotpCode under the hood, and shares the lockout counter with login — combined login + verifyMfa failures count toward the same failedLoginAttempts, not two separate counters.
One-time MFA codes (email / SMS)
For challenges where you mail a 6-digit code, hash it before storing the expected value.
import { generateMfaCode, hashMfaCode, verifyMfaCode } from "@aooth/user";
// at challenge issue
const code = generateMfaCode(); // default 6 digits, crypto-random
const expectedHash = hashMfaCode(code); // SHA-256 hex
// send `code` to user via email/SMS, persist `expectedHash` with a TTL
// at submit
verifyMfaCode(submitted, expectedHash); // timingSafeEqual after re-hashgenerateMfaCode(length) accepts a custom length; output is digits-only.
Persistence is your problem
This package does not persist the issued challenge or its TTL. Use @aooth/auth (or your own short-lived KV) for the challenge state machine. The primitives here are pure.
TOTP enrollment QR (moost workflows)
@aooth/user gives you the otpauth:// URI via generateTotpUri; rendering it as a scannable QR is the SPA's job. In the moost stack, the bundled EnrollConfirmForm.qrCode field carries that URI plus a @ui.form.component 'AsQrCode' annotation, so the SPA's AsQrCode component (from @atscript/vue-aooth) renders the QR + the manual base32 secret automatically. See Moost — SPA Components.
Backup codes
Backup / recovery codes are not a bundled primitive. There is no generateBackupCodePlaintext export and no generateBackupCodes / consumeBackupCode UserService method, and the base UserCredentials carries no backupCodes field — nothing reads or writes one.
If you need recovery codes, compose them from the primitives above: declare your own backupCodes?: string[] column on your user model (like any other custom field on T), generate random codes yourself, hash each with hashMfaCode, store the hashes via users.update(id, { backupCodes }), and verify a submitted code with verifyMfaCode against the stored hashes (removing the matched hash). Wrap consume in a store-layer transaction if you need strict one-shot semantics.
Trusted devices
A trusted-device token lets a user skip MFA on a remembered device. The package mints an opaque HMAC token; you store it (cookie, localStorage) and present it on subsequent logins.
const users = new UserService(store, {
deviceTrust: { secret: process.env.DEVICE_TRUST_SECRET },
});
// issue
const record = users.issueTrustedDevice("user-id-123", {
ip: req.ip, // optional — binds the token to this IP
ttlMs: 30 * 24 * 60 * 60_000, // 30 days
name: "My Laptop",
});
await users.addTrustedDevice("alice", record);
res.cookie("trusted_device", record.token, { httpOnly: true });
// verify on a later request
const ok = await users.verifyTrustedDevice("alice", req.cookies.trusted_device, req.ip);
if (ok) skipMfa();The token is an opaque HMAC-signed string bound to the user (and optionally their IP).
Check in verifyTrustedDevice | Behavior |
|---|---|
| Signature mismatch | false (counterfeit / tampered) |
expiresAt < now | false (expired) |
Issued with ip and request IP differs | false (IP binding) |
Token not in trustedDevices[] | false (revoked) |
Requires deviceTrust.secret
issueTrustedDevice throws a plain Error if config.deviceTrust.secret is unset. Set it in production before exposing trust-this-device UI.
revokeTrustedDevice removes a specific token. listTrustedDevices returns the raw records (token, ip, issuedAt, expiresAt, name) for a "manage your devices" page.
Masking for UI
When showing the user a "send code to" picker, you want the masked target, not the raw email/phone.
import { maskEmail, maskPhone, maskMfaValue } from "@aooth/user";
maskEmail("alice@acme.dev"); // → "a***e@acme.dev"
maskPhone("+15551234567"); // → "+1******4567"
maskMfaValue({ name: "email", confirmed: true, value: "alice@acme.dev" });
// → "a***e@acme.dev"UserService.getAvailableMfaMethods(mfa) runs maskMfaValue for each confirmed method and returns { name, masked }[] — safe to send to the client.
See also
UserService.verifyMfa— the lockout-aware verify path.- Credentials Model —
mfasub-object. - Moost — Manage MFA workflow — the bundled, ARBAC-gated HTTP flow to add / change / remove a factor for a signed-in user, step-up first (re-verify an existing factor before any change), TOTP QR on its own step.
UserService.removeMfaMethodis also a direct domain call. @aooth/auth— challenge state machine, email/SMS senders.