0

Firebase Phone Auth trong Flutter: test OTP SMS với số fictional và số thật nhiều nước

Làm Firebase Phone Auth trong Flutter, chắc nhiều bạn đã gặp cảnh này. Trên máy dev mọi thứ đều xanh: thêm một số điện thoại test trong Firebase console, nhập mã 123456, đăng nhập được, PR được merge. Release xong thì có người báo "không nhận được OTP", khi thì cả một nước, khi thì vài user lẻ tẻ. Log trống trơn, vì verifyPhoneNumber trả về Future<void>, còn lỗi thật nằm trong callback verificationFailed mà không ai log.

Số test của Firebase (fictional phone number, dưới đây gọi là số fictional cho khỏi lẫn với chữ "test") không gửi SMS, không trừ quota, không bị throttle. Nó cho biết app gọi đúng API, nhưng không cho biết SMS thật có về tới máy người dùng không. Còn test bằng SMS thật thì emulator không dùng được số thật, runner CI không có SIM, mỗi lần chạy lại tốn một SMS.

Bài này đi từ cấu hình, service, test bằng số fictional tới test với số thật cho từng thị trường. Hai bài trước mình viết về nhận SMS OTP tự động qua API và webhook OTP cho Playwright trong CI/CD; lần này đến lượt Flutter.

Chuẩn bị Firebase project cho phone auth

Khá nhiều lỗi "không gửi được OTP" của Firebase phone authentication nằm ở cấu hình chứ không phải ở code Dart:

  1. Gắn Cloud Billing (gói Blaze). Từ tháng 9/2024, project phải liên kết Cloud Billing mới dùng được dịch vụ SMS; gói Spark không gửi được SMS xác minh.
  2. Bật provider Phone ở Security > Authentication > Sign-in method.
  3. Cấu hình SMS region policy ở Security > Authentication > Settings. Project mới mặc định không cho phép khu vực nào. Chọn allowlist (chỉ gửi tới khu vực trong danh sách) hoặc denylist (gửi mọi nơi trừ danh sách), mỗi lúc chỉ một kiểu có hiệu lực.
  4. Android: khai SHA-1 và SHA-256 của app trong Project settings; SHA-256 dùng cho Play Integrity. Máy không có Google Play services hoặc app không phân phối qua Play Store thì SDK chuyển sang reCAPTCHA, luồng này cần SHA-1 và API key không bị giới hạn (hoặc cho phép PROJECT_ID.firebaseapp.com).
  5. iOS: bật Push Notifications, Background Modes (Background fetch, Remote notifications), upload APNs key, thêm Encoded App ID làm URL scheme cho reCAPTCHA dự phòng. Simulator luôn đi đường reCAPTCHA.
  6. Web: domain phải nằm trong Authorized domains; docs ghi rõ localhost không được dùng làm domain cho phone auth.

Package: firebase_auth 7.0.0 (bản mới nhất trên pub.dev, tháng 10/2026, yêu cầu Android minSdk 24) và firebase_core ^5.0.0 trong dependencies; integration_test, flutter_test (đều sdk: flutter) và http trong dev_dependencies.

Flutter OTP verification với Firebase: verifyPhoneNumber và 4 callback

verifyPhoneNumber trả về Future<void>, kết quả đi qua 4 callback:

  • verificationCompleted: chỉ có trên Android, khi Google Play services tự đọc được SMS hoặc số được xác minh ngay mà không cần nhập mã.
  • verificationFailed: số sai định dạng, khu vực bị chặn, vượt giới hạn...
  • codeSent(verificationId, resendToken): SMS đã gửi. Trên iOS, resendToken luôn là null.
  • codeAutoRetrievalTimeout: Android hết thời gian chờ tự đọc SMS (timeout mặc định 30 giây, tối đa 2 phút).

Số điện thoại phải có + và mã quốc gia. Web dùng signInWithPhoneNumber (trả về ConfirmationResult, tự tạo reCAPTCHA ẩn nếu không truyền RecaptchaVerifier). Mình gom cả hai nhánh vào một service để UI và test dùng chung:

