Files
halobestie-clone/requirement/phase3.4.md
ramadhan sjamsani 780cade3db Phase 3.3: topic sensitivity + Phase 3.4: auth foundation
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>
2026-04-24 10:15:12 +08:00

418 lines
18 KiB
Markdown

# 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/anonymous` with no body, no auth.
- Backend creates a new `customers` row with no identity (phone, google_sub, apple_sub all null) and a generated `display_name` (e.g., "Teman Anonim #XXXX").
- Backend creates an `auth_sessions` row 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 `customers` row 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/request` with 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_requests` table
- Returns `{ otp_request_id, channel_used, expires_at }`
### Verify OTP
- `POST /api/{client|mitra}/auth/otp/verify` with `{ otp_request_id, code }`
- Backend:
- Looks up `otp_requests` row; validates not expired, not already used, attempts under limit
- Calls Fazpass verify API
- If valid:
- Look up existing `customers` (or `mitras`) 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
- Issues `{ access_token, refresh_token }` + user profile
### 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_in` package (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/google` with `{ id_token }`.
- Backend:
- Verifies the token against Google's JWKS (`https://www.googleapis.com/oauth2/v3/certs`) using `google-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 `customers` by `google_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)
- 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_apple` package.
- `SignInWithApple.getAppleIDCredential()` → returns Apple's ID token.
- App sends `POST /api/client/auth/apple` with `{ id_token, authorization_code? }`.
- Backend:
- Verifies token against Apple's JWKS (`https://appleid.apple.com/auth/keys`) using `apple-signin-auth` or 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
- Returns `{ access_token, refresh_token, profile }`
### Email Handling Quirk
- Apple only returns `email` the first time a user signs in with your app. Subsequent sign-ins omit it.
- We must persist `email` on 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/request` and `/api/mitra/auth/otp/verify`.
---
## 6. Control Center Email/Password
### Login
- `POST /internal/auth/login` with `{ email, password }`
- Backend:
- Looks up `control_center_users` by email
- Compares password against `password_hash` using 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
### First Super-Admin Seeding
- Updated `backend/src/db/seed.js` reads `ADMIN_EMAIL` + `ADMIN_PASSWORD` env vars, bcrypt-hashes the password, inserts a single `control_center_users` row 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-user` accepts `{ email, display_name, role_id, password }`, hashes password, inserts row.
### Password Change (Self-Service)
- `PATCH /internal/cc-user/me/password` with `{ current_password, new_password }`
- Backend verifies `current_password` against stored hash, then replaces hash with `bcrypt.hash(new_password, 12)`
### Password Change (Admin-Forced)
- `PATCH /internal/cc-user/:id/password` with `{ 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_SECRET` env var (strong random, min 32 bytes)
- **Claims**:
- `sub` — user ID
- `user_type``customer` | `mitra` | `cc_user`
- `session_id` — PK of the `auth_sessions` row (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, `httpOnly` cookie on CC — see note below)
- Server: bcrypt-hashed in `auth_sessions.refresh_token_hash` (never raw)
### 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_sessions` rows 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_id` claim 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_sessions` row
- Logging in on device B does not invalidate device A
- Logout only affects the calling device
### Refresh Endpoint
- `POST /api/shared/auth/refresh` with `{ 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/logout` with `{ refresh_token }` (authenticated)
- Backend: delete the matching `auth_sessions` row
---
## 8. Security
### OTP Rate Limits (Configurable via `app_config`)
- `otp_max_per_phone_per_hour` — default **3**
- `otp_max_per_ip_per_hour` — default **10**
- Exceeding returns **429** with `Retry-After` header
### 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_agent` and `ip` in a `device_info JSONB` column
- Not enforced for security; visible in control center for audit/support
### JWT Secret Rotation
- **Out of scope for this phase**: single `AUTH_JWT_SECRET` env 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` (default `3`)
- `otp_max_per_ip_per_hour` (default `10`)
- `otp_resend_cooldown_seconds` (default `60`)
- `otp_verify_max_attempts` (default `5`)
- `cc_login_max_attempts` (default `5`)
- `cc_login_lockout_minutes` (default `15`)
---
## 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-auth` or `jsonwebtoken` + 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 remove `firebase-admin`, how does FCM work? The same package handles both Auth and Messaging. Two options:
- (a) Keep `firebase-admin` but only use its `messaging()` API; never touch `auth()` 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.
### client_app (Flutter)
**Remove**:
- `firebase_auth`
- `firebase_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 if `firebase_core` is 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_SECRET`
- `FAZPASS_API_KEY`, `FAZPASS_BASE_URL`
- `GOOGLE_OAUTH_CLIENT_IDS`
- `APPLE_SERVICES_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY` (PEM contents of the `.p8` file)
- `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_id` claim, 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:
1. **Apple Developer account** must be purchased and Sign in with Apple configured (Services ID + `.p8` key)
2. **Fazpass account** must be provisioned with API credentials
3. 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_messaging` for FCM push