Phase 3.4: client_app self-managed auth cutover
Rips firebase_auth; auth talks directly to the new backend endpoints. Anonymous-first + phone OTP work end-to-end; Google/Apple SDKs are kept but buttons are hidden behind ENABLE_SOCIAL_AUTH until backend OAuth credentials are provisioned. Smoke-tested against the backend via curl: - anonymous → PATCH display_name → /me - OTP request (read stub code from backend log) → verify with anonymous_customer_id → same customer row preserved, display_name preserved, phone added → upgrade confirmed - refresh rotation + logout → post-logout refresh correctly fails REFRESH_INVALID - Debug APK builds clean - pubspec: drop firebase_auth; add flutter_secure_storage - core/auth/auth_bridge.dart: shared mutable state (access token + refresh callback + in-flight de-dup) — keepAlive provider - core/auth/token_storage.dart: flutter_secure_storage wrapper (customer_refresh_token key) - core/auth/social_auth_enabled.dart: const flag from --dart-define=ENABLE_SOCIAL_AUTH (default false) - core/auth/auth_notifier.dart: bootstrap via stored refresh; anonymous via /api/shared/auth/anonymous + PATCH display_name; phone OTP via /api/client/auth/*; Google + Apple wired (passes anonymous_customer_id for upgrade); anonymity config check for ForceRegister state; granular error-code mapping - core/api/api_client.dart: Bearer from bridge + postRaw(skipAuth) for auth endpoints + single-retry 401 refresh - core/chat/chat_notifier.dart + core/pairing/pairing_notifier.dart: WS auth frame reads bridge.accessToken - features/auth/screens/otp_screen.dart: verificationId → otpRequestId - features/auth/screens/register_screen.dart + force_register_screen.dart: Google/Apple buttons gated behind kSocialAuthEnabled; force_register drops obsolete linkAccount() (upgrade happens server-side now via anonymous_customer_id) - client_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:
@@ -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
|
||||
/// (anonymous/refresh/OTP/Google/Apple) to avoid recursion.
|
||||
const _skipAuthKey = 'skipAuth';
|
||||
|
||||
class ApiClient {
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
@@ -7,25 +12,46 @@ 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;
|
||||
|
||||
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
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);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
@@ -33,8 +59,30 @@ class ApiClient {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> post(String path, {Map<String, dynamic>? data}) async {
|
||||
final response = await _dio.post(path, data: data);
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> patch(String path, {Map<String, dynamic>? data}) async {
|
||||
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 anonymous / refresh / OTP / Google / Apple endpoints where
|
||||
/// attaching a 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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -6,7 +6,7 @@ part of 'api_client_provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$apiClientHash() => r'90c807f03b90249684265cc91739139c2c89eeb9';
|
||||
String _$apiClientHash() => r'c0ade0ab10bbe40a30a4e5b17ea8c8c27517f502';
|
||||
|
||||
/// See also [apiClient].
|
||||
@ProviderFor(apiClient)
|
||||
|
||||
Reference in New Issue
Block a user