// lib/phone_auth_service.dart
import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;

class PhoneAuthService {
  PhoneAuthService([FirebaseAuth? auth]) : _auth = auth ?? FirebaseAuth.instance;

  final FirebaseAuth _auth;
  String? _verificationId;
  int? _resendToken; // chỉ Android; trên iOS luôn null
  ConfirmationResult? _webResult; // chỉ web

  /// UserCredential: Android tự xác minh xong (instant / tự đọc SMS).
  /// null: SMS đã gửi, chờ người dùng nhập mã rồi gọi confirm().
  /// Lỗi: FirebaseAuthException từ verificationFailed.
  Future<UserCredential?> start(String phoneE164, {bool resend = false}) async {
    if (kIsWeb) {
      // Không truyền RecaptchaVerifier thì SDK tự tạo reCAPTCHA ẩn
      _webResult = await _auth.signInWithPhoneNumber(phoneE164);
      return null;
    }
    final done = Completer<UserCredential?>();
    await _auth.verifyPhoneNumber(
      phoneNumber: phoneE164, // dạng +84..., bắt buộc có mã quốc gia
      timeout: const Duration(seconds: 60), // mặc định 30 giây, tối đa 2 phút
      forceResendingToken: resend ? _resendToken : null,
      verificationCompleted: (PhoneAuthCredential credential) async {
        // Chỉ Android. Có thể đến SAU codeSent: khi đó app đăng nhập ngầm,
        // nên UI nghe authStateChanges() thay vì chỉ chờ Future này.
        try {
          final uc = await _auth.signInWithCredential(credential);
          if (!done.isCompleted) done.complete(uc);
        } on FirebaseAuthException catch (e) {
          if (!done.isCompleted) done.completeError(e);
        }
      },
      verificationFailed: (FirebaseAuthException e) {
        debugPrint('verificationFailed: ${e.code}'); // lỗi thật nằm ở đây, đừng nuốt
        if (!done.isCompleted) done.completeError(e);
      },
      codeSent: (String verificationId, int? resendToken) {
        _verificationId = verificationId;
        _resendToken = resendToken;
        if (!done.isCompleted) done.complete(null);
      },
      codeAutoRetrievalTimeout: (String verificationId) {
        _verificationId = verificationId; // chỉ Android
      },
    );
    return done.future;
  }

  Future<UserCredential> confirm(String smsCode) {
    if (kIsWeb) return _webResult!.confirm(smsCode);
    final credential = PhoneAuthProvider.credential(
      verificationId: _verificationId!,
      smsCode: smsCode,
    );
    return _auth.signInWithCredential(credential);
  }
}

/// Docs chỉ minh họa 'invalid-phone-number'; mã còn lại là ví dụ hay gặp,
/// nên luôn giữ nhánh mặc định.
String phoneAuthMessage(FirebaseAuthException e) => switch (e.code) {
      'invalid-phone-number' => 'Số điện thoại không hợp lệ, nhớ nhập kèm mã quốc gia.',
      'too-many-requests' => 'Bạn thử quá nhiều lần, vui lòng đợi một lúc.',
      _ => 'Chưa gửi được mã (${e.code}). Vui lòng thử lại sau.',
    };

Vài điểm dễ sót:

  • Gọi lại verifyPhoneNumber cho cùng số khi yêu cầu trước chưa hết hạn thì Firebase không gửi SMS mới, nên nút "Gửi lại mã" gọi start(phone, resend: true) để truyền forceResendingToken (chỉ Android).
  • Số điện thoại mà người dùng nhập sẽ được gửi tới Google và lưu lại để chống spam, lạm dụng, nên cần xin đồng ý và báo trước rằng sẽ có SMS xác minh, có thể tính cước tin nhắn thông thường.

Số fictional của Firebase: test OTP không tốn quota

