# 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 1–20 |
| 31 | E2E: OTP → refresh → logout (mitra_app) | All | Steps 21–23 |
| 32 | E2E: CC login → password change → lockout → logout | All | Steps 24–29 |
| 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
