Skip to main content

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-link landing 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/me 200, 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 AuthcreateDemozAuth(config)auth.handler (mount via @demoz-pay/demoz-auth/node's toNodeHandler) → auth.api.createUser(...). The bespoke Nest controller + user service are deleted. Remaining 5d: TOTP migration (done), migrate the last auth.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)

PersonaMethodOwner
Admin / Business / FI / Merchantemail + password (+ TOTP)Better Auth (via ExternalSessionResolver bridge)
Employeephone → OTP → PINDemozAuth (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.

PortResponsibility
IdentityStorefind/create identities; findOrCreateByPhone is race-safe (fixes the duplicate-identity race)
CredentialStorePIN/password/etc. hashes; failure counters + lock timestamps
SessionStoresession rows keyed by token digest (never the raw token)
MembershipStoreidentity ↔ workspace + roles
OtpStoresingle-active OTP per identity, attempts, consume
Hashersalted Argon2id for low-entropy secrets (PIN, password)
Digestdeterministic SHA-256-class hash for high-entropy values (session tokens) — looked up by digest, so it can't be salted
RandomSourceCSPRNG tokens + numeric codes
Clockinjectable time (testable expiry/lockout)
RateLimiter (optional)per-phone/IP throttle
RiskEngine (optional)new-device / anomaly → challenge / deny
SmsSender / EmailSenderOTP / notification delivery
EventPublisheraudit / outbox sink
ExternalSessionResolverbridges 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

PhaseScopeStatus
0ADR + scaffold
1ports / engine / registry / hook bus / config / SDK contracts / bridge stub
2acore flows (phone/OTP, PIN, sessions, workspace) + RBAC + PIN policy
2bflows wrapped as plugins (phone-auth/pin/session/workspace) + handler resolution
3astateless concrete adapters in apps/api (Argon2id, SHA-256, random, clock)
3bNest transport (catch-all controller, cookie mapping) behind a feature flag, default OFF
3cPrisma persistence over EXISTING Better Auth tables (additive columns) + scrypt credential continuity(this doc)
3dDemozAuth events → hash-chained AuthEventSink audit trail(this doc)
4aemail/password login (backend) — scrypt continuity, shared lockout with PIN(this doc)
4bsession bridge (IdentityProvider resolves DemozAuth sessions) + employer-web pilot behind NEXT_PUBLIC_DEMOZ_AUTH(this doc)
4cDONE — change-password, session list/revoke, per-request ip/UA, password reset, 2FA/TOTP, profile update (PATCH /me/account); employer-web fully wired
4dDONE — admin-web, fi-web, merchant-web wired (flag-gated, full auth surface); fi/merchant verified live
5aGoogle 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)
5bPasswordless 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)
5cJWT 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-aTOTP-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-bAll 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/:

PortTableNotes
PrismaIdentityStoreUserfindOrCreateByPhone = race-safe upsert on phoneNumber; phone-only create synthesizes a …@users.demozpay.internal placeholder email (User.email is required+unique)
PrismaCredentialStoreAccountproviderId='credential'=password, 'pin'=Argon2id; lockout in failedAttempts/lockedUntil
PrismaSessionStoreSessiondigest in tokenHash; token=digest to satisfy unique-not-null; revoke = delete row
PrismaOtpStoreVerificationdemoz:otp:{identityId} namespace; value=Argon2id code hash; attempts/consumedAt
PrismaMembershipStoreMemberroles = 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.