Main coves client
1import 'dart:async';
2
3import 'package:dio/dio.dart';
4import 'package:flutter/foundation.dart';
5import 'package:flutter_secure_storage/flutter_secure_storage.dart';
6import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
7
8import '../config/environment_config.dart';
9import '../config/oauth_config.dart';
10import '../models/coves_session.dart';
11
12/// Coves Authentication Service
13///
14/// Simplified OAuth service that uses the Coves backend's mobile OAuth flow.
15/// The backend handles all the complexity:
16/// - PKCE generation
17/// - DPoP key management
18/// - Token exchange with PDS
19/// - Token sealing (AES-256-GCM encryption)
20/// - CSRF protection
21///
22/// This client just needs to:
23/// 1. Open browser to backend's /oauth/mobile/login
24/// 2. Receive sealed token via Universal Link / custom scheme
25/// 3. Store and use the sealed token
26/// 4. Call /oauth/refresh when needed
27/// 5. Call /oauth/logout to sign out
28class CovesAuthService {
29 factory CovesAuthService({Dio? dio, FlutterSecureStorage? storage}) {
30 _instance ??= CovesAuthService._internal(dio: dio, storage: storage);
31 return _instance!;
32 }
33
34 CovesAuthService._internal({Dio? dio, FlutterSecureStorage? storage})
35 : _storage =
36 storage ??
37 const FlutterSecureStorage(
38 aOptions: AndroidOptions(encryptedSharedPreferences: true),
39 iOptions: IOSOptions(
40 accessibility: KeychainAccessibility.first_unlock,
41 ),
42 ) {
43 // Initialize Dio if provided, otherwise it will be initialized in initialize()
44 if (dio != null) {
45 _dio = dio;
46 }
47 }
48
49 static CovesAuthService? _instance;
50
51 /// Reset the singleton instance (for testing only)
52 @visibleForTesting
53 static void resetInstance() {
54 _instance = null;
55 }
56
57 /// Create a new instance for testing with injected dependencies
58 @visibleForTesting
59 static CovesAuthService createTestInstance({
60 required Dio dio,
61 required FlutterSecureStorage storage,
62 }) {
63 return CovesAuthService._internal(dio: dio, storage: storage);
64 }
65
66 // Secure storage for session data
67 final FlutterSecureStorage _storage;
68
69 // Storage key is namespaced per environment to prevent token reuse across dev/prod
70 // This ensures switching between builds doesn't send prod tokens to dev servers
71 String get _storageKey =>
72 'coves_session_${EnvironmentConfig.current.environment.name}';
73
74 // HTTP client for API calls
75 late final Dio _dio;
76
77 // Current session (cached in memory)
78 CovesSession? _session;
79
80 // Completer to track in-flight token refresh operations
81 // Ensures only one refresh happens at a time, even with concurrent calls
82 Completer<CovesSession>? _refreshCompleter;
83
84 /// Get the current session (if any)
85 CovesSession? get session => _session;
86
87 /// Check if user is authenticated
88 bool get isAuthenticated => _session != null;
89
90 /// Initialize the auth service
91 Future<void> initialize() async {
92 // Set up Dio with base URL if not already provided (e.g., for testing)
93 // This check is necessary because _dio is late final
94 try {
95 // Try to access _dio - if it's already initialized, this won't throw
96 _dio.options;
97 } catch (_) {
98 // Not initialized yet, so initialize it now
99 _dio = Dio(
100 BaseOptions(
101 baseUrl: EnvironmentConfig.current.apiUrl,
102 connectTimeout: const Duration(seconds: 30),
103 receiveTimeout: const Duration(seconds: 30),
104 ),
105 );
106 }
107
108 if (kDebugMode) {
109 print('CovesAuthService initialized');
110 print(' API URL: ${EnvironmentConfig.current.apiUrl}');
111 print(' Redirect URI: ${OAuthConfig.redirectUri}');
112 }
113 }
114
115 /// Sign in with an atProto handle
116 ///
117 /// Opens the system browser to the backend's mobile OAuth endpoint.
118 /// The backend handles the complete OAuth flow with the user's PDS.
119 /// On success, redirects back to the app with sealed token parameters.
120 ///
121 /// Returns the new session on success.
122 /// Throws on error or user cancellation.
123 Future<CovesSession> signIn(String handle) async {
124 try {
125 final normalizedHandle = validateAndNormalizeHandle(handle);
126
127 if (kDebugMode) {
128 print('Starting sign-in for: $normalizedHandle');
129 }
130
131 // Build the OAuth login URL
132 final loginUrl = _buildLoginUrl(normalizedHandle);
133
134 if (kDebugMode) {
135 print('Opening browser: $loginUrl');
136 print('Callback scheme: ${OAuthConfig.callbackScheme}');
137 }
138
139 // Open browser for OAuth flow
140 // Backend redirects to custom scheme: social.coves:/callback
141 final resultUrl = await FlutterWebAuth2.authenticate(
142 url: loginUrl,
143 callbackUrlScheme: OAuthConfig.callbackScheme,
144 options: const FlutterWebAuth2Options(
145 preferEphemeral: true, // Don't persist browser session
146 timeout: 300, // 5 minutes
147 ),
148 );
149
150 if (kDebugMode) {
151 final redactedUrl = _redactSensitiveParams(resultUrl);
152 print('Received callback URL: $redactedUrl');
153 }
154
155 // Parse the callback URL to extract session data
156 final callbackUri = Uri.parse(resultUrl);
157 final session = CovesSession.fromCallbackUri(callbackUri);
158
159 if (kDebugMode) {
160 print('Session created: $session');
161 }
162
163 // Store the session securely
164 await _saveSession(session);
165
166 // Cache in memory
167 _session = session;
168
169 if (kDebugMode) {
170 print('Sign-in successful!');
171 print(' DID: ${session.did}');
172 print(' Handle: ${session.handle}');
173 }
174
175 return session;
176 } on Exception catch (e) {
177 if (kDebugMode) {
178 print('Sign-in failed: $e');
179 }
180
181 // Check for user cancellation
182 if (e.toString().contains('CANCELED') ||
183 e.toString().contains('cancelled')) {
184 throw Exception('Sign in cancelled by user');
185 }
186
187 throw Exception('Sign in failed: $e');
188 }
189 }
190
191 /// Restore a previous session from secure storage
192 ///
193 /// Returns the session if found and valid, null otherwise.
194 Future<CovesSession?> restoreSession() async {
195 try {
196 final jsonString = await _storage.read(key: _storageKey);
197
198 if (jsonString == null) {
199 if (kDebugMode) {
200 print('No stored session found');
201 }
202 return null;
203 }
204
205 final session = CovesSession.fromJsonString(jsonString);
206
207 if (kDebugMode) {
208 print('Session restored: $session');
209 }
210
211 // Cache in memory
212 _session = session;
213
214 return session;
215 } catch (e) {
216 // Catch all errors including TypeError from malformed JSON
217 if (kDebugMode) {
218 print('Failed to restore session: $e');
219 }
220
221 // Clear corrupted data
222 await _storage.delete(key: _storageKey);
223 return null;
224 }
225 }
226
227 /// Refresh the current session token
228 ///
229 /// Calls the backend's /oauth/refresh endpoint to get a new sealed token.
230 /// The backend handles the actual token refresh with the PDS.
231 ///
232 /// Uses a mutex pattern to ensure only one refresh operation is in-flight
233 /// at a time. If multiple callers request a refresh simultaneously, they
234 /// will all wait for and receive the same refreshed session.
235 ///
236 /// Returns the updated session on success.
237 /// Throws on error (caller should handle by signing out).
238 Future<CovesSession> refreshToken() async {
239 if (_session == null) {
240 throw StateError('No session to refresh');
241 }
242
243 // If a refresh is already in progress, wait for it and return its result
244 if (_refreshCompleter != null) {
245 if (kDebugMode) {
246 print('Token refresh already in progress, waiting...');
247 }
248 return _refreshCompleter!.future;
249 }
250
251 // Start a new refresh operation
252 _refreshCompleter = Completer<CovesSession>();
253
254 try {
255 if (kDebugMode) {
256 print('Refreshing token...');
257 }
258
259 // Build request body per backend API contract
260 // Backend expects: {"did": "...", "session_id": "...", "sealed_token": "..."}
261 final requestBody = {
262 'did': _session!.did,
263 'session_id': _session!.sessionId,
264 'sealed_token': _session!.token,
265 };
266
267 final response = await _dio.post<Map<String, dynamic>>(
268 '/oauth/refresh',
269 data: requestBody,
270 );
271
272 // Backend returns: {"sealed_token": "...", "access_token": "..."}
273 // We use the new sealed_token (which already contains everything we need)
274 final newToken = response.data?['sealed_token'] as String?;
275
276 if (newToken == null || newToken.isEmpty) {
277 throw Exception('Invalid refresh response: missing sealed_token');
278 }
279
280 // Create updated session with new token
281 final updatedSession = _session!.copyWithToken(newToken);
282
283 // Save and cache
284 await _saveSession(updatedSession);
285 _session = updatedSession;
286
287 if (kDebugMode) {
288 print('Token refreshed successfully');
289 }
290
291 // Complete the future with the updated session
292 _refreshCompleter!.complete(updatedSession);
293 return updatedSession;
294 } on DioException catch (e) {
295 if (kDebugMode) {
296 print('Token refresh failed: ${e.message}');
297 print('Status code: ${e.response?.statusCode}');
298 }
299
300 // 401 means session is invalid/expired - caller should sign out
301 if (e.response?.statusCode == 401) {
302 final error = Exception('Session expired');
303 _refreshCompleter!.completeError(error);
304 // Return the future to rethrow the error (don't throw directly)
305 return _refreshCompleter!.future;
306 }
307
308 final error = Exception('Token refresh failed: ${e.message}');
309 _refreshCompleter!.completeError(error);
310 // Return the future to rethrow the error (don't throw directly)
311 return _refreshCompleter!.future;
312 } catch (e) {
313 // Catch any other errors and propagate them to all waiters
314 _refreshCompleter!.completeError(e);
315 // Return the future to rethrow the error (don't rethrow directly)
316 return _refreshCompleter!.future;
317 } finally {
318 // Clear the completer so future calls can start a new refresh
319 _refreshCompleter = null;
320 }
321 }
322
323 /// Sign out and revoke the session
324 ///
325 /// Calls the backend's /oauth/logout endpoint to revoke the session.
326 /// The backend handles token revocation with the PDS.
327 /// Always clears local storage even if server call fails.
328 Future<void> signOut() async {
329 try {
330 if (_session != null) {
331 if (kDebugMode) {
332 print('Signing out...');
333 }
334
335 // Best-effort server-side revocation
336 try {
337 await _dio.post<void>(
338 '/oauth/logout',
339 options: Options(
340 headers: {'Authorization': 'Bearer ${_session!.token}'},
341 ),
342 );
343
344 if (kDebugMode) {
345 print('Server-side logout successful');
346 }
347 } on DioException catch (e) {
348 // Log but don't fail - we still want to clear local state
349 if (kDebugMode) {
350 print('Server-side logout failed: ${e.message}');
351 }
352 }
353 }
354 } finally {
355 // Always clear local state
356 await _clearSession();
357 _session = null;
358
359 if (kDebugMode) {
360 print('Local session cleared');
361 }
362 }
363 }
364
365 /// Get the current access token
366 ///
367 /// Returns the sealed token for use in API requests.
368 /// Returns null if not authenticated.
369 String? getToken() {
370 return _session?.token;
371 }
372
373 /// Validate and normalize an atProto handle or DID
374 ///
375 /// Accepts:
376 /// - Handles: alice.bsky.social, @alice.bsky.social
377 /// - DIDs: did:plc:abc123, did:web:example.com
378 /// - URLs: https://bsky.app/profile/alice.bsky.social (extracts handle)
379 ///
380 /// Returns the normalized handle/DID.
381 /// Throws ArgumentError if invalid.
382 @visibleForTesting
383 String validateAndNormalizeHandle(String handle) {
384 // Trim whitespace
385 var normalized = handle.trim();
386
387 // Check for empty input
388 if (normalized.isEmpty) {
389 throw ArgumentError('Handle cannot be empty');
390 }
391
392 // Extract handle from Bluesky profile URLs
393 // e.g., https://bsky.app/profile/alice.bsky.social -> alice.bsky.social
394 final urlPattern = RegExp(
395 r'^https?://(?:www\.)?bsky\.app/profile/([^/?#]+)',
396 caseSensitive: false,
397 );
398 final urlMatch = urlPattern.firstMatch(normalized);
399 if (urlMatch != null) {
400 normalized = urlMatch.group(1)!;
401 }
402
403 // Strip leading @ if present (common user input)
404 if (normalized.startsWith('@')) {
405 normalized = normalized.substring(1);
406 }
407
408 // Check maximum length (atProto spec: 253 characters for handles)
409 if (normalized.length > 253) {
410 throw ArgumentError(
411 'Handle too long (max 253 characters, got ${normalized.length})',
412 );
413 }
414
415 // Validate DID format
416 if (normalized.startsWith('did:')) {
417 return _validateDid(normalized);
418 }
419
420 // Validate handle format
421 return _validateHandle(normalized);
422 }
423
424 /// Validate a DID (Decentralized Identifier)
425 ///
426 /// Supports:
427 /// - did:plc:abc123
428 /// - did:web:example.com
429 ///
430 /// Throws ArgumentError if invalid.
431 String _validateDid(String did) {
432 // DID format: did:method:identifier
433 // method: lowercase alphanumeric
434 // identifier: method-specific, but generally alphanumeric with some special chars
435 final didPattern = RegExp(r'^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$');
436
437 if (!didPattern.hasMatch(did)) {
438 throw ArgumentError(
439 'Invalid DID format. Expected format: did:method:identifier',
440 );
441 }
442
443 return did;
444 }
445
446 /// Validate a handle (domain name format)
447 ///
448 /// Handles must:
449 /// - Contain only alphanumeric characters, hyphens, and periods
450 /// - Not start or end with a hyphen or period
451 /// - Have at least one period (domain format)
452 /// - Each segment between periods must be valid (1-63 chars)
453 /// - TLD (final segment) cannot start with a digit (per atProto spec)
454 /// - Numeric segments are allowed in all positions except the TLD
455 ///
456 /// Throws ArgumentError if invalid.
457 String _validateHandle(String handle) {
458 // Handle must contain at least one period (domain format)
459 if (!handle.contains('.')) {
460 throw ArgumentError(
461 'Invalid handle format. Handles must be in domain format (e.g., alice.bsky.social)',
462 );
463 }
464
465 // Handle format: alphanumeric, hyphens, and periods only
466 // No leading/trailing hyphens or periods
467 final handlePattern = RegExp(
468 r'^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$',
469 );
470
471 if (!handlePattern.hasMatch(handle)) {
472 throw ArgumentError(
473 'Invalid handle format. Handles can only contain letters, numbers, hyphens, '
474 'and periods. Each segment must start and end with a letter or number.',
475 );
476 }
477
478 // Validate each segment (part between periods)
479 final segments = handle.split('.');
480 for (int i = 0; i < segments.length; i++) {
481 final segment = segments[i];
482 if (segment.isEmpty) {
483 throw ArgumentError('Handle cannot have empty segments');
484 }
485
486 // Each segment must not exceed 63 characters (DNS label limit)
487 if (segment.length > 63) {
488 throw ArgumentError(
489 'Handle segment "$segment" too long (max 63 characters)',
490 );
491 }
492
493 // TLD (last segment) cannot start with a digit (to avoid confusion with IP addresses)
494 // Per atProto spec: numeric segments are allowed in all positions except the TLD
495 if (i == segments.length - 1 && RegExp(r'^\d').hasMatch(segment)) {
496 throw ArgumentError(
497 'Handle TLD (final segment) cannot start with a digit (got: "$segment")',
498 );
499 }
500 }
501
502 return handle.toLowerCase();
503 }
504
505 /// Build the OAuth login URL
506 String _buildLoginUrl(String handle) {
507 final baseUrl = EnvironmentConfig.current.apiUrl;
508 final redirectUri = OAuthConfig.redirectUri;
509
510 return '$baseUrl/oauth/mobile/login'
511 '?handle=${Uri.encodeComponent(handle)}'
512 '&redirect_uri=${Uri.encodeComponent(redirectUri)}';
513 }
514
515 /// Save session to secure storage
516 Future<void> _saveSession(CovesSession session) async {
517 await _storage.write(key: _storageKey, value: session.toJsonString());
518 }
519
520 /// Clear session from secure storage
521 Future<void> _clearSession() async {
522 await _storage.delete(key: _storageKey);
523 }
524
525 /// Redact sensitive parameters from URLs for safe logging
526 ///
527 /// Replaces token values with [REDACTED] to prevent leaking
528 /// sealed tokens in debug logs.
529 ///
530 /// Non-sensitive params like DID, handle, and session_id are preserved
531 /// as they're useful for debugging without being security-sensitive.
532 String _redactSensitiveParams(String url) {
533 // Replace token=xxx with token=[REDACTED]
534 // Matches token= followed by any non-whitespace, non-ampersand characters
535 return url.replaceAllMapped(
536 RegExp(r'token=([^&\s]+)'),
537 (match) => 'token=[REDACTED]',
538 );
539 }
540}