DemozAuth Architecture
Status: Phases 1–4d + 5a + 5b LANDED. The full auth surface (phone/OTP/PIN, email/password, workspace, change-password, session management, password reset, 2FA/TOTP, profile) runs through DemozAuth against the EXISTING Better Auth tables, and all four web portals (employer/admin/fi/merchant) have a flag-gated pilot (
NEXT_PUBLIC_DEMOZ_AUTH, default OFF). Phase 5a adds Google social sign-in (OIDC, Authorization-Code + PKCE/S256) with a "Continue with Google" button on every portal login. Phase 5b adds passwordless magic-link login (single-use emailed link, anti-enumeration, 2FA-gated) with an employer-web/magic-linklanding page + "Email me a sign-in link" affordance. Verified end-to-end against real Postgres (OAuth start→Google 302 + open-redirect guard; magic-link request→verify→workspace→/members/me200, single-use replay→401). Phase 5c adds stateless JWT access tokens (HS256,POST /token+Authorization: Bearer). Phase 5d in progress — Better Auth removal: native user lifecycle moved into the package; DemozAuth is now consumed exactly like Better Auth —createDemozAuth(config)→auth.handler(mount via@demoz-pay/demoz-auth/node'stoNodeHandler) →auth.api.createUser(...). The bespoke Nest controller + user service are deleted. Remaining 5d: TOTP migration (done), migrate the lastauth.api.*consumers, then delete Better Auth + rename. Decision record: ADR-029. This is the living implementation doc — updated at each phase.
DemozAuth (@demoz-pay/demoz-auth) is a framework-agnostic authentication & identity platform. It owns the auth domain, the port contracts, the engine, the auth flows AND the HTTP transport (auth.handler) + server API (auth.api) as pure logic — using only Web standards (Request/Response/crypto.subtle). It contains no Nest, no Prisma, no Express, no React — those live in adapters in apps/api. Replacing the auth provider, the web framework, or the database means changing one adapter, never the core.
Usage — exactly like Better Auth
// compose (apps/api/.../demoz-auth.module.ts) — like betterAuth(config)
const auth = createDemozAuth({ adapters, plugins: builtinPlugins(), config: { http, passwordReset, … } });
await auth.init();
// mount (main.ts) — one line, like toNodeHandler(betterAuthInstance)
import { toNodeHandler } from '@demoz-pay/demoz-auth/node';
expressApp.all('/api/demoz-auth/*splat', toNodeHandler(auth));
// server API (any service) — like auth.api.createUser
@Inject(DEMOZ_AUTH_ENGINE) private readonly auth: DemozAuth;
await this.auth.api.createUser({ email, name });
await this.auth.api.requestPasswordReset(email, url);
The package owns ALL transport: endpoint dispatch, cookies, and the OAuth redirect/PKCE flow live in core/http.ts (auth.handler); user lifecycle in core/api.ts (auth.api). The app no longer writes a controller.
Why a package, not a module
Authentication today is split: Better Auth (org-admins, email/password + TOTP) and a bespoke employee phone-OTP flow. Auth should be a reusable company asset across every Demoz product (pay, lending, HR, merchant/partner, mobile). We learn from Better Auth's plugin/hook/schema-extension model without forking it — and keep Better Auth as just one adapter behind a port.
Persona → method (locked)
| Persona | Method | Owner |
|---|---|---|
| Admin / Business / FI / Merchant | email + password (+ TOTP) | Better Auth (via ExternalSessionResolver bridge) |
| Employee | phone → OTP → PIN | DemozAuth (OTP only for first login / new device / PIN reset / risk step-up) |
Identity-first model
Identity (the person — phone and/or email; never assumes an org)
│ memberships
▼
Membership (identity ↔ workspace, with roles[])
│ select workspace
▼
Session two kinds:
IDENTITY — authenticated, no workspace chosen yet
WORKSPACE — identity + tenant + permissions
Auth never assumes an organization. A user authenticates first (IDENTITY session), then selects a workspace (WORKSPACE session). This fixes the review finding that OTP was coupled to an org before authentication.
Package layout
packages/demoz-auth/src/
domain/ pure types + invariants (no I/O)
index.ts Identity, Credential, Session, Membership, AuthEvent
errors.ts typed errors (code → HTTP at the transport edge)
pin-policy.ts validatePin() — weak-PIN rejection
rbac.ts RbacRegistry — roles → permissions, inheritance, '*'
application/
ports/index.ts the ENTIRE contract surface adapters implement
use-cases/ the auth flows (pure orchestration over ports)
core/
config.ts all security params (no magic numbers)
context.ts AuthAdapters + AuthContext
hook-bus.ts lifecycle event bus (plugins subscribe)
plugin-registry.ts plugin composition
auth-engine.ts DemozAuth — composes adapters + plugins + config
sdk/contracts.ts transport-agnostic request/response types
plugins/plugin.ts DemozAuthPlugin contract
testing/in-memory.ts in-memory adapters for tests (NOT production)
Ports (the contract surface)
Everything the core needs from the outside world is a port in application/ports/index.ts. Adapters (Prisma, Redis, SMS, Argon2id, Better Auth bridge) implement them; the core depends only on the interfaces.
| Port | Responsibility |
|---|---|
IdentityStore | find/create identities; findOrCreateByPhone is race-safe (fixes the duplicate-identity race) |
CredentialStore | PIN/password/etc. hashes; failure counters + lock timestamps |
SessionStore | session rows keyed by token digest (never the raw token) |
MembershipStore | identity ↔ workspace + roles |
OtpStore | single-active OTP per identity, attempts, consume |
Hasher | salted Argon2id for low-entropy secrets (PIN, password) |
Digest | deterministic SHA-256-class hash for high-entropy values (session tokens) — looked up by digest, so it can't be salted |
RandomSource | CSPRNG tokens + numeric codes |
Clock | injectable time (testable expiry/lockout) |
RateLimiter (optional) | per-phone/IP throttle |
RiskEngine (optional) | new-device / anomaly → challenge / deny |
SmsSender / EmailSender | OTP / notification delivery |
EventPublisher | audit / outbox sink |
ExternalSessionResolver | bridges a foreign session (Better Auth) → DemozAuth Principal |
Why two hash ports? A PIN is low-entropy and brute-forceable → it needs a salted, memory-hard hash (Argon2id) and is verified by re-hashing. A session token is already 256 bits of randomness → it needs a deterministic digest so the store can look the session up by it. Conflating them would either make tokens unlookupable or make PINs brute-forceable.
Core flows (Phase 2a)
All in application/use-cases/, all pure — they take an AuthContext (adapters + config + hooks) and orchestrate ports. Every state change emits an event via emitEvent(), which writes to the EventPublisher and the HookBus together (an event can never reach one sink but not the other).
Phone + OTP
startPhoneAuth(phone)
→ rate-limit (resend cooldown + daily cap, if a RateLimiter is wired)
→ findByPhone ?? findOrCreateByPhone (race-safe)
→ numericCode → hasher.hash → otps.issue (single-active; hash at rest)
→ sms.sendOtp(code) (plaintext only in the SMS)
→ { otpSent, maskedPhone, isNewIdentity }
verifyOtp(phone, code)
→ findActive OTP; reject if missing/expired/over-max-attempts
→ hasher.verify; on miss: recordAttempt (+consume at max) → InvalidCredentialError
→ consume; issue IDENTITY session
→ { identitySessionToken, requiresPinSetup } (true when no PIN credential)
PIN
setPin(identityId, pin) requires a prior IDENTITY session
→ validatePin (length, not all-same, not sequential, not common) [WeakSecretError]
→ hasher.hash (Argon2id) → credentials.save(type='PIN')
pinLogin(phone, pin) the day-to-day employee login — NO OTP
→ reject if locked (lockedUntil > now) [CredentialLockedError]
→ hasher.verify; on miss: increment failures; at maxFailed → exponential lock
lockMs = baseLockMs * 2^(failed - maxFailed)
→ on hit: reset failures
→ optional RiskEngine: decision != 'allow' → StepUpRequiredError (forces OTP)
→ issue IDENTITY session
Workspace selection
listWorkspaces(identityToken) → memberships → [{ workspaceId, roles }]
selectWorkspace(identityToken, workspaceId)
→ resolve IDENTITY session
→ memberships.exists ? : WorkspaceAccessDeniedError
→ issue WORKSPACE session (carries tenant scope)
Sessions
issueSession mints a CSPRNG token, stores only digest.hash(token), and sets a kind-based TTL. resolveActiveSession re-digests the presented token, looks it up, and enforces expiry / revocation / idle-timeout (revoking on idle). The raw token lives only client-side.
RBAC (a core requirement)
RbacRegistry (domain/rbac.ts) is generic and product-agnostic — permission strings are opaque to it.
const rbac = new RbacRegistry([
{ name: 'viewer', permissions: ['payroll:view'] },
{ name: 'approver', permissions: ['payroll:approve'], inherits: ['viewer'] },
{ name: 'owner', permissions: ['*'] }, // superuser wildcard
]);
rbac.can(['approver'], 'payroll:view'); // true (inherited)
rbac.can(['owner'], 'anything'); // true ('*')
rbac.canAll(['viewer'], ['payroll:view', 'payroll:approve']); // false
Inheritance is cycle-safe. WORKSPACE sessions carry the membership's roles[]; a guard resolves rbac.can(roles, permission) at request time.
Engine & plugins
DemozAuth (core/auth-engine.ts) composes adapters + plugins + config into one AuthContext, wires plugin hooks onto the HookBus, and runs each plugin's register() at init(). A transport adapter (Nest/Express) reads registry.endpoints() / guards() to expose HTTP; tests drive the engine (or the use-cases) directly. Plugins contribute endpoints/guards/schema/hooks as transport-agnostic descriptors — the core never imports a plugin.
Better Auth as an adapter
Better Auth stays the authority for email/password + TOTP (admin/business/FI/merchant). It is reached through the ExternalSessionResolver port; apps/api/src/identity/adapters/better-auth/better-auth-bridge.adapter.ts resolves a Better Auth session into a DemozAuth Principal. Replacing the provider replaces only that adapter. See BETTER_AUTH_INTEGRATION.md.
Roadmap
| Phase | Scope | Status |
|---|---|---|
| 0 | ADR + scaffold | ✅ |
| 1 | ports / engine / registry / hook bus / config / SDK contracts / bridge stub | ✅ |
| 2a | core flows (phone/OTP, PIN, sessions, workspace) + RBAC + PIN policy | ✅ |
| 2b | flows wrapped as plugins (phone-auth/pin/session/workspace) + handler resolution | ✅ |
| 3a | stateless concrete adapters in apps/api (Argon2id, SHA-256, random, clock) | ✅ |
| 3b | Nest transport (catch-all controller, cookie mapping) behind a feature flag, default OFF | ✅ |
| 3c | Prisma persistence over EXISTING Better Auth tables (additive columns) + scrypt credential continuity | ✅ (this doc) |
| 3d | DemozAuth events → hash-chained AuthEventSink audit trail | ✅ (this doc) |
| 4a | email/password login (backend) — scrypt continuity, shared lockout with PIN | ✅ (this doc) |
| 4b | session bridge (IdentityProvider resolves DemozAuth sessions) + employer-web pilot behind NEXT_PUBLIC_DEMOZ_AUTH | ✅ (this doc) |
| 4c | DONE — change-password, session list/revoke, per-request ip/UA, password reset, 2FA/TOTP, profile update (PATCH /me/account); employer-web fully wired | ✅ |
| 4d | DONE — admin-web, fi-web, merchant-web wired (flag-gated, full auth surface); fi/merchant verified live | ✅ |
| 5a | Google social sign-in (OIDC, Authorization-Code + PKCE/S256) — OAuthProvider port, oauth.ts use-cases, GoogleOAuthProvider adapter, controller handleOAuth (state/verifier/redirect cookies, open-redirect guard, single-workspace auto-select). "Continue with Google" button on all 4 portal logins, gated NEXT_PUBLIC_GOOGLE_AUTH | ✅ (this doc) |
| 5b | Passwordless magic-link login — magic-link plugin (/magic-link/request + /magic-link/verify, public), requestMagicLink/loginWithMagicLink use-cases (single-use token, anti-enumeration, 2FA-gated, reuses TokenStore + EmailSender). First-class sendMagicLink across all email providers + template. employer-web /magic-link landing page + "Email me a sign-in link" on login | ✅ (this doc) |
| 5c | JWT issuance (stateless HS256 bearer for service/API clients) — JwtSigner port, issueAccessToken/verifyAccessToken use-cases, POST /token (session-gated), NodeJwtSigner adapter, bridge tryDemozJwt so Authorization: Bearer <jwt> authenticates any /api/* route | ✅ (this doc) |
| 5d-a | TOTP-secret migration — PrismaTwoFactorStore.find() lazily re-encrypts Better-Auth-written TOTP rows into DemozAuth's format (BA symmetricDecrypt fallback → re-encrypt → persist). 2FA-enrolled users (admins) move to DemozAuth with their existing authenticator, no re-enrollment. | ✅ (this doc) |
| 5d-b | All portals cut over (flip NEXT_PUBLIC_DEMOZ_AUTH default) → remove Better Auth at parity (factory + Express mount + deps) | ⏳ |
Transport contract (Phase 2b)
A plugin endpoint is a descriptor; its handler is an AuthHandler resolved by
PluginRegistry.resolvedEndpoints() (which throws at boot on an unbound
handlerId). The transport never imports a use-case — it builds an AuthRequest
(body/params/headers + the raw session token it pulled from the cookie/header)
and turns the AuthResult into a response, setting a session cookie when
issueToken is present and clearing it when clearToken is set. Token semantics
stay in the package; cookie mechanics stay in the transport.
Concrete adapters (Phase 3a)
apps/api/src/identity/demoz-auth/adapters/: Argon2idHasher (PIN/password),
Sha256Digest (session tokens), NodeRandomSource (tokens + codes),
SystemClock. Stateless and @Injectable().
Nest transport (Phase 3b)
DemozAuthController (@Controller('demoz-auth'), @All('*splat')) maps the
engine's resolvedEndpoints() to /api/demoz-auth/*. It is the only HTTP-aware
piece: token extraction (Bearer → IDENTITY cookie → WORKSPACE cookie), cookie
issue/clear from AuthResult, and code → HTTP status via error-http.ts.
DemozAuthModule.forRoot() is flag-gated on DEMOZ_AUTH_TRANSPORT_ENABLED
(default OFF → registers nothing, fully inert). The engine factory wires the
Phase-3a adapters + placeholder in-memory stores (dev/in-memory-stores.ts,
replaced by Prisma in 3c) + logging SMS/event placeholders.
POST /api/demoz-auth/phone/start → { otpSent, maskedPhone, isNewIdentity }
POST /api/demoz-auth/phone/verify → { identitySessionToken, requiresPinSetup } + sets cookie
POST /api/demoz-auth/pin → { ok } (requires session)
POST /api/demoz-auth/pin/login → { identitySessionToken } + sets cookie
POST /api/demoz-auth/password/login → { identitySessionToken } + sets cookie (email+password)
POST /api/demoz-auth/password → { ok } (requires session)
POST /api/demoz-auth/password/change→ { ok } (verify current → set new)
GET /api/demoz-auth/sessions → { sessions: [...] } (your devices)
POST /api/demoz-auth/sessions/revoke→ { ok } (revoke one of your own)
POST /api/demoz-auth/sessions/revoke-others → { ok, revoked } (sign out elsewhere)
POST /api/demoz-auth/password/forgot→ { ok } (anti-enumeration; emails a token)
POST /api/demoz-auth/password/reset → { ok } (token → set new, single-use)
POST /api/demoz-auth/two-factor/enroll → { keyUri, secret } (requires session)
POST /api/demoz-auth/two-factor/enroll/confirm→ { backupCodes } (code → activate)
POST /api/demoz-auth/two-factor/disable → { ok } (password re-auth)
POST /api/demoz-auth/two-factor/login → { identitySessionToken } (challenge + code)
GET /api/demoz-auth/session → { identityId, kind, workspaceId, expiresAt }
POST /api/demoz-auth/session/logout → { ok } + clears cookies
GET /api/demoz-auth/workspaces → { workspaces: [...] }
POST /api/demoz-auth/workspaces/select → { workspaceSessionToken, workspaceId } + sets cookie
Persistence over EXISTING Better Auth tables (Phase 3c)
DemozAuth reuses Better Auth's tables (no Demoz* tables) so credentials and
sessions survive the migration. Prisma adapters in adapters/:
| Port | Table | Notes |
|---|---|---|
PrismaIdentityStore | User | findOrCreateByPhone = race-safe upsert on phoneNumber; phone-only create synthesizes a …@users.demozpay.internal placeholder email (User.email is required+unique) |
PrismaCredentialStore | Account | providerId='credential'=password, 'pin'=Argon2id; lockout in failedAttempts/lockedUntil |
PrismaSessionStore | Session | digest in tokenHash; token=digest to satisfy unique-not-null; revoke = delete row |
PrismaOtpStore | Verification | demoz:otp:{identityId} namespace; value=Argon2id code hash; attempts/consumedAt |
PrismaMembershipStore | Member | roles = org-plugin role + MemberRole.roleName[] |
Additive migration 20260625120000_demoz_auth_existing_table_extensions adds only
nullable columns + one unique index. Credential continuity: ScryptPasswordHasher
reproduces Better Auth's scrypt byte-for-byte (cross-verified both ways), so a future
DemozAuth password login reads existing Account.password and both engines coexist on
that column. SMS goes through the shared SMS_SENDER; auth events flow to the
hash-chained AuthEventSink audit trail via AuditEventPublisher (Phase 3d).
Verification (Phase 2a)
Pure package — verified by tsc (lib + spec), eslint, 42 unit tests (PIN policy, RBAC, full phone→OTP→PIN→workspace flow incl. lockout/expiry/step-up/access-denied), apps/api typecheck (export surface), and nx build demoz-auth. No migrations, no runtime wiring, no behavior change — entirely additive.