Skip to main content

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.go dihapus
  • ✅ 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

OpsiKonsisten Phase 4?Effort Phase 0-2Phase 3 migration
A. Integrasi Firebase di core_api❌ Mengulangi Firebase di core_api3-4 hariExtract code → service baru
B. Bikin sms_service/ baru dengan Firebase SDK5-7 hariSwap provider
C. Extend firebase_service dengan endpoint Phone Auth+ Firebase credential terpusat1-2 hariBikin sms_service baru

Pilihan: Opsi C karena:

  1. Konsisten Phase 4 cleanup goal (no Firebase di core_api)
  2. Single Firebase credential = security clarity
  3. Firebase Admin SDK sudah ada di go.mod firebase_service
  4. Effort minimal (~1-2 hari tambah endpoint)
  5. YAGNI — tidak bikin service kosong untuk 1 endpoint

Nama Service ✅ Renamed 2026-05-19 (sebelumnya direncanakan Phase 3)

Service folder sudah direname services/fcm_serviceservices/firebase_service per Phase 4 final 2026-05-19 (sebelum plan ini dieksekusi). VM produksi ikut direname: folder merchant_fcmmerchant_firebase, systemd unit fcm-service.servicefirebase-service.service, binary fcm-servicefirebase-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:

  1. Mode A (Registrasi) — pilihan pendamping WhatsApp untuk user baru. User pilih.
  2. 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):

AspekStatus
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 existing8 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

KomponenStatus 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 adaPOST /auth/firebase-phone-verify
Decision tree OTP channelHanya WA + EmailTambah SMS branch + fallback logic

2.3 Status mobile_user — UI partial ready

FileBehavior currentAction plan
verification_method_page.dart:102Tampil snackbar smsUnavailableHapus snackbar, plug ke flow real
verification_method_page.dart:188Button "SMS" disabledAktifkan untuk Mode A
app_strings.dart:20smsMethod = 'sms'Tetap dipakai
pubspec.yamlTidak ada firebase_authTambah 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 Stateemail_verifiedPunya emailPrimary auto-pickAuto-fallback"Coba metode lain"
Mode A Registrasin/an/aUser pilih: WA / SMS(manual retry)Swap WA ↔ SMS
State 1 Login, no emailfalseWhatsAppSMSSMS + CTA "Atur email"
State 2 Login, email unverifiedfalse✅ tapi belum verifyWhatsAppSMSSMS + CTA "Verifikasi email"
State 3 Login, email verifiedtrue✅ verifiedEmailSMSWhatsApp, 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?

AspekDetail
PricingGratis sampai 10.000 verifikasi/bulan, lalu ~Rp 950/verif
DeliverabilityHigh (backend pakai Twilio/Sinch)
Indonesia coverageGood (all major operators)
Backend integrationCuma verify ID token (~5 LOC pakai Firebase Admin SDK)
Mobile integrationfirebase_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 merchantOTP/bulan totalSMS via Firebase (~15%)Cost
200 (sekarang)1.000~150Rp 0 (10K free)
1.000 (2027)5.000~750Rp 0
5.000 (2028)25.000~3.750Rp 0
10.000 (2029)50.000~7.500Rp 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

MethodPathBodyAuthPurpose
POST/internal/firebase-auth/verify-phone-token{id_token, flow_mode, fallback_reason?}X-Internal-API-KeyVerify 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

MethodPathBodyPurpose
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):

AspekBehavior
request_idREUSE — same request_id mobile-side, simplify state. Backend update method column di iam.otp_challenges
Row lifecycleUPDATE in-place (bukan DELETE + INSERT) — preserve created_at, append-only audit di audit.auth_audit_logs dengan event_type=otp_channel_switched
OTP code regenGenerate baru — new code, new hash, reset attempts=0, extend expires_at = NOW() + OTP_EXPIRY
From WA/Email → SMSResponse include use_firebase_phone_auth: true — mobile trigger NEW verifyPhoneNumber() call → new Firebase verificationId. Backend tidak track Firebase verificationId.
From SMS → WA/EmailBackend kirim OTP via new channel langsung. Old Firebase verificationId server-side auto-expire ~30 menit (no action needed).
Rate limitSwitch-channel counted sebagai 1 request OTP (kena per-phone window). Per-IP limit tidak apply (sudah lulus initial request)
CooldownSwitch-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