Số fictional là một cặp gồm số điện thoại và mã 6 chữ số cố định, khai ở Security > Authentication > Sign-in method > Phone > Phone numbers for testing, tối đa 10 số. Không SMS nào được gửi, không trừ quota, không bị throttle; nhập đúng mã là đăng nhập xong, user tạo ra không khác gì user dùng số thật. Theo tài liệu phone auth cho Flutter, đăng nhập bằng số điện thoại chỉ chạy trên thiết bị thật và web; muốn test trên emulator thì dùng số fictional.

Số phải là số giả nhưng đúng định dạng, không trùng số của người dùng thật (docs gợi ý đầu số 555 của Mỹ, ví dụ +1 650-555-3434). ID token của user fictional có cùng chữ ký với user thật, nên chọn số và mã khó đoán, đổi định kỳ, không hardcode trong bản production.

setSettings(appVerificationDisabledForTesting: true) tắt bước xác minh app (Play Integrity, APNs, reCAPTCHA) trên Android, iOS và web, nhờ vậy test chạy được trên emulator và simulator. Tùy chọn này chỉ dùng được với số fictional (bật cờ này thì số thật sẽ đăng nhập thất bại), phải gọi trước verifyPhoneNumber và chỉ nằm trong code test.

// integration_test/phone_auth_test.dart
// Chạy: flutter test integration_test/phone_auth_test.dart -d emulator-5554 \
//   --dart-define-from-file=test_secrets.json   (file này nằm trong .gitignore)
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/firebase_options.dart'; // sinh bởi `flutterfire configure`
import 'package:my_app/phone_auth_service.dart';

// Phải là const thì String.fromEnvironment mới chắc chắn đọc được giá trị
const testPhone = String.fromEnvironment('TEST_PHONE');
const testCode = String.fromEnvironment('TEST_SMS_CODE');

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  setUpAll(() async {
    await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
    // CHỈ có tác dụng với số khai trong "Phone numbers for testing"
    await FirebaseAuth.instance.setSettings(appVerificationDisabledForTesting: true);
  });

  tearDown(() => FirebaseAuth.instance.signOut());

  testWidgets('đăng nhập bằng số fictional của Firebase', (tester) async {
    expect(testPhone, isNotEmpty, reason: 'thiếu TEST_PHONE trong --dart-define');
    expect(testCode, isNotEmpty, reason: 'thiếu TEST_SMS_CODE trong --dart-define');
    final service = PhoneAuthService();
    // Có giới hạn thời gian để job CI không treo nếu callback không bao giờ được gọi
    final auto = await service.start(testPhone).timeout(const Duration(seconds: 90));
    final uc = auto ?? await service.confirm(testCode);
    expect(uc.user, isNotNull);
    expect(FirebaseAuth.instance.currentUser?.uid, uc.user!.uid);
  });
}

Muốn test auto-retrieval, gọi thêm setSettings(phoneNumber: testPhone, smsCode: testCode) trước start(): verificationCompleted sẽ được gọi ngay (chỉ Android).

Với web, flutter test không nhận thiết bị web cho integration test (phải dùng flutter drive với chromedriver), lại vướng quy định localhost, nên bài này tập trung vào Android và iOS.

Không gửi được, không nhận được OTP: vì sao vẫn cần test bằng số thật

Test ở trên không gửi SMS và tắt xác minh app, nên không trả lời được câu hỏi quan trọng nhất: SMS thật có tới máy người dùng ở từng nước không. Nguyên nhân thường gặp, tổng hợp từ FAQ và hướng dẫn phone auth của Firebase (mã lỗi là ví dụ thực tế, không phải danh sách chính thức):

Triệu chứng Nguyên nhân Xử lý
Không bật được dịch vụ SMS Chưa gắn Cloud Billing Lên gói Blaze
Project mới, số thật không nhận SMS Region policy mặc định không cho khu vực nào Thêm thị trường vào allowlist
operation-not-allowed, "SMS unable to be sent until this region enabled by the app developer" Khu vực bị region policy chặn (lấy từ báo cáo cộng đồng, docs không ghi) Kiểm tra allowlist/denylist
too-many-requests Quá nhiều yêu cầu liên tiếp từ một thiết bị, hoặc hết quota SMS Chờ rồi thử lại, không retry dồn dập
Lỗi xác minh app Thiếu SHA, API key bị giới hạn, thiếu APNs key hoặc URL scheme Xem phần chuẩn bị
codeSent chạy nhưng SMS không về Nhà mạng không chuyển tin; số đã chuyển mạng giữ số Thử số khác

