1# OAuth Implementation Guide for Coves Flutter 2 3## Overview 4 5This document outlines the OAuth implementation for Coves, a forum-like atProto social media platform. We're using the `atproto_oauth` package (v0.1.0) to authenticate users against their Personal Data Servers (PDS) in the decentralized atProto network. 6 7## ⚠️ Important: Decentralized Authentication 8 9**atProto is a decentralized protocol** - users can be on ANY Personal Data Server (PDS), not just bsky.social! 10 11### How Handle Resolution Works 12 13The OAuth flow must support users from any PDS: 14 151. **Handle Resolver** (`service: 'bsky.social'` parameter) 16 - This is a service that can resolve ANY atProto handle to a DID 17 - Example: `alice.pds.example.com``did:plc:abc123` 18 - Bluesky provides a public resolver that works for all atProto handles 19 - **This is NOT the authorization server!** 20 212. **PDS Discovery** (automatic via DID document) 22 - User's DID document contains their PDS URL 23 - Fetch `did:plc:abc123` → Find PDS endpoint in service array 24 - Example: `https://alice-pds.example.com` 25 263. **OAuth Authorization Server Discovery** (automatic) 27 - Each PDS has its own OAuth server 28 - Discovered from: `https://alice-pds.example.com/.well-known/oauth-authorization-server` 29 - **Users are redirected to THEIR PDS's auth server, not always bsky.social** 30 31### Testing Decentralization 32 33When testing sign-in, check the debug logs: 34 35```dart 36🔍 OAuth Authorization URL: https://example-pds.com/oauth/authorize?... 37🔍 Authorization server host: example-pds.com 38``` 39 40**Correct**: `authUrl.host` matches the user's PDS 41**Wrong**: `authUrl.host` is always `bsky.app` regardless of handle 42 43If users on different PDSes can sign in (not just bsky.social users), then decentralization is working correctly! 44 45--- 46 47## ✅ What's Already Been Completed 48 49### 1. Project Setup & Dependencies 50 51**File:** [`pubspec.yaml`](./pubspec.yaml) 52 53All required OAuth packages are installed and configured: 54-`atproto_oauth: ^0.1.0` - Official atProto OAuth client 55-`flutter_web_auth_2: ^4.1.0` - Browser-based OAuth flow 56-`flutter_secure_storage: ^9.2.2` - Encrypted token storage 57-`go_router: ^16.3.0` - Navigation with deep linking support 58-`provider: ^6.1.5+1` - State management 59 60### 2. OAuth Configuration 61 62**File:** [`lib/config/oauth_config.dart`](./lib/config/oauth_config.dart) 63 64Complete OAuth configuration matching your Cloudflare Worker setup: 65 66```dart 67class OAuthConfig { 68 // OAuth Server (Cloudflare Worker) 69 static const String oauthServerUrl = 70 'https://lingering-darkness-50a6.brettmay0212.workers.dev'; 71 72 // Custom URL scheme for deep linking 73 static const String customScheme = 74 'dev.workers.brettmay0212.lingering-darkness-50a6'; 75 76 // Client metadata URL (hosted on your Cloudflare Worker) 77 static const String clientId = '$oauthServerUrl/client-metadata.json'; 78 79 // OAuth callback URL 80 static const String redirectUri = '$oauthServerUrl/oauth/callback'; 81 82 // atProto scopes 83 static const String scope = 'atproto transition:generic'; 84 85 // Handle resolver (uses Bluesky's resolver) 86 static const String handleResolver = 'https://bsky.social'; 87} 88``` 89 90**Key Points:** 91- ✅ All URLs point to your Cloudflare Worker 92- ✅ Client metadata is hosted at `/client-metadata.json` 93- ✅ Custom scheme matches your app configuration 94- ✅ Scopes allow full atProto access 95 96### 3. OAuth Service Foundation 97 98**File:** [`lib/services/oauth_service.dart`](./lib/services/oauth_service.dart) 99 100OAuth service skeleton with proper architecture: 101 102```dart 103class OAuthService { 104 OAuthClient? _client; 105 final _storage = const FlutterSecureStorage(); 106 107 // Storage keys - properly named for atProto decentralization 108 static const _keyAccessToken = 'atproto_oauth_access_token'; 109 static const _keyRefreshToken = 'atproto_oauth_refresh_token'; 110 static const _keyDid = 'atproto_did'; 111 static const _keyHandle = 'atproto_handle'; 112 113 Future<void> initialize() async { 114 // Fetches client metadata from Cloudflare Worker 115 final metadata = await getClientMetadata(OAuthConfig.clientId); 116 _client = OAuthClient(metadata, service: 'bsky.social'); 117 } 118 119 // Methods ready for implementation: 120 Future<OAuthSession> signIn(String handle) async { /* ... */ } 121 Future<OAuthSession?> restoreSession() async { /* ... */ } 122 Future<void> signOut() async { /* ... */ } 123} 124``` 125 126**What's Ready:** 127- ✅ Singleton pattern for service 128- ✅ Client initialization with metadata fetching 129- ✅ Secure storage using `flutter_secure_storage` 130- ✅ Storage keys properly named for atProto (not app-specific) 131- ✅ Session management methods scaffolded 132 133**Important Design Decision:** 134Storage keys use `atproto_*` prefix instead of `coves_*` to reflect that credentials belong to the user's PDS, not to Coves. This follows the decentralized architecture principle where the user owns their identity. 135 136### 4. Authentication State Management 137 138**File:** [`lib/providers/auth_provider.dart`](./lib/providers/auth_provider.dart) 139 140Complete auth provider using `ChangeNotifier`: 141 142```dart 143class AuthProvider with ChangeNotifier { 144 final OAuthService _oauthService = OAuthService(); 145 146 // State 147 OAuthSession? _session; 148 bool _isAuthenticated = false; 149 bool _isLoading = true; 150 String? _error; 151 String? _did; 152 String? _handle; 153 154 // Methods 155 Future<void> initialize() async { /* Restores session */ } 156 Future<void> signIn(String handle) async { /* OAuth flow */ } 157 Future<void> signOut() async { /* Revokes & clears */ } 158 void clearError() { /* Error handling */ } 159} 160``` 161 162**What's Ready:** 163- ✅ Reactive state management with `ChangeNotifier` 164- ✅ Loading states for UI feedback 165- ✅ Error handling 166- ✅ Session persistence 167- ✅ Integrated with `OAuthService` 168 169### 5. Android Deep Link Configuration 170 171**File:** [`android/app/src/main/AndroidManifest.xml`](./android/app/src/main/AndroidManifest.xml) 172 173Deep links configured for OAuth callbacks: 174 175```xml 176<!-- HTTPS deep link for OAuth callback --> 177<intent-filter android:autoVerify="true"> 178 <action android:name="android.intent.action.VIEW"/> 179 <category android:name="android.intent.category.DEFAULT"/> 180 <category android:name="android.intent.category.BROWSABLE"/> 181 182 <data 183 android:scheme="https" 184 android:host="lingering-darkness-50a6.brettmay0212.workers.dev" 185 android:pathPrefix="/oauth/callback"/> 186</intent-filter> 187 188<!-- Custom scheme fallback --> 189<intent-filter> 190 <action android:name="android.intent.action.VIEW"/> 191 <category android:name="android.intent.category.DEFAULT"/> 192 <category android:name="android.intent.category.BROWSABLE"/> 193 194 <data android:scheme="dev.workers.brettmay0212.lingering-darkness-50a6"/> 195</intent-filter> 196``` 197 198**What's Ready:** 199- ✅ HTTPS deep links (preferred on Android) 200- ✅ Custom scheme fallback 201- ✅ Auto-verify for App Links 202- ✅ Matches OAuth redirect URIs 203 204### 6. Login UI 205 206**File:** [`lib/screens/auth/login_screen.dart`](./lib/screens/auth/login_screen.dart) 207 208Professional login screen with: 209- ✅ Handle input with validation 210- ✅ Loading states 211- ✅ Error handling with SnackBar 212- ✅ Help dialog explaining handles 213- ✅ Integration with AuthProvider 214- ✅ Navigation to feed on success 215 216### 7. App-Level Integration 217 218**File:** [`lib/main.dart`](./lib/main.dart) 219 220Auth provider wrapped around entire app: 221 222```dart 223void main() async { 224 WidgetsFlutterBinding.ensureInitialized(); 225 226 final authProvider = AuthProvider(); 227 await authProvider.initialize(); 228 229 runApp( 230 ChangeNotifierProvider.value( 231 value: authProvider, 232 child: const CovesApp(), 233 ), 234 ); 235} 236``` 237 238**What's Ready:** 239- ✅ Provider initialization before app starts 240- ✅ Session restoration on app launch 241- ✅ Global state access via `Provider.of<AuthProvider>(context)` 242 243--- 244 245## 🚧 What Needs to Be Implemented 246 247### Phase 1: Complete OAuth Flow (High Priority) 248 249#### 1.1 Implement `signIn()` Method 250 251**File to Update:** [`lib/services/oauth_service.dart`](./lib/services/oauth_service.dart) 252 253**Current Status:** Method stub exists but returns `UnimplementedError` 254 255**What to Implement:** 256 257```dart 258Future<OAuthSession> signIn(String handle) async { 259 try { 260 if (_client == null) { 261 throw Exception('OAuth client not initialized'); 262 } 263 264 // Step 1: Use atproto_oauth to resolve handle and build auth URL 265 // The package handles: 266 // - DID resolution from handle 267 // - Finding the user's authorization server 268 // - Generating PKCE challenge/verifier 269 // - Building PAR (Pushed Authorization Request) 270 // - Generating DPoP keys 271 272 final authRequest = await _client!.authorize( 273 identifier: handle, 274 // The package will use the client metadata we fetched 275 ); 276 277 // Step 2: Open browser for user authorization 278 // This opens the user's PDS authorization page 279 final callbackUrl = await FlutterWebAuth2.authenticate( 280 url: authRequest.authorizationUrl.toString(), 281 callbackUrlScheme: OAuthConfig.customScheme, 282 ); 283 284 // Step 3: Extract authorization code from callback 285 final uri = Uri.parse(callbackUrl); 286 final code = uri.queryParameters['code']; 287 final state = uri.queryParameters['state']; 288 289 if (code == null) { 290 throw Exception('No authorization code received'); 291 } 292 293 // Step 4: Exchange code for tokens with DPoP 294 // The package handles: 295 // - Token exchange request 296 // - DPoP proof generation 297 // - Token validation 298 final session = await _client!.callback( 299 uri: uri, 300 // Package manages PKCE and state internally 301 ); 302 303 // Step 5: Extract and store session data 304 final tokens = OAuthSession( 305 accessToken: session.accessToken, 306 refreshToken: session.refreshToken, 307 did: session.sub, // User's DID 308 handle: handle, 309 ); 310 311 await _storeSession(tokens); 312 313 return tokens; 314 } catch (e) { 315 print('Sign in failed: $e'); 316 rethrow; 317 } 318} 319``` 320 321**Key Implementation Notes:** 322- Use `atproto_oauth`'s built-in methods for authorization flow 323- The package handles complex atProto specifics (DPoP, PKCE, PAR) 324- Store DID (not just handle) as the canonical user identifier 325- Handle browser cancellation gracefully 326 327**References:** 328- [atproto_oauth package docs](https://pub.dev/packages/atproto_oauth) 329- [flutter_web_auth_2 docs](https://pub.dev/packages/flutter_web_auth_2) 330 331#### 1.2 Implement `restoreSession()` Method 332 333**What to Implement:** 334 335```dart 336Future<OAuthSession?> restoreSession() async { 337 try { 338 final did = await _storage.read(key: _keyDid); 339 340 if (did == null) { 341 return null; // No stored session 342 } 343 344 if (_client == null) { 345 throw Exception('OAuth client not initialized'); 346 } 347 348 // Check if we have a valid session for this DID 349 // The atproto_oauth package manages session storage internally 350 // We may need to use their session restoration methods 351 352 final session = await _client!.restore(did); 353 354 if (session != null) { 355 // Session still valid, return it 356 final handle = await _storage.read(key: _keyHandle); 357 358 return OAuthSession( 359 accessToken: session.accessToken, 360 refreshToken: session.refreshToken, 361 did: did, 362 handle: handle ?? '', 363 ); 364 } 365 366 // Session expired, try to refresh 367 final refreshToken = await _storage.read(key: _keyRefreshToken); 368 369 if (refreshToken != null) { 370 final newSession = await _refreshSession(did, refreshToken); 371 await _storeSession(newSession); 372 return newSession; 373 } 374 375 // No valid session, user needs to log in again 376 await _clearSession(); 377 return null; 378 379 } catch (e) { 380 print('Failed to restore session: $e'); 381 await _clearSession(); 382 return null; 383 } 384} 385``` 386 387**Key Implementation Notes:** 388- Always validate stored sessions before using them 389- Attempt token refresh if access token expired but refresh token valid 390- Clear invalid sessions to prevent auth loops 391- The `atproto_oauth` package may handle session restoration internally 392 393#### 1.3 Implement Token Refresh 394 395**What to Implement:** 396 397```dart 398Future<OAuthSession> _refreshSession(String did, String refreshToken) async { 399 if (_client == null) { 400 throw Exception('OAuth client not initialized'); 401 } 402 403 // Use atproto_oauth's refresh method 404 final newSession = await _client!.refresh( 405 refreshToken: refreshToken, 406 // Package handles DPoP proof for refresh 407 ); 408 409 final handle = await _storage.read(key: _keyHandle); 410 411 return OAuthSession( 412 accessToken: newSession.accessToken, 413 refreshToken: newSession.refreshToken ?? refreshToken, 414 did: did, 415 handle: handle ?? '', 416 ); 417} 418``` 419 420#### 1.4 Implement `signOut()` Method 421 422**What to Implement:** 423 424```dart 425Future<void> signOut() async { 426 try { 427 final refreshToken = await _storage.read(key: _keyRefreshToken); 428 final did = await _storage.read(key: _keyDid); 429 430 // Revoke tokens on the authorization server 431 if (_client != null && refreshToken != null) { 432 try { 433 await _client!.revoke( 434 token: refreshToken, 435 // May need DID or other params 436 ); 437 } catch (e) { 438 print('Token revocation failed (continuing with local logout): $e'); 439 // Continue even if revocation fails (network issues, etc.) 440 } 441 } 442 443 // Clear local session 444 await _clearSession(); 445 446 } catch (e) { 447 print('Sign out failed: $e'); 448 // Always clear local session even if errors occur 449 await _clearSession(); 450 } 451} 452``` 453 454**Key Implementation Notes:** 455- Always attempt to revoke tokens on server 456- Don't fail if revocation fails (might be offline) 457- Always clear local storage 458 459--- 460 461### Phase 2: API Integration (Medium Priority) 462 463#### 2.1 Create atProto API Client 464 465**File to Create:** `lib/services/atproto_api_service.dart` 466 467The `atproto_oauth` package provides an `OAuthSession` that can be used with the `@atproto/api` equivalent for Dart. You'll need to create an API service that uses the authenticated session. 468 469**What to Implement:** 470 471```dart 472import 'package:atproto/atproto.dart'; // If available 473import 'oauth_service.dart'; 474 475class AtProtoApiService { 476 final OAuthService _oauthService; 477 478 AtProtoApiService(this._oauthService); 479 480 /// Create an authenticated API client 481 Future<ATProto?> getClient() async { 482 final session = await _oauthService.restoreSession(); 483 484 if (session == null) { 485 return null; 486 } 487 488 // Create API client with session 489 // The exact API depends on available Dart atProto packages 490 return ATProto( 491 service: session.pdsUrl, // User's PDS URL 492 session: Session( 493 accessJwt: session.accessToken, 494 refreshJwt: session.refreshToken, 495 did: session.did, 496 handle: session.handle, 497 ), 498 ); 499 } 500 501 /// Fetch user profile 502 Future<Profile> getProfile(String actor) async { 503 final client = await getClient(); 504 if (client == null) throw Exception('Not authenticated'); 505 506 return await client.getProfile(actor: actor); 507 } 508 509 /// Fetch feed 510 Future<Feed> getFeed({int limit = 50}) async { 511 final client = await getClient(); 512 if (client == null) throw Exception('Not authenticated'); 513 514 return await client.getTimeline(limit: limit); 515 } 516 517 /// Create post 518 Future<void> createPost(String text) async { 519 final client = await getClient(); 520 if (client == null) throw Exception('Not authenticated'); 521 522 await client.createRecord( 523 collection: 'app.bsky.feed.post', 524 record: { 525 'text': text, 526 'createdAt': DateTime.now().toIso8601String(), 527 }, 528 ); 529 } 530} 531``` 532 533**Research Needed:** 534- Check if there's a Dart equivalent of `@atproto/api` 535- The `atproto_oauth` package documentation should specify how to use sessions with API calls 536- May need to create HTTP client wrapper for atProto APIs 537 538#### 2.2 Handle Token Expiration in API Calls 539 540Implement automatic token refresh when API calls fail due to expired tokens: 541 542```dart 543Future<T> _withAutoRefresh<T>(Future<T> Function() apiCall) async { 544 try { 545 return await apiCall(); 546 } on UnauthorizedError { 547 // Token expired, try to refresh 548 final session = await _oauthService.restoreSession(); 549 if (session == null) { 550 throw Exception('Session expired, please log in again'); 551 } 552 553 // Retry API call with new token 554 return await apiCall(); 555 } 556} 557``` 558 559--- 560 561### Phase 3: Enhanced Error Handling (Medium Priority) 562 563#### 3.1 Specific Error Types 564 565**File to Create:** `lib/models/auth_errors.dart` 566 567```dart 568class AuthError implements Exception { 569 final String message; 570 final AuthErrorType type; 571 572 AuthError(this.message, this.type); 573} 574 575enum AuthErrorType { 576 networkError, 577 invalidHandle, 578 serverError, 579 userCancelled, 580 tokenExpired, 581 unknown, 582} 583``` 584 585#### 3.2 User-Friendly Error Messages 586 587Update `AuthProvider` to provide actionable error messages: 588 589```dart 590String _getErrorMessage(Exception e) { 591 if (e is AuthError) { 592 switch (e.type) { 593 case AuthErrorType.networkError: 594 return 'Unable to connect. Check your internet connection.'; 595 case AuthErrorType.invalidHandle: 596 return 'Invalid handle. Use format: user.domain.com'; 597 case AuthErrorType.userCancelled: 598 return 'Sign in was cancelled.'; 599 case AuthErrorType.tokenExpired: 600 return 'Your session expired. Please sign in again.'; 601 default: 602 return 'Sign in failed. Please try again.'; 603 } 604 } 605 return e.toString(); 606} 607``` 608 609--- 610 611### Phase 4: Session Lifecycle (Low Priority) 612 613#### 4.1 Automatic Token Refresh 614 615Implement background token refresh before expiration: 616 617```dart 618class AuthProvider with ChangeNotifier { 619 Timer? _refreshTimer; 620 621 void _scheduleTokenRefresh(DateTime expiresAt) { 622 _refreshTimer?.cancel(); 623 624 // Refresh 5 minutes before expiration 625 final refreshTime = expiresAt.subtract(Duration(minutes: 5)); 626 final delay = refreshTime.difference(DateTime.now()); 627 628 if (delay.isNegative) { 629 _refreshTokenNow(); 630 return; 631 } 632 633 _refreshTimer = Timer(delay, _refreshTokenNow); 634 } 635 636 Future<void> _refreshTokenNow() async { 637 try { 638 await _oauthService.restoreSession(); // Triggers refresh 639 } catch (e) { 640 print('Auto-refresh failed: $e'); 641 } 642 } 643} 644``` 645 646#### 4.2 Handle App Lifecycle 647 648React to app going to background/foreground: 649 650```dart 651class AuthProvider with ChangeNotifier, WidgetsBindingObserver { 652 @override 653 void didChangeAppLifecycleState(AppLifecycleState state) { 654 if (state == AppLifecycleState.resumed) { 655 // App came to foreground, validate session 656 _validateSession(); 657 } 658 } 659 660 Future<void> _validateSession() async { 661 if (!_isAuthenticated) return; 662 663 // Check if session is still valid 664 final session = await _oauthService.restoreSession(); 665 if (session == null) { 666 // Session expired while app was in background 667 _isAuthenticated = false; 668 _session = null; 669 notifyListeners(); 670 } 671 } 672} 673``` 674 675--- 676 677## 📚 Resources & References 678 679### atProto OAuth Specifications 680- [atProto OAuth Spec](https://atproto.com/specs/oauth) 681- [DPoP (Demonstrating Proof-of-Possession)](https://datatracker.ietf.org/doc/html/rfc9449) 682- [PKCE (Proof Key for Code Exchange)](https://datatracker.ietf.org/doc/html/rfc7636) 683 684### Package Documentation 685- [`atproto_oauth` on pub.dev](https://pub.dev/packages/atproto_oauth) 686- [`flutter_web_auth_2` on pub.dev](https://pub.dev/packages/flutter_web_auth_2) 687- [`flutter_secure_storage` on pub.dev](https://pub.dev/packages/flutter_secure_storage) 688 689### Cloudflare Worker 690Your OAuth server is hosted at: 691- Base URL: `https://lingering-darkness-50a6.brettmay0212.workers.dev` 692- Client Metadata: `https://lingering-darkness-50a6.brettmay0212.workers.dev/client-metadata.json` 693- Callback: `https://lingering-darkness-50a6.brettmay0212.workers.dev/oauth/callback` 694 695--- 696 697## 🧪 Testing Strategy 698 699### Unit Tests 700 701**File to Create:** `test/services/oauth_service_test.dart` 702 703```dart 704void main() { 705 group('OAuthService', () { 706 late OAuthService service; 707 708 setUp(() { 709 service = OAuthService(); 710 }); 711 712 test('initialize fetches client metadata', () async { 713 await service.initialize(); 714 expect(service._client, isNotNull); 715 }); 716 717 test('signIn returns session on success', () async { 718 final session = await service.signIn('test.bsky.social'); 719 expect(session.did, isNotEmpty); 720 expect(session.accessToken, isNotEmpty); 721 }); 722 723 // Add more tests... 724 }); 725} 726``` 727 728### Integration Tests 729 730Test the full OAuth flow on a real device: 731 7321. Open app (should show landing page) 7332. Tap "Sign in" 7343. Enter valid handle 7354. Browser opens showing PDS authorization page 7365. User authorizes 7376. App receives callback and completes sign in 7387. User is redirected to feed 7398. Close app and reopen (should restore session) 740 741--- 742 743## 🔐 Security Considerations 744 745### Current Implementation ✅ 746- ✅ Tokens stored in encrypted `flutter_secure_storage` 747- ✅ DPoP prevents token theft 748- ✅ PKCE prevents authorization code interception 749- ✅ HTTPS deep links preferred over custom schemes 750- ✅ Storage keys properly scoped to atProto (not app-specific) 751 752### To Verify ⚠️ 753- ⚠️ Client metadata hosted securely on Cloudflare Worker 754- ⚠️ Redirect URIs match exactly (no wildcards) 755- ⚠️ Token refresh implemented securely 756- ⚠️ Session validation on app resume 757 758--- 759 760## 📝 Implementation Checklist 761 762### Phase 1: Core OAuth Flow 763- [ ] Implement `signIn()` with atproto_oauth authorize flow 764- [ ] Implement callback handling and token exchange 765- [ ] Implement `restoreSession()` with validation 766- [ ] Implement token refresh logic 767- [ ] Implement `signOut()` with server-side revocation 768- [ ] Test full sign in flow on device 769- [ ] Test session restoration 770- [ ] Test sign out 771 772### Phase 2: API Integration 773- [ ] Research Dart atProto API packages 774- [ ] Create `AtProtoApiService` 775- [ ] Implement profile fetching 776- [ ] Implement feed fetching 777- [ ] Implement post creation 778- [ ] Add automatic token refresh to API calls 779 780### Phase 3: Error Handling 781- [ ] Create typed error classes 782- [ ] Add user-friendly error messages 783- [ ] Handle network errors gracefully 784- [ ] Handle authorization cancellation 785- [ ] Add error recovery flows 786 787### Phase 4: Session Lifecycle 788- [ ] Implement automatic token refresh 789- [ ] Add app lifecycle observers 790- [ ] Validate session on app resume 791- [ ] Handle session expiration gracefully 792 793### Phase 5: Testing & Polish 794- [ ] Write unit tests for OAuth service 795- [ ] Write integration tests for full flow 796- [ ] Test on slow/unstable networks 797- [ ] Test session restoration edge cases 798- [ ] Add loading indicators for all async operations 799- [ ] Add success/error feedback to user 800 801--- 802 803## 🎯 Next Immediate Steps 804 8051. **Study the `atproto_oauth` Package** 806 - Read the package documentation thoroughly 807 - Look for example code or test files 808 - Understand the `authorize()` and `callback()` methods 809 8102. **Implement Basic Sign In** 811 - Start with `signIn()` method 812 - Get the authorization URL working 813 - Test browser opening and callback 814 8153. **Test on Real Device** 816 - Use your actual Bluesky handle for testing 817 - Verify deep links work correctly 818 - Check token storage 819 8204. **Implement Session Restoration** 821 - Add `restoreSession()` logic 822 - Test app restart with active session 823 - Verify token refresh works 824 825--- 826 827## 💡 Tips & Best Practices 828 8291. **Always validate sessions** before making API calls 8302. **Log OAuth flows** in debug mode for troubleshooting 8313. **Handle offline gracefully** - cache data when possible 8324. **Never log tokens** - even in debug builds 8335. **Test token expiration** by manually invalidating tokens 8346. **Use atomic operations** for session updates to prevent race conditions 8357. **Clear sessions on security errors** to prevent auth loops 836 837--- 838 839## 🐛 Common Issues & Solutions 840 841### Issue: "Authorization cancelled" 842**Solution:** User may have closed browser - handle gracefully, don't show error 843 844### Issue: Deep link not opening app 845**Solution:** Check AndroidManifest.xml intent filters, verify URL scheme matches exactly 846 847### Issue: "Client not initialized" 848**Solution:** Ensure `initialize()` is called before any OAuth operations 849 850### Issue: Token refresh failing 851**Solution:** Check if refresh token is still valid, may need full re-authentication 852 853--- 854 855## 📞 Need Help? 856 857- **atProto Discord**: [atproto.com/community](https://atproto.com/community) 858- **Bluesky API Docs**: [docs.bsky.app](https://docs.bsky.app) 859- **Package Issues**: [atproto_oauth GitHub](https://github.com/myConsciousness/atproto.dart) 860 861--- 862 863**Last Updated:** 2025-10-27 864**Flutter Version:** 3.7.2 865**Dart Version:** 3.7.2 866**Package Version:** atproto_oauth ^0.1.0