EndpointChange
POST /auth/request-otpResponse tambah field use_firebase_phone_auth: bool untuk Mode B auto-fallback signal
POST /auth/request-otp/switch-channelNEW — 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_AUTHENABLE_FIREBASE_PHONE_AUTH_FALLBACKResulting Behavior
falsefalsePre-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.
truefalseMode 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.
falsetrueINVALID config — log.Fatal di startup. Fallback need endpoint untuk verify token. Operator harus enable AUTH dulu.
truetrueFull 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:

  1. Pre-Phase 1 launch: deploy core_api dengan AUTH=false FALLBACK=false — production tidak terpengaruh, endpoint dormant.
  2. Phase 1 launch (Mode A only): flip AUTH=true. Mode A SMS aktif. Mobile force-update min version yang support Firebase SDK.
  3. Phase 1 full (Mode B auto-fallback): setelah mobile adoption >80% dari force-update + WA/Email outage drill test → flip FALLBACK=true.
  4. Rollback: flip FALLBACK=false instan (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:

LayerLimitCounted For
Per-phone window (OTP_MAX_REQUESTS_PER_WINDOW di Kesles)Existing: 5/phone/15minAll channel — WA, Email, SMS via Firebase counted (mencegah spam)
Per-IP window (Phase 0 hardening)10/IP/10minPOST /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/4hAll 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._handleLoginrequestOtp 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 verificationFailed di TriggeringFirebaseSdk state, snackbar action retry ke LoginPage (BUKAN trigger Firebase ulang yang akan loop). User pilih WA/Email lagi dari awal.
  • Saat signInWithCredential fail karena invalid-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 PhoneVerifierdeviation dari spec: pakai manual RS256 verify + JWKS cache (golang-jwt/jwt/v5), bukan firebase.google.com/go/v4 Admin SDK. Alasan: konsisten dengan push.Client style (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 + storeAuditAdapter bridge ke auth.AuditLogger interface)
  • Tambah handler handleVerifyPhoneToken di internal/app/auth_handlers.go (bukan handlers.go — split file supaya scope FCM vs Phone Auth tegas)
  • Register route POST /internal/firebase-auth/verify-phone-token di server.go (di-wrap requireInternalKey middleware)
  • 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.go setup slog.NewJSONHandler global default + handler emit event phone_verify dengan attrs: status, flow_mode, failure_class, fallback_reason, phone_masked, firebase_uid, http_status, duration_ms. Success di-emit INFO, failed di WARN. PII safety: maskPhone() strip middle digit (+628114169868+628***68); full phone tetap di audit table only. 2 new unit test: TestHandleVerifyPhoneToken_EmitsSuccessEvent, TestHandleVerifyPhoneToken_EmitsFailedEvent + TestMaskPhone table-driven 7 case. Server.phoneVerifier di-refactor ke interface phoneVerifierIface untuk test injectability.
  • Self-signed JWT dev tool (cmd/devtoken + FIREBASE_JWKS_URL env override + production guard di validateConfig) — 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-token dengan 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_verifications confirm 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: Login 08114169868 → Verification Method Page (Mode A tampil 2 tombol) → tap SMS → Firebase verifyPhoneNumber → codeSent → OtpPage → input 070110 → 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_api EnsureVerifiedPhoneAuthUser create user 14d6ba11-... + 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.md sudah 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 → label firebase-service ✅ (post-Phase-4-rename, bukan fcm-service lama)
    • /ready{firebase: ok, postgres: ok, status: ready}
    • Middleware: /verify-phone-token tanpa API key → 401 unauthorized (bukan 404 = route terdaftar)
    • Handler: /verify-phone-token dengan 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.go dengan VerifyPhoneToken() method — POST proxy ke firebase_service /internal/firebase-auth/verify-phone-token. Typed error *VerifyPhoneTokenError{HTTPStatus, ErrorCode, FailureClass, Message} untuk caller bisa errors.As map 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-verify di httpapi (file auth_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 new OTPService.IssueSessionForVerifiedPhone() (file firebase_phone_service.go) → mirror VerifyOTP success path (EnsureVerifiedPhoneAuthUser + IssueTokens + TrustDevice + audit event_type=firebase_phone_verify auth_provider=firebase_phone_auth + PersistDeviceSession) → return writeTokenResponse 200 OK (shape match /auth/verify-otp supaya 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 flag ENABLE_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 (tambah use_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.0 SDK di pubspec.yaml
  • Snackbar smsUnavailable di-kept (deprecated, unused); new flow pakai smsFailedUseWhatsapp + snackbar action
  • Aktifkan button SMS untuk Mode A — trigger FirebaseAuth.verifyPhoneNumber():
    • Android: verificationCompleted callback → auto-sign-in + _completeFirebaseSignIn → POST /auth/firebase-phone-verify → home page (best UX, no OTP input)
    • Android manual fallback: codeSent callback → navigate ke OtpPage(firebaseVerificationId: verificationId) → user input 6-digit → signInWithCredentialgetIdToken → POST /auth/firebase-phone-verify
    • Error handling: FirebaseAuthException mapped 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
  • 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)
  • _verifyViaFirebaseCredential helper 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 analyze lib/ 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() (sebelumnya 628... → reject invalid-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() dengan forceResendingToken, bukan backend /auth/request-otp (yang belum support method=sms). State lokal _currentFirebaseVerificationId + _currentFirebaseResendToken di OtpPage update saat resend supaya _verifyViaFirebaseCredential pakai verificationId baru.
  • iOS code integration (2026-05-21):
    • Firebase iOS app added ke project kesles-merchant di Firebase Console (Bundle ID com.kesles.merchant, GOOGLE_APP_ID=1:1700257241:ios:68de99742b42b5519c4dc8)
    • GoogleService-Info.plist di ios/Runner/ + ditambah ke Xcode Runner target Resources build phase via xcodeproj Ruby gem
    • AppDelegate.swift tambah import FirebaseCore + FirebaseApp.configure() (sebelum GeneratedPluginRegistrant.register)
    • Platform.isIOS guard dihapus di _handleSmsViaFirebase; dart:io show Platform import dilepas; firebaseAuthIosNotSupportedYet string deprecated dan dihapus
    • pod install --repo-update setelah delete Podfile.lock lama — Firebase SDK bump 12.9.0 → 12.12.0; firebase_auth 6.4.0 + firebase_app_check 0.4.3 ikut 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/)
  • iOS production real-phone path — final blocker:
    • APNs .p8 Authentication 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_ID tidak 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 dengan REVERSED_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 / 070110 bypass APNs+reCAPTCHA) tapi tidak terima real SMS.
  • Skip verification_method_page untuk Mode B (route langsung ke OtpPage) — DEFERRED (depends on Deliverable C)
  • Detect signal use_firebase_phone_auth dari 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

SkenarioAction
Endpoint /internal/firebase-auth/verify-phone-token bermasalahRevert firebase_service deploy + revert core_api proxy endpoint
Mobile UI banner / bottom sheet bugFirebase 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 mendadakHard-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

FieldContoh
eventphone_verify, phone_verify_success, phone_verify_failed
phone_masked+6281***6789
firebase_uid_maskedXK7a...
flow_moderegistration, login_primary, login_fallback
fallback_reasonwhatsapp_unavailable, email_unavailable, user_switched
duration_ms120
successtrue/false
failure_classinvalid_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

MetricThresholdAction
Verify success rate< 90% selama 24 jamInvestigate phone format / Firebase config
Firebase quota utilization> 70% monthlyTrigger Phase 3 evaluation (migrate aggregator)
Failure class expired_token> 5%Investigate clock skew / Firebase outage
p95 verify latency> 2 detikInvestigate 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 merchantOTP total/bulanSMS via Firebase (~15%)Cost
200 (sekarang)1.000~150Rp 0
1.000 (2027)5.000~750Rp 0
5.000 (2028)25.000~3.750Rp 0
10.000 (2029)50.000~7.500Rp 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)TriggerActionReversibility
70% (~7,000/bulan)Soft warningSlack alert ke #monitoring daily summary. Operator review tren growth, kalau perpetual >70% → start Phase 3 (Aggregator migration) planningManual (operator decision)
85% (~8,500/bulan)Hard warningSlack 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)CriticalAuto-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 exhaustedFirebase 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:

  1. Cron worker firebase_quota_monitor di merchant_core_api/internal/workers/:

    • Run setiap 1 jam
    • Query Firebase Cloud Admin SDK API: getAuthenticationUsage() (atau aggregate dari iam.phone_verifications table sebagai proxy)
    • Compute current_usage / 10000 = utilization_pct
    • Decide threshold tier + execute action
  2. 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
  3. 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
  4. Audit telemetry — log circuit breaker action ke audit.auth_audit_logs:

    • event_type=cost_circuit_breaker_triggered
    • auth_provider=firebase_phone_auth
    • details: {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

ItemStatusCatatan
Firebase Phone Auth aktivasi di Console🔴 BELUMPhase 0 action, ~5 menit
Firebase service account permission untuk Identity Toolkit API🔴 BELUM VERIFYCek di Firebase IAM, mungkin sudah ada
FCM Phase 4 cleanup selesai🟢 DONE 2026-05-19Phase 4 final GO, 24h soak passed, services/firebase_service jadi single source of truth Firebase SDK
Migration slot 148🟢 AVAILABLEVerified 2026-05-19 (146-147 ditempati PIN plan setelah doc ini ditulis 17 Mei)
Owner mobile_user approval🔴 BELUMfolder 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🔴 BELUMButuh ~3-4 hari mobile work di Phase 1
v10 Play Store launch🟡 PENDINGHindari mobile changes selama window (~2026-05-25)
Test phone number QA🟡 NEEDFirebase test phone number atau real device QA
clientutil.ServiceClient standardize🟡 OPTIONALTidak 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 baselineMandatory untuk sms_service Phase 3
1Zero test coverage (1.528 LOC, 0 test)✅ Minimum 60% unit + integration
2No graceful shutdown✅ Signal handler + context drain
3Unstructured logging (log.Printf)✅ slog dengan structured field
4No retry/backoff✅ Exponential backoff
5No circuit breakersony/gobreaker atau equivalent
6No DLQ✅ Persist failed send untuk replay
7No metrics endpoint/metrics Prometheus
8No provider abstraction✅ Interface SMSProvider
9Error classification via string match✅ Typed error + provider-specific parse
10Response format inconsistent✅ Standard envelope
11No 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)