FAQ ghi rõ không phải nhà mạng nào cũng gửi tin xác minh ổn định. Việt Nam và Indonesia có trong danh sách khu vực gửi tốt. Riêng số đã chuyển mạng giữ số thì hiện không nhận được SMS nào và chưa có cách khắc phục, nên khi user báo không nhận được OTP, hỏi xem họ có từng chuyển mạng giữ số không. Firebase cũng giới hạn (không công bố mức) số SMS gửi tới cùng một số điện thoại, nên đừng dồn mọi lượt test vào cùng một số điện thoại.

Cách trực tiếp nhất để bắt các lỗi này trước người dùng là gửi SMS thật tới một số điện thoại ở từng thị trường trước release; riêng dòng cuối của bảng thì chỉ bắt được một phần, vì nhà mạng của số tạm (số thuê) chưa chắc trùng nhà mạng của user.

Integration test OTP trong Flutter với số thật qua SMS API

Ý tưởng: thuê số tạm ở nước cần kiểm tra, để Firebase gửi SMS thật tới đó, đọc mã qua API rồi đăng nhập. Mình lấy HeroSMS làm ví dụ vì endpoint handler_api.php được mô tả rõ trong tài liệu API của HeroSMS; nhà cung cấp khác có API tương tự thì chỉ cần viết lại lớp SmsProvider bên dưới.

Endpoint này theo giao thức handler_api mà nhiều dịch vụ số ảo dùng chung: gọi GET, api_key và action nằm trên query string. Cần ba action:

  • getNumberV2: thuê số, trả JSON có activationId, phoneNumber (không có +), activationCost.
  • getStatus: STATUS_WAIT_CODE khi đang chờ, STATUS_OK:<mã> khi có mã, STATUS_CANCEL khi phiên đã hủy.
  • setStatus: 6 là hoàn tất, 8 là hủy và hoàn tiền.

Lỗi trả về JSON {"title": "BAD_KEY", ...} kèm HTTP 4xx, nên kiểm tra status code trước; riêng NO_NUMBERS là chuỗi thuần với HTTP 200. setStatus còn có thể trả lỗi 409, ví dụ EARLY_CANCEL_DENIED (không cho hủy trong 2 phút đầu) hoặc OTP_RECEIVED (SMS đã về, không hủy được nữa).

Chỗ dễ vấp: integration test chạy trên điện thoại chứ không phải trên máy CI, nên không đọc được biến môi trường của runner. URL và key phải đi qua --dart-define.

// integration_test/support/sms_provider.dart
import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart' as http;

// Test chạy trên điện thoại, không thấy env của runner: giá trị đi qua --dart-define
const _apiUrl = String.fromEnvironment('SMS_API_URL'); // endpoint .../stubs/handler_api.php
const _apiKey = String.fromEnvironment('SMS_API_KEY'); // CI secret, không commit

class SmsProviderException implements Exception {
  SmsProviderException(this.code, [this.httpStatus]);
  final String code; // BAD_KEY, NO_NUMBERS, NO_BALANCE, EARLY_CANCEL_DENIED...
  final int? httpStatus;
  
  String toString() => 'SmsProviderException($code, HTTP ${httpStatus ?? 200})';
}

class Activation {
  Activation(this.id, this.phone, this.cost) : rentedAt = DateTime.now();
  final String id;
  final String phone; // dạng +E.164
  final String cost; // activationCost: cộng dồn để biết mỗi lượt tốn bao nhiêu
  final DateTime rentedAt;
}

class SmsProvider {
  final _http = http.Client();

