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>
This commit is contained in:
2026-04-24 10:15:12 +08:00
parent 97d50a8e08
commit 780cade3db
44 changed files with 3834 additions and 103 deletions

View File

@@ -0,0 +1,742 @@
# Phase 3.4 Implementation Plan: Self-Managed Authentication
## Summary of Clarified Requirements
| Topic | Decision |
|---|---|
| SMS/WhatsApp OTP provider | Fazpass (server-side flow, WhatsApp-first with SMS fallback) |
| Google Sign-In | client_app only; native `google_sign_in` → backend verifies Google ID token |
| Apple Sign-In | client_app only; native `sign_in_with_apple` → backend verifies Apple ID token |
| Mitra auth | Phone OTP only (unchanged scope) |
| Control center auth | Email/password; bcrypt; admin-only provisioning; no password reset |
| Access token | JWT HS256, 1h TTL, includes `session_id` claim |
| Refresh token | Opaque 32-byte random, 30d TTL, rotated on use, bcrypt-hashed in DB |
| Multi-device | Each login = new `auth_sessions` row |
| Revocation (now) | Delete `auth_sessions` row → 1h window until access token dies |
| Revocation (later) | Valkey `revoked_sessions` set, pre-wired via `session_id` claim |
| Anonymous customer | Server-issued anonymous JWT + refresh token (no device ID) |
| Link account (existing customer) | Reject-on-existing for now; merge deferred |
| Data migration | Dev DB can be wiped; cross-app prod migration deferred |
| FCM push | Kept — `firebase-admin` stays but only `.messaging()` used |
| CC access token storage | In-memory (JS variable in AuthContext) |
| CC refresh token storage | httpOnly secure cookie |
| Password hashing | bcrypt cost factor 12 |
| Password complexity | min 8 chars, ≥1 digit, ≥1 uppercase, ≥1 lowercase |
| Rate limits | Configurable via `app_config` (defaults: 3/phone/hour, 10/IP/hour, 60s resend, 5 verify attempts) |
| CC login lockout | 5 failed attempts → 15-min lockout |
| Session fingerprinting | `user_agent` + `ip` in `auth_sessions.device_info` JSONB |
| JWT secret rotation | Single secret for now; rotation procedure documented but not implemented |
| Fazpass API shape | ⚠ **TBD** — pending real API docs; placeholder fields in migration and service |
---
## Prerequisites (Must Be Done Before Coding)
1. **Apple Developer account** active ($99/year), Services ID created (e.g. `com.halobestie.client.signin`), `.p8` private key + Key ID + Team ID on hand
2. **Fazpass** account provisioned with API key; real API docs obtained (auth method, send OTP endpoint, verify endpoint, webhook spec)
3. **Google OAuth Client IDs** per platform recorded (Android, iOS) — likely already exist from Firebase setup, survive Firebase removal
4. **Agreement** that dev DB can be wiped
5. **Env vars provisioned** in dev / staging / prod:
- `AUTH_JWT_SECRET` (min 32 random bytes)
- `FAZPASS_API_KEY`, `FAZPASS_BASE_URL`, `FAZPASS_WEBHOOK_SECRET` (if callbacks used)
- `GOOGLE_OAUTH_CLIENT_IDS` (comma-separated)
- `APPLE_SERVICES_ID`, `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_PRIVATE_KEY`
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` (CC seed)
---
## Work Stream 1: Backend — Schema, Services, Routes
### 1.1 DB Migration
**File:** [backend/src/db/migrate.js](backend/src/db/migrate.js)
```sql
-- Drop firebase_uid columns
ALTER TABLE customers DROP COLUMN IF EXISTS firebase_uid;
ALTER TABLE mitras DROP COLUMN IF EXISTS firebase_uid;
ALTER TABLE control_center_users DROP COLUMN IF EXISTS firebase_uid;
-- Add social identity columns to customers
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255) UNIQUE,
ADD COLUMN IF NOT EXISTS apple_sub VARCHAR(255) UNIQUE;
-- CC password columns
ALTER TABLE control_center_users
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(60) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS lockout_until TIMESTAMPTZ;
-- (after backfilling real hashes, drop the DEFAULT '')
-- New auth_sessions table
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_type VARCHAR(16) NOT NULL,
user_id UUID NOT NULL,
refresh_token_hash VARCHAR(60) NOT NULL,
device_info JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_type, user_id);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL;
-- New otp_requests table (shape is provisional — confirm with Fazpass docs)
CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL,
fazpass_reference VARCHAR(255) NOT NULL,
channel VARCHAR(16),
attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at);
-- app_config defaults
INSERT INTO app_config (key, value) VALUES
('otp_max_per_phone_per_hour', '{"value": 3}'),
('otp_max_per_ip_per_hour', '{"value": 10}'),
('otp_resend_cooldown_seconds', '{"value": 60}'),
('otp_verify_max_attempts', '{"value": 5}'),
('cc_login_max_attempts', '{"value": 5}'),
('cc_login_lockout_minutes', '{"value": 15}')
ON CONFLICT (key) DO NOTHING;
```
### 1.2 Constants
**File:** [backend/src/constants.js](backend/src/constants.js)
```js
export const AuthProvider = Object.freeze({
ANONYMOUS: 'anonymous',
PHONE: 'phone',
GOOGLE: 'google',
APPLE: 'apple',
PASSWORD: 'password',
})
export const OtpChannel = Object.freeze({
WHATSAPP: 'whatsapp',
SMS: 'sms',
})
```
### 1.3 Config Service Additions
**File:** [backend/src/services/config.service.js](backend/src/services/config.service.js)
Add getters/setters for:
- `getOtpRateLimits()``{ max_per_phone_per_hour, max_per_ip_per_hour }`
- `getOtpResendCooldownSeconds()`
- `getOtpVerifyMaxAttempts()`
- `getCcLoginLockoutConfig()``{ max_attempts, lockout_minutes }`
- Matching setters for CC settings page
### 1.4 New Service: Token Service
**New file:** `backend/src/services/token.service.js`
Exports:
- `issueTokens({ userType, userId, deviceInfo })` — creates `auth_sessions` row + returns `{ access_token, refresh_token, expires_at }`
- `refreshTokens({ refresh_token, deviceInfo })` — validates + rotates + returns new pair
- `revokeSession({ sessionId })` — marks `revoked_at`; deletes the row
- `verifyAccessToken(token)` — returns decoded claims or throws
- `hashRefreshToken(raw)` — bcrypt
- `generateRefreshToken()` — 32-byte crypto random base64url
Internals:
- Access token claims: `{ sub, user_type, session_id, iat, exp }`
- HS256 with `AUTH_JWT_SECRET`
- Access TTL: 1h (`ACCESS_TOKEN_TTL_SECONDS` env, default 3600)
- Refresh TTL: 30d (`REFRESH_TOKEN_TTL_DAYS` env, default 30)
### 1.5 New Service: OTP Service
**New file:** `backend/src/services/otp.service.js`
Exports:
- `requestOtp({ phone, ipAddress })` — enforces rate limits, calls Fazpass send, inserts `otp_requests` row
- `verifyOtp({ otpRequestId, code })` — calls Fazpass verify, increments attempts, marks used
- Helpers: `checkOtpRateLimit(phone, ip)` using `app_config` + `otp_requests` history
**Fazpass-specific parts TBD** until real API docs are in hand. Placeholder functions:
```js
async function fazpassSendOtp({ phone, channel }) {
// TBD: POST to Fazpass send endpoint
// Returns: { reference, channel_used, expires_at }
}
async function fazpassVerifyOtp({ reference, code }) {
// TBD: POST to Fazpass verify endpoint
// Returns: { valid: bool }
}
```
### 1.6 New Service: Social Identity Service
**New file:** `backend/src/services/social-identity.service.js`
Exports:
- `verifyGoogleIdToken(idToken)` — uses `google-auth-library` `OAuth2Client.verifyIdToken`, validates audience against env `GOOGLE_OAUTH_CLIENT_IDS`, returns `{ sub, email, name, email_verified }`
- `verifyAppleIdToken(idToken)` — fetches Apple JWKS, verifies JWT signature + audience, returns `{ sub, email? }`
### 1.7 New Service: Auth Service
**New file:** `backend/src/services/auth.service.js`
Orchestrates sign-in flows. Exports:
- `signInAnonymous({ deviceInfo })` — creates customer row with generated display name, issues tokens
- `signInWithPhone({ phone, deviceInfo, upgradeFromCustomerId? })` — lookup/create customer by phone (or upgrade anonymous), issue tokens
- `signInMitraWithPhone({ phone, deviceInfo })` — lookup/create mitra by phone, issue tokens
- `signInWithGoogle({ idToken, deviceInfo, upgradeFromCustomerId? })` — verify, lookup/create/upgrade, issue tokens (409 if google_sub already linked to another customer)
- `signInWithApple({ idToken, deviceInfo, upgradeFromCustomerId? })` — same as Google
- `signInCcUser({ email, password, deviceInfo })` — verify password, lockout check, issue tokens
- `logout({ sessionId })` — delegate to token service
### 1.8 Password Service
**New file:** `backend/src/services/password.service.js`
Exports:
- `hashPassword(plain)` — bcrypt 12
- `verifyPassword(plain, hash)` — bcrypt compare
- `validateComplexity(plain)` — min 8 chars, ≥1 digit, ≥1 uppercase, ≥1 lowercase; throws with specific error code
### 1.9 Middleware Rewrite: `authenticate`
**File:** [backend/src/plugins/auth.js](backend/src/plugins/auth.js)
Replace Firebase verification with:
- Extract `Authorization: Bearer <token>` header
- Call `verifyAccessToken(token)` from token service
- On success: attach `request.auth = { userType, userId, sessionId }`
- On failure: return 401 UNAUTHORIZED
- **Future hook for Valkey revocation**: after JWT verification, check `SISMEMBER revoked_sessions <session_id>` — skipped in this phase but the code location is noted for later
Remove entirely:
- `verifyFirebaseToken` usage
- `request.firebaseUser.uid` references
### 1.10 Update All Routes
Every route that currently does `getCustomerByFirebaseUid(request.firebaseUser.uid)` (or mitra/cc variant) must be updated to read `request.auth.userId` + `request.auth.userType` directly. No more DB lookup needed — the token already contains the resolved ID.
Files:
- [backend/src/routes/public/client.chat.routes.js](backend/src/routes/public/client.chat.routes.js)
- [backend/src/routes/public/mitra.chat.routes.js](backend/src/routes/public/mitra.chat.routes.js)
- [backend/src/routes/public/shared.chat.routes.js](backend/src/routes/public/shared.chat.routes.js)
- [backend/src/routes/public/mitra.status.routes.js](backend/src/routes/public/mitra.status.routes.js)
- [backend/src/routes/internal/config.routes.js](backend/src/routes/internal/config.routes.js)
- [backend/src/routes/internal/roles.routes.js](backend/src/routes/internal/roles.routes.js)
- [backend/src/routes/internal/mitra.routes.js](backend/src/routes/internal/mitra.routes.js)
- [backend/src/routes/internal/mitra-activity.routes.js](backend/src/routes/internal/mitra-activity.routes.js)
- [backend/src/routes/internal/cc-user.routes.js](backend/src/routes/internal/cc-user.routes.js)
- [backend/src/routes/internal/session.routes.js](backend/src/routes/internal/session.routes.js)
Convert `resolveCustomer` / `resolveMitra` / `attachCcUser` helpers to use `request.auth` instead of Firebase.
### 1.11 Replace Auth Route Files
**Rewrite:** [backend/src/routes/public/client.auth.routes.js](backend/src/routes/public/client.auth.routes.js)
New endpoints (all public, no auth unless noted):
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/shared/auth/anonymous` | Create anonymous customer + tokens |
| POST | `/api/client/auth/otp/request` | Start phone OTP (customer) |
| POST | `/api/client/auth/otp/verify` | Verify phone OTP (customer) |
| POST | `/api/client/auth/google` | Google sign-in |
| POST | `/api/client/auth/apple` | Apple sign-in |
| POST | `/api/shared/auth/refresh` | Refresh token rotation |
| POST | `/api/shared/auth/logout` | Logout (authenticated) |
| GET | `/api/client/auth/me` | Current user profile (authenticated) |
**Rewrite:** [backend/src/routes/public/mitra.auth.routes.js](backend/src/routes/public/mitra.auth.routes.js)
| Method | Path | Purpose |
|---|---|---|
| POST | `/api/mitra/auth/otp/request` | Start phone OTP (mitra) |
| POST | `/api/mitra/auth/otp/verify` | Verify phone OTP (mitra) |
| GET | `/api/mitra/auth/me` | Current mitra profile (authenticated) |
**Rewrite:** [backend/src/routes/internal/auth.routes.js](backend/src/routes/internal/auth.routes.js)
| Method | Path | Purpose |
|---|---|---|
| POST | `/internal/auth/login` | CC email/password login (sets httpOnly refresh cookie + returns access token in body) |
| POST | `/internal/auth/refresh` | Refresh using httpOnly cookie |
| POST | `/internal/auth/logout` | Clear cookie + delete session |
| GET | `/internal/auth/me` | Current CC user profile |
**New:** `backend/src/routes/internal/cc-user.routes.js` — password endpoints
| Method | Path | Purpose |
|---|---|---|
| POST | `/internal/cc-user` | Create CC user (super admin only; accepts initial password) |
| PATCH | `/internal/cc-user/me/password` | Self-service change (current_password + new_password) |
| PATCH | `/internal/cc-user/:id/password` | Admin-forced reset (super admin only) |
Existing CC user listing / create (currently calls Firebase) is reworked to accept plain-password fields.
### 1.12 WebSocket Plugin Update
**File:** [backend/src/plugins/websocket.js](backend/src/plugins/websocket.js)
Replace `verifyFirebaseToken(msg.token)` with `verifyAccessToken(msg.token)`. Remove customer/mitra lookup (now encoded in the JWT claims).
### 1.13 FCM Plugin Isolation
**File:** [backend/src/plugins/firebase.js](backend/src/plugins/firebase.js) → rename to `backend/src/plugins/fcm.js`
Keep `firebase-admin` import but only expose `admin.messaging()`. Delete `verifyFirebaseToken` and Firebase Auth exports. All callers of `verifyFirebaseToken` are already rewritten in 1.9.
### 1.14 Seed Script Rewrite
**File:** [backend/src/db/seed.js](backend/src/db/seed.js)
Replace `admin.auth().createUser()` / `admin.auth().getUserByEmail()` with direct DB insert:
```js
const email = process.env.ADMIN_EMAIL
const password = process.env.ADMIN_PASSWORD
const passwordHash = await hashPassword(password)
await sql`
INSERT INTO control_center_users (email, password_hash, display_name, role_id)
VALUES (${email}, ${passwordHash}, 'Super Admin', ${superAdminRoleId})
ON CONFLICT (email) DO NOTHING
`
```
### 1.15 Dependency Changes
**File:** `backend/package.json`
Add:
- `jsonwebtoken`
- `bcrypt`
- `google-auth-library`
- `apple-signin-auth` OR implement manual Apple JWKS verification with `jsonwebtoken` + `jwks-rsa`
- HTTP client for Fazpass (can reuse `undici` if Node 18+)
Keep:
- `firebase-admin` (for FCM only)
- `@fastify/cors`, `@fastify/websocket`, `postgres`, `dotenv`, etc.
---
## Work Stream 2: client_app (Flutter)
### 2.1 Dependencies
**File:** `client_app/pubspec.yaml`
Remove:
- `firebase_auth`
Keep:
- `firebase_core`, `firebase_messaging` (for FCM)
- `google_sign_in`, `sign_in_with_apple`
Add:
- `flutter_secure_storage` (if not present)
- `jwt_decoder` (optional, for reading token expiry on client)
### 2.2 New: Secure Storage Service
**New file:** `client_app/lib/core/auth/token_storage.dart`
Wrapper around `flutter_secure_storage`:
- `saveRefreshToken(String)`
- `getRefreshToken()``String?`
- `clearRefreshToken()`
- `saveAccessToken(String)` / `getAccessToken()` / `clearAccessToken()` (access token can live in memory but keeping encrypted storage is safer against cold-start race)
### 2.3 New: Auth Notifier Rewrite
**File:** [client_app/lib/core/auth/auth_notifier.dart](client_app/lib/core/auth/auth_notifier.dart)
Replace Firebase-based flows. New methods (Riverpod `Notifier`):
- `bootstrap()` — on app start, check for refresh token → refresh → set authed state; else call `signInAnonymous()`
- `signInAnonymous()` — POST `/api/shared/auth/anonymous`
- `requestOtp(phone)` — POST `/api/client/auth/otp/request`
- `verifyOtp(otpRequestId, code)` — POST `/api/client/auth/otp/verify`, passes current anonymous customer session_id for upgrade
- `signInWithGoogle()` — native `google_sign_in` → POST `/api/client/auth/google`
- `signInWithApple()` — native `sign_in_with_apple` → POST `/api/client/auth/apple`
- `logout()` — POST `/api/shared/auth/logout`, clear secure storage
State shape: `{ accessToken, refreshToken, profile, authStatus }`
### 2.4 API Client Interceptor Rewrite
**File:** [client_app/lib/core/api/api_client.dart](client_app/lib/core/api/api_client.dart)
Replace `user.getIdToken()` with in-memory access token from `authNotifier`:
- Request interceptor: attach `Authorization: Bearer <access_token>`
- Response interceptor: on 401, try refresh once; if refresh fails, trigger logout
- No more Firebase dependency
### 2.5 WebSocket Auth Update
**File:** [client_app/lib/core/chat/chat_notifier.dart](client_app/lib/core/chat/chat_notifier.dart)
Replace `FirebaseAuth.instance.currentUser!.getIdToken()` with current access token from `authNotifier`. If expired, refresh first.
### 2.6 Remove Firebase Auth Initialization
**File:** [client_app/lib/main.dart](client_app/lib/main.dart)
Keep `Firebase.initializeApp()` for FCM. Remove all `FirebaseAuth.instance` references (auth state listener, etc.). Replace with `authNotifier.bootstrap()` call.
### 2.7 Remove Firebase Options (Partial)
**File:** [client_app/lib/firebase_options.dart](client_app/lib/firebase_options.dart)
Keep — FCM still needs Firebase project config. Just don't call `FirebaseAuth` anywhere.
### 2.8 Auth Screens
**Files:** `client_app/lib/features/auth/screens/*`
Update each screen to call new `AuthNotifier` methods. The screen structure (welcome, OTP, register, force-register, display name) stays the same — only the underlying method calls change.
### 2.9 FCM Token Registration
**File:** [client_app/lib/core/notifications/notification_service.dart](client_app/lib/core/notifications/notification_service.dart)
`FirebaseMessaging.instance.getToken()` still works. Registration endpoint (`POST /api/shared/device-token`) is unchanged — only auth header changes (new JWT instead of Firebase token).
---
## Work Stream 3: mitra_app (Flutter)
Identical pattern to client_app but phone-only. No Google/Apple code added.
### 3.1 Dependencies
**File:** `mitra_app/pubspec.yaml`
Remove `firebase_auth`. Keep `firebase_core`, `firebase_messaging`. Add `flutter_secure_storage`.
### 3.2 Token Storage + Auth Notifier + API Client + WebSocket
Same shape as client_app changes above.
**Files:**
- New: `mitra_app/lib/core/auth/token_storage.dart`
- Rewrite: [mitra_app/lib/core/auth/auth_notifier.dart](mitra_app/lib/core/auth/auth_notifier.dart) — methods: `bootstrap`, `requestOtp`, `verifyOtp`, `logout`
- Update: [mitra_app/lib/core/api/api_client.dart](mitra_app/lib/core/api/api_client.dart)
- Update: [mitra_app/lib/core/chat/mitra_chat_notifier.dart](mitra_app/lib/core/chat/mitra_chat_notifier.dart) and [mitra_app/lib/core/chat/chat_request_notifier.dart](mitra_app/lib/core/chat/chat_request_notifier.dart) — replace Firebase token retrieval in WS auth
### 3.3 Auth Screens
**Files:** `mitra_app/lib/features/auth/screens/*`
Update OTP screens to call new notifier methods. Screen structure stays.
---
## Work Stream 4: control_center (React)
### 4.1 Dependencies
**File:** `control_center/package.json`
Remove `firebase`.
### 4.2 Remove Firebase Initialization
**Delete:** `control_center/src/core/auth/firebase.js`
### 4.3 AuthContext Rewrite
**File:** [control_center/src/core/auth/AuthContext.jsx](control_center/src/core/auth/AuthContext.jsx)
New state:
- `accessToken` in memory (React state)
- `profile`
- `authStatus` = `loading | authed | unauthenticated`
New methods:
- `bootstrap()` — on mount, call `/internal/auth/refresh` (uses existing httpOnly cookie if present); set state
- `login(email, password)` — POST `/internal/auth/login`, server sets cookie + returns access token in body
- `logout()` — POST `/internal/auth/logout`, clear state
- `refreshAccessToken()` — used by API interceptor on 401
### 4.4 API Client Update
**File:** [control_center/src/core/api/api-client.js](control_center/src/core/api/api-client.js)
Replace `auth.currentUser.getIdToken()` with access token from AuthContext. On 401, call `refreshAccessToken()` and retry. All requests send `credentials: 'include'` (for cookie).
### 4.5 CORS Update
**File:** [backend/src/app.internal.js](backend/src/app.internal.js)
Ensure `@fastify/cors` config allows credentials from the CC origin (required for httpOnly cookie):
```js
await app.register(cors, {
origin: process.env.CC_ORIGIN,
credentials: true,
})
```
### 4.6 Login Page
**File:** [control_center/src/pages/login/LoginPage.jsx](control_center/src/pages/login/LoginPage.jsx) (or wherever the login page lives)
Update form to call `authContext.login(email, password)`. Error handling for lockout (15-min) and wrong-credentials.
### 4.7 CC User Management Page
**File:** existing users management page (likely `control_center/src/pages/users/UsersPage.jsx`)
Update create-user form:
- Add "Initial Password" field with "Generate" button (`crypto.randomUUID().slice(0,16)`)
- Submit calls `POST /internal/cc-user` with plain password; backend hashes
Add password-change UI:
- Self: account menu → "Change password" modal (current + new)
- Admin-forced: row action on user list → "Reset password" modal (new password only)
---
## Work Stream 5: Env & Config
### 5.1 Backend `.env.example`
Add:
```
AUTH_JWT_SECRET=
ACCESS_TOKEN_TTL_SECONDS=3600
REFRESH_TOKEN_TTL_DAYS=30
FAZPASS_API_KEY=
FAZPASS_BASE_URL=
FAZPASS_WEBHOOK_SECRET=
GOOGLE_OAUTH_CLIENT_IDS=
APPLE_SERVICES_ID=
APPLE_TEAM_ID=
APPLE_KEY_ID=
APPLE_PRIVATE_KEY=
ADMIN_EMAIL=admin@halobestie.com
ADMIN_PASSWORD=
CC_ORIGIN=http://localhost:5173
```
Remove:
```
FIREBASE_PROJECT_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
```
### 5.2 control_center `.env.example`
Remove:
```
VITE_FIREBASE_API_KEY=
VITE_FIREBASE_AUTH_DOMAIN=
VITE_FIREBASE_PROJECT_ID=
```
### 5.3 Mobile Firebase Configs
- Keep `google-services.json` (Android) / `GoogleService-Info.plist` (iOS) — required by `firebase_core` + `firebase_messaging` for FCM
- Keep `firebase_options.dart` — only referenced for FCM
---
## 6. Implementation Order
| Step | What | Apps | Dependencies |
|---|---|---|---|
| **Prerequisites** | | | |
| 0 | Apple Dev account, Fazpass creds, env vars provisioned | Ops | — |
| **Work Stream 1: Backend** | | | |
| 1 | DB migration + constants + config service additions | Backend | Step 0 |
| 2 | Token service (JWT issue / verify / refresh rotation) | Backend | Step 1 |
| 3 | Password service (hash, verify, complexity) | Backend | — |
| 4 | Rewrite `authenticate` middleware (JWT-based) | Backend | Step 2 |
| 5 | Social identity service (Google + Apple JWKS verify) | Backend | — |
| 6 | OTP service (Fazpass send + verify + rate-limit) | Backend | Step 1, Fazpass docs |
| 7 | Auth service orchestrator (all sign-in flows) | Backend | Steps 2, 3, 5, 6 |
| 8 | Rewrite auth routes (public/client, public/mitra, internal/auth) | Backend | Step 7 |
| 9 | CC user provisioning + password change routes | Backend | Step 3 |
| 10 | Update all other routes to use `request.auth` | Backend | Step 4 |
| 11 | Rewrite WebSocket auth handshake | Backend | Step 2 |
| 12 | Isolate Firebase to FCM-only (`firebase.js``fcm.js`) | Backend | Step 10, 11 |
| 13 | Rewrite seed script | Backend | Step 3 |
| **Work Stream 2: client_app** | | | |
| 14 | Secure storage wrapper + auth notifier skeleton | client_app | Step 8 |
| 15 | API client interceptor (JWT attach + 401 refresh) | client_app | Step 14 |
| 16 | Phone OTP flow (request + verify + bootstrap anonymous) | client_app | Step 14 |
| 17 | Google sign-in wiring | client_app | Step 16 |
| 18 | Apple sign-in wiring | client_app | Step 17 |
| 19 | WebSocket auth handshake update | client_app | Step 15 |
| 20 | Remove all `FirebaseAuth.instance` references | client_app | Step 19 |
| **Work Stream 3: mitra_app** | | | |
| 21 | Secure storage + auth notifier (phone-only) | mitra_app | Step 8 |
| 22 | API client + WebSocket auth update | mitra_app | Step 21 |
| 23 | Remove `FirebaseAuth.instance` references | mitra_app | Step 22 |
| **Work Stream 4: control_center** | | | |
| 24 | AuthContext rewrite (in-memory access + cookie refresh) | control_center | Step 8 |
| 25 | API client update (credentials: include, 401 refresh) | control_center | Step 24 |
| 26 | CORS config to allow credentials | Backend + control_center | Step 24 |
| 27 | Login page rewrite | control_center | Step 24 |
| 28 | CC user management password UI | control_center | Step 9 |
| 29 | Remove `firebase` dependency + firebase.js | control_center | Step 27 |
| **Testing / Cleanup** | | | |
| 30 | E2E: anonymous → OTP → Google → Apple → refresh → logout (client_app) | All | Steps 120 |
| 31 | E2E: OTP → refresh → logout (mitra_app) | All | Steps 2123 |
| 32 | E2E: CC login → password change → lockout → logout | All | Steps 2429 |
| 33 | Regression: all Phase 3 / 3.1 / 3.2 / 3.3 flows still work with new auth | All | All above |
---
## 7. New Files
| File | Purpose |
|---|---|
| `backend/src/services/token.service.js` | JWT + refresh rotation + session storage |
| `backend/src/services/otp.service.js` | Fazpass integration + rate-limit enforcement |
| `backend/src/services/social-identity.service.js` | Google + Apple ID token verification |
| `backend/src/services/auth.service.js` | Sign-in flow orchestrator |
| `backend/src/services/password.service.js` | bcrypt + complexity validation |
| `backend/src/plugins/fcm.js` | Renamed from firebase.js, FCM-only |
| `client_app/lib/core/auth/token_storage.dart` | Secure storage wrapper |
| `mitra_app/lib/core/auth/token_storage.dart` | Secure storage wrapper |
## 8. Modified Files (Primary)
| File | Change |
|---|---|
| `backend/src/db/migrate.js` | Drop firebase_uid, add new columns, add auth_sessions + otp_requests tables, seed config keys |
| `backend/src/db/seed.js` | Bcrypt-based admin seed instead of Firebase |
| `backend/src/constants.js` | AuthProvider + OtpChannel |
| `backend/src/services/config.service.js` | OTP + CC lockout config getters/setters |
| `backend/src/services/customer.service.js` | Drop firebase_uid lookup; add google_sub/apple_sub/phone lookups |
| `backend/src/services/mitra.service.js` | Drop firebase_uid lookup; phone-based lookup |
| `backend/src/services/cc-user.service.js` | Drop firebase_uid; email + password_hash based |
| `backend/src/services/notification.service.js` | Use FCM-only import from new fcm.js |
| `backend/src/plugins/auth.js` | JWT-based middleware |
| `backend/src/plugins/websocket.js` | JWT auth in WS handshake |
| `backend/src/routes/public/client.auth.routes.js` | Full rewrite |
| `backend/src/routes/public/mitra.auth.routes.js` | Full rewrite |
| `backend/src/routes/internal/auth.routes.js` | Full rewrite |
| `backend/src/routes/internal/cc-user.routes.js` | Password endpoints + plain-password provisioning |
| All route files currently using `request.firebaseUser.uid` | Switch to `request.auth.userId` / `request.auth.userType` |
| `backend/src/app.internal.js` | CORS with credentials for cookie |
| `backend/package.json` | Add jsonwebtoken, bcrypt, google-auth-library, apple-signin-auth |
| `client_app/pubspec.yaml` | Remove firebase_auth; add flutter_secure_storage |
| `client_app/lib/main.dart` | Remove FirebaseAuth listener; call authNotifier.bootstrap |
| `client_app/lib/core/auth/auth_notifier.dart` | Full rewrite |
| `client_app/lib/core/api/api_client.dart` | JWT interceptor |
| `client_app/lib/core/chat/chat_notifier.dart` | JWT in WS handshake |
| All client_app auth screens | Call new notifier methods |
| `mitra_app/pubspec.yaml` | Remove firebase_auth; add flutter_secure_storage |
| `mitra_app/lib/main.dart` | Remove FirebaseAuth listener |
| `mitra_app/lib/core/auth/auth_notifier.dart` | Full rewrite (phone-only) |
| `mitra_app/lib/core/api/api_client.dart` | JWT interceptor |
| `mitra_app/lib/core/chat/mitra_chat_notifier.dart` | JWT in WS handshake |
| `mitra_app/lib/core/chat/chat_request_notifier.dart` | JWT in WS handshake |
| All mitra_app auth screens | Call new notifier methods |
| `control_center/package.json` | Remove firebase |
| `control_center/src/core/auth/AuthContext.jsx` | Full rewrite |
| `control_center/src/core/api/api-client.js` | In-memory token + cookie refresh |
| `control_center/src/pages/login/LoginPage.jsx` | Rewrite |
| `control_center/src/pages/users/UsersPage.jsx` | Password provisioning UI |
## 9. Deleted Files
| File | Reason |
|---|---|
| `control_center/src/core/auth/firebase.js` | No longer needed |
## 10. Env File Changes
See Section 5 above.
---
## 11. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Fazpass API shape assumed wrong → service rewrite mid-implementation | Block on real docs before Step 6; mark `otp.service.js` as stub until then |
| Apple Developer setup blocks iOS testing | Flagged as Prerequisite 0; other flows (Google, phone) still testable on Android |
| CC cookie auth breaks with cross-origin CC ↔ backend | Set `SameSite=None; Secure` on cookie in prod; use `sameSite=lax` in dev; CORS `credentials: true` |
| JWT secret leak compromises all sessions | Env var stored in secret manager; rotation procedure documented (deferred impl) |
| Existing users locked out after cutover | Dev DB wiped by agreement; production migration explicitly deferred |
| Refresh token theft | Rotation-on-use means stolen token becomes invalid on next legitimate use; device_info audit trail |
| bcrypt cost 12 is slow on low-end hardware | Cost 12 takes ~250ms on modern CPU; acceptable for OTP/login frequency; revisit if latency spikes |
| Anonymous refresh token loss (uninstall / secure storage wipe) | Accepted — reinstall = fresh anonymous session + lost chat history (matches Firebase behavior today) |
| Google/Apple sign-in fails on first launch after install | Both native SDKs handle this; backend returns 401 with clear error code; client shows "Try again" |
| Fazpass rate-limit separate from our rate-limit | Our backend enforces first (configurable); Fazpass enforces second (provider's own); log both distinctly |
| Concurrent OTP requests for same phone | DB row per request; rate-limit check before Fazpass call prevents double-charge |
| Token claim drift (adding fields later) | JWT is versionless; all claims read via property access, missing claims tolerated in verifier |
---
## 12. Testing Checklist
**Backend unit (per service):**
- [ ] `token.service.js`: issue → verify → refresh rotation → logout → verify fails
- [ ] `password.service.js`: hash → verify → complexity rejections (short, missing digit, missing case)
- [ ] `social-identity.service.js`: mocked Google + Apple valid tokens verified; invalid signature rejected; wrong audience rejected
- [ ] `otp.service.js`: rate-limit throws; resend cooldown honored; verify increments attempts; stub Fazpass with a mock
**Backend integration:**
- [ ] Anonymous → phone OTP → upgrade (same customer row)
- [ ] Anonymous → Google → upgrade (same customer row)
- [ ] Anonymous → Google → 409 if google_sub already linked elsewhere
- [ ] OTP resend within 60s → 429
- [ ] 4 OTP requests from same phone in an hour → 429
- [ ] CC 5 failed logins → lockout 15 min; 6th attempt even with correct password → 423/429
- [ ] Refresh token reuse (after rotation) → rejected
- [ ] Logout → refresh rejected, access expires naturally
**client_app E2E:**
- [ ] First launch → anonymous session created → can request chat
- [ ] Request OTP → verify → upgraded, chat history preserved
- [ ] Google sign-in (fresh customer)
- [ ] Apple sign-in (fresh customer)
- [ ] Token refresh transparent to user (break access token early to test)
- [ ] Logout → returns to splash → anonymous auto-created
- [ ] Kill app with active chat → restart → session restored via refresh
**mitra_app E2E:**
- [ ] OTP request + verify → new mitra row created
- [ ] Token refresh transparent
- [ ] Chat request WebSocket auth works
- [ ] Logout
**control_center E2E:**
- [ ] Seeded super admin can log in
- [ ] Create second admin with initial password → new admin logs in
- [ ] Self-service password change
- [ ] Admin-forced password reset
- [ ] Refresh cookie persists across browser reloads
- [ ] Logout clears cookie + state
**Regression:**
- [ ] Phase 3 flows (chat, extension, closure) work end-to-end with new auth
- [ ] Phase 3.2 overlay still works with JWT-based WS auth
- [ ] Phase 3.3 topic sensitivity flow still works
**Negative:**
- [ ] Tampered JWT → 401
- [ ] Expired JWT → 401 → client auto-refreshes
- [ ] Refresh token from another device/user → 401
- [ ] Missing `Authorization` header on protected route → 401
- [ ] Customer calling mitra-only endpoint with valid customer JWT → 403