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}