Phase 3.4: mitra_app self-managed auth cutover

Rips firebase_auth; phone OTP flow now talks directly to the new
backend endpoints, JWT access token lives in memory, refresh token
persists via flutter_secure_storage. WebSocket handshakes read the
access token from AuthBridge instead of Firebase.

Smoke-tested end-to-end against the backend via curl:
- otp/request → read stub code from backend log → otp/verify
- /api/mitra/auth/me + /api/shared/auth/refresh rotation
- logout → post-logout refresh correctly fails REFRESH_INVALID
- ACCOUNT_INACTIVE (403) + WRONG_FLOW (400) error paths verified
- Debug APK links cleanly

- pubspec: drop firebase_auth, add flutter_secure_storage
- core/auth/auth_bridge.dart: shared mutable state (access token +
  refresh callback + in-flight de-dup) as keepAlive provider
- core/auth/token_storage.dart: flutter_secure_storage wrapper
- core/auth/auth_notifier.dart: bootstrap → refresh; requestOtp +
  verifyOtp via /api/mitra/auth/*; logout; granular OTP error codes
- core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for
  auth endpoints + single-retry 401 refresh
- core/chat/*_notifier.dart: WS auth frame reads bridge.accessToken
- features/auth/screens/otp_screen.dart: verificationId → otpRequestId
- mitra_app/CLAUDE.md: Auth section rewritten (was stale on Firebase)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 15:58:25 +08:00
parent 4a796277b8
commit 2b61c79a86
17 changed files with 496 additions and 140 deletions

View File

@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'auth_bridge.g.dart';
/// Shared mutable auth state: decouples api_client / WS notifiers
/// (read-only consumers) from auth_notifier (writer). Also de-dupes
/// concurrent 401-triggered refreshes.
///
/// Auth notifier calls [setAccessToken] on successful auth / refresh,
/// registers [refresh] and [onUnauthenticated] callbacks on build.
class AuthBridge {
String? accessToken;
Future<String?> Function()? refresh;
void Function()? onUnauthenticated;
Completer<String?>? _inFlight;
void setAccessToken(String? token) {
accessToken = token;
}
void clear() {
accessToken = null;
}
Future<String?> tryRefresh() {
final existing = _inFlight;
if (existing != null) return existing.future;
final completer = Completer<String?>();
_inFlight = completer;
() async {
try {
final next = await refresh?.call();
completer.complete(next);
} catch (_) {
completer.complete(null);
} finally {
_inFlight = null;
}
}();
return completer.future;
}
void notifyUnauthenticated() {
onUnauthenticated?.call();
}
}
@Riverpod(keepAlive: true)
AuthBridge authBridge(Ref ref) => AuthBridge();

View File

@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_bridge.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authBridgeHash() => r'eb546dd1e79626bfaa8619d004b4cf64b596f65a';
/// See also [authBridge].
@ProviderFor(authBridge)
final authBridgeProvider = Provider<AuthBridge>.internal(
authBridge,
name: r'authBridgeProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$authBridgeHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthBridgeRef = ProviderRef<AuthBridge>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,14 +1,13 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../api/api_client.dart';
import '../api/api_client_provider.dart';
import 'auth_bridge.dart';
import 'token_storage.dart';
part 'auth_notifier.g.dart';
// States
sealed class MitraAuthData {
const MitraAuthData();
}
@@ -23,103 +22,170 @@ class MitraAuthAuthenticatedData extends MitraAuthData {
}
class MitraAuthOtpSentData extends MitraAuthData {
final String verificationId;
const MitraAuthOtpSentData(this.verificationId);
final String otpRequestId;
final String? channelUsed;
const MitraAuthOtpSentData(this.otpRequestId, {this.channelUsed});
}
@Riverpod(keepAlive: true)
class MitraAuth extends _$MitraAuth {
FirebaseAuth get _auth => FirebaseAuth.instance;
final _storage = TokenStorage();
ApiClient get _apiClient => ref.read(apiClientProvider);
ConfirmationResult? _webConfirmationResult;
AuthBridge get _bridge => ref.read(authBridgeProvider);
@override
FutureOr<MitraAuthData> build() async {
if (_auth.currentUser != null) {
return await _verifyAndReturn();
_bridge.refresh = _refreshForBridge;
_bridge.onUnauthenticated = () {
_bridge.clear();
state = const AsyncData(MitraAuthInitialData());
};
final profile = await _refreshFromStorage();
if (profile != null) {
return MitraAuthAuthenticatedData(profile);
}
return const MitraAuthInitialData(); // FIX: was missing in BLoC version
return const MitraAuthInitialData();
}
Future<void> requestOtp(String phone) async {
state = const AsyncLoading();
if (kIsWeb) {
try {
final confirmationResult = await _auth.signInWithPhoneNumber(phone);
_webConfirmationResult = confirmationResult;
state = const AsyncData(MitraAuthOtpSentData('web'));
} catch (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
}
} else {
final completer = Completer<void>();
await _auth.verifyPhoneNumber(
phoneNumber: phone,
verificationCompleted: (credential) async {
try {
await _auth.signInWithCredential(credential);
state = AsyncData(await _verifyAndReturn());
} catch (_) {}
if (!completer.isCompleted) completer.complete();
},
verificationFailed: (e) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
if (!completer.isCompleted) completer.complete();
},
codeSent: (verificationId, _) {
state = AsyncData(MitraAuthOtpSentData(verificationId));
if (!completer.isCompleted) completer.complete();
},
codeAutoRetrievalTimeout: (_) {
if (!completer.isCompleted) completer.complete();
},
/// Reads the stored refresh token, rotates it against the backend, and
/// returns the fresh profile. Also updates the [AuthBridge] access token
/// and persists the new refresh token. Returns null if no token is stored
/// or the refresh call fails (refresh expired, revoked, etc).
Future<Map<String, dynamic>?> _refreshFromStorage() async {
final refreshToken = await _storage.readRefreshToken();
if (refreshToken == null) return null;
try {
final response = await _apiClient.postRaw(
'/api/shared/auth/refresh',
data: {'refresh_token': refreshToken},
skipAuth: true,
);
await completer.future;
final data = response['data'] as Map<String, dynamic>;
final newAccess = data['access_token'] as String;
final newRefresh = data['refresh_token'] as String;
final profile = data['profile'] as Map<String, dynamic>;
await _storage.writeRefreshToken(newRefresh);
_bridge.setAccessToken(newAccess);
// Sync notifier state if it was already authenticated (e.g. mid-session
// 401 refresh). On initial bootstrap, build() sets state from our return.
final current = state.valueOrNull;
if (current is MitraAuthAuthenticatedData) {
state = AsyncData(MitraAuthAuthenticatedData(profile));
}
return profile;
} catch (_) {
await _storage.clear();
_bridge.clear();
final current = state.valueOrNull;
if (current is MitraAuthAuthenticatedData) {
state = const AsyncData(MitraAuthInitialData());
}
return null;
}
}
Future<void> verifyOtp(String verificationId, String smsCode) async {
/// Thin adapter for [AuthBridge.refresh]: returns the rotated access
/// token (or null on failure) so api_client can retry a 401.
Future<String?> _refreshForBridge() async {
final profile = await _refreshFromStorage();
return profile == null ? null : _bridge.accessToken;
}
Future<void> requestOtp(String phone, {String? channel}) async {
state = const AsyncLoading();
try {
if (kIsWeb && _webConfirmationResult != null) {
await _webConfirmationResult!.confirm(smsCode);
} else if (_auth.currentUser == null) {
// Only sign in if not already signed in via auto-verification
final credential = PhoneAuthProvider.credential(
verificationId: verificationId,
smsCode: smsCode,
);
await _auth.signInWithCredential(credential);
}
state = AsyncData(await _verifyAndReturn());
} on FirebaseAuthException {
state = AsyncError('OTP tidak valid. Coba lagi.', StackTrace.current);
} catch (e) {
state = AsyncError(e.toString().replaceFirst('Exception: ', ''), StackTrace.current);
final response = await _apiClient.postRaw(
'/api/mitra/auth/otp/request',
data: {
'phone': phone,
if (channel != null) 'channel': channel,
},
skipAuth: true,
);
final data = response['data'] as Map<String, dynamic>;
state = AsyncData(MitraAuthOtpSentData(
data['otp_request_id'] as String,
channelUsed: data['channel_used'] as String?,
));
} on DioException catch (e) {
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
}
}
Future<void> verifyOtp(String otpRequestId, String code) async {
state = const AsyncLoading();
try {
final response = await _apiClient.postRaw(
'/api/mitra/auth/otp/verify',
data: {'otp_request_id': otpRequestId, 'code': code},
skipAuth: true,
);
final data = response['data'] as Map<String, dynamic>;
final accessToken = data['access_token'] as String;
final refreshToken = data['refresh_token'] as String;
final profile = data['profile'] as Map<String, dynamic>;
await _storage.writeRefreshToken(refreshToken);
_bridge.setAccessToken(accessToken);
state = AsyncData(MitraAuthAuthenticatedData(profile));
} on DioException catch (e) {
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
} catch (_) {
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
}
}
Future<void> logout() async {
await _auth.signOut();
// Best-effort server-side revocation; local clear always runs.
try {
await _apiClient.post('/api/shared/auth/logout');
} catch (_) {}
await _storage.clear();
_bridge.clear();
state = const AsyncData(MitraAuthInitialData());
}
Future<MitraAuthData> _verifyAndReturn() async {
try {
final response = await _apiClient.post('/api/mitra/auth/verify');
return MitraAuthAuthenticatedData(response['data'] as Map<String, dynamic>);
} on DioException catch (e) {
await _auth.signOut();
final code = e.response?.data?['error']?['code'] as String?;
if (code == 'ACCOUNT_NOT_FOUND' || e.response?.statusCode == 404) {
throw Exception('Akun tidak ditemukan. Hubungi administrator.');
} else if (code == 'ACCOUNT_INACTIVE' || e.response?.statusCode == 403) {
throw Exception('Akun tidak aktif. Hubungi administrator.');
}
throw Exception('Gagal masuk. Coba lagi.');
} on Exception {
await _auth.signOut();
throw Exception('Gagal masuk. Coba lagi.');
String _otpRequestMessage(DioException e) {
final code = e.response?.data?['error']?['code'] as String?;
switch (code) {
case 'PHONE_INVALID':
return 'Nomor HP tidak valid.';
case 'OTP_COOLDOWN':
return e.response?.data?['error']?['message'] as String? ??
'Tunggu sebentar sebelum minta OTP lagi.';
case 'OTP_RATE_LIMIT_PHONE':
case 'OTP_RATE_LIMIT_IP':
return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
default:
return 'Gagal mengirim OTP. Coba lagi.';
}
}
String _otpVerifyMessage(DioException e) {
final code = e.response?.data?['error']?['code'] as String?;
switch (code) {
case 'ACCOUNT_INACTIVE':
return 'Akun tidak aktif. Hubungi administrator.';
case 'WRONG_FLOW':
return 'OTP tidak valid untuk login mitra.';
case 'CODE_MISMATCH':
case 'CODE_INVALID':
return 'Kode OTP salah.';
case 'OTP_EXPIRED':
return 'Kode OTP kedaluwarsa. Minta kode baru.';
case 'OTP_USED':
return 'Kode OTP sudah digunakan.';
case 'OTP_ATTEMPTS_EXCEEDED':
return 'Terlalu banyak percobaan. Minta kode baru.';
default:
return 'Gagal verifikasi. Coba lagi.';
}
}
}

View File

@@ -6,7 +6,7 @@ part of 'auth_notifier.dart';
// RiverpodGenerator
// **************************************************************************
String _$mitraAuthHash() => r'ddb09225b47b4e7683c9f8ad46abc21d9fb7a37b';
String _$mitraAuthHash() => r'c353c4b6cc0335c6276baa4029e361f4ec3b4a36';
/// See also [MitraAuth].
@ProviderFor(MitraAuth)

View File

@@ -0,0 +1,17 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStorage {
static const _refreshKey = 'mitra_refresh_token';
final _storage = const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
Future<String?> readRefreshToken() => _storage.read(key: _refreshKey);
Future<void> writeRefreshToken(String token) =>
_storage.write(key: _refreshKey, value: token);
Future<void> clear() => _storage.delete(key: _refreshKey);
}