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({
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}