Skip to content

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

CapabilityWhere 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 deliveryYour transport layer (or @aooth/auth senders)
Challenge state machine ("issue OTP → wait → verify within N min")@aooth/auth + @aooth/auth-moost workflows
Backup / recovery codesNot bundled — see below
WebAuthn / FIDO2Not 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

ts
import { generateTotpSecret } from "@aooth/user";

const secret = generateTotpSecret(); // 20 random bytes, base32-encoded, unpadded, uppercase
const secret32 = generateTotpSecret(32); // optional byte length

The 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)

ts
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

ts
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 steps

verifyTotpCode 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 counterUserService.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

ts
// 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.

ts
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-hash

generateMfaCode(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.

ts
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 verifyTrustedDeviceBehavior
Signature mismatchfalse (counterfeit / tampered)
expiresAt < nowfalse (expired)
Issued with ip and request IP differsfalse (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.

ts
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

Released under the MIT License.