  Future<Object?> _call(Map<String, String> params) async {
    if (_apiUrl.isEmpty || _apiKey.isEmpty) {
      throw StateError('Thiếu SMS_API_URL hoặc SMS_API_KEY'); // không in giá trị
    }
    final base = Uri.parse(_apiUrl);
    final uri = base.replace(
        queryParameters: {...base.queryParameters, 'api_key': _apiKey, ...params});
    final http.Response res;
    try {
      res = await _http.get(uri).timeout(const Duration(seconds: 30));
    } on http.ClientException {
      throw SmsProviderException('NETWORK'); // message gốc chứa URI, tức có cả api_key
    }
    Object? body;
    try {
      body = jsonDecode(res.body); // object JSON hoặc chuỗi trong ngoặc kép
    } on FormatException {
      body = res.body.trim(); // text thuần: STATUS_OK:123456, NO_NUMBERS...
    }
    if (res.statusCode >= 400) {
      // Lỗi là JSON {"title":"BAD_KEY","details":"..."} kèm HTTP 4xx/5xx
      final code = body is Map ? '${body['title']}' : 'HTTP_${res.statusCode}';
      throw SmsProviderException(code, res.statusCode);
    }
    return body;
  }

  /// service: mã từ action=getServicesList; country: ID từ action=getCountries
  Future<Activation> rentNumber({required String service, required int country}) async {
    final body = await _call(
        {'action': 'getNumberV2', 'service': service, 'country': '$country'});
    if (body is! Map) throw SmsProviderException('$body'); // NO_NUMBERS: chuỗi, HTTP 200
    return Activation(
        '${body['activationId']}', '+${body['phoneNumber']}', '${body['activationCost']}');
  }

  /// Poll getStatus tới khi có STATUS_OK:<mã>; STATUS_WAIT_* thì chờ tiếp.
  Future<String> waitForCode(String id, {Duration timeout = const Duration(minutes: 3)}) async {
    final deadline = DateTime.now().add(timeout);
    while (DateTime.now().isBefore(deadline)) {
      final status = '${await _call({'action': 'getStatus', 'id': id})}';
      if (status.startsWith('STATUS_OK:')) return status.substring('STATUS_OK:'.length);
      // STATUS_CANCEL hoặc chuỗi lạ: dừng ngay, không poll vô ích tới hết timeout
      if (!status.startsWith('STATUS_WAIT')) throw SmsProviderException(status);
      await Future<void>.delayed(const Duration(seconds: 5));
    }
    throw TimeoutException('Không có SMS cho phiên $id', timeout);
  }

  /// 6 = hoàn tất (đã dùng mã), 8 = hủy và hoàn tiền (chưa dùng mã)
  Future<void> close(Activation a, {required bool used}) async {
    Future<Object?> setStatus(String s) =>
        _call({'action': 'setStatus', 'id': a.id, 'status': s});
    try {
      await setStatus(used ? '6' : '8');
    } on SmsProviderException catch (e) {
      if (e.code == 'EARLY_CANCEL_DENIED') {
        // Không hủy được trong 2 phút đầu (minActivationTime: 120): đợi qua mốc rồi hủy
        final wait = a.rentedAt.add(const Duration(seconds: 125)).difference(DateTime.now());
        if (!wait.isNegative) await Future<void>.delayed(wait);
        await setStatus('8');
      } else if (e.code == 'OTP_RECEIVED') {
        await setStatus('6'); // SMS đã về: đóng phiên với trạng thái hoàn tất
      } else {
        rethrow;
      }
    }
  }

  void dispose() => _http.close();
}

Test case dùng lại PhoneAuthService ở trên:

// integration_test/phone_auth_real_test.dart (chỉ chạy trên thiết bị thật)
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/firebase_options.dart'; // trỏ tới Firebase project dành cho test
import 'package:my_app/phone_auth_service.dart';

import 'support/sms_provider.dart';

