@alluxi/mfa
v0.3.0
Published
Alluxi/Axionix shared two-factor authentication — passkeys, TOTP, emailed backup codes, recovery codes and cross-app device trust
Downloads
1,011
Maintainers
Readme
@alluxi/mfa
Shared two-factor authentication for the Axionix suite: Chrome/platform passkeys (WebAuthn) and authenticator apps (TOTP), with single-use recovery codes, an emailed backup code for lost devices, and cross-app device trust.
A user enrolls once. All eight tools — admin, bdr, compliance, gov, hr,
otto, pm, v3 — honor that enrollment.
How "enroll once" works
Two things make it possible:
One shared store. All MFA records live in an
mfaschema in the shared Neon database, keyed on lowercased email. Not on a user id — users are provisioned separately in each tool andaxionix-govhas no user table at all, so email is the only identity spanning the suite.One Relying Party ID. Every tool is served from
*.alluxi.com, so the WebAuthn RP ID is the registrable parentalluxi.com. A passkey created attime.alluxi.comis valid atpm.alluxi.comwith no re-enrollment.Passkeys are bound to the RP ID. Reaching an app by its
*.vercel.apphostname instead of its*.alluxi.comdomain will reject them.
Verification is tracked separately from enrollment: each app's own session carries the
result, and a .alluxi.com-scoped signed cookie means clearing the challenge in one tool
satisfies the rest for MFA_TRUSTED_DEVICE_DAYS (default 30). The cookie is HMAC-signed
and backed by a database row, so a lost laptop can be cut off immediately rather than
waiting for expiry.
Install
npm install @alluxi/mfa # or: file:../axionix-mfa for local developmentApply the schema once per database (idempotent, safe to re-run):
MFA_DATABASE_URL="postgresql://…?schema=mfa" npm run migrate --prefix ../axionix-mfaThe tables sit outside every app's Prisma schema on purpose: gov uses Drizzle and could
never share a Prisma client, and folding them into v3 would force multiSchema — and an
@@schema annotation on all ~40 existing models — onto that app.
Environment
| Variable | Required | Notes |
|---|---|---|
| MFA_DATABASE_URL | yes | Shared Neon URL with ?schema=mfa |
| MFA_ENCRYPTION_KEY | yes | 32 bytes, hex or base64. openssl rand -hex 32. Encrypts TOTP secrets at rest |
| MFA_SESSION_SECRET | yes | ≥32 chars. Must be byte-identical across all eight apps |
| MFA_RP_ID | prod | alluxi.com in production, localhost in development (default) |
| MFA_RP_ORIGIN | no | Comma-separated allowed origins. Defaults to https://<rp-id>, or ports 3000-3006 on localhost |
| MFA_RP_NAME | no | Shown in the passkey prompt. Default Alluxi |
| MFA_ENFORCE | no | true turns on the gate. Default false |
| MFA_GRACE_DAYS | no | Days to enroll before the hard gate. Default 7 |
| MFA_TRUSTED_DEVICE_DAYS | no | Default 30 |
| MFA_RESEND_API_KEY | email | Falls back to the app's own RESEND_API_KEY |
| MFA_MAIL_FROM | email | Falls back to the app's own RESEND_FROM_EMAIL |
The two mail variables are only read when someone actually uses an emailed backup code, so
seven of the eight apps need no new configuration — they already carry Resend credentials.
axionix-compliance ships no mailer of its own and needs RESEND_API_KEY and
RESEND_FROM_EMAIL (or the MFA_-prefixed pair) added before the backup route works there.
A mismatched MFA_SESSION_SECRET degrades silently to re-prompting rather than erroring —
worth asserting at startup.
Server usage
import { evaluateMfa, blocksAccess, TRUSTED_DEVICE_COOKIE } from "@alluxi/mfa";
const state = await evaluateMfa({
email: session.user.email,
trustedDeviceCookie: cookies().get(TRUSTED_DEVICE_COOKIE)?.value,
sessionVerified: token.mfa === "ok", // skips the DB round-trip
});
if (blocksAccess(state.status)) redirect("/mfa");state.status is one of:
| Status | Meaning | Gates? |
|---|---|---|
| ok | Second factor satisfied | no |
| challenge | Enrolled, but this session/device hasn't proven it | yes |
| enroll | Nothing registered and grace has expired | yes |
| grace | Nothing registered, still inside the window — show the nag banner | no |
| off | Enforcement disabled and nothing registered | no |
Enrolled users are always challenged, even with MFA_ENFORCE=false: turning enforcement
off must never silently weaken an account that already has a second factor.
Non-interactive callers must bypass this — axk_ personal access tokens and cron
routes authenticate as machines and have no way to answer a challenge.
Client usage
import { registerPasskey, authenticateWithPasskey, verifyTotpCode } from "@alluxi/mfa/browser";These post to the /api/mfa/* routes each app mounts.
Recovery
Ten single-use codes are issued at enrollment and shown exactly once; only SHA-256
hashes are stored. resetMfa(email) clears every factor for a locked-out user and is what
an admin "reset MFA" action should call.
Emailed backup codes
A user may confirm an address that can be mailed a 6-digit code at the challenge screen — the self-service way out when both the device and the written-down recovery codes are gone.
It is not a standalone factor and cannot be enrolled with. Sign-in across the suite is
Google OAuth on the same address, so a code delivered to the account mailbox proves nothing
the OAuth session did not already prove; treating it as a factor would be a gate that
unlocks itself. evaluateMfa therefore reports factors.email but excludes it from
enrolled. Pointing the backup at a different mailbox — which email/setup accepts and
the UI recommends — is what makes it genuinely independent.
The split across the gate matters:
| Endpoint | Reachable while gated | Why |
|---|---|---|
| email/send | yes | Only ever mails the address already confirmed on the account |
| email/verify | yes | It is how someone proves the factor |
| email/setup | no | Chooses the destination; a hijacked, unverified session could otherwise redirect the gate to its own mailbox |
| email/disable | no | Factor management |
Codes live 10 minutes, are stored as SHA-256 hashes, allow 5 guesses each, and a resend is
refused inside a 30-second cooldown. Verification runs through verifySecondFactor, so the
suite-wide 10-failures-per-15-minutes lockout covers it too. Unlike the other methods,
email/verify defaults remember to false: someone reaching for the backup has usually
just lost a device, which is the wrong moment to grant a 30-day pass by default.
Tests
npm testCovers TOTP drift windows, recovery-code reuse, tampered/expired/cross-account trust cookies, emailed-code expiry/replay/attempt caps, and the grace-period boundaries. No database required — the store layer is mocked, and the mailer never sends.
