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