const otpService = String.fromEnvironment('OTP_SERVICE'); // mã từ getServicesList
const otpCountry = int.fromEnvironment('OTP_COUNTRY', defaultValue: -1); // ID từ getCountries

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  // KHÔNG bật appVerificationDisabledForTesting: với số thật, đăng nhập sẽ thất bại
  setUpAll(() => Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform));

  testWidgets('OTP SMS thật về số tạm và đăng nhập được', (tester) async {
    // Kiểm tra cấu hình trước khi tiêu tiền (không đưa key vào expect)
    expect(otpCountry, isNonNegative, reason: 'thiếu --dart-define=OTP_COUNTRY');

    final sms = SmsProvider();
    Activation? activation;
    var used = false;
    try {
      final a = await sms.rentNumber(service: otpService, country: otpCountry);
      activation = a;
      debugPrint('activationCost: ${a.cost}');
      final auth = PhoneAuthService();
      // Có giới hạn thời gian: nếu kẹt ở reCAPTCHA, khối finally vẫn kịp hủy phiên
      final auto = await auth.start(a.phone).timeout(const Duration(minutes: 2));
      if (auto != null) {
        // Android xác minh tức thì, không có SMS: lượt này không kiểm tra được gì
        await auto.user?.delete();
        fail('instant verification, không có SMS để kiểm tra');
      }
      final code = await sms.waitForCode(a.id);
      used = true; // SMS đã về: đóng phiên bằng status 6 kể cả khi bước sau lỗi
      final uc = await auth.confirm(code);
      expect(uc.user?.phoneNumber, isNotNull);
      await uc.user?.delete(); // dọn user test cho project sạch
    } finally {
      if (activation != null) {
        try {
          await sms.close(activation, used: used);
        } catch (e) {
          debugPrint('Không đóng được phiên: $e'); // chỉ có mã lỗi, không có key
        }
      }
      sms.dispose();
      await FirebaseAuth.instance.signOut();
    }
  },
      // Không truyền OTP_SERVICE thì bỏ qua: pipeline thông thường chạy cả thư mục cũng không thuê số
      skip: otpService.isEmpty,
      timeout: const Timeout(Duration(minutes: 8)));
}

Cần để ý:

  • Test này tự bỏ qua (skip) khi không có OTP_SERVICE, nên pipeline thông thường (chạy cả thư mục bằng flutter test integration_test) cũng không thuê số. Nó cần điện thoại Android thật có Google Play services hoặc iPhone thật, và project phải cấu hình đủ xác minh app.
  • Bản build cài qua flutter test không phân phối qua Play Store, nên Android đi đường reCAPTCHA chứ không phải Play Integrity. Nếu reCAPTCHA hiện thử thách mà không ai giải, start() hết thời gian chờ sau 2 phút và phiên thuê số được hủy; nên có người ngồi cạnh máy để giải tay, đừng tìm cách vượt tự động. Play Integrity của bản release thì kiểm tra bằng bản build từ track internal testing.
  • Nhịp poll 5 giây là mình tự chọn, tài liệu không khuyến nghị con số nào; muốn mã tự đẩy về thì dùng webhook như bài trước.

Đưa lên CI: chạy theo lịch, mỗi nước một lượt

Test số fictional (integration_test/phone_auth_test.dart) chạy trên emulator trong pipeline thường. Test số thật cần điện thoại thật, nên mình đặt trên self-hosted runner cắm máy Android, chỉ chạy tay trước release hoặc tự động mỗi tuần một lần theo lịch:

# .github/workflows/phone-auth-real-sms.yml
name: phone-auth-real-sms

on:
  workflow_dispatch:            # bấm tay trước release
  schedule:
    - cron: '0 1 * * 1'         # 01:00 UTC thứ Hai = 8h sáng giờ Việt Nam

concurrency:
  group: phone-auth-real-sms    # một điện thoại: không cho hai lần chạy chồng nhau
  cancel-in-progress: false

