Files
halobestie-clone/requirement/phase3.4-plan.md
ramadhan sjamsani 3a25ddc41d docs: mark Phase 3.4 backend done in plan doc
Added progress snapshot at the top of phase3.4-plan.md noting:
- Backend cutover complete (commit f860ab6)
- Fazpass stubbed until real API docs arrive
- Frontend rewrites (client_app, mitra_app, control_center) pending
- Apple Developer prereqs still required
- Consolidated phase3.4-testing.md still to be written

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:51:19 +08:00

750 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 3.4 Implementation Plan: Self-Managed Authentication
> **Progress snapshot (2026-04-24):**
> - ✅ **Backend cutover complete** — commit `f860ab6`. All services, middleware, routes, WS auth, seed, env, deps done. Both apps (`app.public.js`, `app.internal.js`) build clean.
> - ⚠ **Fazpass stubbed** — `otp.service.js` generates codes locally until real API docs arrive. Replace `fazpassSendStub` + `fazpassVerifyStub` at that time.
> - ❌ **client_app / mitra_app / control_center rewrites** — not started. Until these land, the Flutter apps and control center will NOT work end-to-end against the new backend.
> - ❌ **Apple Developer prereqs** still required before iOS E2E testing.
> - ❌ **Consolidated `phase3.4-testing.md`** — not yet written. `phase3.3-testing.md` remains the canonical test list until then.
## 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