Phone OTP Integration Plan — Firebase Phone Auth via firebase_service
Status (per 2026-06-11): Plan C (Mode B auto-fallback) ✅ DONE — verified di otp_service.go:366+426 + buildFallbackResult() + flag ENABLE_FIREBASE_PHONE_AUTH_FALLBACK. Plan E (E2E smoke test real device) ✅ DONE 2026-05-21. Sisa pending: Plan D backend (POST /auth/request-otp/switch-channel, 3 file core_api), Plan D mobile (locked — folder apps/mobile_user/ no-touch tanpa diskusi detail), Plan iOS APNs setup, Plan Polqo migration. Sumber: memori project_firebase_phone_auth_status.md.
Owner: Backend Lead
Catatan judul: Plan ini awalnya berjudul "SMS OTP Service Plan" (asumsi service mandiri dengan provider Zenziva). Setelah diskusi & audit firebase_service, arsitektur direvisi:
- Phase 0-2: integrasi Firebase Phone Auth dengan extend firebase_service (bukan bikin service baru). Konsisten dengan Phase 4 cleanup goal yang mengeluarkan Firebase SDK dari core_api.
- Phase 3: kalau migrasi ke aggregator (Zenziva/Twilio), BARU bikin
services/sms_service/mandiri.
Dokumen terkait:
services/email/extraction-plan.md— pola Phase -1 telemetri (di working notes)services/firebase/extraction-plan.md— host service yang akan di-extend (Phase 4 final 2026-05-19; di working notes)Architecture.md— channel notifikasi map (di working notes)
0. Kenapa Extend firebase_service (Bukan Bikin Service Baru)?
Konteks Phase 4 FCM cleanup ✅ DONE 2026-05-19
Phase 4 cleanup (selesai 2026-05-19, per services/firebase/extraction-plan.md)
sudah mengeluarkan Firebase SDK dari core_api:
- ✅
merchant_core_api/internal/push/fcm.godihapus - ✅ 3 env Firebase (
FIREBASE_PROJECT_ID,FIREBASE_CLIENT_EMAIL,FIREBASE_PRIVATE_KEY) tidak lagi dipakai core_api - ✅ Single owner Firebase Admin SDK = firebase_service (rename dari fcm_service per Phase 4 final)
Implikasi untuk Phone Auth
Firebase Phone Auth juga butuh Firebase Admin SDK (untuk verify ID token). Kalau integrasi langsung di core_api, kita mengulangi pattern yang baru saja di-clean. Inkonsisten dengan Phase 4 goal.
Opsi yang Dievaluasi
| Opsi | Konsisten Phase 4? | Effort Phase 0-2 | Phase 3 migration |
|---|---|---|---|
| A. Integrasi Firebase di core_api | ❌ Mengulangi Firebase di core_api | 3-4 hari | Extract code → service baru |
B. Bikin sms_service/ baru dengan Firebase SDK | ✅ | 5-7 hari | Swap provider |
C. Extend firebase_service dengan endpoint Phone Auth | ✅ + Firebase credential terpusat | 1-2 hari | Bikin sms_service baru |
Pilihan: Opsi C karena:
- Konsisten Phase 4 cleanup goal (no Firebase di core_api)
- Single Firebase credential = security clarity
- Firebase Admin SDK sudah ada di
go.modfirebase_service - Effort minimal (~1-2 hari tambah endpoint)
- YAGNI — tidak bikin service kosong untuk 1 endpoint
Nama Service ✅ Renamed 2026-05-19 (sebelumnya direncanakan Phase 3)
Service folder sudah direname services/fcm_service → services/firebase_service
per Phase 4 final 2026-05-19 (sebelum plan ini dieksekusi). VM produksi ikut
direname: folder merchant_fcm → merchant_firebase, systemd unit
fcm-service.service → firebase-service.service, binary fcm-service →
firebase-service. Rationale rename early: single source of truth Firebase SDK
setelah merchant_core_api/internal/push/ dihapus.
Phase 0 plan ini langsung kerja di services/firebase_service/.
1. Tujuan
Mengaktifkan channel SMS OTP untuk login Kesles Merchant via Firebase Phone Auth (Google's SMS service backend), dengan 2 peran:
- Mode A (Registrasi) — pilihan pendamping WhatsApp untuk user baru. User pilih.
- Mode B (Login Existing) — disaster recovery automatic. Backend decide.
Implementasi via extend firebase_service existing, bukan bikin service baru. Backend Kesles cuma verify Firebase ID token; SMS send + delivery + retry = handled by Firebase Cloud (Google's infrastructure).
Non-goal
- Tidak bikin
services/sms_service/di Phase 0-2 (deferred ke Phase 3) - Tidak integrasi Firebase SDK di core_api (konsisten Phase 4 cleanup goal)
- Tidak kelola SMS gateway / provider (Firebase yang handle)
- Tidak support voice OTP (Phase 0-2; tapi Firebase Phone Auth support, bisa di-enable Phase 1+)
2. Kondisi Awal (Pre-Build)
2.1 Status firebase_service — Ready, Firebase Admin SDK sudah ada
Per state actual services/firebase_service/ (verified 2026-05-19, post Phase 4 final):
| Aspek | Status |
|---|---|
| Firebase Admin SDK di go.mod | ✅ Ada (firebase.google.com/go/v4) |
| Firebase credential di env | ✅ Aktif di VM (FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY di .env.production) |
| HTTP server pattern | ✅ Established (Go std mux, internal API key auth) |
| Internal endpoints existing | 8 endpoints (fcm send + token management) — verified /health+/ready+/internal/fcm/send live di VM 2026-05-19 |
| Postgres connection pool | ✅ Configured (25 max, 5 idle) |
Folder internal/auth/ | ❌ Belum ada — akan dibuat untuk Phone Auth verifier |
Effort tambah Phone Auth verifier: ~1-2 hari (tambah folder internal/auth/,
1 verifier file, 1 handler, register route).
2.2 Status core_api — Sudah jadi proxy ke firebase_service
| Komponen | Status sekarang (post Phase 4 final 2026-05-19) | Status setelah Phase 1 Phone Auth |
|---|---|---|
| Firebase SDK dependency | ❌ Sudah dihapus (Phase 4 cleanup done) | (kept removed) |
| Firebase HTTP client | ✅ Ada di internal/fcmservice/client.go (FCM-only) | Extend tambah VerifyPhoneToken() method |
| Auth endpoint Phone | ❌ Belum ada | ✅ POST /auth/firebase-phone-verify |
| Decision tree OTP channel | Hanya WA + Email | Tambah SMS branch + fallback logic |
2.3 Status mobile_user — UI partial ready
| File | Behavior current | Action plan |
|---|---|---|
verification_method_page.dart:102 | Tampil snackbar smsUnavailable | Hapus snackbar, plug ke flow real |
verification_method_page.dart:188 | Button "SMS" disabled | Aktifkan untuk Mode A |
app_strings.dart:20 | smsMethod = 'sms' | Tetap dipakai |
pubspec.yaml | Tidak ada firebase_auth | Tambah dependency Firebase Auth SDK |
3. Channel Strategy & UX Design
3.1 Mode A — Registrasi (User Pilih)
User input nomor HP baru
│
▼
Backend cek: phone TIDAK terdaftar
│
▼
Response: {
"registration_flow": true,
"available_channels": ["whatsapp", "sms"]
}
│
▼
Mobile show menu pilihan:
[📱 WhatsApp] [💬 SMS]
│
▼
User pilih → trigger:
- WA: backend kirim via whatsapp_service (existing)
- SMS: mobile trigger Firebase Auth SDK (`verifyPhoneNumber()`)
│
▼
OTP page tampil → user input → verify
3.2 Mode B — Login Existing (Backend Auto-pick + Auto-fallback)
User input nomor HP terdaftar
│
▼
Backend cek profile.email_verified
│
┌────┴─────┐
│ │
true false
│ │
▼ ▼
Try Email Try WhatsApp
│ │
└─────┬────┘
│
Send result?
│
┌─────┴──────┐
│ │
OK Fail (service down)
│ │
▼ ▼
return Backend signal mobile:
primary "use_sms_fallback: true"
│
▼
Mobile trigger Firebase Auth SDK
│
┌─┴─┐
OK Fail
│ │
▼ ▼
return 503 "semua channel gangguan"
SMS + banner kuning
Penting: auto-fallback SMS = backend tidak kirim SMS langsung. Backend signal mobile untuk pakai Firebase Phone Auth SDK. Send delegated ke Google Firebase Cloud (mobile-driven via SDK).
3.3 Channel Matrix per User State
| User State | email_verified | Punya email | Primary auto-pick | Auto-fallback | "Coba metode lain" |
|---|---|---|---|---|---|
| Mode A Registrasi | n/a | n/a | User pilih: WA / SMS | (manual retry) | Swap WA ↔ SMS |
| State 1 Login, no email | false | ❌ | SMS | SMS + CTA "Atur email" | |
| State 2 Login, email unverified | false | ✅ tapi belum verify | SMS | SMS + CTA "Verifikasi email" | |
| State 3 Login, email verified | true | ✅ verified | SMS | WhatsApp, SMS |
3.4 Fallback Mechanism — 2 Tier
Tier 1: Auto-fallback (Backend Signal → Mobile Execute)
Trigger: primary service send() return error (WA/Email down).
Backend response:
{
"channel": "sms",
"fallback_reason": "whatsapp_unavailable",
"use_firebase_phone_auth": true,
"phone": "+6281234567890",
"request_id": "..."
}
Mobile action: terima signal → trigger FirebaseAuth.verifyPhoneNumber() →
tampil OTP page dengan banner kuning info.
┌────────────────────────────────────┐
│ ← Verifikasi OTP │
├────────────────────────────────────┤
│ ℹ WhatsApp sedang gangguan, │
│ OTP dikirim via SMS │
│ │
│ Kode dikirim ke +6281***6789 │
│ │
│ ┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ │
│ │ ││ ││ ││ ││ ││ │ │
│ └──┘└──┘└──┘└──┘└──┘└──┘ │
│ │
│ Kirim ulang dalam 0:58 │
└────────────────────────────────────┘
Tier 2: Manual Fallback (User Trigger)
Trigger: primary sukses kirim tapi user tidak terima OTP setelah 30 detik.
┌────────────────────────────────────┐
│ ← Verifikasi OTP │
├────────────────────────────────────┤
│ Kode dikirim ke +6281***6789 │
│ │
│ ┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ │
│ │ ││ ││ ││ ││ ││ │ │
│ └──┘└──┘└──┘└──┘└──┘└──┘ │
│ │
│ Kirim ulang dalam 0:42 │
│ │
│ ──────────── │
│ Tidak menerima kode? │
│ [ ↻ Coba metode lain ] │ ← muncul setelah 30s
└────────────────────────────────────┘
klik
▼
┌────────────────────────────────────┐
│ Coba metode lain │
├────────────────────────────────────┤
│ 💬 SMS │ ← trigger Firebase Auth SDK
│ +6281***6789 │
│ │
│ ──────── │
│ ℹ Email belum diverifikasi │
│ [ Verifikasi sekarang ] │ ← CTA growth (State 2)
│ │
│ [ Batal ] │
└────────────────────────────────────┘
3.5 Error Screen — Semua Channel Gagal
┌────────────────────────────────────┐
│ ⚠ Sistem Gangguan │
│ │
│ WhatsApp dan SMS sedang │
│ tidak tersedia. │
│ │
│ Silakan coba 5 menit lagi. │
│ │
│ [ Coba lagi ] │
│ [ Hubungi Support ] │
└────────────────────────────────────┘
4. Provider: Firebase Phone Auth (Phase 0-2)
4.1 Kenapa Firebase Phone Auth?
| Aspek | Detail |
|---|---|
| Pricing | Gratis sampai 10.000 verifikasi/bulan, lalu ~Rp 950/verif |
| Deliverability | High (backend pakai Twilio/Sinch) |
| Indonesia coverage | Good (all major operators) |
| Backend integration | Cuma verify ID token (~5 LOC pakai Firebase Admin SDK) |
| Mobile integration | firebase_auth SDK (mature, well-documented) |
| Voice OTP fallback | ✅ Built-in (bisa di-enable Phase 1+) |
| Auto-receive Android | ✅ Built-in (UX better) |
| Existing infra | ✅ Firebase project Kesles aktif (per memory project_firebase_operator_team.md) |
4.2 Volume Projection vs Free Tier
| Skala merchant | OTP/bulan total | SMS via Firebase (~15%) | Cost |
|---|---|---|---|
| 200 (sekarang) | 1.000 | ~150 | Rp 0 (10K free) |
| 1.000 (2027) | 5.000 | ~750 | Rp 0 |
| 5.000 (2028) | 25.000 | ~3.750 | Rp 0 |
| 10.000 (2029) | 50.000 | ~7.500 | Rp 0 (mendekati ceiling) |
| 15.000+ (2030+) | 75.000+ | ~11.250+ | >Rp 1.2 juta/bulan (over free tier) |
Trigger Phase 3 migration: saat utilization >70% free tier (7K verif/bulan).
4.3 Architecture Flow
┌────────────┐
│ Mobile │
│ App │── firebase_auth SDK ────┐
└────────────┘ │
▼
┌─────────────────────────────┐
│ Firebase Cloud (Google) │
│ • SMS send │
│ • Voice fallback │
│ • Rate limiting (5/jam) │
│ • Fraud detection │
│ • Retry & delivery │
└─────────────────────────────┘
│
▼ (SMS via Twilio/Sinch)
┌─────────────────────────────┐
│ User HP │
└─────────────────── ──────────┘
Mobile dapat ID token dari Firebase
│
▼
┌────────────────────┐ HTTP ┌──────────────────────┐
│ merchant_core_api │ ─────────► │ firebase_service │
│ │ │ (extended) │
│ POST /auth/ │ │ │
│ firebase-phone- │ │ POST /internal/ │
│ verify │ │ firebase-auth/ │
└────────────────────┘ │ verify-phone-token │
│ │
│ ↓ Firebase Admin SDK │
│ ↓ VerifyIDToken() │
│ ↓ Return phone+claims│
└──────────────────────┘
5. Database Schema
5.1 Tabel Audit Phone Verifications
File migration: merchant_database/db_kesles_merchant/migrations/148_create_iam_phone_verifications.sql
CREATE TABLE iam.phone_verifications (
id UUID PRIMARY KEY,
phone TEXT NOT NULL,
firebase_uid TEXT, -- dari claim ID token Firebase
verification_status TEXT NOT NULL, -- success | failed | expired
failure_class TEXT, -- invalid_token | expired_token | network | unknown
flow_mode TEXT, -- registration | login_primary | login_fallback
fallback_reason TEXT, -- whatsapp_unavailable | email_unavailable | user_switched
user_id UUID, -- nullable: NULL untuk registrasi user baru
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_phone_verifications_phone ON iam.phone_verifications(phone, created_at DESC);
CREATE INDEX idx_phone_verifications_user ON iam.phone_verifications(user_id, created_at DESC) WHERE user_id IS NOT NULL;
CREATE INDEX idx_phone_verifications_flow ON iam.phone_verifications(flow_mode, verification_status, created_at);
Catatan ownership: Tabel ini di-WRITE oleh firebase_service (single-writer
pattern, konsisten dengan notification.fcm_push_tokens post Phase 4). core_api hanya
READ untuk audit/analytics.
Retention policy (migration 149, applied 2026-05-20): iam.cleanup_expired_auth_pii()
hourly worker hapus row dengan policy:
verification_status = 'failed'→ 24 jam (rate-limit telemetry + debug)verification_status IN ('success', 'expired')→ 90 hari (audit trail)
PDP-compliance per UU PDP No. 27/2022 Pasal 16(1)(c) + 43. Detail di
otp-pii-retention-plan.md (di working notes).
5.2 Tidak Perlu Tabel SMS Message
Beda dengan plan lama (yang punya notification.sms_messages), Phase 0-2
tidak butuh tabel SMS karena:
- Firebase yang track delivery (cek di Firebase Console)
- Tidak ada server-side SMS body untuk audit
- Cukup track outcome verify (success/fail/reason)
Phase 3 (kalau aggregator migration) baru bikin notification.sms_messages.
5.3 Slot Migration
148 verified next available (per check 2026-05-19):
- 144 = product_taxonomy
- 145 = create_auth_pii_cleanup_function
- 146 = create_user_security_pins (PIN plan, sudah merged)
- 147 = extend_auth_pii_cleanup_for_pins (PIN plan, sudah merged)
- 148 = create_iam_phone_verifications (this plan)
6. Endpoint Baru
6.1 firebase_service — Tambah Phone Auth Verifier
| Method | Path | Body | Auth | Purpose |
|---|---|---|---|---|
| POST | /internal/firebase-auth/verify-phone-token | {id_token, flow_mode, fallback_reason?} | X-Internal-API-Key | Verify ID token Firebase, return phone + claims |
Implementasi (services/firebase_service/internal/auth/verifier.go, ~80 LOC):
type PhoneVerifier struct {
firebaseAuth *firebaseauth.Client
store *store.Postgres // untuk audit insert
}
func (v *PhoneVerifier) VerifyPhoneToken(ctx context.Context, req VerifyRequest) (*VerifyResponse, error) {
token, err := v.firebaseAuth.VerifyIDToken(ctx, req.IDToken)
if err != nil {
v.store.LogPhoneVerification(ctx, AuditEntry{
Phone: req.HintPhone,
VerificationStatus: "failed",
FailureClass: classifyFirebaseErr(err),
FlowMode: req.FlowMode,
FallbackReason: req.FallbackReason,
})
return nil, err
}
phone, _ := token.Claims["phone_number"].(string)
firebaseUID := token.UID
v.store.LogPhoneVerification(ctx, AuditEntry{
Phone: phone,
FirebaseUID: firebaseUID,
VerificationStatus: "success",
FlowMode: req.FlowMode,
FallbackReason: req.FallbackReason,
})
return &VerifyResponse{
Phone: phone,
FirebaseUID: firebaseUID,
Verified: true,
}, nil
}
6.2 core_api — Proxy Endpoint
| Method | Path | Body | Purpose |
|---|---|---|---|
| POST | /auth/firebase-phone-verify | {id_token, request_id?} | Mobile submit ID token, core_api proxy ke firebase_service, issue session JWT |
Implementasi (~50 LOC di httpapi/auth_firebase_phone_handlers.go):
func (s *Server) handleFirebasePhoneVerify(w http.ResponseWriter, r *http.Request) {
var req VerifyPhoneRequest
json.NewDecoder(r.Body).Decode(&req)
// Proxy ke firebase_service (yang punya Firebase Admin SDK)
verifyResp, err := s.fcmServiceClient.VerifyPhoneToken(r.Context(), req.IDToken, ...)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid_firebase_token")
return
}
// Get-or-create user dengan phone yang sudah verified
user, err := s.repo.GetOrCreateUserByPhone(r.Context(), verifyResp.Phone)
if err != nil {
writeError(w, http.StatusInternalServerError, "user_lookup_failed")
return
}
// Issue session JWT seperti flow OTP existing
session := s.authService.IssueSession(user)
writeJSON(w, http.StatusOK, session)
}
6.4 Switch-channel Endpoint — Invalidation Spec (Deliverable D)
Question (Gap): Saat user tap "Coba metode lain" → backend invalidate OTP lama. Tapi belum spec exact: DELETE row vs UPDATE in-place? request_id baru vs reuse? Firebase verificationId lifecycle?
Default decision (rekomendasi):
| Aspek | Behavior |
|---|---|
| request_id | REUSE — same request_id mobile-side, simplify state. Backend update method column di iam.otp_challenges |
| Row lifecycle | UPDATE in-place (bukan DELETE + INSERT) — preserve created_at, append-only audit di audit.auth_audit_logs dengan event_type=otp_channel_switched |
| OTP code regen | Generate baru — new code, new hash, reset attempts=0, extend expires_at = NOW() + OTP_EXPIRY |
| From WA/Email → SMS | Response include use_firebase_phone_auth: true — mobile trigger NEW verifyPhoneNumber() call → new Firebase verificationId. Backend tidak track Firebase verificationId. |
| From SMS → WA/Email | Backend kirim OTP via new channel langsung. Old Firebase verificationId server-side auto-expire ~30 menit (no action needed). |
| Rate limit | Switch-channel counted sebagai 1 request OTP (kena per-phone window). Per-IP limit tidak apply (sudah lulus initial request) |
| Cooldown | Switch-channel boleh dalam 30 detik setelah initial request (sebelum retry_after habis) — flexibility supaya user tidak tunggu countdown WA-fail sebelum bisa pindah |
Endpoint contract:
POST /auth/request-otp/switch-channel
{
"request_id": "otp_xxx",
"target_channel": "sms" | "whatsapp" | "email"
}
Response 200 (target=whatsapp|email):
{
"request_id": "otp_xxx", // same
"channel": "whatsapp",
"method": "whatsapp",
"masked_destination": "*****8868",
"expires_in_secs": 300,
"retry_after_secs": 60
}
Response 200 (target=sms — trigger Firebase):
{
"request_id": "otp_xxx",
"channel": "sms",
"use_firebase_phone_auth": true,
"fallback_reason": "user_switched",
"masked_destination": "+62*****8868",
"expires_in_secs": 300,
"retry_after_secs": 60
}
Response 400 (invalid target):
{
"code": "invalid_target_channel",
"message": "Channel tidak tersedia untuk user ini",
"available_channels": ["whatsapp", "sms"]
}
Response 404 (challenge expired/not found):
{
"code": "challenge_not_found",
"message": "Request OTP sudah kadaluarsa, silakan minta ulang"
}
6.3 Endpoint Existing yang Di-update
| Endpoint | Change |
|---|---|
POST /auth/request-otp | Response tambah field use_firebase_phone_auth: bool untuk Mode B auto-fallback signal |
POST /auth/request-otp/switch-channel | NEW — handle manual "Coba metode lain" |
7. Backend Refactor (merchant_core_api)
7.1 otp_service.go — Decision Tree Baru
func (s *OTPService) RequestOTP(ctx context.Context, phone string) (RequestOTPResponse, error) {
profile, _ := s.repo.GetProfileByPhone(ctx, phone)
// ── Mode A: Registrasi ──
if profile == nil {
requestID := s.createChallenge(ctx, phone, ChannelTBD)
return RequestOTPResponse{
RegistrationFlow: true,
AvailableChannels: []string{"whatsapp", "sms"},
RequestID: requestID,
}, nil
}
// ── Mode B: Login ──
primary, fallback := s.resolveChannels(profile)
requestID := s.createChallenge(ctx, phone, primary)
code := s.generateOTP()
// Try primary
if err := s.sendVia(ctx, primary, profile, code, "login_primary", ""); err == nil {
return RequestOTPResponse{
Channel: string(primary),
MaskedDestination: s.maskFor(primary, profile),
RequestID: requestID,
}, nil
}
// Auto-fallback: SIGNAL mobile untuk pakai Firebase Phone Auth
return RequestOTPResponse{
Channel: "sms",
UseFirebasePhoneAuth: true,
FallbackReason: s.fallbackReasonFor(primary),
MaskedDestination: maskPhone(profile.Phone),
RequestID: requestID,
}, nil
}
func (s *OTPService) resolveChannels(p *Profile) (primary, fallback Channel) {
if p.EmailVerified && p.Email != "" {
return ChannelEmail, ChannelSMS
}
return ChannelWhatsApp, ChannelSMS
}
func (s *OTPService) AvailableChannelsForSwitch(p *Profile, current Channel) []ChannelOption {
options := []ChannelOption{}
// SMS = always available (via Firebase)
if current != ChannelSMS {
options = append(options, ChannelOption{
Channel: ChannelSMS,
Masked: maskPhone(p.Phone),
UseFirebasePhoneAuth: true,
})
}
if current != ChannelWhatsApp {
options = append(options, ChannelOption{
Channel: ChannelWhatsApp,
Masked: maskPhone(p.Phone),
})
}
// Email HANYA kalau verified (no exception)
if current != ChannelEmail && p.EmailVerified && p.Email != "" {
options = append(options, ChannelOption{
Channel: ChannelEmail,
Masked: maskEmail(p.Email),
})
}
return options
}
7.2 fcmservice/client.go — Extend dengan VerifyPhoneToken
// Existing methods: Send(), SendBulk(), RegisterToken(), dst
// NEW method:
func (c *Client) VerifyPhoneToken(ctx context.Context, idToken string, flowMode string, fallbackReason string) (*VerifyPhoneResponse, error) {
body := map[string]any{
"id_token": idToken,
"flow_mode": flowMode,
"fallback_reason": fallbackReason,
}
req, _ := http.NewRequestWithContext(ctx, "POST",
c.baseURL+"/internal/firebase-auth/verify-phone-token", jsonBody(body))
req.Header.Set("X-Internal-API-Key", c.apiKey)
// ... standard error handling
}
7.3 Feature Flag Matrix — Dual-Flag Behavior Spec
Question (Gap): 2 flag env terpisah, interaction matrix belum spec.
Default rekomendasi: both flag default false di production env, enable explicit by ops. Startup-time validation: FALLBACK=true butuh AUTH=true (else fatal).
ENABLE_FIREBASE_PHONE_AUTH | ENABLE_FIREBASE_PHONE_AUTH_FALLBACK | Resulting Behavior |
|---|---|---|
false | false | Pre-Phase-1 mode (current production state). Endpoint /auth/firebase-phone-verify NOT registered di routes_auth.go. Mode B WA/Email fail → 502. Mode A button SMS di mobile bisa hide. |
true | false | Mode A SMS-only mode (Phase 1 launch — Deliverable A+B done, C deferred). Endpoint live, mobile button SMS aktif untuk registrasi. Tapi Mode B existing user kalau WA fail → tetap 502 (no auto-fallback). Production OK saat Deliverable C belum siap. |
false | true | INVALID config — log.Fatal di startup. Fallback need endpoint untuk verify token. Operator harus enable AUTH dulu. |
true | true | Full Phase 1 mode (Deliverable C done). Mode A SMS works + Mode B auto-fallback. Response /auth/request-otp extended dengan use_firebase_phone_auth signal saat primary channel fail. |
Validation di merchant_core_api/internal/config/config.go:
if cfg.EnableFirebasePhoneAuthFallback && !cfg.EnableFirebasePhoneAuth {
return errors.New("ENABLE_FIREBASE_PHONE_AUTH_FALLBACK requires ENABLE_FIREBASE_PHONE_AUTH=true")
}
Rollout strategy:
- Pre-Phase 1 launch: deploy core_api dengan
AUTH=falseFALLBACK=false— production tidak terpengaruh, endpoint dormant. - Phase 1 launch (Mode A only): flip
AUTH=true. Mode A SMS aktif. Mobile force-update min version yang support Firebase SDK. - Phase 1 full (Mode B auto-fallback): setelah mobile adoption >80% dari force-update + WA/Email outage drill test → flip
FALLBACK=true. - Rollback: flip
FALLBACK=falseinstan (env reload) — kembali ke 502 error untuk Mode B WA fail. Endpoint Mode A tetap jalan.
7.4 Rate Limit Interaction — IP vs Firebase
Question (Gap): auth-session-hardening-plan.md plan IP-based limit 10/IP/10min. Firebase punya 5 SMS/phone/4h. Belum spec interaction.
Default rekomendasi:
| Layer | Limit | Counted For |
|---|---|---|
Per-phone window (OTP_MAX_REQUESTS_PER_WINDOW di Kesles) | Existing: 5/phone/15min | All channel — WA, Email, SMS via Firebase counted (mencegah spam) |
| Per-IP window (Phase 0 hardening) | 10/IP/10min | POST /auth/request-otp initial request ONLY. Switch-channel + fallback signal carve-out (sudah lulus initial gate). |
| Firebase Phone Auth (Google-side, immutable) | 5 SMS/phone/4h | All verifyPhoneNumber() invocation termasuk auto-fallback dan switch-channel ke SMS |
Rationale carve-out IP limit untuk fallback/switch:
- 1 user retry karena WA fail bisa generate 3-5 request dalam 5 menit (WA fail → SMS fallback → switch back ke WA → switch ke Email). Tanpa carve-out, user kena IP block.
- Risk attacker: bisa exploit carve-out untuk amplification? NO — Firebase per-phone limit 5/4h jadi hard ceiling. Attacker tidak bisa lebih banyak SMS regardless of IP carve-out.
Effective ceiling per user normal flow: 5 SMS per 4 jam dari Firebase, lebih dari cukup untuk normal scenario (retry 1-2 kali).
Audit telemetry: audit.auth_audit_logs log event_type=otp_request_rate_limited dengan field limit_layer (per_phone|per_ip|firebase_per_phone) supaya operator tahu source rejection.
7.5 Mobile State Machine — Fallback Signal Detection
Question (Gap): auth-session-lifecycle.md belum punya FSM untuk handle response use_firebase_phone_auth: true di mobile.
Default state machine (di LoginPage._handleLogin → requestOtp response handler):
State: Idle
↓ user input phone + tap continue
↓
State: ResolvingPhone (POST /auth/resolve-phone)
├── response.isAuthenticated = true (trusted device) → State: NavigateHome
└── response.isAuthenticated = false → State: RequestingOTP
↓ (POST /auth/request-otp dengan default channel)
↓
State: RequestingOTP
↓ response from backend
↓
├── response.use_firebase_phone_auth = true
│ ↓
│ State: TriggeringFirebaseSdk
│ ↓ FirebaseAuth.verifyPhoneNumber(phone)
│ ↓
│ ├── codeSent(verificationId, resendToken) →
│ │ State: OtpPageFirebaseInput
│ │ (Navigate to OtpPage with firebaseVerificationId)
│ │ ↓ user input OTP
│ │ ↓ signInWithCredential(verificationId, smsCode)
│ │ ↓ user.getIdToken()
│ │ ↓ POST /auth/firebase-phone-verify
│ │ → State: NavigateHome
│ │
│ ├── verificationCompleted(credential) [Android auto-fill] →
│ │ ↓ signInWithCredential(credential)
│ │ ↓ user.getIdToken()
│ │ ↓ POST /auth/firebase-phone-verify
│ │ → State: NavigateHome
│ │
│ └── verificationFailed(error) →
│ State: ShowError (snackbar dengan action "Pakai WhatsApp OTP")
│
└── response.use_firebase_phone_auth = false (default WA/Email)
↓
State: OtpPageNormalInput
(Navigate to OtpPage with request_id only)
↓ user input OTP
↓ POST /auth/verify-otp
→ State: NavigateHome
Signal detection di auth_api_service.dart:
- Field baru di
RequestOtpResponse:useFirebasePhoneAuth: bool(default false)fallbackReason: String?(whatsapp_unavailable|email_unavailable|user_switched)availableChannels: List<String>(untuk Deliverable D switch UI)
- Backward compat: field default ke
false/null/[]— mobile lama yang tidak baca tetap berfungsi (fallback ke jalur OTP normal yang sudah deprecated).
Error edge cases:
- Saat Firebase
verificationFaileddiTriggeringFirebaseSdkstate, snackbar action retry keLoginPage(BUKAN trigger Firebase ulang yang akan loop). User pilih WA/Email lagi dari awal. - Saat
signInWithCredentialfail karenainvalid-verification-code: snackbar di OtpPage + counter retry → max 3x sebelum redirect back ke LoginPage.
8. Mobile UI Changes
8.1 Tambah Firebase Auth SDK
pubspec.yaml:
dependencies:
firebase_auth: ^4.x.x
# firebase_core sudah ada (untuk FCM messaging)
8.2 Mode A (Registrasi) — verification_method_page.dart
- Hapus snackbar
smsUnavailable(line 102) - Aktifkan button SMS
- Saat user pilih SMS → trigger
FirebaseAuth.instance.verifyPhoneNumber() - Receive verificationId + auto-receive OTP (Android) atau manual input (iOS)
8.3 Mode B (Login) — Skip verification_method_page
Flow lebih pendek:
LoginPage (input phone)
↓
[POST /auth/request-otp]
↓
Backend response: { use_firebase_phone_auth: true, fallback_reason: "..." }
↓
Mobile detect → trigger FirebaseAuth.verifyPhoneNumber()
↓
OtpPage tampil dengan banner kuning (kalau fallback)
8.4 OtpPage — Tambah Banner + "Coba metode lain"
class OtpPage extends StatefulWidget {
final String maskedDestination;
final String method;
final String requestId;
final String? fallbackReason; // NEW
final bool useFirebasePhoneAuth; // NEW
final String? firebaseVerificationId; // NEW (Firebase SDK output)
// ...
}
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
if (fallbackReason != null) _buildFallbackBanner(),
// ... existing OTP boxes
_buildResendCountdown(),
if (_secondsSinceMounted > 30)
_buildTryOtherMethodLink(),
],
),
);
}
8.5 Bottom Sheet "Coba metode lain"
Component baru switch_channel_bottom_sheet.dart — show channel options dari
backend response, plus CTA growth (Atur email / Verifikasi email) untuk State 1 & 2.
9. Phase Rollout
Phase -1 — Decision & Spec ✅ DONE (2026-05-17)
- Provider: Firebase Phone Auth
- Arsitektur: extend firebase_service (no service mandiri Phase 0-2)
- UX: Mode A user pilih, Mode B auto-pick + 2-tier fallback
- Channel matrix per user state
- Plan dokumen draft
Phase 0 — firebase_service Extension + DB (target: 2-3 hari)
Status: 🟢 Backend wiring validated 2026-05-20 via self-signed JWT smoke test. Real Google JWT smoke test sekarang unblocked — mobile-side (Android Mode A) selesai 2026-05-20 (Phase 1 Mobile section), tinggal jalankan E2E di device pakai Firebase Test Phone Number +62 811-4169-868 / OTP 070110 yang sudah disetup di Console.
Tujuan: firebase_service punya endpoint Phone Auth, DB migration applied.
- Buat folder
services/firebase_service/internal/auth/(2026-05-20) - Implement
PhoneVerifier— deviation dari spec: pakai manual RS256 verify + JWKS cache (golang-jwt/jwt/v5), bukanfirebase.google.com/go/v4Admin SDK. Alasan: konsisten denganpush.Clientstyle (pure HTTP + manual JWT), hindari transitive Google Cloud SDK deps. Surface ~340 LOC termasuk classification + cache (vs target 80 LOC pseudocode di §6.1; ekstra LOC = JWKS fetch+cache yang Admin SDK lakukan internal). - Implement audit log writer ke
iam.phone_verifications(store.LogPhoneVerification+storeAuditAdapterbridge keauth.AuditLoggerinterface) - Tambah handler
handleVerifyPhoneTokendiinternal/app/auth_handlers.go(bukanhandlers.go— split file supaya scope FCM vs Phone Auth tegas) - Register route
POST /internal/firebase-auth/verify-phone-tokendiserver.go(di-wraprequireInternalKeymiddleware) - DB migration 148 — apply di dev (2026-05-20; lihat §5.1, §5.3)
- Unit test verifier — 7/7 PASS (mock JWKS HTTP server + RSA signed token, cover: success, expired, wrong aud, unknown kid, not configured, empty token, JWKS cache reuse)
- Telemetri slog event
phone_verify(2026-05-20) —cmd/server/main.gosetupslog.NewJSONHandlerglobal default + handler emit eventphone_verifydengan attrs:status,flow_mode,failure_class,fallback_reason,phone_masked,firebase_uid,http_status,duration_ms. Success di-emitINFO, failed diWARN. PII safety:maskPhone()strip middle digit (+628114169868→+628***68); full phone tetap di audit table only. 2 new unit test:TestHandleVerifyPhoneToken_EmitsSuccessEvent,TestHandleVerifyPhoneToken_EmitsFailedEvent+TestMaskPhonetable-driven 7 case. Server.phoneVerifier di-refactor ke interfacephoneVerifierIfaceuntuk test injectability. - Self-signed JWT dev tool (
cmd/devtoken+FIREBASE_JWKS_URLenv override + production guard divalidateConfig) — bukan di plan original, ditambah 2026-05-20 untuk smoke test backend tanpa Firebase Console / mobile setup. Production tetap pakai Google JWKS default (override fatal kalau APP_ENV=production). - Smoke test self-signed lokal 2026-05-20:
go run ./cmd/devtoken -phone=+628114169868 -uid=test-uid-001- service running (port 8094, JWKS override active)
POST /internal/firebase-auth/verify-phone-tokendengan token signed → 200 OK{phone, firebase_uid, verified:true}- Invalid token + hint_phone → 401
{failure_class:"invalid_token"}+ audit row status=failed - Empty token → 400
validation_failed - Missing API key → 401
unauthorized - DB query
iam.phone_verificationsconfirm 2 row tercatat (success + failed) dengan semua field plan §5.1 populated (phone, firebase_uid, flow_mode, fallback_reason, failure_class)
- Smoke test real Google JWT E2E — ✅ PASS 2026-05-20 di Medium_Phone_API_36.1 emulator. Backend lokal (firebase_service:8093 + merchant_core_api:8080) + mobile dengan
--dart-define=API_BASE_URL=http://10.0.2.2:8080. Flow: Login08114169868→ Verification Method Page (Mode A tampil 2 tombol) → tap SMS → Firebase verifyPhoneNumber → codeSent → OtpPage → input070110→ Lanjutkan → signInWithCredential → getIdToken → POST/auth/firebase-phone-verify→ backend verify Google JWKS chain + audit row iam.phone_verifications (success, flow_mode=registration, firebase_uid=QM2YYuw50LPAeCij9g9fH1ODMcu2) → core_apiEnsureVerifiedPhoneAuthUsercreate user14d6ba11-...+ audit_audit_logs (event_type=firebase_phone_verify, auth_provider=firebase_phone_auth) + IssueTokens + PersistDeviceSession → response 200 + token pair → mobile save session → navigate Home Page ✅ - Memory
feedback_fcm_service_mirror.mdsudah catat rename Phase 4 (folder + VM) — tidak perlu update lagi
Acceptance Phase 0:
- Endpoint live di firebase_service VM
ptikn3-vm(port 8093) —merchant_firebase/synced + service restarted 2026-05-20. Verified:/health→ labelfirebase-service✅ (post-Phase-4-rename, bukanfcm-servicelama)/ready→{firebase: ok, postgres: ok, status: ready}✅- Middleware:
/verify-phone-tokentanpa API key →401 unauthorized(bukan 404 = route terdaftar) - Handler:
/verify-phone-tokendengan API key + empty body →400 {"error":"validation_failed","message":"id_token required"}(handler reached, body validation kerja)
- Smoke test pass (lokal self-signed) 2026-05-20: token valid → 200, token invalid → 401, validation/auth → 400/401
- Audit log row tercatat di
iam.phone_verifications(lokal dev DB) 2026-05-20 — success + failed paths verified - Build firebase_service hijau (2026-05-20,
go build ./...+go vet ./...+go test ./...semua pass, total 14 test: 7 verifier + 7 config guard)
Phase 1 — Backend Refactor + Mobile Integration (target: 5-7 hari)
Backend (~2-3 hari):
🟡 Deliverable A + B done 2026-05-20 (additive only, zero regression):
- A. Extend
internal/fcmservice/client.godenganVerifyPhoneToken()method — POST proxy ke firebase_service/internal/firebase-auth/verify-phone-token. Typed error*VerifyPhoneTokenError{HTTPStatus, ErrorCode, FailureClass, Message}untuk caller bisaerrors.Asmap ke HTTP status mobile-facing. 8/8 unit test PASS (success, invalid_token, expired_token, verifier_not_configured, network, client_not_configured, empty_id_token, non_JSON_error_body fallback). - B. Endpoint baru
POST /auth/firebase-phone-verifydi httpapi (fileauth_firebase_phone_handlers.go) — mobile submit{id_token, request_id?, platform_code?, device_id?, device_name?, flow_mode?, fallback_reason?, hint_phone?}→ handler verify via fcmservice → call newOTPService.IssueSessionForVerifiedPhone()(filefirebase_phone_service.go) → mirror VerifyOTP success path (EnsureVerifiedPhoneAuthUser+IssueTokens+TrustDevice+ auditevent_type=firebase_phone_verify auth_provider=firebase_phone_auth+PersistDeviceSession) → returnwriteTokenResponse200 OK (shape match/auth/verify-otpsupaya mobile parser sama). HTTP status mapping: 401 invalid/expired token, 502 network, 503 verifier_not_configured, 400 unsupported phone region (non-Indonesia). - C. Refactor
otp_service.go(DEFERRED — touches existing critical OTP path; needs feature flagENABLE_FIREBASE_PHONE_AUTH_FALLBACK):- Decision tree §7.1
AvailableChannelsForSwitch§7.1- Auto-fallback signal (
use_firebase_phone_auth: true)
- D. Endpoint baru
POST /auth/request-otp/switch-channel(DEFERRED — depends on C) - Update response shape
POST /auth/request-otp(tambahuse_firebase_phone_auth,fallback_reason,available_channels) (DEFERRED — depends on C)
Mobile (~3-4 hari):
🟢 Mode A SMS button aktif Android (2026-05-20) + iOS code integration (2026-05-21). iOS final blocker = APNs .p8 + device provisioning, lihat catatan iOS di bawah.
- Tambah
firebase_auth: ^6.1.0SDK di pubspec.yaml - Snackbar
smsUnavailabledi-kept (deprecated, unused); new flow pakaismsFailedUseWhatsapp+ snackbar action - Aktifkan button SMS untuk Mode A — trigger
FirebaseAuth.verifyPhoneNumber():- Android:
verificationCompletedcallback → auto-sign-in +_completeFirebaseSignIn→ POST/auth/firebase-phone-verify→ home page (best UX, no OTP input) - Android manual fallback:
codeSentcallback → navigate keOtpPage(firebaseVerificationId: verificationId)→ user input 6-digit →signInWithCredential→getIdToken→ POST/auth/firebase-phone-verify - Error handling:
FirebaseAuthExceptionmapped ke 4 user-facing message saja (smsNetworkError,smsInvalidPhone,smsFailedUseWhatsapp,firebaseAuthIosNotSupportedYet); full error code di-log ke Crashlytics untuk admin (TIDAK ditampilkan ke user) - Snackbar dengan
SnackBarAction: "Pakai WhatsApp" → trigger_handleMethod(whatsapp)(fallback graceful, durasi 6 detik) - Network error → action "Coba lagi" retry
_handleSmsViaFirebase
- Android:
- Inline disclaimer di bawah tombol SMS: "Kode SMS akan dikirim ke nomor Anda"
- OtpPage extended dengan optional param
firebaseVerificationId String?— backward-compat untuk WA/Email caller (default null = jalur existing) -
_verifyViaFirebaseCredentialhelper di OtpPage — Channel B verify path (build credential → sign-in → getIdToken → POST/auth/firebase-phone-verify) - Mirror 1:1 ke
repo_exports/kesles/merchant/mobile-user/(5 file) -
flutter analyzelib/ clean;flutter test test/features/auth/20/20 PASS (no regression) - UX polish + bug fix selama E2E smoke test 2026-05-20:
- E.164 phone conversion sebelum
verifyPhoneNumber()(sebelumnya628...→ rejectinvalid-phone-number) setSettings(appVerificationDisabledForTesting: true)untuk APP_FLAVOR=development supaya Test Phone Number instant approve tanpa reCAPTCHA delay 3-10s (production gate!AppConfig.isProduction)- Disclaimer "Kode SMS akan dikirim ke nomor Anda" misleading saat di bawah tombol SMS (kalau user pilih WA jadi salah info) — dihapus, replaced dengan VerificationMethodPage subtitle "Mulai membuat akun Merchant" netral
- OtpPage subtitle simplified ke 2-line
"Masukkan OTP\ndikirim ke {phone}"(sebelumnya 3-line per-channel verbose); title page (Cek SMS/Whatsapp/Gmail) sudah sebutkan channel - "Gunakan metode lainnya" TextButton di bawah Lanjutkan — fontSize=12, weight=normal, color textSecondary; behavior:
Navigator.maybePop()ke method picker - Layout: SizedBox sebelum Lanjutkan dikecilkan dari 180 → AppSpacing.section (34) supaya CTA naik ke atas, lebih reachable
- "Kirim ulang OTP" sekarang tersedia juga untuk Channel B SMS — di-handle via Firebase
verifyPhoneNumber()denganforceResendingToken, bukan backend/auth/request-otp(yang belum support method=sms). State lokal_currentFirebaseVerificationId+_currentFirebaseResendTokendi OtpPage update saat resend supaya_verifyViaFirebaseCredentialpakai verificationId baru.
- E.164 phone conversion sebelum
- iOS code integration (2026-05-21):
- Firebase iOS app added ke project
kesles-merchantdi Firebase Console (Bundle IDcom.kesles.merchant,GOOGLE_APP_ID=1:1700257241:ios:68de99742b42b5519c4dc8) GoogleService-Info.plistdiios/Runner/+ ditambah ke Xcode Runner target Resources build phase viaxcodeprojRuby gemAppDelegate.swifttambahimport FirebaseCore+FirebaseApp.configure()(sebelumGeneratedPluginRegistrant.register)Platform.isIOSguard dihapus di_handleSmsViaFirebase;dart:io show Platformimport dilepas;firebaseAuthIosNotSupportedYetstring deprecated dan dihapuspod install --repo-updatesetelah deletePodfile.locklama — Firebase SDK bump 12.9.0 → 12.12.0;firebase_auth 6.4.0+firebase_app_check 0.4.3ikut ter-install (total 64 pods)- Mirror
repo_exports/.../mobile-user/1:1 clean (4 file) - Regression test PASS 111/111 (
flutter test test/features/auth/)
- Firebase iOS app added ke project
- iOS production real-phone path — final blocker:
- APNs
.p8Authentication Key dari Apple Developer Console → upload Firebase Console → Cloud Messaging → APNs Authentication Key. Tanpa ini, real-phone iOS fallback ke reCAPTCHA web view. - URL Type di Info.plist untuk reCAPTCHA — saat ini
REVERSED_CLIENT_IDtidak ada di plist (Google Sign-In OAuth tidak di-enable). Pilihan: (a) setup APNs supaya reCAPTCHA tidak dipakai sama sekali, atau (b) enable Google Sign-In OAuth provider di Firebase Auth → regen plist denganREVERSED_CLIENT_ID→ tambah URL Type. - iOS device fisik + provisioning profile untuk run di luar Simulator. Simulator bisa test integration code path (Test Number
+62 811-4169-868 / 070110bypass APNs+reCAPTCHA) tapi tidak terima real SMS.
- APNs
- Skip verification_method_page untuk Mode B (route langsung ke OtpPage) — DEFERRED (depends on Deliverable C)
- Detect signal
use_firebase_phone_authdari backend response — DEFERRED (depends on Deliverable C) - Trigger Firebase Auth SDK saat fallback signal received — DEFERRED (depends on Deliverable C)
- Tambah banner kuning di OtpPage (
_buildFallbackBanner) — DEFERRED (depends on Deliverable C) - Tambah link "Coba metode lain" di OtpPage (muncul setelah 30s) — DEFERRED (depends on Deliverable D)
- Bikin component
SwitchChannelBottomSheet— DEFERRED (depends on Deliverable D) - Integrasi API
/auth/request-otp/switch-channel— DEFERRED (depends on Deliverable D) - CTA growth untuk State 1 & 2 — DEFERRED
Acceptance Phase 1:
- User bisa registrasi via SMS (Mode A) end-to-end
- User bisa login via auto-fallback SMS (Mode B, primary down)
- User bisa "Coba metode lain" → switch ke SMS / WA / Email
- Banner muncul kalau fallback active
- Tidak ada regression di flow WA/Email existing
Phase 2 — Production Soak (target: 7 hari)
- Deploy production
- Telemetri review harian
- Monitor:
- Firebase quota usage (target <10% dari 10K/bulan = <1K verif/bulan di awal)
- Verification success rate (target >90%)
- Adoption: % registrasi pilih SMS vs WA
- Audit log distribution (registration vs login_primary vs login_fallback)
- Stakeholder review minggu ke-1
Acceptance Phase 2:
- 7 hari soak tanpa incident
- Success rate >90%
- Firebase quota projection sehat
Phase 3 — Aggregator Migration (Conditional)
Trigger: Salah satu:
- Firebase Phone Auth quota >70% free tier (7K/bulan)
- Firebase reliability issue persistent
- Business decision: independence dari Google
Aksi di Phase 3:
- Bikin
services/sms_service/mandiri (provider Zenziva / Twilio) - Apply lessons dari audit firebase_service (lihat §16):
- Provider abstraction interface (mandatory)
- Retry + backoff + DLQ (mandatory untuk OTP)
- Test coverage minimal 60%
- Structured logging (slog)
- Graceful shutdown
- Circuit breaker
- Migrate gradually: Firebase → Zenziva shadow mode → cutover
- (Opsional)
Rename fcm_service ke firebase_service— sudah dilakukan Phase 4 final 2026-05-19 - Atau hapus Phone Auth endpoint dari firebase_service kalau Zenziva mengambil 100%
Detail di plan terpisah sms-aggregator-migration-plan.md (akan dibuat saat trigger).
10. Rollback Plan
Phase 0 rollback
- Revert PR firebase_service (hapus endpoint baru) → 0 user impact (belum dipakai)
- Revert migration 148 via down SQL
Phase 1 rollback
| Skenario | Action |
|---|---|
Endpoint /internal/firebase-auth/verify-phone-token bermasalah | Revert firebase_service deploy + revert core_api proxy endpoint |
| Mobile UI banner / bottom sheet bug | Firebase Remote Config flag enable_sms_features=false → hide UI baru |
| Auto-fallback bermasalah (false positive) | Disable signal di backend: enableFirebasePhoneAuthFallback=false env flag |
| Firebase quota exhausted mendadak | Hard-stop signal fallback, kembali ke "primary down" error 503 |
Yang tidak rollback: tabel iam.phone_verifications — keep audit log
historis.
11. Konfigurasi Env
merchant_core_api/.env.*
# FCM service endpoint (existing untuk FCM, dipakai juga untuk Phone Auth)
FCM_SERVICE_BASE_URL=http://127.0.0.1:8093
FCM_SERVICE_API_KEY=<sync dengan INTERNAL_NOTIFICATION_API_KEY firebase_service>
FCM_SERVICE_TIMEOUT_MS=5000
# Phone Auth feature flag
ENABLE_FIREBASE_PHONE_AUTH=true
ENABLE_FIREBASE_PHONE_AUTH_FALLBACK=true
services/firebase_service/.env.* (VM: merchant_firebase/.env.production)
# Existing — sudah ada Firebase config
APP_ENV=production
APP_PORT=8093
INTERNAL_NOTIFICATION_API_KEY=
POSTGRES_DSN=
FIREBASE_PROJECT_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
# NO new env needed — Phone Auth pakai Firebase Admin SDK yang sama
# Cukup pastikan service account credential punya permission untuk Identity Toolkit API
Firebase Console Setup
- Aktifkan Phone Authentication di Firebase Console → Authentication → Sign-in method
- Pastikan SHA-256 fingerprint Android app di-register (untuk app verification)
- Add test phone number untuk QA (Console → Authentication → Phone numbers for testing)
- (Opsional) Set quota limit untuk hindari surprise bill
12. Observability
Logging (slog JSON) — di firebase_service
| Field | Contoh |
|---|---|
event | phone_verify, phone_verify_success, phone_verify_failed |
phone_masked | +6281***6789 |
firebase_uid_masked | XK7a... |
flow_mode | registration, login_primary, login_fallback |
fallback_reason | whatsapp_unavailable, email_unavailable, user_switched |
duration_ms | 120 |
success | true/false |
failure_class | invalid_token, expired_token, network, unknown |
Aggregator script
scripts/phone-auth-telemetry-daily.sh (mirror struktur email-telemetry-daily.sh):
=== Phone Auth telemetry 2026-05-17 ===
flow_mode total ok err err_rate
registration 25 24 1 4.00%
login_primary 0 0 0 n/a (Mode B SMS via fallback)
login_fallback 17 16 1 5.88%
Firebase quota est (monthly): 360/10000 (3.6%)
Daily cost: Rp 0
=== Trigger evaluation ===
- Success rate: 95.24% / threshold 90% → ok
- Firebase quota: 3.6% / threshold 70% → ok
Alert threshold
| Metric | Threshold | Action |
|---|---|---|
| Verify success rate | < 90% selama 24 jam | Investigate phone format / Firebase config |
| Firebase quota utilization | > 70% monthly | Trigger Phase 3 evaluation (migrate aggregator) |
Failure class expired_token | > 5% | Investigate clock skew / Firebase outage |
| p95 verify latency | > 2 detik | Investigate firebase_service / Firebase network |
13. Cost Analysis
Firebase Phone Auth Free Tier
Gratis 10.000 verifikasi/bulan. Di atas itu: ~$0.06/verif (~Rp 950).
Skenario growth Kesles
| Skala merchant | OTP total/bulan | SMS via Firebase (~15%) | Cost |
|---|---|---|---|
| 200 (sekarang) | 1.000 | ~150 | Rp 0 |
| 1.000 (2027) | 5.000 | ~750 | Rp 0 |
| 5.000 (2028) | 25.000 | ~3.750 | Rp 0 |
| 10.000 (2029) | 50.000 | ~7.500 | Rp 0 (75% quota) |
| 15.000 (2030) | 75.000 | ~11.250 | ~Rp 1,2 juta/bulan (over by 1.250) |
| 20.000+ | 100.000+ | ~15.000+ | ~Rp 4,7 juta/bulan (over by 5.000) |
Phase 3 trigger candidate: saat utilization mendekati 70% (~7K verif/bulan). Estimasi: skala ~9.000-10.000 merchant aktif.
13.3 Cost Circuit Breaker — Multi-Tier Threshold
Question (Gap): Cost projection ada, tapi tidak ada alert mechanism atau auto-control saat approach free tier ceiling.
Default rekomendasi:
| Threshold (% free tier 10K) | Trigger | Action | Reversibility |
|---|---|---|---|
| 70% (~7,000/bulan) | Soft warning | Slack alert ke #monitoring daily summary. Operator review tren growth, kalau perpetual >70% → start Phase 3 (Aggregator migration) planning | Manual (operator decision) |
| 85% (~8,500/bulan) | Hard warning | Slack alert ke #security+#pembayaran_device per hour. Email DPO + operator. Auto-disable ENABLE_FIREBASE_PHONE_AUTH_FALLBACK=false via Remote Config push (Mode B fallback OFF — kembali ke 502 untuk WA fail) | Manual flip flag setelah quota reset bulanan |
| 95% (~9,500/bulan) | Critical | Auto-disable Mode A SMS button via Firebase Remote Config (enable_sms_in_mode_a=false). Mobile hide tombol SMS. Force WhatsApp-only registration. | Manual flip Remote Config setelah quota reset |
| 100% (10,000+) | Quota exhausted | Firebase reject verifyPhoneNumber() dengan error quota-exceeded. Mobile snackbar generic "Gagal Kirim OTP SMS — Gunakan WhatsApp OTP" (sesuai admin-only error principle per §12). | Auto-clear awal bulan saat quota reset |
Implementation komponen:
-
Cron worker
firebase_quota_monitordimerchant_core_api/internal/workers/:- Run setiap 1 jam
- Query Firebase Cloud Admin SDK API:
getAuthenticationUsage()(atau aggregate dariiam.phone_verificationstable sebagai proxy) - Compute
current_usage / 10000 = utilization_pct - Decide threshold tier + execute action
-
Slack publisher integration — reuse existing
internal/slack/(sudah ada untuk transaksi alert):- 70%: warning blue
- 85%: warning yellow + @here
- 95%: critical red + @channel
- 100%: critical red + @owner
-
Firebase Remote Config flag untuk runtime control:
enable_firebase_phone_auth_fallback(mirror env, supaya disable tanpa redeploy)enable_sms_in_mode_a(hide SMS button di mobile)- Mobile fetch Remote Config di app startup + per-30-menit refresh
-
Audit telemetry — log circuit breaker action ke
audit.auth_audit_logs:event_type=cost_circuit_breaker_triggeredauth_provider=firebase_phone_authdetails: {tier, utilization_pct, action_taken}
Cost forecast vs action timeline:
Bulan ke-N (utilization < 70%):
→ Normal operation, weekly daily summary di Slack
Bulan ke-N+1 (utilization 70-85%):
→ Alert ke ops team — start considering Phase 3 (Twilio/Zenziva aggregator)
→ Begin negotiation dengan SMS provider lokal
Bulan ke-N+2 (utilization 85-95%):
→ Auto-disable Mode B fallback (preserve free tier untuk Mode A registration)
→ Existing user WA fail → 502 error (less risk dari customer onboarding fail)
Bulan ke-N+3 (utilization 95-100%):
→ Auto-disable Mode A SMS button — protect from quota exhaust mid-onboarding
→ Phase 3 migration MUST be ready by now
Rollback per tier: flip flag instan (env reload atau Remote Config) saat quota reset awal bulan. Tidak perlu code change.
14. Testing Strategy
Unit test (firebase_service)
internal/auth/verifier_test.go— mock Firebase Admin SDK, test:- Valid token → return phone + claims
- Invalid token → error + audit log "failed"
- Expired token → specific error class
- Network error → specific error class
Integration test
- Spin up firebase_service + Postgres test container
- Generate real Firebase ID token via emulator atau test account
- POST
/internal/firebase-auth/verify-phone-token→ assert response + audit log row
Failure injection test (MANDATORY Phase 1 acceptance)
func TestPrimaryDownAutoFallbackSignalsFirebase(t *testing.T) {
// Mock WA service down
// Request OTP untuk user existing (State 1)
// Assert: response.use_firebase_phone_auth == true
// Assert: response.fallback_reason == "whatsapp_unavailable"
// Assert: response time < 6s
}
func TestFCMServiceDownReturns503(t *testing.T) {
// Mock firebase_service down (Phone Auth endpoint unreachable)
// Request firebase-phone-verify
// Assert: response code 503
// Assert: error message user-friendly
}
func TestSwitchChannelInvalidatesOldOTP(t *testing.T) {
// Request OTP (sent via WA)
// POST /auth/request-otp/switch-channel new_channel=SMS
// Assert: OTP lama tidak valid lagi
// Assert: response include use_firebase_phone_auth=true
}
Smoke test post-deploy
# firebase_service health
curl http://firebase-service:8093/health
curl http://firebase-service:8093/ready
# Phone Auth verify (perlu real Firebase ID token dari mobile QA)
curl -X POST http://firebase-service:8093/internal/firebase-auth/verify-phone-token \
-H "X-Internal-API-Key: $KEY" \
-d '{"id_token":"<TOKEN>","flow_mode":"login_primary"}'
# core_api proxy
curl -X POST http://core-api:8080/auth/firebase-phone-verify \
-d '{"id_token":"<TOKEN>"}'
15. Dependencies & Blockers
| Item | Status | Catatan |
|---|---|---|
| Firebase Phone Auth aktivasi di Console | 🔴 BELUM | Phase 0 action, ~5 menit |
| Firebase service account permission untuk Identity Toolkit API | 🔴 BELUM VERIFY | Cek di Firebase IAM, mungkin sudah ada |
| FCM Phase 4 cleanup selesai | 🟢 DONE 2026-05-19 | Phase 4 final GO, 24h soak passed, services/firebase_service jadi single source of truth Firebase SDK |
| Migration slot 148 | 🟢 AVAILABLE | Verified 2026-05-19 (146-147 ditempati PIN plan setelah doc ini ditulis 17 Mei) |
| Owner mobile_user approval | 🔴 BELUM | folder apps/mobile_user/ locked per feedback_mobile_user_lock.md; Phase 1 mobile work (~3-4 hari) butuh approval eksplisit sebelum mulai |
| Mobile team capacity | 🔴 BELUM | Butuh ~3-4 hari mobile work di Phase 1 |
| v10 Play Store launch | 🟡 PENDING | Hindari mobile changes selama window (~2026-05-25) |
| Test phone number QA | 🟡 NEED | Firebase test phone number atau real device QA |
clientutil.ServiceClient standardize | 🟡 OPTIONAL | Tidak hard-block, bisa pakai existing fcm client pattern |
Kickoff target Phase 0: post-2026-05-25 (after v10 launch live).
16. Lessons dari Audit firebase_service
Phase 0-2 extend firebase_service inherit pattern existing. Phase 3 (bikin sms_service)
harus level up dari firebase_service baseline. Audit services/firebase_service/
(audit awal 2026-05-17, saat masih bernama fcm_service) menemukan gap berikut yang
TIDAK boleh inherit di sms_service:
| # | Gap firebase_service baseline | Mandatory untuk sms_service Phase 3 |
|---|---|---|
| 1 | Zero test coverage (1.528 LOC, 0 test) | ✅ Minimum 60% unit + integration |
| 2 | No graceful shutdown | ✅ Signal handler + context drain |
| 3 | Unstructured logging (log.Printf) | ✅ slog dengan structured field |
| 4 | No retry/backoff | ✅ Exponential backoff |
| 5 | No circuit breaker | ✅ sony/gobreaker atau equivalent |
| 6 | No DLQ | ✅ Persist failed send untuk replay |
| 7 | No metrics endpoint | ✅ /metrics Prometheus |
| 8 | No provider abstraction | ✅ Interface SMSProvider |
| 9 | Error classification via string match | ✅ Typed error + provider-specific parse |
| 10 | Response format inconsistent | ✅ Standard envelope |
| 11 | No fail-fast config validation | ✅ Required env check di startup |
Effort sms_service Phase 3: ~2-3 minggu (vs firebase_service ~1 minggu), karena quality bar lebih tinggi.
Side benefit: lessons di sms_service bisa di-backport ke firebase_service via incremental refactor (separate plan).
17. Resolved Decisions
Tanggal: 2026-05-17 (diskusi internal)
| # | Topik | Decision |
|---|---|---|
| 1 | Provider Phase 0-2 | Firebase Phone Auth (gratis sampai 10K/bulan, mobile-driven SDK) |
| 2 | Arsitektur Phase 0-2 | Extend firebase_service (tambah endpoint Phone Auth verify), bukan bikin sms_service baru |
| 3 | Alasan pilih extend | Konsisten Phase 4 cleanup goal (Firebase tidak boleh di core_api), single Firebase credential, YAGNI |
| 4 | Nama service | services/firebase_service sebagai single source of truth Firebase SDK. Phase 0 plan ini langsung kerja di nama baru. |
| 5 | Mode A registrasi | User pilih channel: WA atau SMS (Email tidak ditampilkan karena belum verified) |
| 6 | Mode B login | Backend auto-pick: Email (verified) atau WA. SMS = disaster recovery (signal mobile pakai Firebase) |
| 7 | Auto-fallback UX | Silent switch + banner kuning di OTP page (opsi C) |
| 8 | Manual fallback UX | Link "Coba metode lain" muncul setelah 30 detik → bottom sheet |
| 9 | Email channel untuk unverified user | TIDAK ditawarkan (strict, security) |
| 10 | Cooldown switch channel | Klik baru = invalidate OTP lama (1 OTP aktif per request_id) |
| 11 | CTA growth di bottom sheet | Tampilkan untuk State 1 (Atur email) & State 2 (Verifikasi email) |
| 12 | Error "semua channel down" | Tampil error screen + "Coba lagi" + "Hubungi Support" |
| 13 | Tabel audit | iam.phone_verifications di-WRITE oleh firebase_service (single-writer), core_api read-only |
| 14 | Phase 3 sms_service standard | Mandatory improvements vs firebase_service baseline (lihat §16) |
18. Dokumen Terkait
services/email/extraction-plan.md— pola Phase -1 telemetri yang ditiru (di working notes)services/firebase/extraction-plan.md— host service yang di-extend (Phase 4 final 2026-05-19; di working notes)Architecture.md— diagram channel notifikasi (di working notes)- Follow-up belum dibuat:
sms-aggregator-migration-plan.md— Phase 3 migrasi Firebase → Zenziva (saat trigger volume kena)