ADR-0102: AuthN/AuthZ architecture for app.tlsstress.art¶
- Status: Accepted
- Date: 2026-05-28
- Driver: Project owner request — full rebuild of sign-up + sign-in following modern B2B SaaS standards (NIST SP 800-63B, LGPD, GDPR, ISO 27001, SOC 2, OWASP ASVS)
- Supersedes: ADR-0099 §"Customer auth foundation" (HMAC-only)
- Sources: docs/auth-blueprint/Arquitetura_Seguranca_SaaS_B2B_v2.md (operator blueprint v2, 2026-05-28)
Context¶
The current customer-app auth (/api/auth/signup + /api/auth/login +
HMAC customer_session cookie) was built for go-live speed. It works
but does not meet the operator-mandated security blueprint for a
globally-marketed B2B SaaS subscription product.
Blueprint v2 (revised 2026-05-28) imposes 6 architectural decisions that shape the entire auth surface. This ADR locks those decisions before implementation begins.
Decision¶
D1 — Framework: Next.js + NestJS-like module structure (no framework switch)¶
Keep Next.js App Router (current prod). Adopt NestJS-inspired pasta
organization under src/lib/:
src/lib/
auth/ # authn (signup, signin, refresh, logout)
mfa/ # totp + backup codes + step-up
recovery/ # password reset flow
email-validation/ # pipeline: normalize → blocklist → MX → opt-in
authorization/ # RBAC + ABAC per-tenant
risk/ # geo + velocity + fingerprint + IP reputation
audit/ # hash-chained events + SIEM export
sessions/ # JWT access + refresh rotation
Rationale: the blueprint requires "segregation between authN/authZ/audit as distinct services with separate storage". The Next.js route handlers become thin controllers that delegate to module services — same spiritual segregation without a 2-3 week framework migration.
D2 — Edge: Cloudflare proxy ON (reactivate)¶
Reactivate Cloudflare orange-cloud proxy on app.tlsstress.art and
admin.tlsstress.art. Replace the AWS ACM cert on App Runner with a
Cloudflare Origin Certificate (15-year validity, free).
This enables: - Cloudflare WAF with OWASP Core Rule Set - Cloudflare Bot Management (deeper than Turnstile alone) - Geo-fencing at the edge (block embargoed countries before app) - DDoS L7 + Argo Smart Routing
Carries risk: reactivation may regress Stripe webhook signature (CF adds headers) and Turnstile (already CF-native — should be fine). Smoke both immediately after flip.
D3 — Compute: AWS App Runner (cloud) + k3s (on-prem)¶
No migration to EKS for the cloud SaaS — App Runner is 6× cheaper than EKS at our scale and the operator confirmed it stays. The blueprint's "Infra: Kubernetes" maps to: - Cloud customer-app: AWS App Runner (this stays) - On-prem modules: k3s (already shipped per ADR-0101)
If/when we cross the 3-5 microservice threshold, revisit EKS migration.
D4 — Observability: OpenSearch AWS (managed) as SIEM sink¶
Adopt @opentelemetry/sdk-node with auto-instrumentation. Export traces
+ logs + metrics to Amazon OpenSearch Service (managed). No
third-party vendor (Datadog, New Relic).
This unifies:
- App Runner application traces
- Database query spans (Drizzle pg adapter)
- Auth event correlation (every signup, signin, MFA, refresh tagged
with correlationId)
- SIEM dashboards for the operator (Open Distro Security / Kibana)
Free tier: AWS gives 1 t3.small.search node free for 12 months; afterwards ~$25/mo for a single-node domain in us-east-1.
D5 — Email blocklist: DB-versioned with admin UI¶
Replace any hardcoded blocklist with a email_blocklist table:
CREATE TABLE email_blocklist (
domain TEXT PRIMARY KEY, -- lowercased apex
category TEXT NOT NULL, -- 'free' | 'disposable' | 'competitor' | 'custom'
rationale TEXT, -- why blocked (audit-friendly)
added_by UUID REFERENCES customer_users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
removed_by UUID REFERENCES customer_users(id),
list_version INT NOT NULL, -- bump per batch update
-- For audit + compliance proof
source TEXT, -- 'disposable-email-domains-npm' | 'manual' | etc.
source_version TEXT -- npm pkg version or commit sha
);
CREATE INDEX email_blocklist_active_idx
ON email_blocklist(category) WHERE removed_at IS NULL;
Seed at apply-time with:
- disposable-email-domains (npm pkg) — ~3k disposable domains
- Free providers: gmail.com, outlook.com, hotmail.com, yahoo.com,
icloud.com, proton.me, aol.com, live.com, msn.com, mail.com,
gmx.com, yandex.com, 163.com, qq.com, naver.com, seznam.cz,
mail.ru, web.de
- Competitors (manually curated): keysight.com, spirentcom.com,
viavisolutions.com, ostinato.org
Admin UI at /admin/email-blocklist for managing entries + audit trail
of additions/removals.
D6 — Sessions: JWT access (5-15 min) + refresh JWT rotation (httpOnly)¶
Refactor from HMAC cookie to full JWT model:
- Access token: JWT signed with
LICENSE_JWT_SECRET-style HS256 (or newAUTH_JWT_SECRET), 15-minute TTL, sent asAuthorization: Bearer <jwt>ORtlsstress_athttpOnly cookie. - Refresh token: JWT signed with separate secret, 30-day TTL, stored
as
tlsstress_rthttpOnly cookie withSameSite=Strict; Secure; Path=/api/auth/refresh. - Refresh family tracking: each refresh token carries a
family_id parent_jti. On/api/v1/auth/refreshthe old token is markedused_at; presenting an already-used refresh = token theft → revoke the entire family + audit event + force re-login.- Schema:
customer_refresh_tokenstable (jti, family_id, parent_jti, user_id, account_id, issued_at, expires_at, used_at, revoked_at, ip, ua).
This is a larger refactor than option A but aligns with the blueprint's "refresh token reused → revoke entire family + alert" requirement.
Consequences¶
Positive¶
- Aligns 100% with operator-mandated blueprint
- Refresh rotation gives revoke-at-leak-detection capability
- DB-versioned blocklist is auditable for compliance reviews
- OpenSearch SIEM gives correlation IDs across the request lifecycle
- Cloudflare WAF reduces application-layer load (rate limit / bot drop at edge)
Negative / Risks¶
- JWT refactor breaks all existing customer sessions — operator must notify customers (single email blast) before deploy. Sessions reset to login.
- Cloudflare proxy reactivation risks Stripe webhook signature verification (CF strips/rewrites some headers); needs smoke test in Test mode before flipping production.
- OpenSearch managed adds $25/mo recurring cost after free tier (acceptable for compliance value).
- Email blocklist as policy may reject some legitimate B2B users at free providers (the blueprint v2 explicitly accepts this trade-off for lead qualification).
Implementation roadmap (6 waves, ~3 weeks)¶
| Wave | Scope | Estimate |
|---|---|---|
| W1 | Email pipeline (blocklist + MX + HIBP) + Argon2id pepper + signup refactor | 3-4 days |
| W2 | 2FA TOTP enrollment-mandatory + backup codes + step-up | 3-4 days |
| W3 | Sign-in flow refactor (JWT access + refresh rotation + lockout progressive) | 3-4 days |
| W4 | Recovery flow (uniform response + HIBP on new pwd + session invalidation) | 2 days |
| W5 | Risk engine + CF WAF reactivation + audit hash-chained | 4-5 days |
| W6 | OpenTelemetry → OpenSearch + customer RBAC/ABAC wire-up | 3-4 days |
How to verify¶
After all 6 waves:
# 1. Sign-up flow
curl -X POST https://app.tlsstress.art/api/v1/auth/signup \
-d '{"email":"test@gmail.com",...}' # → 400 free_domain_blocked
curl -X POST .../api/v1/auth/signup \
-d '{"email":"test@acme.com",...}' # → 201 + verify email sent
# 2. Sign-in flow (verify timing-uniform)
time curl -X POST .../api/v1/auth/signin -d '{"email":"nobody@x.com",...}'
time curl -X POST .../api/v1/auth/signin -d '{"email":"real@user.com","password":"wrong"}'
# Both should take same time (~argon2id verify cost)
# 3. MFA enrollment mandatory
# After signup+verify, /account/* returns 403 until /api/v1/auth/mfa/enroll
# 4. Refresh rotation
# POST /api/v1/auth/refresh with old token after refresh = 403 + family revoked
# 5. Edge WAF
curl -A "sqlmap" https://app.tlsstress.art/ # → 403 blocked at CF
# 6. SIEM correlation
# Every endpoint should emit OpenTelemetry span with correlationId
# OpenSearch dashboard at /admin/security shows the trail
Pointers¶
- Memory: [[../memory/project_auth_rebuild_b2b_blueprint_2026_05_28]]
- Prior: [[0099-token-economy-v5]] — tier definitions consumed by AuthZ
- Prior: [[0100-token-economy-security-and-admin]] — admin auth (this ADR is customer-side parallel)
- Prior: [[0101-saas-onprem-loop]] — license JWT model (similar architecture, different audience)
- Blueprint source: operator-supplied
Arquitetura_Seguranca_SaaS_B2B_v2.md(private)