Phase 3.3 — Session Topic Sensitivity (complete): - Backend: topic_sensitivity column + session_sensitivity_log, sensitivity service (flip with one-way-latch + audit), PATCH /api/shared/chat/sessions/:id/topic, topic carried in pairing + extension WS payloads, CC filter + sensitive stats + per-mitra sensitive columns on activity page - client_app: TopicSelectionBottomSheet before pricing, topic flows through pairing request, silent WS handler for session_topic_updated - mitra_app: SensitivityBadge + SensitivityTheme + sensitivityConfigProvider, overlay badge + yellow accent, chat screen app-bar toggle with configurable confirmation + latch, extension card shows current flag, history + transcript yellow theme - control_center: Sensitivitas Topik settings section, topic filter + column with inline audit log, sensitive stats dashboard card, mitra activity sensitive columns with QC flag Phase 3.4 — Self-Managed Auth (foundation only): - Migration: auth_sessions + otp_requests tables, social identity columns on customers, password_hash + lockout on control_center_users, OTP + CC lockout app_config keys - New services: password (bcrypt + complexity), token (JWT HS256 + refresh rotation, session_id claim pre-wires future Valkey revocation), social-identity (Google + Apple JWKS), OTP (Fazpass stub — real API TBD) - Constants: AuthProvider + OtpChannel - Middleware, auth route rewrites, WS auth update, Firebase → FCM isolation still pending (next chunk); Fazpass docs + Apple Developer setup still required before E2E testing Docs: - requirement/phase3.3.md, phase3.3-plan.md, phase3.3-testing.md - requirement/phase3.4.md, phase3.4-plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
PRD: Self-Managed Authentication
Overview
Goal: Replace Firebase Auth with a self-managed authentication system across all apps, using Fazpass for SMS/WhatsApp OTP delivery and direct verification of Google/Apple ID tokens for social login.
Success looks like: Customers, mitras, and control center admins can all authenticate through our own backend without any dependency on Firebase Auth. User identity is anchored in our database, session tokens are issued and revoked by our backend, and Firebase Admin SDK is fully removed from the backend. FCM push notifications continue to work (Firebase Cloud Messaging is kept).
Affects: backend, client_app, mitra_app, control_center
Background
Firebase Auth currently handles:
- Phone OTP for customers and mitras
- Google and Apple social login for customers
- Email/password for control center admins
- Anonymous sessions for customers
- JWT issuance and verification
Moving off Firebase gives us direct control over:
- Session management (revocation, multi-device, TTL tuning)
- OTP delivery (Fazpass for WhatsApp + SMS, better Indonesian coverage than Firebase phone auth)
- User data (no user identity locked inside Firebase)
- Cost (Firebase phone auth is expensive at scale)
- Branding (fully native flows, no Firebase-branded fallback screens)
FCM (Firebase Cloud Messaging) is separate from Firebase Auth and stays — it handles push notifications and is not affected by this phase.
Functional Requirements
1. Anonymous Customer Flow
Trigger
- App launches for the first time OR local refresh token is missing/expired.
Behavior
- App calls
POST /api/shared/auth/anonymouswith no body, no auth. - Backend creates a new
customersrow with no identity (phone, google_sub, apple_sub all null) and a generateddisplay_name(e.g., "Teman Anonim #XXXX"). - Backend creates an
auth_sessionsrow and returns{ access_token, refresh_token }. - App stores refresh token in secure storage (Keychain / Keystore).
- All subsequent requests use the access token.
Upgrade to Authenticated
- When the user chooses a real identity (phone / Google / Apple), the existing anonymous
customersrow gets its identity columns populated (phone / google_sub / apple_sub / email). - Tokens are rotated — a new access + refresh token pair is issued on identity upgrade.
- If the identity is already linked to another customer, backend returns 409 CONFLICT (reject-on-existing). Merging is deferred (see Out of Scope).
2. Phone OTP Flow (Customer + Mitra)
Request OTP
POST /api/{client|mitra}/auth/otp/requestwith body{ phone: "+628..." }- Backend:
- Validates phone format (E.164)
- Checks rate limits (see Section 8)
- Calls Fazpass API to send OTP via configured channel (default: WhatsApp with SMS fallback)
- Stores the Fazpass request reference (not the OTP code itself — Fazpass holds it) in a new
otp_requeststable - Returns
{ otp_request_id, channel_used, expires_at }
Verify OTP
POST /api/{client|mitra}/auth/otp/verifywith{ otp_request_id, code }- Backend:
- Looks up
otp_requestsrow; validates not expired, not already used, attempts under limit - Calls Fazpass verify API
- If valid:
- Look up existing
customers(ormitras) by phone - If found: issue tokens against that row (for customer, this is the "upgrade anonymous" path if the current session is anonymous — see Section 1)
- If not found: create a new row keyed by phone
- Look up existing
- Issues
{ access_token, refresh_token }+ user profile
- Looks up
Resend
- Same endpoint as Request OTP. Rate limiter enforces 60s cooldown (see Section 8).
3. Google Sign-In (Customer Only)
Flow
- Flutter app uses the existing
google_sign_inpackage (a Google library, not a Firebase library — survives Firebase removal). GoogleSignIn().signIn()→ returns a Google ID token (JWT signed by Google).- App sends
POST /api/client/auth/googlewith{ id_token }. - Backend:
- Verifies the token against Google's JWKS (
https://www.googleapis.com/oauth2/v3/certs) usinggoogle-auth-library - Validates audience matches our OAuth client ID (configured per-platform: Android, iOS)
- Extracts
sub(Google user ID),email,name,email_verified - Look up existing
customersbygoogle_sub:- If found: issue tokens against that row
- If not found and caller has anonymous session: upgrade anonymous row by setting
google_sub+email+display_name - If not found and no anonymous session: create a new customer row
- If the Google sub is already linked to a different customer and caller is anonymous: return 409 (reject-on-existing)
- Verifies the token against Google's JWKS (
- Returns
{ access_token, refresh_token, profile }
OAuth Client IDs
- Separate client IDs per platform (Android / iOS) must be configured in Google Cloud Console.
- Configured via env vars on the backend:
GOOGLE_OAUTH_CLIENT_IDS(comma-separated list, all validated as valid audiences).
4. Apple Sign-In (Customer Only)
Prerequisites (External)
- Apple Developer account ($99/year) — required.
- Services ID created in Apple Developer portal (e.g.,
com.halobestie.client.signin) - Private key (
.p8) generated with Key ID - Team ID noted
This setup is a hard blocker for end-to-end testing. Backend code can be written and dry-verified without it; runtime flow cannot function on iOS until this is done.
Flow
- Flutter app uses
sign_in_with_applepackage. SignInWithApple.getAppleIDCredential()→ returns Apple's ID token.- App sends
POST /api/client/auth/applewith{ id_token, authorization_code? }. - Backend:
- Verifies token against Apple's JWKS (
https://appleid.apple.com/auth/keys) usingapple-signin-author manual JWT verification - Validates audience matches Services ID
- Extracts
sub(Apple user ID),email(only on first sign-in; subsequent sign-ins don't include it) - Customer lookup/creation/anonymous-upgrade flow identical to Google
- Verifies token against Apple's JWKS (
- Returns
{ access_token, refresh_token, profile }
Email Handling Quirk
- Apple only returns
emailthe first time a user signs in with your app. Subsequent sign-ins omit it. - We must persist
emailon first sign-in; don't rely on getting it again.
App Store Policy
- Per Apple guidelines, any iOS app offering third-party social login (Google) must also offer Sign in with Apple. So Apple is required on iOS since client_app has Google.
5. Mitra Authentication
- Mitras use phone OTP only (Section 2). No Google, no Apple, no anonymous.
- Existing mitra auth flow wraps the same Fazpass integration behind
/api/mitra/auth/otp/requestand/api/mitra/auth/otp/verify.
6. Control Center Email/Password
Login
POST /internal/auth/loginwith{ email, password }- Backend:
- Looks up
control_center_usersby email - Compares password against
password_hashusing bcrypt - Checks brute-force lockout state (see Section 8)
- On success: issues
{ access_token, refresh_token, profile } - On failure: increments failure counter; after 5 failures, 15-minute lockout
- Looks up
First Super-Admin Seeding
- Updated
backend/src/db/seed.jsreadsADMIN_EMAIL+ADMIN_PASSWORDenv vars, bcrypt-hashes the password, inserts a singlecontrol_center_usersrow with super-admin role. - Replaces the existing Firebase
admin.auth().createUser()call.
New Admin Provisioning (CC UI)
- Super-admin creates new CC users via existing CC "Users" page.
- Form adds an "Initial Password" field (with a "Generate" button that creates a 16-char random).
- Backend
POST /internal/cc-useraccepts{ email, display_name, role_id, password }, hashes password, inserts row.
Password Change (Self-Service)
PATCH /internal/cc-user/me/passwordwith{ current_password, new_password }- Backend verifies
current_passwordagainst stored hash, then replaces hash withbcrypt.hash(new_password, 12)
Password Change (Admin-Forced)
PATCH /internal/cc-user/:id/passwordwith{ new_password }- Requires super-admin role
- Same bcrypt flow
Password Complexity Rules
- Minimum 8 characters
- At least 1 digit
- At least 1 uppercase letter
- At least 1 lowercase letter
- Enforced server-side on create + change endpoints
Password Hashing
- bcrypt with cost factor 12
- Salt is embedded in the hash (bcrypt handles automatically; no separate column needed)
- Stored in
control_center_users.password_hash VARCHAR(60)
7. Token Strategy
Access Token (JWT, HS256)
- TTL: 1 hour
- Signed with:
AUTH_JWT_SECRETenv var (strong random, min 32 bytes) - Claims:
sub— user IDuser_type—customer|mitra|cc_usersession_id— PK of theauth_sessionsrow (used for future Valkey-based revocation; see Section 7.3)iat,exp— standard JWT fields
- Verification: backend validates signature and expiry on every authenticated request — no DB lookup on happy path
Refresh Token (Opaque)
- Format: 32-byte random, base64url encoded
- TTL: 30 days
- Rotation: every time the refresh token is used, a new refresh token is issued and the old one is invalidated
- Storage:
- Client: secure storage (Keychain / Keystore on mobile,
httpOnlycookie on CC — see note below) - Server: bcrypt-hashed in
auth_sessions.refresh_token_hash(never raw)
- Client: secure storage (Keychain / Keystore on mobile,
Control Center Token Storage
- Browser-based, so JWT access token lives in memory (JS variable in AuthContext) — not localStorage (XSS risk)
- Refresh token: httpOnly secure cookie on the CC domain. Browser auto-sends; JS can't read.
- Automatic refresh when access token expires (via 401 interceptor)
Revocation (Now)
- Logout:
DELETE FROM auth_sessions WHERE id = :session_id— refresh token becomes unusable; access token dies within 1 hour - Admin ban mitra: delete all
auth_sessionsrows for that mitra — same 1-hour window for active access tokens - Accepted tradeoff: up to 1 hour between revocation and full session death
Revocation (Future, Pre-wired)
session_idclaim in JWT enables Valkey-based instant revocation without changing token shape- Future enhancement: add
SISMEMBER revoked_sessions <session_id>check to the authenticate middleware - Mentioned in the plan as a deferred enhancement — not implemented in Phase 3.4
Multi-Device Sessions
- Each login / OTP verify / social verify = new
auth_sessionsrow - Logging in on device B does not invalidate device A
- Logout only affects the calling device
Refresh Endpoint
POST /api/shared/auth/refreshwith{ refresh_token }- Backend: look up session by token hash, verify not expired, rotate refresh token, issue new access token, return both
Logout Endpoint
POST /api/shared/auth/logoutwith{ refresh_token }(authenticated)- Backend: delete the matching
auth_sessionsrow
8. Security
OTP Rate Limits (Configurable via app_config)
otp_max_per_phone_per_hour— default 3otp_max_per_ip_per_hour— default 10- Exceeding returns 429 with
Retry-Afterheader
OTP Resend Cooldown
- Same phone cannot request another OTP within 60 seconds of the last request
OTP Verification Attempts
- Max 5 wrong code attempts per OTP request before invalidating the request (new OTP required)
Control Center Login Brute-Force
- Max 5 failed password attempts per email per 15 minutes
- 6th failure locks the account for 15 minutes (tracked in
control_center_users.lockout_until) - Failed attempts counter resets on successful login
Session Fingerprinting
- On every auth_sessions creation, record
user_agentandipin adevice_info JSONBcolumn - Not enforced for security; visible in control center for audit/support
JWT Secret Rotation
- Out of scope for this phase: single
AUTH_JWT_SECRETenv var - Documentation will note the rotation procedure (dual-secret window) but implementation is deferred
HTTPS Only
- All auth endpoints enforce HTTPS in production (assumed via existing infra — Cloud Run + Nginx)
9. Data Model
New Table: auth_sessions
id UUID PK
user_type VARCHAR(16) NOT NULL -- 'customer' | 'mitra' | 'cc_user'
user_id UUID NOT NULL
refresh_token_hash VARCHAR(60) NOT NULL -- bcrypt hash of refresh token
device_info JSONB -- { user_agent, ip, platform? }
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_at TIMESTAMPTZ NOT NULL -- created_at + 30 days
revoked_at TIMESTAMPTZ -- null if active
Indexes: (user_type, user_id), (expires_at) for cleanup.
New Table: otp_requests
id UUID PK
phone VARCHAR(20) NOT NULL
fazpass_reference VARCHAR(255) NOT NULL -- Fazpass's OTP session ID
channel VARCHAR(16) -- 'whatsapp' | 'sms'
attempts INT NOT NULL DEFAULT 0
used_at TIMESTAMPTZ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
expires_at TIMESTAMPTZ NOT NULL
Index: (phone, created_at) for rate-limit lookups.
Schema Changes to Existing Tables
customers
- DROP
firebase_uid - ADD
phone VARCHAR(20) UNIQUE(already exists, keep) - ADD
email VARCHAR(255)(nullable, populated from Google/Apple) - ADD
google_sub VARCHAR(255) UNIQUE(nullable) - ADD
apple_sub VARCHAR(255) UNIQUE(nullable)
mitras
- DROP
firebase_uid
control_center_users
- DROP
firebase_uid - ADD
password_hash VARCHAR(60) NOT NULL - ADD
failed_login_count INT NOT NULL DEFAULT 0 - ADD
lockout_until TIMESTAMPTZ(nullable)
New app_config Keys
otp_max_per_phone_per_hour(default3)otp_max_per_ip_per_hour(default10)otp_resend_cooldown_seconds(default60)otp_verify_max_attempts(default5)cc_login_max_attempts(default5)cc_login_lockout_minutes(default15)
10. Dependencies to Add / Remove
Backend
Add:
jsonwebtoken(JWT signing/verification)bcrypt(password + refresh token hashing)google-auth-library(Google ID token verification)apple-signin-authorjsonwebtoken+ JWKS fetch (Apple ID token verification)- Fazpass SDK (if they ship one) or direct HTTP client (axios/undici)
Remove:
firebase-admin
Keep:
- Firebase Cloud Messaging via
firebase-admin— wait — if we removefirebase-admin, how does FCM work? The same package handles both Auth and Messaging. Two options:- (a) Keep
firebase-adminbut only use itsmessaging()API; never touchauth()or the ID token verification functions - (b) Switch to the lower-level FCM HTTP v1 API directly (no SDK)
- Recommendation: (a) — simpler, still removes the Auth dependency at runtime.
- (a) Keep
client_app (Flutter)
Remove:
firebase_authfirebase_core(if no other Firebase features remain — but FCM needs it, so keep)
Keep:
firebase_core,firebase_messaging(for FCM)google_sign_in(standalone Google library)sign_in_with_apple(standalone Apple library)flutter_secure_storage(add if not present — refresh token storage)
Remove config:
firebase_options.dart— only keep iffirebase_coreis still in use for FCM; otherwise remove
mitra_app (Flutter)
Remove:
firebase_auth
Keep:
firebase_core,firebase_messaging(for FCM)flutter_secure_storage(refresh token)
control_center (React)
Remove:
firebase
Add:
- None — the existing Axios + cookies setup handles auth.
11. Prerequisites (External, Non-Code)
- Apple Developer account active ($99/year) — required for Sign in with Apple
- Apple Services ID + private key + Team ID + Key ID — required for backend verification
- Google OAuth Client IDs per platform (Android / iOS) — survive Firebase removal, likely already exist
- Fazpass API key + webhook setup (if applicable) — required for OTP delivery
- New env vars provisioned in all environments (dev, staging, prod):
AUTH_JWT_SECRETFAZPASS_API_KEY,FAZPASS_BASE_URLGOOGLE_OAUTH_CLIENT_IDSAPPLE_SERVICES_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY(PEM contents of the.p8file)ADMIN_EMAIL,ADMIN_PASSWORD(for CC seeding)
12. Out of Scope for Phase 3.4
- Password reset for control center — explicitly deferred (admin-forced reset is the only recovery path)
- Email delivery infrastructure (SMTP / Sendgrid / SES) — no emails sent in this phase
- Cross-app data migration from the other existing production app — handled separately
- Merge-on-link for social login (reject-on-existing for now; merge added later)
- Valkey-based instant revocation — pre-wired via
session_idclaim, implementation deferred - JWT secret rotation procedure — documented only, not implemented
- 2FA / MFA for control center admins
- Phone number change flow (re-linking phone to an existing account)
- Account deletion / soft-delete flow — separate privacy/compliance work
13. Prerequisites Before Implementing
Before writing a single line of code:
- Apple Developer account must be purchased and Sign in with Apple configured (Services ID +
.p8key) - Fazpass account must be provisioned with API credentials
- Agreement that the existing halobestie-clone dev database can be wiped and users re-registered (no migration within this phase; cross-app user migration is a separate task)
Tech Stack
- Backend: Fastify (existing), PostgreSQL (existing), new deps:
jsonwebtoken,bcrypt,google-auth-library,apple-signin-auth, Fazpass HTTP integration - client_app: Flutter,
google_sign_in,sign_in_with_apple,flutter_secure_storage - mitra_app: Flutter,
flutter_secure_storage - control_center: React + Vite, httpOnly cookies for refresh tokens
- Kept:
firebase-admin(Messaging only),firebase_core,firebase_messagingfor FCM push