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>
750 lines
33 KiB
Markdown
750 lines
33 KiB
Markdown
# 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
|