jobs:
  real-sms:
    runs-on: [self-hosted, android-device]   # runner đã cài Flutter, cắm máy Android thật
    strategy:
      fail-fast: false
      max-parallel: 1                          # một máy, chạy lần lượt từng nước
      matrix:
        country: [VN, ID]                      # nước nào cũng phải có trong region policy
    steps:
      - uses: actions/checkout@v5
      - run: flutter pub get
      - name: Integration test với số thật (${{ matrix.country }})
        env:
          SMS_API_URL: ${{ vars.SMS_API_URL }}
          SMS_API_KEY: ${{ secrets.SMS_API_KEY }}   # chỉ nằm trong Secrets
          OTP_SERVICE: ${{ vars.OTP_SERVICE }}
          OTP_COUNTRY: ${{ vars[format('OTP_COUNTRY_{0}', matrix.country)] }}
          DEVICE_ID: ${{ vars.ANDROID_DEVICE_ID }}
        run: |
          test -n "$OTP_SERVICE" || { echo 'OTP_SERVICE chưa được cấu hình'; exit 1; }  # thiếu biến thì test bị skip mà job vẫn xanh
          flutter test integration_test/phone_auth_real_test.dart -d "$DEVICE_ID" \
            --dart-define=SMS_API_URL="$SMS_API_URL" \
            --dart-define=SMS_API_KEY="$SMS_API_KEY" \
            --dart-define=OTP_SERVICE="$OTP_SERVICE" \
            --dart-define=OTP_COUNTRY="$OTP_COUNTRY"

DEVICE_ID là serial của máy; mỗi nước một biến OTP_COUNTRY_<mã> trong repo variables, giá trị lấy từ getCountries. Mỗi lượt tốn một SMS Firebase cộng một lần thuê số, nên đừng chạy theo từng commit; chọn giờ cron lúc có người ở cạnh máy.

Giá Firebase OTP: free limit và chi phí mỗi lượt test

Firebase phone authentication tính phí theo từng SMS gửi đi, giá theo quốc gia nhận (bảng giá Identity Platform). Giá lúc mình kiểm tra ngày 21/10/2026 (có thể đổi, nhớ xem lại):

Quốc gia Giá mỗi SMS
Việt Nam 0,12 USD
Indonesia 0,35 USD
Ấn Độ 0,07 USD
Thổ Nhĩ Kỳ 0,01 USD
Mỹ 0,01 USD
  • Cùng trang đó ghi 10 SMS đầu tiên mỗi ngày không tính phí. Đây không phải free tier của Spark: project vẫn phải gắn billing.
  • Theo trang giới hạn, Firebase Authentication trên Blaze gửi tối đa 3.000 SMS/ngày (nâng lên Identity Platform thì mục này ghi "No limit"); ngoài ra mỗi địa chỉ IP bị giới hạn 50 SMS/phút và 500 SMS/giờ.
  • Mỗi lần "Gửi lại mã" là thêm một SMS.
  • Số tạm tính tiền riêng, tùy nhà cung cấp và quốc gia; test ở trên in activationCost để bạn cộng chi phí.

Bảo mật khi test Firebase Phone Auth

  • Key không vào repo, không vào log: local dùng --dart-define-from-file với file trong .gitignore, CI dùng Secrets. Không echo key, không log nội dung SMS, không in nguyên exception của http (message có URI kèm api_key).
  • Bản build test chứa key: giá trị --dart-define được biên dịch vào binary, ai có file APK là đọc được. Đừng upload bản build này làm artifact, chỉ nạp số dư vừa đủ, nghi lộ thì đổi key.
  • Phiên không dùng thì hủy (status 8) để được hoàn tiền; hủy trong 2 phút đầu sẽ bị từ chối (EARLY_CANCEL_DENIED), còn khi SMS đã về thì không hủy được nữa (OTP_RECEIVED). Code ở trên đã xử lý cả hai trường hợp.
  • Số thuê chỉ dùng cho test. Dùng cho tài khoản test của chính app bạn, không dùng cho ngân hàng, ví điện tử hay dịch vụ gắn danh tính; cũng đừng test bằng số của người thật (đồng nghiệp, khách hàng).
  • Không chạy trên project production. Tách project test riêng để user, quota, hóa đơn không lẫn với người dùng thật, nhưng billing, region policy và app verification phải giống production.

Chống SMS pumping cho Firebase Phone Auth