#TopikDecision
1Provider Phase 0-2Firebase Phone Auth (gratis sampai 10K/bulan, mobile-driven SDK)
2Arsitektur Phase 0-2Extend firebase_service (tambah endpoint Phone Auth verify), bukan bikin sms_service baru
3Alasan pilih extendKonsisten Phase 4 cleanup goal (Firebase tidak boleh di core_api), single Firebase credential, YAGNI
4Nama serviceKeep nama fcm_serviceSuperseded oleh Phase 4 final 2026-05-19: folder direname ke services/firebase_service sebagai single source of truth Firebase SDK. Phase 0 plan ini langsung kerja di nama baru.
5Mode A registrasiUser pilih channel: WA atau SMS (Email tidak ditampilkan karena belum verified)
6Mode B loginBackend auto-pick: Email (verified) atau WA. SMS = disaster recovery (signal mobile pakai Firebase)
7Auto-fallback UXSilent switch + banner kuning di OTP page (opsi C)
8Manual fallback UXLink "Coba metode lain" muncul setelah 30 detik → bottom sheet
9Email channel untuk unverified userTIDAK ditawarkan (strict, security)
10Cooldown switch channelKlik baru = invalidate OTP lama (1 OTP aktif per request_id)
11CTA growth di bottom sheetTampilkan untuk State 1 (Atur email) & State 2 (Verifikasi email)
12Error "semua channel down"Tampil error screen + "Coba lagi" + "Hubungi Support"
13Tabel auditiam.phone_verifications di-WRITE oleh firebase_service (single-writer), core_api read-only
14Phase 3 sms_service standardMandatory 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)