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

@@ -1,5 +1,10 @@
import 'package:dio/dio.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../auth/auth_bridge.dart';
/// Per-request flag: set `options.extra['skipAuth'] = true` to skip
/// token attachment + 401-refresh retry. Used by auth endpoints themselves
/// (login/refresh/OTP) to avoid recursion.
const _skipAuthKey = 'skipAuth';
class ApiClient {
static const String baseUrl = String.fromEnvironment(
@@ -7,19 +12,45 @@ class ApiClient {
defaultValue: 'https://api.halobestie.com',
);
final AuthBridge _bridge;
late final Dio _dio;
ApiClient() {
ApiClient(this._bridge) {
_dio = Dio(BaseOptions(baseUrl: baseUrl));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
final token = await user.getIdToken();
options.headers['Authorization'] = 'Bearer $token';
onRequest: (options, handler) {
if (options.extra[_skipAuthKey] != true) {
final token = _bridge.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
}
handler.next(options);
},
onError: (err, handler) async {
final req = err.requestOptions;
final is401 = err.response?.statusCode == 401;
final alreadyRetried = req.extra['_retry'] == true;
final skipAuth = req.extra[_skipAuthKey] == true;
if (is401 && !alreadyRetried && !skipAuth) {
final newToken = await _bridge.tryRefresh();
if (newToken != null) {
req.extra['_retry'] = true;
req.headers['Authorization'] = 'Bearer $newToken';
try {
final response = await _dio.fetch(req);
return handler.resolve(response);
} catch (e) {
if (e is DioException) return handler.next(e);
rethrow;
}
} else {
_bridge.notifyUnauthenticated();
}
}
handler.next(err);
},
));
}
@@ -37,4 +68,20 @@ class ApiClient {
final response = await _dio.patch(path, data: data);
return response.data as Map<String, dynamic>;
}
/// Raw POST with per-call control over auth attachment. Used by the auth
/// notifier for OTP / refresh endpoints where attaching a (possibly stale)
/// access token — or triggering a 401-refresh loop — would be wrong.
Future<Map<String, dynamic>> postRaw(
String path, {
Map<String, dynamic>? data,
bool skipAuth = false,
}) async {
final response = await _dio.post(
path,
data: data,
options: Options(extra: {_skipAuthKey: skipAuth}),
);
return response.data as Map<String, dynamic>;
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../auth/auth_bridge.dart';
import 'api_client.dart';
part 'api_client_provider.g.dart';
@Riverpod(keepAlive: true)
ApiClient apiClient(Ref ref) => ApiClient();
ApiClient apiClient(Ref ref) => ApiClient(ref.read(authBridgeProvider));

View File

@@ -6,7 +6,7 @@ part of 'api_client_provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
/// See also [apiClient].
@ProviderFor(apiClient)