SMS pumping (toll fraud) là khi bot bấm "gửi mã" hàng loạt tới những dải số mà kẻ gian được chia cước, còn hóa đơn thì bạn trả. Vài lớp phòng thủ:

  • Region policy: allowlist chỉ các thị trường bạn phục vụ, hoặc denylist khu vực có tỷ lệ thành công thấp, như FAQ của Firebase khuyến nghị.
  • Theo dõi tỷ lệ verified/sent. Theo FAQ, tỷ lệ bình thường là 70–85%; dưới 50% là dấu hiệu phổ biến của bot hoặc pumping.
  • Bỏ localhost khỏi Authorized domains của project production (với project tạo sau 28/4/2025, localhost không còn được thêm sẵn).
  • App Check cho Firebase Authentication bảo vệ bước gửi mã và đăng nhập bằng số điện thoại, nhưng đang ở dạng Preview, cần nâng cấp lên Identity Platform, và theo FAQ không chặn được kiểu mở luồng đăng nhập rồi bỏ dở để sinh SMS.
  • reCAPTCHA SMS defense chấm điểm rủi ro từng yêu cầu SMS; ở chế độ Enforce, yêu cầu vượt ngưỡng không được gửi. Cần bật reCAPTCHA Enterprise API và SDK đủ mới (Android Auth 23.1.0+, iOS 11.6.0+).
  • Phía app: đếm ngược cho nút "Gửi lại mã", giới hạn số lần thử, không tự retry khi gặp too-many-requests. Nên có, nhưng không chặn được kẻ gọi thẳng API.

FAQ

Firebase Phone Auth có miễn phí không, free limit là bao nhiêu?

Không hẳn. Từ tháng 9/2024 phải gắn Cloud Billing (Blaze) mới gửi được SMS; bảng giá Identity Platform ghi 10 SMS đầu mỗi ngày không tính phí, sau đó tính theo tin và theo quốc gia. Số fictional không gửi SMS nên không tốn quota.

Vì sao project Firebase mới không gửi được OTP về số Việt Nam?

Thường do region policy chưa cho phép Việt Nam (project mới mặc định không cho khu vực nào) hoặc chưa gắn billing. Hai mục đó ổn mà SMS vẫn không về thì xem bảng ở trên.

Test Firebase OTP verification trên Android emulator được không?

Được, với số fictional kèm appVerificationDisabledForTesting: true. Số thật chỉ dùng được trên thiết bị thật và web.

Flutter OTP autofill với Firebase hoạt động thế nào?

Trên Android, Google Play services có thể tự đọc SMS chứa mã; khi đó verificationCompleted được gọi, người dùng không cần gõ mã, thời gian chờ chỉnh bằng timeout. iOS không có callback này, nhưng nếu ô nhập mã đặt autofillHints: const [AutofillHints.oneTimeCode] và keyboardType: TextInputType.number thì bàn phím sẽ gợi ý mã từ SMS (người dùng chỉ cần chạm vào gợi ý); autofillHints này cũng giúp một số bàn phím Android gợi ý mã.

Kết luận

Test Firebase Phone Auth trong Flutter nên chia hai tầng: số fictional cho mỗi pull request (nhanh, chạy trên emulator) và số thật tạm thời cho từng thị trường trước release, vì chỉ tầng sau gửi SMS thật với xác minh app đang bật (trên Android, bản build test đi đường reCAPTCHA; Play Integrity của bản release kiểm tra bằng track internal testing). Checklist trước release:

  1. Đã gắn billing; region policy có đủ thị trường mục tiêu, không mở thừa.
  2. SHA-1/SHA-256 của mọi key ký app (kể cả key release và key Play App Signing lấy trong Play Console), APNs key và Authorized domains đã khai đủ.
  3. appVerificationDisabledForTesting và số fictional chỉ nằm trong code test; key của nhà cung cấp số nằm trong secrets.
  4. Sau release có theo dõi tỷ lệ verified/sent.

Bạn gặp lỗi OTP lạ với Firebase ở Việt Nam hay Đông Nam Á thì comment nhé, mình sẽ bổ sung vào bảng.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí