chore: remove atproto_oauth_flutter package

Remove the client-side OAuth implementation now that auth is delegated
to the Coves backend. This eliminates ~14,000 lines of complex OAuth
code that handled:

Removed oauth_service.dart:
- Complex OAuthSession management
- Client-side token refresh
- DPoP key generation and proof signing
- PKCE code verifier/challenge generation

Removed atproto_oauth_flutter package:
- DPoP implementation (fetch_dpop.dart)
- Identity resolution (did/handle resolvers)
- OAuth server discovery and metadata
- Token exchange and refresh logic
- Cryptographic key management
- Session state persistence

The backend now handles all of this complexity, returning opaque
sealed tokens that the client simply stores and sends.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

-296
lib/services/oauth_service.dart
··· 1 - import 'dart:async'; 2 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 3 - import 'package:flutter/foundation.dart'; 4 - import '../config/environment_config.dart'; 5 - import '../config/oauth_config.dart'; 6 - 7 - /// OAuth Service for atProto authentication using the new 8 - /// atproto_oauth_flutter package 9 - /// 10 - /// Key improvements over the old implementation: 11 - /// ✅ Proper decentralized OAuth discovery - works with ANY PDS 12 - /// (not just bsky.social) 13 - /// ✅ Built-in session management - no manual token storage 14 - /// ✅ Automatic token refresh with concurrency control 15 - /// ✅ Session event streams for updates and deletions 16 - /// ✅ Secure storage handled internally 17 - /// (iOS Keychain, Android EncryptedSharedPreferences) 18 - /// 19 - /// The new package handles the complete OAuth flow: 20 - /// 1. Handle/DID resolution 21 - /// 2. PDS discovery from DID document 22 - /// 3. Authorization server discovery 23 - /// 4. PKCE + DPoP generation 24 - /// 5. Browser-based authorization 25 - /// 6. Token exchange and storage 26 - /// 7. Automatic refresh and revocation 27 - class OAuthService { 28 - factory OAuthService() => _instance; 29 - OAuthService._internal(); 30 - static final OAuthService _instance = OAuthService._internal(); 31 - 32 - FlutterOAuthClient? _client; 33 - 34 - // Session event stream subscriptions 35 - StreamSubscription<SessionUpdatedEvent>? _onUpdatedSubscription; 36 - StreamSubscription<SessionDeletedEvent>? _onDeletedSubscription; 37 - 38 - /// Initialize the OAuth client 39 - /// 40 - /// This creates a FlutterOAuthClient with: 41 - /// - Discoverable client metadata (HTTPS URL) 42 - /// - Custom URL scheme for deep linking 43 - /// - DPoP enabled for token security 44 - /// - Automatic session management 45 - Future<void> initialize() async { 46 - try { 47 - // Get environment configuration 48 - final config = EnvironmentConfig.current; 49 - 50 - // Create client with metadata from config 51 - // For local development, use custom resolvers 52 - _client = FlutterOAuthClient( 53 - clientMetadata: OAuthConfig.createClientMetadata(), 54 - plcDirectoryUrl: config.plcDirectoryUrl, 55 - handleResolverUrl: config.handleResolverUrl, 56 - allowHttp: config.isLocal, // Allow HTTP for local development 57 - ); 58 - 59 - // Set up session event listeners 60 - _setupEventListeners(); 61 - 62 - if (kDebugMode) { 63 - print('✅ FlutterOAuthClient initialized'); 64 - print(' Environment: ${config.environment}'); 65 - print(' Client ID: ${OAuthConfig.clientId}'); 66 - print(' Redirect URI: ${OAuthConfig.customSchemeCallback}'); 67 - print(' Scope: ${OAuthConfig.scope}'); 68 - print(' Handle Resolver: ${config.handleResolverUrl}'); 69 - print(' PLC Directory: ${config.plcDirectoryUrl}'); 70 - print(' Allow HTTP: ${config.isLocal}'); 71 - } 72 - } catch (e) { 73 - if (kDebugMode) { 74 - print('❌ Failed to initialize OAuth client: $e'); 75 - } 76 - rethrow; 77 - } 78 - } 79 - 80 - /// Set up listeners for session events 81 - void _setupEventListeners() { 82 - if (_client == null) { 83 - return; 84 - } 85 - 86 - // Listen for session updates (token refresh, etc.) 87 - _onUpdatedSubscription = _client!.onUpdated.listen((event) { 88 - if (kDebugMode) { 89 - print('📝 Session updated for: ${event.sub}'); 90 - } 91 - }); 92 - 93 - // Listen for session deletions (revoke, expiry, errors) 94 - _onDeletedSubscription = _client!.onDeleted.listen((event) { 95 - if (kDebugMode) { 96 - print('🗑️ Session deleted for: ${event.sub}'); 97 - print(' Cause: ${event.cause}'); 98 - } 99 - }); 100 - } 101 - 102 - /// Sign in with an atProto handle 103 - /// 104 - /// The new package handles the complete OAuth flow: 105 - /// 1. Resolves handle → DID (using any handle resolver) 106 - /// 2. Fetches DID document to find the user's PDS 107 - /// 3. Discovers authorization server from PDS metadata 108 - /// 4. Generates PKCE challenge and DPoP keys 109 - /// 5. Opens browser for user authorization 110 - /// 6. Handles callback and exchanges code for tokens 111 - /// 7. Stores session securely (iOS Keychain / Android EncryptedSharedPreferences) 112 - /// 113 - /// This works with ANY PDS - not just bsky.social! 🎉 114 - /// 115 - /// Examples: 116 - /// - `signIn('alice.bsky.social')` → Bluesky PDS 117 - /// - `signIn('bob.custom-pds.com')` → Custom PDS ✅ 118 - /// - `signIn('did:plc:abc123')` → Direct DID (skips handle resolution) 119 - /// 120 - /// Returns the authenticated OAuthSession. 121 - Future<OAuthSession> signIn(String input) async { 122 - try { 123 - if (_client == null) { 124 - throw Exception( 125 - 'OAuth client not initialized. Call initialize() first.', 126 - ); 127 - } 128 - 129 - // Validate input 130 - final trimmedInput = input.trim(); 131 - if (trimmedInput.isEmpty) { 132 - throw Exception('Please enter a valid handle or DID'); 133 - } 134 - 135 - if (kDebugMode) { 136 - print('🔐 Starting sign-in for: $trimmedInput'); 137 - print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); 138 - } 139 - 140 - // Call the new package's signIn method 141 - // This does EVERYTHING: handle resolution, PDS discovery, OAuth flow, 142 - // token storage 143 - if (kDebugMode) { 144 - print('📞 Calling FlutterOAuthClient.signIn()...'); 145 - } 146 - 147 - final session = await _client!.signIn(trimmedInput); 148 - 149 - if (kDebugMode) { 150 - print('✅ Sign-in successful!'); 151 - print(' DID: ${session.sub}'); 152 - print(' PDS: ${session.serverMetadata['issuer'] ?? 'unknown'}'); 153 - print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); 154 - } 155 - 156 - return session; 157 - } on OAuthCallbackError catch (e, stackTrace) { 158 - // OAuth-specific errors (access denied, invalid request, etc.) 159 - final errorCode = e.params['error']; 160 - final errorDescription = e.params['error_description'] ?? e.message; 161 - 162 - if (kDebugMode) { 163 - print('❌ OAuth callback error details:'); 164 - print(' Error code: $errorCode'); 165 - print(' Description: $errorDescription'); 166 - print(' Message: ${e.message}'); 167 - print(' All params: ${e.params}'); 168 - print(' Exception type: ${e.runtimeType}'); 169 - print(' Exception: $e'); 170 - print(' Stack trace:'); 171 - print('$stackTrace'); 172 - } 173 - 174 - if (errorCode == 'access_denied') { 175 - throw Exception('Sign in cancelled by user'); 176 - } 177 - 178 - throw Exception('OAuth error: $errorDescription'); 179 - } catch (e, stackTrace) { 180 - // Catch all other errors including user cancellation 181 - if (kDebugMode) { 182 - print('❌ Sign in failed - detailed error:'); 183 - print(' Error type: ${e.runtimeType}'); 184 - print(' Error: $e'); 185 - print(' Stack trace:'); 186 - print('$stackTrace'); 187 - } 188 - 189 - // Check if user cancelled (flutter_web_auth_2 throws 190 - // PlatformException with "CANCELED" code) 191 - if (e.toString().contains('CANCELED') || 192 - e.toString().contains('User cancelled')) { 193 - throw Exception('Sign in cancelled by user'); 194 - } 195 - 196 - throw Exception('Sign in failed: $e'); 197 - } 198 - } 199 - 200 - /// Restore a previous session if available 201 - /// 202 - /// The new package handles session restoration automatically: 203 - /// - Loads session from secure storage 204 - /// - Checks token expiration 205 - /// - Automatically refreshes if needed 206 - /// - Returns null if no valid session exists 207 - /// 208 - /// Parameters: 209 - /// - `did`: User's DID (e.g., "did:plc:abc123") 210 - /// - `refresh`: Token refresh strategy: 211 - /// - 'auto' (default): Refresh only if expired 212 - /// - true: Force refresh even if not expired 213 - /// - false: Use cached tokens even if expired 214 - /// 215 - /// Returns the restored session or null if no session found. 216 - Future<OAuthSession?> restoreSession( 217 - String did, { 218 - String refresh = 'auto', 219 - }) async { 220 - try { 221 - if (_client == null) { 222 - throw Exception( 223 - 'OAuth client not initialized. Call initialize() first.', 224 - ); 225 - } 226 - 227 - if (kDebugMode) { 228 - print('🔄 Attempting to restore session for: $did'); 229 - } 230 - 231 - // Call the new package's restore method 232 - final session = await _client!.restore(did, refresh: refresh); 233 - 234 - if (kDebugMode) { 235 - print('✅ Session restored successfully'); 236 - final info = await session.getTokenInfo(); 237 - print(' Token expires: ${info.expiresAt}'); 238 - } 239 - 240 - return session; 241 - } on Exception catch (e) { 242 - if (kDebugMode) { 243 - print('⚠️ Failed to restore session: $e'); 244 - } 245 - return null; 246 - } 247 - } 248 - 249 - /// Sign out and revoke session 250 - /// 251 - /// The new package handles revocation properly: 252 - /// - Calls server's token revocation endpoint (best-effort) 253 - /// - Deletes session from secure storage (always) 254 - /// - Emits 'deleted' event 255 - /// 256 - /// This is a complete sign-out with server-side revocation! 🎉 257 - Future<void> signOut(String did) async { 258 - try { 259 - if (_client == null) { 260 - throw Exception( 261 - 'OAuth client not initialized. Call initialize() first.', 262 - ); 263 - } 264 - 265 - if (kDebugMode) { 266 - print('👋 Signing out: $did'); 267 - } 268 - 269 - // Call the new package's revoke method 270 - await _client!.revoke(did); 271 - 272 - if (kDebugMode) { 273 - print('✅ Sign out successful'); 274 - } 275 - } catch (e) { 276 - if (kDebugMode) { 277 - print('⚠️ Sign out failed: $e'); 278 - } 279 - // Re-throw to let caller handle 280 - rethrow; 281 - } 282 - } 283 - 284 - /// Get the current OAuth client instance 285 - /// 286 - /// Useful for advanced use cases like: 287 - /// - Listening to session events directly 288 - /// - Using lower-level OAuth methods 289 - FlutterOAuthClient? get client => _client; 290 - 291 - /// Clean up resources 292 - void dispose() { 293 - _onUpdatedSubscription?.cancel(); 294 - _onDeletedSubscription?.cancel(); 295 - } 296 - }
-337
packages/atproto_oauth_flutter/CHUNK3_IMPLEMENTATION_REPORT.md
··· 1 - # Chunk 3 Implementation Report: Identity Resolution Layer 2 - 3 - ## Status: ✅ COMPLETE 4 - 5 - Implementation Date: 2025-10-27 6 - Implementation Time: ~2 hours 7 - Lines of Code: ~1,431 lines across 9 Dart files 8 - 9 - ## Overview 10 - 11 - Successfully ported the **atProto Identity Resolution Layer** from TypeScript to Dart with full 1:1 API compatibility. This is the **most critical component for atProto decentralization**, enabling users to host their data on any Personal Data Server (PDS) instead of being locked to bsky.social. 12 - 13 - ## What Was Implemented 14 - 15 - ### Core Files Created 16 - 17 - ``` 18 - lib/src/identity/ 19 - ├── constants.dart (30 lines) - atProto constants 20 - ├── did_document.dart (124 lines) - DID document parsing 21 - ├── did_helpers.dart (227 lines) - DID validation utilities 22 - ├── did_resolver.dart (269 lines) - DID → Document resolution 23 - ├── handle_helpers.dart (31 lines) - Handle validation 24 - ├── handle_resolver.dart (209 lines) - Handle → DID resolution 25 - ├── identity_resolver.dart (378 lines) - Main resolver (orchestrates everything) 26 - ├── identity_resolver_error.dart (53 lines) - Error types 27 - ├── identity.dart (43 lines) - Public API exports 28 - └── README.md (267 lines) - Comprehensive documentation 29 - ``` 30 - 31 - ### Additional Files 32 - 33 - ``` 34 - test/identity_resolver_test.dart (231 lines) - 21 passing unit tests 35 - example/identity_resolver_example.dart (95 lines) - Usage examples 36 - ``` 37 - 38 - ## Critical Functionality Implemented 39 - 40 - ### 1. Handle Resolution (Handle → DID) 41 - 42 - Resolves atProto handles like `alice.bsky.social` to DIDs using XRPC: 43 - 44 - ```dart 45 - final resolver = XrpcHandleResolver('https://bsky.social'); 46 - final did = await resolver.resolve('alice.bsky.social'); 47 - // Returns: did:plc:... 48 - ``` 49 - 50 - **Features:** 51 - - XRPC-based resolution via `com.atproto.identity.resolveHandle` 52 - - Proper error handling for invalid/non-existent handles 53 - - Built-in caching with configurable TTL (1 hour default) 54 - - Validates DIDs are proper atProto DIDs (plc or web) 55 - 56 - ### 2. DID Resolution (DID → DID Document) 57 - 58 - Fetches DID documents from PLC directory or HTTPS: 59 - 60 - ```dart 61 - final resolver = AtprotoDidResolver(); 62 - 63 - // Resolve did:plc from PLC directory 64 - final doc = await resolver.resolve('did:plc:z72i7hdynmk6r22z27h6abc2'); 65 - 66 - // Resolve did:web via HTTPS 67 - final doc2 = await resolver.resolve('did:web:example.com'); 68 - ``` 69 - 70 - **Features:** 71 - - `did:plc` method: Queries https://plc.directory/ 72 - - `did:web` method: Fetches from HTTPS URLs (/.well-known/did.json or /did.json) 73 - - Validates DID document structure 74 - - Caching with 24-hour default TTL 75 - - No HTTP redirects (security) 76 - 77 - ### 3. Identity Resolution (Handle/DID → Complete Info) 78 - 79 - Main resolver that orchestrates everything: 80 - 81 - ```dart 82 - final resolver = AtprotoIdentityResolver.withDefaults( 83 - handleResolverUrl: 'https://bsky.social', 84 - ); 85 - 86 - // Resolve handle to full identity info 87 - final info = await resolver.resolve('alice.bsky.social'); 88 - print('DID: ${info.did}'); 89 - print('Handle: ${info.handle}'); 90 - print('PDS: ${info.pdsUrl}'); 91 - 92 - // Or resolve directly to PDS URL (most common use case) 93 - final pdsUrl = await resolver.resolveToPds('alice.bsky.social'); 94 - ``` 95 - 96 - **Features:** 97 - - Accepts both handles and DIDs as input 98 - - Enforces bi-directional validation (security) 99 - - Extracts PDS URL from DID document 100 - - Validates handle in DID document matches original 101 - - Complete error handling with specific error types 102 - - Configurable caching at all layers 103 - 104 - ### 4. Bi-directional Validation (CRITICAL for Security) 105 - 106 - For every resolution, we validate both directions: 107 - 108 - 1. **Handle → DID** resolution succeeds 109 - 2. **DID Document** contains the original handle 110 - 3. **Both directions** agree 111 - 112 - This prevents: 113 - - Handle hijacking 114 - - DID spoofing 115 - - MITM attacks 116 - 117 - ### 5. DID Document Parsing 118 - 119 - Full W3C DID Document support: 120 - 121 - ```dart 122 - final doc = DidDocument.fromJson(json); 123 - 124 - // Extract atProto-specific info 125 - final pdsUrl = doc.extractPdsUrl(); 126 - final handle = doc.extractNormalizedHandle(); 127 - 128 - // Access standard DID doc fields 129 - print(doc.id); // DID 130 - print(doc.alsoKnownAs); // Alternative identifiers 131 - print(doc.service); // Service endpoints 132 - ``` 133 - 134 - ### 6. Validation Utilities 135 - 136 - **DID Validation:** 137 - - `isDid()` - Checks if string is valid DID 138 - - `isDidPlc()` - Validates did:plc format (exactly 32 chars, base32) 139 - - `isDidWeb()` - Validates did:web format 140 - - `isAtprotoDid()` - Checks if DID uses blessed methods 141 - - `assertDid()` - Throws detailed errors for invalid DIDs 142 - 143 - **Handle Validation:** 144 - - `isValidHandle()` - Validates handle format per spec 145 - - `normalizeHandle()` - Converts to lowercase 146 - - `asNormalizedHandle()` - Validates and normalizes 147 - 148 - ### 7. Caching Layer 149 - 150 - Two-tier caching system: 151 - 152 - **Handle Cache:** 153 - - TTL: 1 hour default (handles can change) 154 - - In-memory implementation 155 - - Optional `noCache` bypass 156 - 157 - **DID Document Cache:** 158 - - TTL: 24 hours default (more stable) 159 - - In-memory implementation 160 - - Optional `noCache` bypass 161 - 162 - ### 8. Error Handling 163 - 164 - Comprehensive error hierarchy: 165 - 166 - ```dart 167 - IdentityResolverError - Base error 168 - ├── InvalidDidError - Malformed DID 169 - ├── InvalidHandleError - Malformed handle 170 - ├── HandleResolverError - Handle resolution failed 171 - └── DidResolverError - DID resolution failed 172 - ``` 173 - 174 - All errors include: 175 - - Detailed error messages 176 - - Original cause (if any) 177 - - Context about what failed 178 - 179 - ## Testing 180 - 181 - ### Unit Tests: ✅ 21 tests, all passing 182 - 183 - ```bash 184 - $ flutter test test/identity_resolver_test.dart 185 - All tests passed! 186 - ``` 187 - 188 - **Test Coverage:** 189 - - DID validation (did:plc, did:web, general DIDs) 190 - - DID method extraction 191 - - URL ↔ did:web conversion 192 - - Handle validation and normalization 193 - - DID document parsing 194 - - PDS URL extraction 195 - - Handle extraction from DID docs 196 - - Cache functionality (store, retrieve, expire) 197 - - Error types and messages 198 - 199 - ### Static Analysis: ✅ No issues 200 - 201 - ```bash 202 - $ flutter analyze lib/src/identity/ 203 - No issues found! 204 - ``` 205 - 206 - ## Source Traceability 207 - 208 - This implementation is a 1:1 port from official atProto TypeScript packages: 209 - 210 - **Source Files:** 211 - - `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts` 212 - - `/home/bretton/Code/atproto/packages/internal/identity-resolver/src/` 213 - - `/home/bretton/Code/atproto/packages/internal/did-resolver/src/` 214 - - `/home/bretton/Code/atproto/packages/internal/handle-resolver/src/` 215 - 216 - **Key Differences from TypeScript:** 217 - 1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups, use XRPC only 218 - 2. **Dio instead of Fetch**: Using Dio HTTP client 219 - 3. **Explicit Types**: Dart's stricter type system 220 - 4. **Simplified Caching**: In-memory only (TypeScript has more backends) 221 - 222 - ## Why This Is Critical for Decentralization 223 - 224 - ### Problem Without This Layer 225 - 226 - Without proper identity resolution: 227 - - Apps hardcode `bsky.social` as the only server 228 - - Users can't use custom domains 229 - - Self-hosting is impossible 230 - - atProto becomes centralized like Twitter/X 231 - 232 - ### Solution With This Layer 233 - 234 - ✅ **Users host data on any PDS** they choose 235 - ✅ **Custom domain handles** work (e.g., `alice.example.com`) 236 - ✅ **Identity is portable** (change PDS without losing DID) 237 - ✅ **True decentralization** is achieved 238 - 239 - ## Real-World Usage Example 240 - 241 - ```dart 242 - // Create resolver 243 - final resolver = AtprotoIdentityResolver.withDefaults( 244 - handleResolverUrl: 'https://bsky.social', 245 - ); 246 - 247 - // Resolve custom domain handle (NOT bsky.social!) 248 - final info = await resolver.resolve('jay.bsky.team'); 249 - 250 - // Result: 251 - // - DID: did:plc:... 252 - // - Handle: jay.bsky.team (validated) 253 - // - PDS: https://bsky.team (NOT hardcoded!) 254 - 255 - // This user hosts their data on their own PDS! 256 - ``` 257 - 258 - ## Performance Characteristics 259 - 260 - **With Cold Cache:** 261 - - Handle → PDS: ~200-500ms (1 handle lookup + 1 DID fetch) 262 - - DID → PDS: ~100-200ms (1 DID fetch only) 263 - 264 - **With Warm Cache:** 265 - - Any resolution: <1ms (in-memory lookup) 266 - 267 - **Recommendations:** 268 - - Enable caching (default) 269 - - Use connection pooling (Dio does this automatically) 270 - - Consider warming cache for known users 271 - - Monitor resolver errors and timeouts 272 - 273 - ## Security Considerations 274 - 275 - 1. ✅ **Bi-directional Validation**: Always enforced 276 - 2. ✅ **HTTPS Only**: All requests use HTTPS (except localhost) 277 - 3. ✅ **No Redirects**: HTTP redirects rejected 278 - 4. ✅ **Input Validation**: All handles/DIDs validated before use 279 - 5. ✅ **Cache Poisoning Protection**: TTLs prevent stale data 280 - 281 - ## Dependencies 282 - 283 - **Required:** 284 - - `dio: ^5.9.0` - HTTP client (already in pubspec.yaml) 285 - 286 - **No additional dependencies needed!** 287 - 288 - ## Future Improvements 289 - 290 - Potential enhancements (not required for MVP): 291 - - [ ] Add DNS-over-HTTPS for handle resolution 292 - - [ ] Implement .well-known handle resolution 293 - - [ ] Add persistent cache backends (SQLite, Hive) 294 - - [ ] Support custom DID methods beyond plc/web 295 - - [ ] Add metrics and observability 296 - - [ ] Implement resolver timeouts and retries 297 - 298 - ## Integration Checklist 299 - 300 - To integrate this into OAuth flow: 301 - 302 - - [x] Identity resolver implemented 303 - - [x] Unit tests passing 304 - - [x] Static analysis clean 305 - - [x] Documentation complete 306 - - [ ] Export from main package (add to lib/atproto_oauth_flutter.dart) 307 - - [ ] Use in OAuth client for PDS discovery 308 - - [ ] Test with real handles (bretton.dev, etc.) 309 - 310 - ## Files to Review 311 - 312 - **Implementation:** 313 - - `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/lib/src/identity/` 314 - 315 - **Tests:** 316 - - `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/test/identity_resolver_test.dart` 317 - 318 - **Examples:** 319 - - `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/example/identity_resolver_example.dart` 320 - 321 - **Documentation:** 322 - - `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/lib/src/identity/README.md` 323 - 324 - ## Conclusion 325 - 326 - ✅ **Chunk 3 is COMPLETE and production-ready.** 327 - 328 - The identity resolution layer has been successfully ported from TypeScript with: 329 - - Full API compatibility 330 - - Comprehensive testing 331 - - Detailed documentation 332 - - Clean static analysis 333 - - Real-world usage examples 334 - 335 - This implementation enables true atProto decentralization by ensuring apps discover where each user's data lives, rather than hardcoding centralized servers. 336 - 337 - **Next Steps:** Integrate this into the OAuth client (Chunk 4+) to complete the full OAuth flow with proper PDS discovery.
-373
packages/atproto_oauth_flutter/CHUNK_5_IMPLEMENTATION.md
··· 1 - # Chunk 5 Implementation: Session Management Layer 2 - 3 - ## Overview 4 - 5 - This chunk implements the session management layer for atproto OAuth in Dart, providing a complete 1:1 port of the TypeScript implementation from `@atproto/oauth-client`. 6 - 7 - ## Files Created 8 - 9 - ### Core Session Files 10 - 11 - 1. **`lib/src/session/state_store.dart`** 12 - - `InternalStateData` - Ephemeral OAuth state during authorization flow 13 - - `StateStore` - Abstract interface for state storage 14 - - Stores PKCE verifiers, state parameters, nonces, and other temporary OAuth data 15 - 16 - 2. **`lib/src/session/oauth_session.dart`** 17 - - `TokenSet` - OAuth token container (access, refresh, metadata) 18 - - `TokenInfo` - Token information for client use 19 - - `Session` - Session with DPoP key and tokens 20 - - `OAuthSession` - High-level API for authenticated requests 21 - - `SessionGetterInterface` - Abstract interface to avoid circular dependencies 22 - 23 - 3. **`lib/src/session/session_getter.dart`** 24 - - `SessionGetter` - Main session management class 25 - - `CachedGetter` - Generic caching/refresh utility (base class) 26 - - `SimpleStore` - Abstract key-value store interface 27 - - `GetCachedOptions` - Options for cache retrieval 28 - - Event types: `SessionUpdatedEvent`, `SessionDeletedEvent` 29 - - Placeholder types: `OAuthServerFactory`, `Runtime`, `OAuthResponseError` 30 - 31 - 4. **`lib/src/session/session.dart`** 32 - - Barrel file exporting all session-related classes 33 - 34 - ## Key Design Decisions 35 - 36 - ### 1. Avoiding Circular Dependencies 37 - 38 - **Problem**: `OAuthSession` needs `SessionGetter`, but `SessionGetter` returns `Session` objects that are used by `OAuthSession`. 39 - 40 - **Solution**: Created `SessionGetterInterface` in `oauth_session.dart` as an abstract interface. `SessionGetter` in `session_getter.dart` will implement this interface in later chunks when all dependencies are available. 41 - 42 - ```dart 43 - // oauth_session.dart 44 - abstract class SessionGetterInterface { 45 - Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale}); 46 - Future<void> delStored(AtprotoDid sub, [Object? cause]); 47 - } 48 - 49 - // OAuthSession uses this interface 50 - class OAuthSession { 51 - final SessionGetterInterface sessionGetter; 52 - // ... 53 - } 54 - ``` 55 - 56 - ### 2. TypeScript EventEmitter → Dart Streams 57 - 58 - **TypeScript Pattern**: 59 - ```typescript 60 - class SessionGetter extends EventEmitter { 61 - emit('updated', session) 62 - emit('deleted', sub) 63 - } 64 - ``` 65 - 66 - **Dart Pattern**: 67 - ```dart 68 - class SessionGetter { 69 - final _updatedController = StreamController<SessionUpdatedEvent>.broadcast(); 70 - Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream; 71 - 72 - final _deletedController = StreamController<SessionDeletedEvent>.broadcast(); 73 - Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream; 74 - 75 - void dispose() { 76 - _updatedController.close(); 77 - _deletedController.close(); 78 - } 79 - } 80 - ``` 81 - 82 - ### 3. CachedGetter Implementation 83 - 84 - The `CachedGetter` is a critical component that ensures: 85 - - At most one token refresh happens at a time for a given user 86 - - Concurrent requests wait for in-flight refreshes 87 - - Stale values are detected and refreshed automatically 88 - - Errors trigger deletion when appropriate 89 - 90 - **Key Features**: 91 - - Generic `CachedGetter<K, V>` base class 92 - - `SessionGetter` extends `CachedGetter<AtprotoDid, Session>` 93 - - Pending request tracking prevents duplicate refreshes 94 - - Configurable staleness detection with randomization (reduces thundering herd) 95 - 96 - ### 4. Placeholder Types for Future Chunks 97 - 98 - Since this is Chunk 5 and some dependencies come from later chunks, we use placeholders: 99 - 100 - ```dart 101 - // In oauth_session.dart 102 - abstract class OAuthServerAgent { 103 - OAuthAuthorizationServerMetadata get serverMetadata; 104 - Map<String, dynamic> get dpopKey; 105 - String get authMethod; 106 - Future<void> revoke(String token); 107 - Future<TokenSet> refresh(TokenSet tokenSet); 108 - } 109 - 110 - // In session_getter.dart 111 - abstract class OAuthServerFactory { 112 - Future<OAuthServerAgent> fromIssuer( 113 - String issuer, 114 - String authMethod, 115 - Map<String, dynamic> dpopKey, 116 - ); 117 - } 118 - 119 - abstract class Runtime { 120 - bool get hasImplementationLock; 121 - Future<T> usingLock<T>(String key, Future<T> Function() callback); 122 - Future<List<int>> sha256(List<int> data); 123 - } 124 - 125 - class OAuthResponseError implements Exception { 126 - final int status; 127 - final String? error; 128 - final String? errorDescription; 129 - } 130 - ``` 131 - 132 - These will be replaced with actual implementations in later chunks. 133 - 134 - ### 5. Token Expiration Logic 135 - 136 - **TypeScript**: 137 - ```typescript 138 - expires_at != null && 139 - new Date(expires_at).getTime() < 140 - Date.now() + 10e3 + 30e3 * Math.random() 141 - ``` 142 - 143 - **Dart**: 144 - ```dart 145 - if (tokenSet.expiresAt == null) return false; 146 - 147 - final expiresAt = DateTime.parse(tokenSet.expiresAt!); 148 - final now = DateTime.now(); 149 - 150 - // 10 seconds buffer + 0-30 seconds randomization 151 - final buffer = Duration( 152 - milliseconds: 10000 + (math.Random().nextDouble() * 30000).toInt(), 153 - ); 154 - 155 - return expiresAt.isBefore(now.add(buffer)); 156 - ``` 157 - 158 - The randomization prevents multiple instances from refreshing simultaneously. 159 - 160 - ### 6. HTTP Client Integration 161 - 162 - **TypeScript** uses global `fetch`: 163 - ```typescript 164 - const response = await fetch(url, { method: 'POST', ... }) 165 - ``` 166 - 167 - **Dart** uses `package:http`: 168 - ```dart 169 - import 'package:http/http.dart' as http; 170 - 171 - final request = http.Request(method, url); 172 - request.headers.addAll(headers); 173 - request.body = body; 174 - final streamedResponse = await _httpClient.send(request); 175 - return await http.Response.fromStream(streamedResponse); 176 - ``` 177 - 178 - ### 7. Record Types for Pending Results 179 - 180 - **TypeScript**: 181 - ```typescript 182 - type PendingItem<V> = Promise<{ value: V; isFresh: boolean }> 183 - ``` 184 - 185 - **Dart (using Dart 3.0 records)**: 186 - ```dart 187 - class _PendingItem<V> { 188 - final Future<({V value, bool isFresh})> future; 189 - _PendingItem(this.future); 190 - } 191 - ``` 192 - 193 - ## API Compatibility 194 - 195 - ### Session Management 196 - 197 - | TypeScript | Dart | Notes | 198 - |------------|------|-------| 199 - | `SessionGetter.getSession(sub, refresh?)` | `SessionGetter.getSession(sub, [refresh])` | Identical API | 200 - | `SessionGetter.addEventListener('updated', ...)` | `SessionGetter.onUpdated.listen(...)` | Stream-based | 201 - | `SessionGetter.addEventListener('deleted', ...)` | `SessionGetter.onDeleted.listen(...)` | Stream-based | 202 - 203 - ### OAuth Session 204 - 205 - | TypeScript | Dart | Notes | 206 - |------------|------|-------| 207 - | `session.getTokenInfo(refresh?)` | `session.getTokenInfo([refresh])` | Identical API | 208 - | `session.signOut()` | `session.signOut()` | Identical API | 209 - | `session.fetchHandler(pathname, init?)` | `session.fetchHandler(pathname, {method, headers, body})` | Named parameters | 210 - 211 - ## Testing Strategy 212 - 213 - The implementation compiles successfully with only 2 minor linting suggestions: 214 - - Use null-aware operator in one place (style preference) 215 - - Use `rethrow` in one catch block (style preference) 216 - 217 - Both are cosmetic and don't affect functionality. 218 - 219 - ### Manual Testing Checklist 220 - 221 - When later chunks provide concrete implementations: 222 - 223 - ```dart 224 - // 1. Create a session 225 - final session = Session( 226 - dpopKey: {'kty': 'EC', ...}, 227 - authMethod: 'none', 228 - tokenSet: TokenSet( 229 - iss: 'https://bsky.social', 230 - sub: 'did:plc:abc123', 231 - aud: 'https://bsky.social', 232 - scope: 'atproto', 233 - accessToken: 'token', 234 - refreshToken: 'refresh', 235 - expiresAt: DateTime.now().add(Duration(hours: 1)).toIso8601String(), 236 - ), 237 - ); 238 - 239 - // 2. Store in session getter 240 - await sessionGetter.setStored('did:plc:abc123', session); 241 - 242 - // 3. Retrieve (should not refresh) 243 - final retrieved = await sessionGetter.getSession('did:plc:abc123', false); 244 - assert(retrieved.tokenSet.accessToken == 'token'); 245 - 246 - // 4. Force refresh 247 - final refreshed = await sessionGetter.getSession('did:plc:abc123', true); 248 - // Should have new tokens 249 - 250 - // 5. Check expiration 251 - assert(!session.tokenSet.isExpired); 252 - 253 - // 6. Delete 254 - await sessionGetter.delStored('did:plc:abc123'); 255 - final deleted = await sessionGetter.getSession('did:plc:abc123'); 256 - // Should throw or return null 257 - ``` 258 - 259 - ## Security Considerations 260 - 261 - ### 1. Token Storage 262 - 263 - **Critical**: Tokens MUST be stored securely: 264 - ```dart 265 - // ❌ NEVER do this 266 - final prefs = await SharedPreferences.getInstance(); 267 - await prefs.setString('token', tokenSet.toJson().toString()); 268 - 269 - // ✅ Use flutter_secure_storage (implemented in Chunk 7) 270 - final storage = FlutterSecureStorage(); 271 - await storage.write( 272 - key: 'session_$sub', 273 - value: jsonEncode(session.toJson()), 274 - ); 275 - ``` 276 - 277 - ### 2. Token Logging 278 - 279 - **Never log sensitive data**: 280 - ```dart 281 - // ❌ NEVER 282 - print('Access token: ${tokenSet.accessToken}'); 283 - 284 - // ✅ Safe logging 285 - print('Token expires at: ${tokenSet.expiresAt}'); 286 - print('Token type: ${tokenSet.tokenType}'); 287 - ``` 288 - 289 - ### 3. Session Lifecycle 290 - 291 - Sessions are automatically deleted when: 292 - - Token refresh fails with `invalid_grant` 293 - - Token is revoked by the server 294 - - User explicitly signs out 295 - - Token is marked invalid by resource server 296 - 297 - ### 4. Concurrency Protection 298 - 299 - The `SessionGetter` includes multiple layers of protection: 300 - 1. **Runtime locks**: Prevent simultaneous refreshes across app instances 301 - 2. **Pending request tracking**: Coalesce concurrent requests 302 - 3. **Store-based detection**: Detect concurrent refreshes without locks 303 - 4. **Randomized expiry**: Reduce thundering herd at startup 304 - 305 - ## Integration with Other Chunks 306 - 307 - ### Dependencies (Available) 308 - - ✅ Chunk 1: Error types (`TokenRefreshError`, `TokenRevokedError`, etc.) 309 - - ✅ Chunk 1: Utilities (`CustomEventTarget`, `CancellationToken`) 310 - - ✅ Chunk 1: Constants 311 - 312 - ### Dependencies (Future Chunks) 313 - - ⏳ Chunk 6: `OAuthServerAgent` implementation 314 - - ⏳ Chunk 7: `OAuthServerFactory` implementation 315 - - ⏳ Chunk 7: `Runtime` implementation 316 - - ⏳ Chunk 7: Concrete storage implementations (SecureSessionStore) 317 - - ⏳ Chunk 8: DPoP fetch wrapper integration 318 - 319 - ## File Structure 320 - 321 - ``` 322 - lib/src/session/ 323 - ├── state_store.dart # OAuth state storage (PKCE, nonce, etc.) 324 - ├── oauth_session.dart # Session types and OAuthSession class 325 - ├── session_getter.dart # SessionGetter and CachedGetter 326 - └── session.dart # Barrel file 327 - ``` 328 - 329 - ## Next Steps 330 - 331 - For Chunk 6+: 332 - 1. Implement `OAuthServerAgent` with actual token refresh logic 333 - 2. Implement `OAuthServerFactory` for creating server agents 334 - 3. Implement `Runtime` with platform-specific lock mechanisms 335 - 4. Create concrete `SessionStore` using `flutter_secure_storage` 336 - 5. Create concrete `StateStore` for ephemeral OAuth state 337 - 6. Integrate DPoP proof generation in `fetchHandler` 338 - 7. Add proper error handling for network failures 339 - 8. Implement session migration for schema changes 340 - 341 - ## Performance Notes 342 - 343 - ### Memory Management 344 - - `SessionGetter` maintains a `_pending` map for in-flight requests 345 - - This map is automatically cleaned up when requests complete 346 - - Stream controllers must be disposed via `dispose()` 347 - - HTTP clients should be reused, not created per request 348 - 349 - ### Optimization Opportunities 350 - - The randomized expiry buffer (0-30s) spreads refresh load 351 - - Pending request coalescing reduces redundant network calls 352 - - Cached values avoid unnecessary store reads 353 - 354 - ## Known Limitations 355 - 356 - 1. **No DPoP yet**: `fetchHandler` doesn't generate DPoP proofs (Chunk 8) 357 - 2. **No actual refresh**: `OAuthServerAgent.refresh()` is a placeholder 358 - 3. **No secure storage**: Storage implementations come in Chunk 7 359 - 4. **No runtime locks**: Lock implementation comes in Chunk 7 360 - 361 - These are intentional - this chunk focuses on the session management *structure*, with concrete implementations following in later chunks. 362 - 363 - ## Conclusion 364 - 365 - Chunk 5 successfully implements the session management layer with: 366 - - ✅ Complete API compatibility with TypeScript 367 - - ✅ Proper abstractions for future implementations 368 - - ✅ Security-conscious design (even if storage is placeholder) 369 - - ✅ Event-driven architecture using Dart streams 370 - - ✅ Comprehensive error handling 371 - - ✅ Zero compilation errors 372 - 373 - The code is production-ready structurally and awaits concrete implementations from subsequent chunks.
-102
packages/atproto_oauth_flutter/IMPLEMENTATION_PLAN.md
··· 1 - # Implementation Plan: atproto_oauth_flutter 2 - 3 - ## Overview 4 - 1:1 port of `@atproto/oauth-client` from TypeScript to Dart/Flutter 5 - 6 - **Source:** `/home/bretton/Code/atproto/packages/oauth/oauth-client/` 7 - **Target:** `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/` 8 - 9 - ## Implementation Chunks 10 - 11 - ### Chunk 1: Foundation Layer ✅ 12 - **Files to port:** 13 - - `src/constants.ts` → `lib/src/constants.dart` 14 - - `src/types.ts` → `lib/src/types.dart` 15 - - `src/errors/*.ts` → `lib/src/errors/*.dart` 16 - - `src/util.ts` → `lib/src/util.dart` 17 - 18 - **Dependencies:** None (pure types and utilities) 19 - **Estimated LOC:** ~300 lines 20 - 21 - ### Chunk 2: Crypto & DPoP Layer 22 - **Files to port:** 23 - - `src/runtime-implementation.ts` → `lib/src/runtime/runtime_implementation.dart` 24 - - `src/runtime.ts` → `lib/src/runtime/runtime.dart` 25 - - `src/fetch-dpop.ts` → `lib/src/dpop/fetch_dpop.dart` 26 - - `src/lock.ts` → `lib/src/utils/lock.dart` 27 - 28 - **Dependencies:** Chunk 1 (types, errors) 29 - **Dart packages:** `crypto`, `pointycastle`, `convert` 30 - **Estimated LOC:** ~500 lines 31 - 32 - ### Chunk 3: Identity Resolution 33 - **Files to port:** 34 - - `src/identity-resolver.ts` → `lib/src/identity/identity_resolver.dart` 35 - 36 - **Dependencies:** Chunk 1, Chunk 2 37 - **Estimated LOC:** ~200 lines 38 - 39 - ### Chunk 4: OAuth Protocol Layer 40 - **Files to port:** 41 - - `src/oauth-authorization-server-metadata-resolver.ts` → `lib/src/oauth/authorization_server_metadata_resolver.dart` 42 - - `src/oauth-protected-resource-metadata-resolver.ts` → `lib/src/oauth/protected_resource_metadata_resolver.dart` 43 - - `src/oauth-resolver.ts` → `lib/src/oauth/oauth_resolver.dart` 44 - - `src/oauth-client-auth.ts` → `lib/src/oauth/client_auth.dart` 45 - - `src/validate-client-metadata.ts` → `lib/src/oauth/validate_client_metadata.dart` 46 - - `src/oauth-callback-error.ts` → `lib/src/errors/oauth_callback_error.dart` 47 - - `src/oauth-resolver-error.ts` → `lib/src/errors/oauth_resolver_error.dart` 48 - - `src/oauth-response-error.ts` → `lib/src/errors/oauth_response_error.dart` 49 - 50 - **Dependencies:** Chunk 1, Chunk 2, Chunk 3 51 - **Estimated LOC:** ~800 lines 52 - 53 - ### Chunk 5: Session Management 54 - **Files to port:** 55 - - `src/session-getter.ts` → `lib/src/session/session_getter.dart` 56 - - `src/state-store.ts` → `lib/src/session/state_store.dart` 57 - - `src/oauth-session.ts` → `lib/src/session/oauth_session.dart` 58 - 59 - **Dependencies:** Chunk 1, Chunk 2 60 - **Estimated LOC:** ~400 lines 61 - 62 - ### Chunk 6: Core OAuth Client 63 - **Files to port:** 64 - - `src/oauth-server-agent.ts` → `lib/src/client/oauth_server_agent.dart` 65 - - `src/oauth-server-factory.ts` → `lib/src/client/oauth_server_factory.dart` 66 - - `src/oauth-client.ts` → `lib/src/client/oauth_client.dart` 67 - 68 - **Dependencies:** All previous chunks 69 - **Estimated LOC:** ~700 lines 70 - 71 - ### Chunk 7: Flutter Platform Layer (NEW) 72 - **Files to create:** 73 - - `lib/src/platform/flutter_stores.dart` - Secure storage implementations 74 - - `lib/src/platform/flutter_runtime.dart` - Flutter crypto implementations 75 - - `lib/src/platform/flutter_oauth_client.dart` - Flutter-specific client 76 - - `lib/atproto_oauth_flutter.dart` - Main export file 77 - 78 - **Dependencies:** All previous chunks, Flutter packages 79 - **Estimated LOC:** ~300 lines 80 - 81 - ## Agent Execution Plan 82 - 83 - Each chunk will be implemented by a sub-agent with: 84 - 1. **Implementation Agent** - Ports TypeScript to Dart 85 - 2. **Review Agent** - Reviews for bugs, best practices, API compatibility 86 - 87 - ## Success Criteria 88 - 89 - - [ ] All TypeScript files ported to Dart 90 - - [ ] API matches Expo package (same method signatures) 91 - - [ ] Zero compilation errors 92 - - [ ] Proper decentralization (PDS discovery works) 93 - - [ ] Works with bretton.dev (custom PDS) 94 - 95 - ## Testing Plan 96 - 97 - After all chunks complete: 98 - 1. Unit tests for each module 99 - 2. Integration test with bretton.dev 100 - 3. Integration test with bsky.social 101 - 4. Session persistence test 102 - 5. Token refresh test
-394
packages/atproto_oauth_flutter/IMPLEMENTATION_STATUS.md
··· 1 - # atproto_oauth_flutter - Implementation Status 2 - 3 - ## Overview 4 - 5 - This is a **complete 1:1 port** of the TypeScript `@atproto/oauth-client` package to Dart/Flutter. 6 - 7 - **Status**: ✅ **COMPLETE - Ready for Testing** 8 - 9 - All 7 chunks have been implemented and the library compiles without errors. 10 - 11 - ## Implementation Chunks 12 - 13 - ### ✅ Chunk 1: Foundation & Type System 14 - **Status**: Complete 15 - **Files**: 5 files, ~800 LOC 16 - **Location**: `lib/src/types.dart`, `lib/src/constants.dart`, etc. 17 - 18 - Core types and constants: 19 - - ClientMetadata, AuthorizeOptions, CallbackOptions 20 - - OAuth/OIDC constants 21 - - Utility functions (base64url, URL parsing, etc.) 22 - 23 - ### ✅ Chunk 2: Runtime & Crypto Abstractions 24 - **Status**: Complete 25 - **Files**: 4 files, ~500 LOC 26 - **Location**: `lib/src/runtime/`, `lib/src/utils/` 27 - 28 - Runtime abstractions: 29 - - RuntimeImplementation interface 30 - - Key interface (for JWT signing) 31 - - Lock implementation (for concurrency control) 32 - - PKCE generation, JWK thumbprints 33 - 34 - ### ✅ Chunk 3: Identity Resolution 35 - **Status**: Complete 36 - **Files**: 11 files, ~1,200 LOC 37 - **Location**: `lib/src/identity/` 38 - 39 - DID and handle resolution: 40 - - DID resolver (did:plc, did:web) 41 - - Handle resolver (XRPC-based) 42 - - DID document parsing 43 - - Caching with TTL 44 - 45 - ### ✅ Chunk 4: OAuth Metadata & Discovery 46 - **Status**: Complete 47 - **Files**: 5 files, ~800 LOC 48 - **Location**: `lib/src/oauth/` 49 - 50 - OAuth server discovery: 51 - - Authorization server metadata (/.well-known/oauth-authorization-server) 52 - - Protected resource metadata (/.well-known/oauth-protected-resource) 53 - - Client authentication negotiation 54 - - PAR (Pushed Authorization Request) support 55 - 56 - ### ✅ Chunk 5: DPoP (Demonstrating Proof of Possession) 57 - **Status**: Complete 58 - **Files**: 2 files, ~400 LOC 59 - **Location**: `lib/src/dpop/` 60 - 61 - DPoP implementation: 62 - - DPoP proof generation 63 - - Nonce management 64 - - Access token hash (ath claim) 65 - - Dio interceptor for automatic DPoP header injection 66 - 67 - ### ✅ Chunk 6: OAuth Flow & Session Management 68 - **Status**: Complete 69 - **Files**: 8 files, ~2,000 LOC 70 - **Location**: `lib/src/client/`, `lib/src/session/`, `lib/src/oauth/` 71 - 72 - Complete OAuth flow: 73 - - OAuthClient (main API) 74 - - Token management (access, refresh, ID tokens) 75 - - Session storage and retrieval 76 - - Automatic token refresh with concurrency control 77 - - Error handling and cleanup 78 - 79 - ### ✅ Chunk 7: Flutter Platform Layer (FINAL) 80 - **Status**: Complete 81 - **Files**: 4 files, ~1,100 LOC 82 - **Location**: `lib/src/platform/` 83 - 84 - Flutter-specific implementations: 85 - - FlutterOAuthClient (high-level API) 86 - - FlutterKey (EC keys with pointycastle) 87 - - FlutterRuntime (crypto operations) 88 - - FlutterSessionStore (secure storage) 89 - - In-memory caches with TTL 90 - 91 - ## Statistics 92 - 93 - ### Code 94 - - **Total Files**: ~40 Dart files 95 - - **Total Lines**: ~6,000 LOC (excluding tests) 96 - - **Core Library**: ~5,000 LOC 97 - - **Platform Layer**: ~1,100 LOC 98 - - **Examples**: ~200 LOC 99 - - **Documentation**: ~1,000 lines 100 - 101 - ### Compilation 102 - - ✅ **Zero errors** 103 - - ⚠️ 2 warnings (pre-existing, not from platform layer) 104 - - ℹ️ 68 info messages (style suggestions) 105 - 106 - ### Dependencies 107 - ```yaml 108 - dependencies: 109 - flutter_secure_storage: ^9.2.2 # Secure token storage 110 - flutter_web_auth_2: ^4.1.0 # Browser OAuth flow 111 - pointycastle: ^3.9.1 # EC cryptography 112 - crypto: ^3.0.3 # SHA hashing 113 - dio: ^5.9.0 # HTTP client 114 - ``` 115 - 116 - ## API Surface 117 - 118 - ### High-Level API (Recommended) 119 - 120 - ```dart 121 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 122 - 123 - // Initialize 124 - final client = FlutterOAuthClient( 125 - clientMetadata: ClientMetadata( 126 - clientId: 'https://example.com/client-metadata.json', 127 - redirectUris: ['myapp://oauth/callback'], 128 - ), 129 - ); 130 - 131 - // Sign in 132 - final session = await client.signIn('alice.bsky.social'); 133 - 134 - // Restore 135 - final restored = await client.restore(session.sub); 136 - 137 - // Revoke 138 - await client.revoke(session.sub); 139 - ``` 140 - 141 - ### Core API (Advanced) 142 - 143 - ```dart 144 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 145 - 146 - // Lower-level control with OAuthClient 147 - final client = OAuthClient( 148 - OAuthClientOptions( 149 - clientMetadata: {...}, 150 - sessionStore: CustomSessionStore(), 151 - runtimeImplementation: CustomRuntime(), 152 - // ... full control over all components 153 - ), 154 - ); 155 - 156 - // Manual flow 157 - final authUrl = await client.authorize('alice.bsky.social'); 158 - // ... open browser, handle callback 159 - final result = await client.callback(params); 160 - ``` 161 - 162 - ## Features Implemented 163 - 164 - ### OAuth 2.0 / OIDC 165 - - ✅ Authorization Code Flow with PKCE 166 - - ✅ Token refresh with automatic retry 167 - - ✅ Token revocation 168 - - ✅ PAR (Pushed Authorization Request) 169 - - ✅ Response modes (query, fragment) 170 - - ✅ State parameter (CSRF protection) 171 - - ✅ Nonce parameter (replay protection) 172 - 173 - ### atProto Specifics 174 - - ✅ DID resolution (did:plc, did:web) 175 - - ✅ Handle resolution (via XRPC) 176 - - ✅ PDS discovery 177 - - ✅ DPoP (Demonstrating Proof of Possession) 178 - - ✅ Multi-tenant authorization servers 179 - 180 - ### Security 181 - - ✅ Secure token storage (Keychain/EncryptedSharedPreferences) 182 - - ✅ DPoP key generation and signing 183 - - ✅ PKCE (code challenge/verifier) 184 - - ✅ Automatic session cleanup on errors 185 - - ✅ Concurrency control (lock for token refresh) 186 - - ✅ Input validation 187 - 188 - ### Platform 189 - - ✅ iOS support (URL schemes, Keychain) 190 - - ✅ Android support (Intent filters, EncryptedSharedPreferences) 191 - - ✅ FlutterWebAuth2 integration 192 - - ✅ Secure random number generation 193 - - ✅ EC key generation (ES256/ES384/ES512/ES256K) 194 - 195 - ## Testing Status 196 - 197 - ### Unit Tests 198 - - ❌ Not yet implemented 199 - - **Next step**: Add unit tests for core logic 200 - 201 - ### Integration Tests 202 - - ❌ Not yet implemented 203 - - **Next step**: Test with real OAuth servers 204 - 205 - ### Manual Testing 206 - - ⏳ **Ready for testing** 207 - - Test with: `bretton.dev` (your own atproto identity) 208 - 209 - ## Known Limitations 210 - 211 - ### 1. Key Serialization (Minor) 212 - DPoP keys are regenerated on app restart. This works but: 213 - - Old tokens require refresh (bound to old keys) 214 - - Slight performance impact 215 - 216 - **Impact**: Low - Automatic refresh handles this transparently 217 - **Fix**: Implement `Key.toJson()` / `Key.fromJson()` in `flutter_key.dart` 218 - 219 - ### 2. Local Lock Only (Minor) 220 - Lock is in-memory, doesn't work across: 221 - - Multiple isolates 222 - - Multiple processes 223 - 224 - **Impact**: Low - Most Flutter apps run in single isolate 225 - **Fix**: Implement platform-specific lock if needed 226 - 227 - ### 3. No Token Caching (Minor) 228 - Tokens aren't cached in memory between requests. 229 - 230 - **Impact**: Low - Secure storage is fast enough 231 - **Fix**: Add in-memory token cache if performance is critical 232 - 233 - ## Next Steps 234 - 235 - ### Immediate (Before Production) 236 - 1. ✅ **Complete implementation** - DONE 237 - 2. ⏳ **Manual testing** - Test sign-in flow with bretton.dev 238 - 3. ⏳ **Add unit tests** - Test core OAuth logic 239 - 4. ⏳ **Add integration tests** - Test with real servers 240 - 241 - ### Short-term 242 - 5. Fix key serialization (implement `Key.toJson()` / `fromJson()`) 243 - 6. Add comprehensive error handling examples 244 - 7. Add token introspection support 245 - 8. Add more example apps 246 - 247 - ### Long-term 248 - 9. Implement platform-specific locks (iOS/Android) 249 - 10. Add biometric authentication option 250 - 11. Add background token refresh 251 - 12. Performance optimizations (token caching) 252 - 253 - ## Files Created (Chunk 7) 254 - 255 - ### Core Platform Files 256 - 1. **`lib/src/platform/flutter_key.dart`** (429 lines) 257 - - EC key implementation with pointycastle 258 - - JWT signing (ES256/ES384/ES512/ES256K) 259 - - Key serialization (to/from JWK) 260 - 261 - 2. **`lib/src/platform/flutter_runtime.dart`** (91 lines) 262 - - RuntimeImplementation for Flutter 263 - - SHA hashing with crypto package 264 - - Secure random number generation 265 - - Local lock integration 266 - 267 - 3. **`lib/src/platform/flutter_stores.dart`** (355 lines) 268 - - FlutterSessionStore (secure storage) 269 - - FlutterStateStore (ephemeral state) 270 - - In-memory caches (metadata, nonces, DIDs, handles) 271 - 272 - 4. **`lib/src/platform/flutter_oauth_client.dart`** (235 lines) 273 - - High-level FlutterOAuthClient 274 - - Simplified sign-in API 275 - - FlutterWebAuth2 integration 276 - - Sensible defaults 277 - 278 - ### Documentation 279 - 5. **`lib/src/platform/README.md`** (~300 lines) 280 - - Architecture overview 281 - - Security features 282 - - Usage examples 283 - - Platform setup instructions 284 - 285 - 6. **`example/flutter_oauth_example.dart`** (~200 lines) 286 - - Complete usage example 287 - - All OAuth flows demonstrated 288 - - Platform configuration examples 289 - 290 - 7. **`lib/atproto_oauth_flutter.dart`** (updated) 291 - - Clean public API exports 292 - - Comprehensive library documentation 293 - 294 - ## Security Review 295 - 296 - ### ✅ Secure Storage 297 - - Tokens stored in flutter_secure_storage 298 - - iOS: Keychain with device encryption 299 - - Android: EncryptedSharedPreferences (AES-256) 300 - 301 - ### ✅ Cryptography 302 - - pointycastle for EC key generation (NIST curves) 303 - - crypto package for SHA hashing (FIPS 140-2 compliant) 304 - - Random.secure() for randomness (cryptographically secure) 305 - 306 - ### ✅ Token Binding 307 - - DPoP binds tokens to cryptographic keys 308 - - Every request includes signed proof 309 - - Prevents token theft 310 - 311 - ### ✅ Authorization Code Protection 312 - - PKCE with SHA-256 challenge 313 - - State parameter for CSRF protection 314 - - Nonce parameter for replay protection 315 - 316 - ### ✅ Concurrency Safety 317 - - Lock prevents concurrent token refresh 318 - - Automatic retry on refresh failure 319 - - Session cleanup on errors 320 - 321 - ## Production Readiness Checklist 322 - 323 - ### Code Quality 324 - - ✅ Zero compilation errors 325 - - ✅ Clean architecture (separation of concerns) 326 - - ✅ Comprehensive documentation 327 - - ✅ Type safety (null safety enabled) 328 - - ✅ Error handling throughout 329 - 330 - ### Security 331 - - ✅ Secure storage implementation 332 - - ✅ Proper cryptography (NIST curves, SHA-256+) 333 - - ✅ DPoP implementation 334 - - ✅ PKCE implementation 335 - - ✅ Input validation 336 - 337 - ### Functionality 338 - - ✅ Complete OAuth 2.0 flow 339 - - ✅ Token refresh 340 - - ✅ Token revocation 341 - - ✅ Session management 342 - - ✅ Identity resolution 343 - 344 - ### Platform Support 345 - - ✅ iOS support 346 - - ✅ Android support 347 - - ✅ Flutter 3.7.2+ compatible 348 - - ✅ Null safety enabled 349 - 350 - ### Documentation 351 - - ✅ API documentation 352 - - ✅ Usage examples 353 - - ✅ Platform setup guides 354 - - ✅ Security documentation 355 - 356 - ### Testing (TODO) 357 - - ⏳ Unit tests 358 - - ⏳ Integration tests 359 - - ⏳ Manual testing with real servers 360 - 361 - ## Comparison with TypeScript Original 362 - 363 - This Dart port maintains **1:1 feature parity** with the TypeScript implementation: 364 - 365 - | Feature | TypeScript | Dart/Flutter | Notes | 366 - |---------|-----------|--------------|-------| 367 - | OAuth 2.0 Core | ✅ | ✅ | Complete | 368 - | PKCE | ✅ | ✅ | SHA-256 | 369 - | DPoP | ✅ | ✅ | ES256/ES384/ES512/ES256K | 370 - | PAR | ✅ | ✅ | Pushed Authorization | 371 - | Token Refresh | ✅ | ✅ | With concurrency control | 372 - | DID Resolution | ✅ | ✅ | did:plc, did:web | 373 - | Handle Resolution | ✅ | ✅ | XRPC-based | 374 - | Secure Storage | ✅ (MMKV) | ✅ (flutter_secure_storage) | Platform-specific | 375 - | Crypto | ✅ (Web Crypto) | ✅ (pointycastle + crypto) | Platform-specific | 376 - | Key Serialization | ✅ | ⏳ | Minor limitation | 377 - 378 - ## Conclusion 379 - 380 - **The atproto_oauth_flutter library is COMPLETE and ready for testing!** 381 - 382 - All core functionality has been implemented with: 383 - - ✅ Zero errors 384 - - ✅ Production-grade security 385 - - ✅ Clean API 386 - - ✅ Comprehensive documentation 387 - 388 - **Next milestone**: Manual testing with bretton.dev OAuth flow. 389 - 390 - --- 391 - 392 - Generated: 2025-10-27 393 - Chunk 7 (FINAL): Flutter Platform Layer 394 - Status: ✅ **COMPLETE**
-1238
packages/atproto_oauth_flutter/README.md
··· 1 - # atproto_oauth_flutter 2 - 3 - **Official AT Protocol OAuth client for Flutter** - A complete 1:1 port of the TypeScript `@atproto/oauth-client` package. 4 - 5 - [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 6 - 7 - ## Table of Contents 8 - 9 - - [Overview](#overview) 10 - - [Why This Package?](#why-this-package) 11 - - [Features](#features) 12 - - [Installation](#installation) 13 - - [Quick Start](#quick-start) 14 - - [Platform Setup](#platform-setup) 15 - - [iOS Configuration](#ios-configuration) 16 - - [Android Configuration](#android-configuration) 17 - - [Router Integration](#router-integration-go_router-auto_route-etc) 18 - - [API Reference](#api-reference) 19 - - [FlutterOAuthClient (High-Level)](#flutteroauthclient-high-level) 20 - - [OAuthClient (Core)](#oauthclient-core) 21 - - [Types](#types) 22 - - [Errors](#errors) 23 - - [Usage Guide](#usage-guide) 24 - - [Sign In Flow](#sign-in-flow) 25 - - [Session Restoration](#session-restoration) 26 - - [Token Refresh](#token-refresh) 27 - - [Sign Out (Revoke)](#sign-out-revoke) 28 - - [Session Events](#session-events) 29 - - [Advanced Usage](#advanced-usage) 30 - - [Custom Storage Configuration](#custom-storage-configuration) 31 - - [Direct OAuthClient Usage](#direct-oauthclient-usage) 32 - - [Custom Identity Resolution](#custom-identity-resolution) 33 - - [Decentralization Explained](#decentralization-explained) 34 - - [Security Features](#security-features) 35 - - [OAuth Flows](#oauth-flows) 36 - - [Troubleshooting](#troubleshooting) 37 - - [Migration Guide](#migration-guide) 38 - - [Architecture](#architecture) 39 - - [Examples](#examples) 40 - - [Contributing](#contributing) 41 - - [License](#license) 42 - 43 - ## Overview 44 - 45 - `atproto_oauth_flutter` is a complete OAuth 2.0 + OpenID Connect client for the AT Protocol, designed specifically for Flutter applications. It handles the full authentication lifecycle including: 46 - 47 - - **Complete OAuth 2.0 Flow** - Authorization Code Flow with PKCE 48 - - **Automatic Token Management** - Refresh tokens automatically, handle expiration gracefully 49 - - **Secure Storage** - iOS Keychain and Android EncryptedSharedPreferences 50 - - **DPoP Security** - Token binding with cryptographic proof-of-possession 51 - - **Decentralized Discovery** - Works with ANY atProto PDS, not just bsky.social 52 - - **Production Ready** - Based on Bluesky's official TypeScript implementation 53 - 54 - ## Why This Package? 55 - 56 - ### The Problem with Existing Packages 57 - 58 - The existing `atproto_oauth` package has a **critical flaw**: it **hardcodes `bsky.social`** as the OAuth provider. This breaks the decentralized nature of the AT Protocol. 59 - 60 - **What this means:** 61 - - ❌ Only works with Bluesky's servers 62 - - ❌ Can't authenticate users on custom PDS instances 63 - - ❌ Defeats the purpose of decentralization 64 - - ❌ Your app won't work with the broader atProto ecosystem 65 - 66 - ### How This Package Solves It 67 - 68 - `atproto_oauth_flutter` implements **proper decentralized OAuth discovery**: 69 - 70 - ```dart 71 - // ✅ Works with ANY PDS: 72 - await client.signIn('alice.bsky.social'); // → https://bsky.app 73 - await client.signIn('bob.custom-pds.com'); // → https://custom-pds.com 74 - await client.signIn('bretton.dev'); // → https://pds.bretton.dev ✅ 75 - 76 - // The library automatically: 77 - // 1. Resolves handle → DID 78 - // 2. Fetches DID document 79 - // 3. Discovers PDS URL 80 - // 4. Discovers authorization server 81 - // 5. Completes OAuth flow with the correct server 82 - ``` 83 - 84 - **Bottom line:** This is the only Flutter package that properly implements decentralized atProto OAuth. 85 - 86 - ## Features 87 - 88 - ### OAuth 2.0 / OIDC Compliance 89 - - ✅ Authorization Code Flow with PKCE (SHA-256) 90 - - ✅ Automatic token refresh with concurrency control 91 - - ✅ Token revocation (best-effort) 92 - - ✅ PAR (Pushed Authorization Request) support 93 - - ✅ Response modes: query, fragment 94 - - ✅ State parameter (CSRF protection) 95 - - ✅ Nonce parameter (replay protection) 96 - 97 - ### atProto Specifics 98 - - ✅ **DID Resolution** - Supports `did:plc` and `did:web` 99 - - ✅ **Handle Resolution** - XRPC-based handle → DID resolution 100 - - ✅ **PDS Discovery** - Automatic PDS discovery from DID documents 101 - - ✅ **DPoP (Demonstrating Proof of Possession)** - Cryptographic token binding 102 - - ✅ **Multi-tenant Auth Servers** - Works with any authorization server 103 - 104 - ### Security 105 - - ✅ **Secure Storage** - iOS Keychain, Android EncryptedSharedPreferences 106 - - ✅ **DPoP Key Generation** - EC keys (ES256/ES384/ES512/ES256K) 107 - - ✅ **PKCE** - SHA-256 code challenge/verifier 108 - - ✅ **Automatic Cleanup** - Sessions deleted on errors 109 - - ✅ **Concurrency Control** - Lock prevents simultaneous token refresh 110 - - ✅ **Input Validation** - All inputs validated before use 111 - 112 - ### Platform Support 113 - - ✅ iOS (11.0+) with Keychain storage 114 - - ✅ Android (API 21+) with EncryptedSharedPreferences 115 - - ✅ Deep linking (custom URL schemes + HTTPS) 116 - - ✅ Flutter 3.7.2+ with null safety 117 - 118 - ## Installation 119 - 120 - Add this to your `pubspec.yaml`: 121 - 122 - ```yaml 123 - dependencies: 124 - atproto_oauth_flutter: 125 - path: packages/atproto_oauth_flutter # For local development 126 - 127 - # OR (when published to pub.dev): 128 - # atproto_oauth_flutter: ^0.1.0 129 - ``` 130 - 131 - Then install: 132 - 133 - ```bash 134 - flutter pub get 135 - ``` 136 - 137 - ## Quick Start 138 - 139 - Here's a complete working example to get you started in 5 minutes: 140 - 141 - ```dart 142 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 143 - 144 - void main() async { 145 - // 1. Initialize the client 146 - final client = FlutterOAuthClient( 147 - clientMetadata: ClientMetadata( 148 - clientId: 'http://localhost', // For development 149 - redirectUris: ['myapp://oauth/callback'], 150 - scope: 'atproto transition:generic', 151 - ), 152 - ); 153 - 154 - // 2. Sign in with a handle 155 - try { 156 - final session = await client.signIn('alice.bsky.social'); 157 - print('Signed in as: ${session.sub}'); 158 - 159 - // 3. Use the session for authenticated requests 160 - final info = await session.getTokenInfo(); 161 - print('Token expires: ${info.expiresAt}'); 162 - 163 - } on OAuthCallbackError catch (e) { 164 - print('OAuth error: ${e.error} - ${e.errorDescription}'); 165 - } 166 - 167 - // 4. Later: restore session on app restart 168 - final restored = await client.restore('did:plc:abc123'); 169 - 170 - // 5. Sign out 171 - await client.revoke('did:plc:abc123'); 172 - } 173 - ``` 174 - 175 - **Next step:** Configure platform deep linking (see [Platform Setup](#platform-setup)). 176 - 177 - ## Platform Setup 178 - 179 - OAuth requires deep linking to redirect back to your app after authentication. You must configure both platforms: 180 - 181 - ### iOS Configuration 182 - 183 - Add a custom URL scheme to `ios/Runner/Info.plist`: 184 - 185 - ```xml 186 - <key>CFBundleURLTypes</key> 187 - <array> 188 - <dict> 189 - <key>CFBundleURLSchemes</key> 190 - <array> 191 - <string>myapp</string> <!-- Your custom scheme --> 192 - </array> 193 - <key>CFBundleURLName</key> 194 - <string>com.example.myapp</string> 195 - </dict> 196 - </array> 197 - ``` 198 - 199 - **For HTTPS universal links** (production), also add: 200 - 201 - ```xml 202 - <key>com.apple.developer.associated-domains</key> 203 - <array> 204 - <string>applinks:example.com</string> 205 - </array> 206 - ``` 207 - 208 - Then create an `apple-app-site-association` file on your server at `https://example.com/.well-known/apple-app-site-association`. 209 - 210 - ### Android Configuration 211 - 212 - Add an intent filter to `android/app/src/main/AndroidManifest.xml`: 213 - 214 - ```xml 215 - <activity 216 - android:name=".MainActivity" 217 - ...> 218 - 219 - <!-- Existing intent filters --> 220 - 221 - <!-- OAuth callback intent filter --> 222 - <intent-filter> 223 - <action android:name="android.intent.action.VIEW" /> 224 - <category android:name="android.intent.category.DEFAULT" /> 225 - <category android:name="android.intent.category.BROWSABLE" /> 226 - 227 - <!-- Custom URL scheme --> 228 - <data android:scheme="myapp" /> 229 - </intent-filter> 230 - 231 - <!-- For HTTPS universal links (production) --> 232 - <intent-filter android:autoVerify="true"> 233 - <action android:name="android.intent.action.VIEW" /> 234 - <category android:name="android.intent.category.DEFAULT" /> 235 - <category android:name="android.intent.category.BROWSABLE" /> 236 - 237 - <data android:scheme="https" /> 238 - <data android:host="example.com" /> 239 - <data android:pathPrefix="/oauth/callback" /> 240 - </intent-filter> 241 - </activity> 242 - ``` 243 - 244 - **For HTTPS universal links**, also create a `assetlinks.json` file at `https://example.com/.well-known/assetlinks.json`. 245 - 246 - ### Verify Deep Linking 247 - 248 - Test that deep linking works: 249 - 250 - ```bash 251 - # iOS (simulator) 252 - xcrun simctl openurl booted "myapp://oauth/callback?code=test" 253 - 254 - # Android (emulator or device) 255 - adb shell am start -W -a android.intent.action.VIEW -d "myapp://oauth/callback?code=test" 256 - ``` 257 - 258 - If your app opens, deep linking is configured correctly. 259 - 260 - ### Router Integration (go_router, auto_route, etc.) 261 - 262 - **⚠️ Important:** If you're using declarative routing packages like `go_router` or `auto_route`, you MUST configure them to ignore OAuth callback deep links. Otherwise, the router will intercept the callback and OAuth will fail with "User canceled login". 263 - 264 - #### Why This is Needed 265 - 266 - When the OAuth server redirects back to your app with the authorization code, your router may try to handle the deep link before `flutter_web_auth_2` can capture it. This causes the OAuth flow to fail. 267 - 268 - #### Solution: Use FlutterOAuthRouterHelper 269 - 270 - We provide a helper that makes router configuration easy: 271 - 272 - **With go_router** (Recommended approach): 273 - 274 - ```dart 275 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 276 - import 'package:go_router/go_router.dart'; 277 - 278 - final router = GoRouter( 279 - routes: [ 280 - // Your app routes... 281 - ], 282 - // Use the helper to automatically ignore OAuth callbacks 283 - redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( 284 - customSchemes: ['myapp'], // Your custom URL scheme(s) 285 - ), 286 - ); 287 - ``` 288 - 289 - **Manual configuration** (if you need custom redirect logic): 290 - 291 - ```dart 292 - final router = GoRouter( 293 - routes: [...], 294 - redirect: (context, state) { 295 - // Check if this is an OAuth callback 296 - if (FlutterOAuthRouterHelper.isOAuthCallback( 297 - state.uri, 298 - customSchemes: ['myapp'], 299 - )) { 300 - return null; // Let flutter_web_auth_2 handle it 301 - } 302 - 303 - // Your custom redirect logic here 304 - if (!isAuthenticated) return '/login'; 305 - 306 - return null; // Normal routing 307 - }, 308 - ); 309 - ``` 310 - 311 - **Extract scheme from your OAuth config:** 312 - 313 - ```dart 314 - final scheme = FlutterOAuthRouterHelper.extractScheme( 315 - 'myapp://oauth/callback' 316 - ); 317 - // Returns: 'myapp' 318 - 319 - // Use it in your router config 320 - redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( 321 - customSchemes: [scheme], 322 - ), 323 - ``` 324 - 325 - #### Other Routers 326 - 327 - The same concept applies to other routing packages: 328 - 329 - - **auto_route**: Use guards to ignore OAuth callback routes 330 - - **beamer**: Configure `beamGuard` to skip OAuth URIs 331 - - **fluro**: Add a custom route handler that ignores OAuth schemes 332 - 333 - The key is to **not process URIs with your custom OAuth scheme** - let `flutter_web_auth_2` handle them. 334 - 335 - ## API Reference 336 - 337 - ### FlutterOAuthClient (High-Level) 338 - 339 - **Recommended for most apps.** Provides a simplified API with sensible defaults. 340 - 341 - #### Constructor 342 - 343 - ```dart 344 - FlutterOAuthClient({ 345 - required ClientMetadata clientMetadata, 346 - OAuthResponseMode responseMode = OAuthResponseMode.query, 347 - bool allowHttp = false, 348 - FlutterSecureStorage? secureStorage, 349 - Dio? dio, 350 - String? plcDirectoryUrl, 351 - String? handleResolverUrl, 352 - }) 353 - ``` 354 - 355 - **Parameters:** 356 - 357 - - `clientMetadata` (required) - Client configuration (see [ClientMetadata](#clientmetadata)) 358 - - `responseMode` - How OAuth parameters are returned: `query` (default, URL query string) or `fragment` (URL fragment) 359 - - `allowHttp` - Allow HTTP connections for development (default: `false`, **never use in production**) 360 - - `secureStorage` - Custom `FlutterSecureStorage` instance (optional) 361 - - `dio` - Custom HTTP client (optional) 362 - - `plcDirectoryUrl` - Custom PLC directory URL (default: `https://plc.directory`) 363 - - `handleResolverUrl` - Custom handle resolver URL (default: `https://bsky.social`) 364 - 365 - #### Methods 366 - 367 - ##### `signIn()` 368 - 369 - Complete OAuth sign-in flow (authorize + browser + callback). 370 - 371 - ```dart 372 - Future<OAuthSession> signIn( 373 - String input, { 374 - AuthorizeOptions? options, 375 - CancelToken? cancelToken, 376 - }) 377 - ``` 378 - 379 - **Parameters:** 380 - 381 - - `input` - Handle (e.g., `"alice.bsky.social"`), DID (e.g., `"did:plc:..."`), PDS URL, or auth server URL 382 - - `options` - Additional OAuth parameters (optional, see [AuthorizeOptions](#authorizeoptions)) 383 - - `cancelToken` - Dio cancellation token (optional) 384 - 385 - **Returns:** `OAuthSession` - Authenticated session 386 - 387 - **Throws:** 388 - - `FormatException` - Invalid parameters 389 - - `OAuthResolverError` - Identity/server resolution failed 390 - - `OAuthCallbackError` - OAuth error from server 391 - - `FlutterWebAuth2UserCanceled` - User cancelled browser flow 392 - 393 - **Example:** 394 - 395 - ```dart 396 - // Simple sign-in 397 - final session = await client.signIn('alice.bsky.social'); 398 - 399 - // With custom state 400 - final session = await client.signIn( 401 - 'alice.bsky.social', 402 - options: AuthorizeOptions(state: 'my-app-state'), 403 - ); 404 - ``` 405 - 406 - ##### `restore()` 407 - 408 - Restore a stored session (automatically refreshes if expired). 409 - 410 - ```dart 411 - Future<OAuthSession> restore( 412 - String sub, { 413 - dynamic refresh = 'auto', 414 - CancelToken? cancelToken, 415 - }) 416 - ``` 417 - 418 - **Parameters:** 419 - 420 - - `sub` - User's DID (e.g., `"did:plc:abc123"`) 421 - - `refresh` - Token refresh strategy: 422 - - `'auto'` (default) - Refresh only if expired 423 - - `true` - Force refresh even if not expired 424 - - `false` - Use cached tokens even if expired 425 - - `cancelToken` - Dio cancellation token (optional) 426 - 427 - **Returns:** `OAuthSession` - Restored session 428 - 429 - **Throws:** 430 - - `Exception` - Session not found 431 - - `TokenRefreshError` - Refresh failed 432 - - `AuthMethodUnsatisfiableError` - Auth method not supported 433 - 434 - **Example:** 435 - 436 - ```dart 437 - // Auto-refresh if expired 438 - final session = await client.restore('did:plc:abc123'); 439 - 440 - // Force refresh 441 - final fresh = await client.restore('did:plc:abc123', refresh: true); 442 - ``` 443 - 444 - ##### `revoke()` 445 - 446 - Revoke a session (sign out). 447 - 448 - ```dart 449 - Future<void> revoke( 450 - String sub, { 451 - CancelToken? cancelToken, 452 - }) 453 - ``` 454 - 455 - **Parameters:** 456 - 457 - - `sub` - User's DID 458 - - `cancelToken` - Dio cancellation token (optional) 459 - 460 - **Behavior:** 461 - - Calls server's token revocation endpoint (best-effort) 462 - - Deletes session from local storage (always) 463 - - Emits `deleted` event 464 - 465 - **Example:** 466 - 467 - ```dart 468 - await client.revoke('did:plc:abc123'); 469 - ``` 470 - 471 - #### Properties 472 - 473 - ##### `onUpdated` 474 - 475 - Stream of session update events (token refresh, etc.). 476 - 477 - ```dart 478 - Stream<SessionUpdatedEvent> get onUpdated 479 - ``` 480 - 481 - **Example:** 482 - 483 - ```dart 484 - client.onUpdated.listen((event) { 485 - print('Session ${event.sub} updated'); 486 - }); 487 - ``` 488 - 489 - ##### `onDeleted` 490 - 491 - Stream of session deletion events (revoke, expiry, errors). 492 - 493 - ```dart 494 - Stream<SessionDeletedEvent> get onDeleted 495 - ``` 496 - 497 - **Example:** 498 - 499 - ```dart 500 - client.onDeleted.listen((event) { 501 - print('Session ${event.sub} deleted: ${event.cause}'); 502 - // Navigate to sign-in screen 503 - }); 504 - ``` 505 - 506 - --- 507 - 508 - ### OAuthClient (Core) 509 - 510 - **For advanced use cases.** Provides lower-level control over the OAuth flow. 511 - 512 - #### Constructor 513 - 514 - ```dart 515 - OAuthClient(OAuthClientOptions options) 516 - ``` 517 - 518 - See [OAuthClientOptions](#oauthclientoptions) for all parameters. 519 - 520 - #### Methods 521 - 522 - ##### `authorize()` 523 - 524 - Start OAuth authorization flow (returns URL to open in browser). 525 - 526 - ```dart 527 - Future<Uri> authorize( 528 - String input, { 529 - AuthorizeOptions? options, 530 - CancelToken? cancelToken, 531 - }) 532 - ``` 533 - 534 - **Parameters:** Same as `signIn()` but returns URL instead of completing flow. 535 - 536 - **Returns:** `Uri` - Authorization URL to open in browser 537 - 538 - **Throws:** Same as `signIn()` 539 - 540 - **Example:** 541 - 542 - ```dart 543 - final authUrl = await client.authorize('alice.bsky.social'); 544 - // Open authUrl in browser yourself 545 - ``` 546 - 547 - ##### `callback()` 548 - 549 - Handle OAuth callback after user authorization. 550 - 551 - ```dart 552 - Future<CallbackResult> callback( 553 - Map<String, String> params, { 554 - CallbackOptions? options, 555 - CancelToken? cancelToken, 556 - }) 557 - ``` 558 - 559 - **Parameters:** 560 - 561 - - `params` - Query/fragment parameters from callback URL 562 - - `options` - Callback options (see [CallbackOptions](#callbackoptions)) 563 - - `cancelToken` - Dio cancellation token (optional) 564 - 565 - **Returns:** `CallbackResult` - Contains session and app state 566 - 567 - **Throws:** 568 - - `OAuthCallbackError` - OAuth error or invalid callback 569 - 570 - **Example:** 571 - 572 - ```dart 573 - // Extract params from callback URL 574 - final uri = Uri.parse(callbackUrl); 575 - final params = uri.queryParameters; 576 - 577 - // Complete OAuth flow 578 - final result = await client.callback(params); 579 - print('Signed in: ${result.session.sub}'); 580 - print('App state: ${result.state}'); 581 - ``` 582 - 583 - ##### `restore()` and `revoke()` 584 - 585 - Same as `FlutterOAuthClient`. 586 - 587 - #### Static Methods 588 - 589 - ##### `fetchMetadata()` 590 - 591 - Fetch client metadata from a discoverable client ID URL. 592 - 593 - ```dart 594 - static Future<Map<String, dynamic>> fetchMetadata( 595 - OAuthClientFetchMetadataOptions options, 596 - ) 597 - ``` 598 - 599 - **Parameters:** 600 - 601 - - `options.clientId` - HTTPS URL to client metadata JSON 602 - - `options.dio` - Custom HTTP client (optional) 603 - - `options.cancelToken` - Cancellation token (optional) 604 - 605 - **Returns:** Client metadata as JSON 606 - 607 - **Example:** 608 - 609 - ```dart 610 - final metadata = await OAuthClient.fetchMetadata( 611 - OAuthClientFetchMetadataOptions( 612 - clientId: 'https://example.com/client-metadata.json', 613 - ), 614 - ); 615 - ``` 616 - 617 - #### Properties 618 - 619 - Same as `FlutterOAuthClient` (`onUpdated`, `onDeleted`). 620 - 621 - --- 622 - 623 - ### Types 624 - 625 - #### ClientMetadata 626 - 627 - OAuth client configuration. 628 - 629 - ```dart 630 - class ClientMetadata { 631 - final String? clientId; 632 - final List<String> redirectUris; 633 - final List<String> responseTypes; 634 - final List<String> grantTypes; 635 - final String? scope; 636 - final String tokenEndpointAuthMethod; 637 - final String? tokenEndpointAuthSigningAlg; 638 - final String? jwksUri; 639 - final Map<String, dynamic>? jwks; 640 - final String applicationType; 641 - final String subjectType; 642 - final String authorizationSignedResponseAlg; 643 - final String? clientName; 644 - final String? clientUri; 645 - final String? policyUri; 646 - final String? tosUri; 647 - final String? logoUri; 648 - final int? defaultMaxAge; 649 - final bool? requireAuthTime; 650 - final List<String>? contacts; 651 - final bool? dpopBoundAccessTokens; 652 - final List<String>? authorizationDetailsTypes; 653 - 654 - // ... more fields 655 - } 656 - ``` 657 - 658 - **Key Fields:** 659 - 660 - - `clientId` - Client identifier: 661 - - Discoverable: HTTPS URL to client metadata JSON (production) 662 - - Loopback: `http://localhost` (development only) 663 - - `redirectUris` - Array of valid redirect URIs (must match deep link configuration) 664 - - `scope` - Requested scope (default: `"atproto"`, recommended: `"atproto transition:generic"`) 665 - - `clientName` - Human-readable app name 666 - - `dpopBoundAccessTokens` - Enable DPoP (recommended: `true`) 667 - 668 - **Example:** 669 - 670 - ```dart 671 - // Development (loopback client) 672 - final metadata = ClientMetadata( 673 - clientId: 'http://localhost', 674 - redirectUris: ['myapp://oauth/callback'], 675 - scope: 'atproto transition:generic', 676 - ); 677 - 678 - // Production (discoverable client) 679 - final metadata = ClientMetadata( 680 - clientId: 'https://example.com/client-metadata.json', 681 - redirectUris: [ 682 - 'myapp://oauth/callback', // Custom scheme 683 - 'https://example.com/oauth/callback' // Universal link 684 - ], 685 - scope: 'atproto transition:generic', 686 - clientName: 'My Awesome App', 687 - clientUri: 'https://example.com', 688 - dpopBoundAccessTokens: true, 689 - ); 690 - ``` 691 - 692 - #### AuthorizeOptions 693 - 694 - Additional parameters for `authorize()` / `signIn()`. 695 - 696 - ```dart 697 - class AuthorizeOptions { 698 - final String? redirectUri; 699 - final String? state; 700 - final String? scope; 701 - final String? nonce; 702 - final String? display; 703 - final String? prompt; 704 - final int? maxAge; 705 - final Map<String, dynamic>? claims; 706 - final String? uiLocales; 707 - final String? idTokenHint; 708 - final Map<String, dynamic>? authorizationDetails; 709 - } 710 - ``` 711 - 712 - **Key Fields:** 713 - 714 - - `redirectUri` - Override default redirect URI 715 - - `state` - Application state to preserve (returned in callback) 716 - - `scope` - Override default scope 717 - - `display` - Display mode: `"touch"` (default for mobile), `"page"`, `"popup"` 718 - - `prompt` - Prompt user: `"none"`, `"login"`, `"consent"`, `"select_account"` 719 - 720 - **Example:** 721 - 722 - ```dart 723 - final session = await client.signIn( 724 - 'alice.bsky.social', 725 - options: AuthorizeOptions( 726 - state: jsonEncode({'returnTo': '/home'}), 727 - prompt: 'login', // Force re-authentication 728 - ), 729 - ); 730 - ``` 731 - 732 - #### CallbackOptions 733 - 734 - Options for `callback()`. 735 - 736 - ```dart 737 - class CallbackOptions { 738 - final String? redirectUri; 739 - } 740 - ``` 741 - 742 - **Note:** `redirectUri` must match the one used in `authorize()`. 743 - 744 - #### OAuthSession 745 - 746 - Authenticated session with token management. 747 - 748 - ```dart 749 - class OAuthSession { 750 - final OAuthServerAgent server; 751 - final String sub; // User's DID 752 - 753 - // Properties 754 - String get did => sub; 755 - Map<String, dynamic> get serverMetadata; 756 - 757 - // Methods 758 - Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']); 759 - Future<void> signOut(); 760 - Future<http.Response> fetchHandler( 761 - String pathname, { 762 - String method = 'GET', 763 - Map<String, String>? headers, 764 - dynamic body, 765 - }); 766 - } 767 - ``` 768 - 769 - **Key Methods:** 770 - 771 - - `getTokenInfo()` - Get current token info (automatically refreshes if expired) 772 - - `signOut()` - Revoke tokens and delete session 773 - - `fetchHandler()` - Make authenticated HTTP request (with auto-refresh and DPoP) 774 - 775 - **Example:** 776 - 777 - ```dart 778 - final session = await client.signIn('alice.bsky.social'); 779 - 780 - // Get token info 781 - final info = await session.getTokenInfo(); 782 - print('Expires: ${info.expiresAt}'); 783 - print('Scope: ${info.scope}'); 784 - 785 - // Make authenticated request 786 - final response = await session.fetchHandler( 787 - '/xrpc/com.atproto.repo.getRecord', 788 - method: 'GET', 789 - ); 790 - ``` 791 - 792 - #### TokenInfo 793 - 794 - Information about the current access token. 795 - 796 - ```dart 797 - class TokenInfo { 798 - final DateTime? expiresAt; 799 - final bool? expired; 800 - final String scope; 801 - final String iss; // Issuer URL 802 - final String aud; // Audience (PDS URL) 803 - final String sub; // User's DID 804 - } 805 - ``` 806 - 807 - --- 808 - 809 - ### Errors 810 - 811 - All errors extend `Exception` and can be caught with standard try-catch. 812 - 813 - #### OAuthCallbackError 814 - 815 - OAuth error from server or invalid callback. 816 - 817 - ```dart 818 - class OAuthCallbackError implements Exception { 819 - final String? error; // OAuth error code 820 - final String? errorDescription; // Human-readable description 821 - final String? errorUri; // URL with more info 822 - final String? state; // App state from authorize 823 - final Map<String, String> params; // All callback parameters 824 - } 825 - ``` 826 - 827 - **Common error codes:** 828 - - `access_denied` - User denied authorization 829 - - `invalid_request` - Invalid parameters 830 - - `server_error` - Server error 831 - 832 - **Example:** 833 - 834 - ```dart 835 - try { 836 - final session = await client.signIn('alice.bsky.social'); 837 - } on OAuthCallbackError catch (e) { 838 - if (e.error == 'access_denied') { 839 - print('User cancelled sign-in'); 840 - } else { 841 - print('OAuth error: ${e.error} - ${e.errorDescription}'); 842 - } 843 - } 844 - ``` 845 - 846 - #### OAuthResolverError 847 - 848 - Failed to resolve identity or discover OAuth server. 849 - 850 - **When thrown:** 851 - - Handle doesn't resolve 852 - - DID document not found 853 - - PDS URL missing from DID document 854 - - OAuth server metadata not found 855 - 856 - #### TokenRefreshError 857 - 858 - Failed to refresh access token. 859 - 860 - **When thrown:** 861 - - Refresh token expired 862 - - Refresh token revoked 863 - - Network error 864 - - Server error 865 - 866 - #### TokenRevokedError 867 - 868 - Token was revoked (intentional sign-out). 869 - 870 - #### TokenInvalidError 871 - 872 - Token is invalid (rejected by resource server). 873 - 874 - #### AuthMethodUnsatisfiableError 875 - 876 - Client authentication method not supported. 877 - 878 - --- 879 - 880 - ## Usage Guide 881 - 882 - ### Sign In Flow 883 - 884 - Complete example with error handling: 885 - 886 - ```dart 887 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 888 - 889 - Future<void> signIn(String handle) async { 890 - final client = FlutterOAuthClient( 891 - clientMetadata: ClientMetadata( 892 - clientId: 'http://localhost', 893 - redirectUris: ['myapp://oauth/callback'], 894 - scope: 'atproto transition:generic', 895 - ), 896 - ); 897 - 898 - try { 899 - final session = await client.signIn(handle); 900 - 901 - print('✓ Signed in successfully!'); 902 - print(' DID: ${session.sub}'); 903 - 904 - final info = await session.getTokenInfo(); 905 - print(' Expires: ${info.expiresAt}'); 906 - 907 - } on OAuthCallbackError catch (e) { 908 - if (e.error == 'access_denied') { 909 - print('User denied authorization'); 910 - } else { 911 - print('OAuth error: ${e.error}'); 912 - } 913 - } catch (e) { 914 - print('Unexpected error: $e'); 915 - } 916 - } 917 - ``` 918 - 919 - ### Session Restoration 920 - 921 - Restore session when app restarts: 922 - 923 - ```dart 924 - Future<OAuthSession?> restoreSession(FlutterOAuthClient client) async { 925 - final did = await loadSavedDid(); 926 - if (did == null) return null; 927 - 928 - try { 929 - final session = await client.restore(did); 930 - print('✓ Session restored for ${session.sub}'); 931 - return session; 932 - 933 - } on TokenRefreshError catch (e) { 934 - print('❌ Session refresh failed: ${e.message}'); 935 - await clearSavedDid(); 936 - return null; 937 - } 938 - } 939 - ``` 940 - 941 - ### Token Refresh 942 - 943 - Tokens are refreshed **automatically**: 944 - 945 - ```dart 946 - // Auto-refresh (default) 947 - final session = await client.restore(did); 948 - 949 - // Force refresh 950 - final fresh = await client.restore(did, refresh: true); 951 - 952 - // Check token status 953 - final info = await session.getTokenInfo(); 954 - if (info.expired == true) { 955 - print('Token will refresh on next API call'); 956 - } 957 - ``` 958 - 959 - ### Sign Out (Revoke) 960 - 961 - ```dart 962 - Future<void> signOut(FlutterOAuthClient client, String did) async { 963 - try { 964 - await client.revoke(did); 965 - print('✓ Signed out successfully'); 966 - await clearSavedDid(); 967 - } catch (e) { 968 - print('⚠ Revoke failed: $e'); 969 - await clearSavedDid(); 970 - } 971 - } 972 - ``` 973 - 974 - ### Session Events 975 - 976 - ```dart 977 - void setupSessionListeners(FlutterOAuthClient client) { 978 - client.onUpdated.listen((event) { 979 - print('Session updated: ${event.sub}'); 980 - }); 981 - 982 - client.onDeleted.listen((event) { 983 - print('Session deleted: ${event.sub}'); 984 - navigateToSignIn(); 985 - }); 986 - } 987 - ``` 988 - 989 - --- 990 - 991 - ## Advanced Usage 992 - 993 - ### Custom Storage Configuration 994 - 995 - ```dart 996 - final client = FlutterOAuthClient( 997 - clientMetadata: metadata, 998 - secureStorage: FlutterSecureStorage( 999 - iOptions: IOSOptions( 1000 - accessibility: KeychainAccessibility.first_unlock, 1001 - ), 1002 - aOptions: AndroidOptions( 1003 - encryptedSharedPreferences: true, 1004 - ), 1005 - ), 1006 - ); 1007 - ``` 1008 - 1009 - ### Direct OAuthClient Usage 1010 - 1011 - For full control over the OAuth flow: 1012 - 1013 - ```dart 1014 - final client = OAuthClient( 1015 - OAuthClientOptions( 1016 - responseMode: OAuthResponseMode.query, 1017 - clientMetadata: metadata.toJson(), 1018 - stateStore: MyCustomStateStore(), 1019 - sessionStore: MyCustomSessionStore(), 1020 - runtimeImplementation: FlutterRuntime(), 1021 - ), 1022 - ); 1023 - 1024 - // Manual flow 1025 - final authUrl = await client.authorize('alice.bsky.social'); 1026 - // Open browser yourself 1027 - final result = await client.callback(params); 1028 - ``` 1029 - 1030 - --- 1031 - 1032 - ## Decentralization Explained 1033 - 1034 - This is the **critical feature** that sets this package apart. 1035 - 1036 - ### The Problem: Hardcoded Servers 1037 - 1038 - ```dart 1039 - // ❌ BROKEN - Only works with bsky.social 1040 - const authServer = 'https://bsky.social'; // Hardcoded! 1041 - ``` 1042 - 1043 - ### The Solution: Dynamic Discovery 1044 - 1045 - ```dart 1046 - // ✅ CORRECT - Discovers auth server dynamically 1047 - await client.signIn('bob.custom-pds.com'); 1048 - 1049 - // What happens: 1050 - // 1. Resolve handle → DID 1051 - // 2. Fetch DID document 1052 - // 3. Discover PDS URL 1053 - // 4. Fetch PDS metadata 1054 - // 5. Discover authorization server 1055 - // 6. Complete OAuth with correct server ✅ 1056 - ``` 1057 - 1058 - ### Why This Matters 1059 - 1060 - **atProto is decentralized.** Users can host their data on any PDS. Your app should work with ALL of them. 1061 - 1062 - ### Real-World Example 1063 - 1064 - ```dart 1065 - // Alice uses Bluesky 1066 - await client.signIn('alice.bsky.social'); 1067 - // → https://bsky.app 1068 - 1069 - // Bob runs his own 1070 - await client.signIn('bob.example.com'); 1071 - // → https://auth.example.com 1072 - 1073 - // All work! 🎉 1074 - ``` 1075 - 1076 - --- 1077 - 1078 - ## Security Features 1079 - 1080 - ### Secure Token Storage 1081 - 1082 - - **iOS:** Keychain with device encryption 1083 - - **Android:** EncryptedSharedPreferences (AES-256) 1084 - 1085 - ### DPoP (Token Binding) 1086 - 1087 - - Binds tokens to cryptographic keys 1088 - - Prevents token theft 1089 - - Every request includes signed proof 1090 - 1091 - ### PKCE (Code Protection) 1092 - 1093 - - SHA-256 challenge/verifier 1094 - - Prevents code interception 1095 - 1096 - ### State Parameter 1097 - 1098 - - CSRF protection 1099 - - One-time use 1100 - 1101 - --- 1102 - 1103 - ## OAuth Flows 1104 - 1105 - ### Authorization Flow 1106 - 1107 - ``` 1108 - App → Resolve identity → Discover servers → Generate PKCE/DPoP 1109 - → Open browser → User authenticates → Callback → Exchange code 1110 - → Store session → Return OAuthSession 1111 - ``` 1112 - 1113 - ### Token Refresh Flow 1114 - 1115 - ``` 1116 - API call → Detect expiration → Acquire lock → Refresh tokens 1117 - → Update storage → Release lock → Retry API call 1118 - ``` 1119 - 1120 - --- 1121 - 1122 - ## Troubleshooting 1123 - 1124 - ### Deep Linking Not Working 1125 - 1126 - 1. Check platform configuration (Info.plist / AndroidManifest.xml) 1127 - 2. Test manually: `xcrun simctl openurl booted "myapp://..."` 1128 - 3. Verify URL scheme matches `redirectUris` 1129 - 1130 - ### OAuth Errors 1131 - 1132 - - `invalid_request` - Check ClientMetadata 1133 - - `access_denied` - User cancelled 1134 - - `server_error` - Check server status 1135 - 1136 - ### Token Refresh Failures 1137 - 1138 - - Token expired → User must re-authenticate 1139 - - Session auto-deleted on failure 1140 - 1141 - --- 1142 - 1143 - ## Migration Guide 1144 - 1145 - ### From `atproto_oauth` 1146 - 1147 - **Before (Broken):** 1148 - ```dart 1149 - // Only works with bsky.social 1150 - final session = await client.signIn('bob.custom-pds.com'); // BROKEN 1151 - ``` 1152 - 1153 - **After (Fixed):** 1154 - ```dart 1155 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 1156 - 1157 - final client = FlutterOAuthClient( 1158 - clientMetadata: ClientMetadata( 1159 - clientId: 'http://localhost', 1160 - redirectUris: ['myapp://oauth/callback'], 1161 - ), 1162 - ); 1163 - 1164 - final session = await client.signIn('bob.custom-pds.com'); // WORKS! 1165 - ``` 1166 - 1167 - --- 1168 - 1169 - ## Architecture 1170 - 1171 - Built in **7 layers** matching TypeScript original: 1172 - 1173 - 1. **Foundation** - Types, constants, utilities 1174 - 2. **Runtime** - Crypto abstractions, PKCE, keys 1175 - 3. **Identity Resolution** - DID/handle → PDS discovery (**critical for decentralization**) 1176 - 4. **OAuth Discovery** - Dynamic server metadata fetching 1177 - 5. **DPoP** - Token binding proofs 1178 - 6. **OAuth Flow** - Authorization, tokens, sessions 1179 - 7. **Flutter Platform** - Secure storage, crypto implementation 1180 - 1181 - --- 1182 - 1183 - ## Examples 1184 - 1185 - See `example/flutter_oauth_example.dart` for complete examples. 1186 - 1187 - ### Minimal Example 1188 - 1189 - ```dart 1190 - final client = FlutterOAuthClient( 1191 - clientMetadata: ClientMetadata( 1192 - clientId: 'http://localhost', 1193 - redirectUris: ['myapp://oauth/callback'], 1194 - ), 1195 - ); 1196 - 1197 - final session = await client.signIn('alice.bsky.social'); 1198 - print('Signed in: ${session.sub}'); 1199 - ``` 1200 - 1201 - --- 1202 - 1203 - ## Contributing 1204 - 1205 - Contributions welcome! Please: 1206 - 1. Fork the repo 1207 - 2. Create feature branch 1208 - 3. Run `flutter analyze` 1209 - 4. Submit PR 1210 - 1211 - --- 1212 - 1213 - ## License 1214 - 1215 - MIT License - See LICENSE file 1216 - 1217 - --- 1218 - 1219 - ## Credits 1220 - 1221 - - **Based on:** Official Bluesky [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) 1222 - - **Architecture:** 1:1 port maintaining API compatibility 1223 - 1224 - --- 1225 - 1226 - ## Status 1227 - 1228 - **Version:** 0.1.0 1229 - **Status:** ✅ Complete - Ready for Testing 1230 - 1231 - **Next:** 1232 - - Manual testing with real servers 1233 - - Unit/integration tests 1234 - - Publish to pub.dev 1235 - 1236 - --- 1237 - 1238 - **Made with ❤️ for the decentralized web**
-220
packages/atproto_oauth_flutter/example/flutter_oauth_example.dart
··· 1 - /// Example usage of FlutterOAuthClient for atProto OAuth authentication. 2 - /// 3 - /// This demonstrates the complete OAuth flow for a Flutter application: 4 - /// 1. Initialize the client 5 - /// 2. Sign in with a handle 6 - /// 3. Use the authenticated session 7 - /// 4. Restore session on app restart 8 - /// 5. Sign out (revoke session) 9 - 10 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 11 - 12 - void main() async { 13 - // ======================================================================== 14 - // 1. Initialize the OAuth client 15 - // ======================================================================== 16 - 17 - final client = FlutterOAuthClient( 18 - clientMetadata: ClientMetadata( 19 - // For development: use loopback client (no client metadata URL needed) 20 - clientId: 'http://localhost', 21 - 22 - // For production: use discoverable client metadata 23 - // clientId: 'https://example.com/client-metadata.json', 24 - 25 - // Redirect URIs for your app 26 - // - Custom URL scheme: myapp://oauth/callback 27 - // - Universal links: https://example.com/oauth/callback 28 - redirectUris: ['myapp://oauth/callback'], 29 - 30 - // Scope: what permissions to request 31 - // - 'atproto': Full atproto access 32 - // - 'transition:generic': Additional permissions for legacy systems 33 - scope: 'atproto transition:generic', 34 - 35 - // Client metadata 36 - clientName: 'My Awesome App', 37 - clientUri: 'https://example.com', 38 - 39 - // Token binding 40 - dpopBoundAccessTokens: true, // Enable DPoP for security 41 - ), 42 - 43 - // Response mode (query or fragment) 44 - responseMode: OAuthResponseMode.query, 45 - 46 - // Allow HTTP only for development (never in production!) 47 - allowHttp: false, 48 - ); 49 - 50 - // ======================================================================== 51 - // 2. Sign in with a handle 52 - // ======================================================================== 53 - 54 - try { 55 - print('Starting sign-in flow for alice.bsky.social...'); 56 - 57 - // This will: 58 - // 1. Resolve the handle to find the authorization server 59 - // 2. Generate PKCE code challenge/verifier 60 - // 3. Generate DPoP key 61 - // 4. Open browser for user authentication 62 - // 5. Handle OAuth callback 63 - // 6. Exchange authorization code for tokens 64 - // 7. Store session securely 65 - final session = await client.signIn('alice.bsky.social'); 66 - 67 - print('✓ Signed in successfully!'); 68 - print(' DID: ${session.sub}'); 69 - print(' Session info: ${session.info}'); 70 - 71 - // ======================================================================== 72 - // 3. Use the authenticated session 73 - // ======================================================================== 74 - 75 - // The session has a PDS client you can use for authenticated requests 76 - // (This requires integrating with an atproto API client library) 77 - // 78 - // Example: 79 - // final agent = session.pdsClient; 80 - // final profile = await agent.getProfile(); 81 - 82 - print('Session is ready for API calls'); 83 - } on OAuthCallbackError catch (e) { 84 - // Handle OAuth errors (user cancelled, invalid state, etc.) 85 - print('OAuth callback error: ${e.error}'); 86 - print('Description: ${e.errorDescription}'); 87 - return; 88 - } catch (e) { 89 - print('Sign-in error: $e'); 90 - return; 91 - } 92 - 93 - // ======================================================================== 94 - // 4. Restore session on app restart 95 - // ======================================================================== 96 - 97 - // Later, when the app restarts, restore the session: 98 - try { 99 - final did = 'did:plc:abc123'; // Get from storage or previous session 100 - 101 - print('Restoring session for $did...'); 102 - 103 - // This will: 104 - // 1. Load session from secure storage 105 - // 2. Check if tokens are expired 106 - // 3. Automatically refresh if needed 107 - // 4. Return authenticated session 108 - final session = await client.restore(did); 109 - 110 - print('✓ Session restored!'); 111 - print(' Access token expires: ${session.info['expiresAt']}'); 112 - } catch (e) { 113 - print('Failed to restore session: $e'); 114 - // Session may have been revoked or expired 115 - // Prompt user to sign in again 116 - } 117 - 118 - // ======================================================================== 119 - // 5. Sign out (revoke session) 120 - // ======================================================================== 121 - 122 - try { 123 - final did = 'did:plc:abc123'; 124 - 125 - print('Signing out $did...'); 126 - 127 - // This will: 128 - // 1. Call token revocation endpoint (best effort) 129 - // 2. Delete session from secure storage 130 - // 3. Emit 'deleted' event 131 - await client.revoke(did); 132 - 133 - print('✓ Signed out successfully'); 134 - } catch (e) { 135 - print('Sign out error: $e'); 136 - // Session is still deleted locally even if revocation fails 137 - } 138 - 139 - // ======================================================================== 140 - // Advanced: Listen to session events 141 - // ======================================================================== 142 - 143 - // Listen for session updates (token refresh, etc.) 144 - client.onUpdated.listen((event) { 145 - print('Session updated: ${event.sub}'); 146 - print(' New access token received'); 147 - }); 148 - 149 - // Listen for session deletions (revoked, expired, etc.) 150 - client.onDeleted.listen((event) { 151 - print('Session deleted: ${event.sub}'); 152 - print(' Cause: ${event.cause}'); 153 - // Handle session deletion (navigate to sign-in screen, etc.) 154 - }); 155 - 156 - // ======================================================================== 157 - // Advanced: Custom configuration 158 - // ======================================================================== 159 - 160 - // You can customize storage, caching, and crypto: 161 - final customClient = FlutterOAuthClient( 162 - clientMetadata: ClientMetadata( 163 - clientId: 'https://example.com/client-metadata.json', 164 - redirectUris: ['myapp://oauth/callback'], 165 - ), 166 - 167 - // Custom secure storage instance 168 - secureStorage: const FlutterSecureStorage( 169 - aOptions: AndroidOptions(encryptedSharedPreferences: true), 170 - ), 171 - 172 - // Custom PLC directory URL (for private deployments) 173 - plcDirectoryUrl: 'https://plc.example.com', 174 - 175 - // Custom handle resolver URL 176 - handleResolverUrl: 'https://bsky.social', 177 - ); 178 - 179 - print('Custom client initialized'); 180 - 181 - // ======================================================================== 182 - // Platform configuration (iOS) 183 - // ======================================================================== 184 - 185 - // iOS: Add URL scheme to Info.plist 186 - // <key>CFBundleURLTypes</key> 187 - // <array> 188 - // <dict> 189 - // <key>CFBundleURLSchemes</key> 190 - // <array> 191 - // <string>myapp</string> 192 - // </array> 193 - // </dict> 194 - // </array> 195 - 196 - // ======================================================================== 197 - // Platform configuration (Android) 198 - // ======================================================================== 199 - 200 - // Android: Add intent filter to AndroidManifest.xml 201 - // <intent-filter> 202 - // <action android:name="android.intent.action.VIEW" /> 203 - // <category android:name="android.intent.category.DEFAULT" /> 204 - // <category android:name="android.intent.category.BROWSABLE" /> 205 - // <data android:scheme="myapp" /> 206 - // </intent-filter> 207 - 208 - // ======================================================================== 209 - // Security best practices 210 - // ======================================================================== 211 - 212 - // ✓ Tokens stored in secure storage (Keychain/EncryptedSharedPreferences) 213 - // ✓ DPoP binds tokens to cryptographic keys 214 - // ✓ PKCE prevents authorization code interception 215 - // ✓ State parameter prevents CSRF attacks 216 - // ✓ Automatic token refresh with concurrency control 217 - // ✓ Session cleanup on errors 218 - 219 - print('Example complete!'); 220 - }
-104
packages/atproto_oauth_flutter/example/identity_resolver_example.dart
··· 1 - /// Example usage of the atProto identity resolution layer. 2 - /// 3 - /// This demonstrates the critical functionality for decentralization: 4 - /// resolving handles and DIDs to find where user data is actually stored. 5 - 6 - import 'package:atproto_oauth_flutter/src/identity/identity.dart'; 7 - 8 - Future<void> main() async { 9 - print('=== atProto Identity Resolution Examples ===\n'); 10 - 11 - // Create an identity resolver 12 - // The handleResolverUrl should point to an XRPC service that implements 13 - // com.atproto.identity.resolveHandle (typically bsky.social for public resolution) 14 - final resolver = AtprotoIdentityResolver.withDefaults( 15 - handleResolverUrl: 'https://bsky.social', 16 - ); 17 - 18 - print('Example 1: Resolve a Bluesky handle to find their PDS'); 19 - print('--------------------------------------------------'); 20 - try { 21 - // This is the most common use case: find where a user's data lives 22 - final pdsUrl = await resolver.resolveToPds('pfrazee.com'); 23 - print('Handle: pfrazee.com'); 24 - print('PDS URL: $pdsUrl'); 25 - print('✓ This user hosts their data on: $pdsUrl\n'); 26 - } catch (e) { 27 - print('Error: $e\n'); 28 - } 29 - 30 - print('Example 2: Get full identity information'); 31 - print('--------------------------------------------------'); 32 - try { 33 - final info = await resolver.resolve('pfrazee.com'); 34 - print('Handle: ${info.handle}'); 35 - print('DID: ${info.did}'); 36 - print('PDS URL: ${info.pdsUrl}'); 37 - print('Has valid handle: ${info.hasValidHandle}'); 38 - print('Also known as: ${info.didDoc.alsoKnownAs}'); 39 - print('✓ Complete identity information retrieved\n'); 40 - } catch (e) { 41 - print('Error: $e\n'); 42 - } 43 - 44 - print('Example 3: Resolve from a DID'); 45 - print('--------------------------------------------------'); 46 - try { 47 - // You can also start from a DID 48 - final info = await resolver.resolveFromDid( 49 - 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', 50 - ); 51 - print('DID: ${info.did}'); 52 - print('Handle: ${info.handle}'); 53 - print('PDS URL: ${info.pdsUrl}'); 54 - print('✓ Resolved DID to handle and PDS\n'); 55 - } catch (e) { 56 - print('Error: $e\n'); 57 - } 58 - 59 - print('Example 4: Custom domain handle (CRITICAL for decentralization)'); 60 - print('--------------------------------------------------'); 61 - try { 62 - // This demonstrates why this code is essential: 63 - // Users can use their own domains and host on their own PDS 64 - final info = await resolver.resolve('jay.bsky.team'); 65 - print('Handle: ${info.handle}'); 66 - print('DID: ${info.did}'); 67 - print('PDS URL: ${info.pdsUrl}'); 68 - print('✓ Custom domain resolves to custom PDS (not hardcoded!)\n'); 69 - } catch (e) { 70 - print('Error: $e\n'); 71 - } 72 - 73 - print('Example 5: Validation - Invalid handle'); 74 - print('--------------------------------------------------'); 75 - try { 76 - await resolver.resolve('not-a-valid-handle'); 77 - } catch (e) { 78 - print('✓ Correctly rejected invalid handle: $e\n'); 79 - } 80 - 81 - print('=== Why This Matters ==='); 82 - print(''' 83 - This identity resolution layer is THE CRITICAL PIECE for atProto decentralization: 84 - 85 - 1. **No Hardcoded Servers**: Unlike broken implementations that hardcode bsky.social, 86 - this correctly resolves each user's actual PDS location. 87 - 88 - 2. **Custom Domains**: Users can use their own domains (e.g., alice.example.com) 89 - and host on any PDS they choose. 90 - 91 - 3. **Portability**: Users can change their PDS without losing their DID or identity. 92 - The DID document always points to the current PDS location. 93 - 94 - 4. **Bi-directional Validation**: We verify that: 95 - - Handle → DID resolution works 96 - - DID document contains the handle 97 - - Both directions match (security!) 98 - 99 - 5. **Caching**: Built-in caching prevents redundant lookups while respecting TTLs. 100 - 101 - Without this layer, apps are locked to centralized servers. With it, atProto 102 - achieves true decentralization where users control their data location. 103 - '''); 104 - }
-104
packages/atproto_oauth_flutter/lib/atproto_oauth_flutter.dart
··· 1 - /// atproto OAuth client for Flutter. 2 - /// 3 - /// This library provides OAuth authentication capabilities for AT Protocol 4 - /// (atproto) applications on Flutter/Dart platforms. 5 - /// 6 - /// This is a 1:1 port of the TypeScript @atproto/oauth-client package to Dart. 7 - /// 8 - /// ## Quick Start 9 - /// 10 - /// ```dart 11 - /// import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 12 - /// 13 - /// // 1. Initialize client 14 - /// final client = FlutterOAuthClient( 15 - /// clientMetadata: ClientMetadata( 16 - /// clientId: 'https://example.com/client-metadata.json', 17 - /// redirectUris: ['myapp://oauth/callback'], 18 - /// scope: 'atproto transition:generic', 19 - /// ), 20 - /// ); 21 - /// 22 - /// // 2. Sign in with handle 23 - /// final session = await client.signIn('alice.bsky.social'); 24 - /// print('Signed in as: ${session.sub}'); 25 - /// 26 - /// // 3. Use authenticated session 27 - /// // (Integrate with your atproto API client) 28 - /// 29 - /// // 4. Later: restore session 30 - /// final restored = await client.restore(session.sub); 31 - /// 32 - /// // 5. Sign out 33 - /// await client.revoke(session.sub); 34 - /// ``` 35 - /// 36 - /// ## Features 37 - /// 38 - /// - Full OAuth 2.0 + OIDC support with PKCE 39 - /// - DPoP (Demonstrating Proof of Possession) for token security 40 - /// - Automatic token refresh 41 - /// - Secure session storage (flutter_secure_storage) 42 - /// - Handle and DID resolution 43 - /// - PAR (Pushed Authorization Request) support 44 - /// - Works with any atProto PDS or authorization server 45 - /// 46 - /// ## Security 47 - /// 48 - /// - Tokens stored in device secure storage (Keychain/EncryptedSharedPreferences) 49 - /// - DPoP binds tokens to cryptographic keys 50 - /// - PKCE prevents authorization code interception 51 - /// - Automatic session cleanup on errors 52 - /// 53 - library; 54 - 55 - // ============================================================================ 56 - // Main API - Start here! 57 - // ============================================================================ 58 - 59 - /// High-level Flutter OAuth client (recommended for most apps) 60 - export 'src/platform/flutter_oauth_client.dart'; 61 - 62 - /// Router integration helpers (for go_router, auto_route, etc.) 63 - export 'src/platform/flutter_oauth_router_helper.dart'; 64 - 65 - // ============================================================================ 66 - // Core OAuth Client 67 - // ============================================================================ 68 - 69 - /// Core OAuth client and types (for advanced use cases) 70 - export 'src/client/oauth_client.dart'; 71 - 72 - // ============================================================================ 73 - // Sessions 74 - // ============================================================================ 75 - 76 - /// OAuth session types 77 - export 'src/session/oauth_session.dart'; 78 - 79 - // ============================================================================ 80 - // Types 81 - // ============================================================================ 82 - 83 - /// Core types and options 84 - export 'src/types.dart'; 85 - 86 - // ============================================================================ 87 - // Platform Implementations (for custom configurations) 88 - // ============================================================================ 89 - 90 - /// Storage implementations (for customization) 91 - export 'src/platform/flutter_stores.dart'; 92 - 93 - /// Runtime implementation (cryptographic operations) 94 - export 'src/platform/flutter_runtime.dart'; 95 - 96 - /// Key implementation (EC keys with pointycastle) 97 - export 'src/platform/flutter_key.dart'; 98 - 99 - // ============================================================================ 100 - // Errors 101 - // ============================================================================ 102 - 103 - /// All OAuth error types 104 - export 'src/errors/errors.dart';
-977
packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart
··· 1 - import 'dart:async'; 2 - import 'package:dio/dio.dart'; 3 - import 'package:flutter/foundation.dart'; 4 - 5 - import '../constants.dart'; 6 - import '../dpop/fetch_dpop.dart' show InMemoryStore; 7 - import '../errors/auth_method_unsatisfiable_error.dart'; 8 - import '../errors/oauth_callback_error.dart'; 9 - import '../errors/token_revoked_error.dart'; 10 - import '../identity/constants.dart'; 11 - import '../identity/did_helpers.dart' show assertAtprotoDid; 12 - import '../identity/did_resolver.dart' show DidCache; 13 - import '../identity/handle_resolver.dart' show HandleCache; 14 - import '../identity/identity_resolver.dart'; 15 - import '../oauth/authorization_server_metadata_resolver.dart' as auth_resolver; 16 - import '../oauth/client_auth.dart'; 17 - import '../oauth/oauth_resolver.dart'; 18 - import '../oauth/oauth_server_agent.dart'; 19 - import '../oauth/oauth_server_factory.dart'; 20 - import '../oauth/protected_resource_metadata_resolver.dart'; 21 - import '../oauth/validate_client_metadata.dart'; 22 - import '../platform/flutter_key.dart'; 23 - import '../runtime/runtime.dart' as runtime_lib; 24 - import '../runtime/runtime_implementation.dart'; 25 - import '../session/oauth_session.dart' 26 - show OAuthSession, Session, SessionGetterInterface; 27 - import '../session/session_getter.dart'; 28 - import '../session/state_store.dart'; 29 - import '../types.dart'; 30 - import '../util.dart'; 31 - 32 - // Re-export types needed for OAuthClientOptions 33 - export '../identity/did_resolver.dart' show DidCache, DidResolver; 34 - export '../identity/handle_resolver.dart' show HandleCache, HandleResolver; 35 - export '../identity/identity_resolver.dart' show IdentityResolver; 36 - export '../oauth/authorization_server_metadata_resolver.dart' 37 - show AuthorizationServerMetadataCache; 38 - export '../oauth/oauth_server_agent.dart' show DpopNonceCache; 39 - export '../oauth/protected_resource_metadata_resolver.dart' 40 - show ProtectedResourceMetadataCache; 41 - export '../runtime/runtime_implementation.dart' show RuntimeImplementation, Key; 42 - export '../oauth/client_auth.dart' show Keyset; 43 - export '../session/session_getter.dart' 44 - show SessionStore, SessionUpdatedEvent, SessionDeletedEvent; 45 - export '../session/state_store.dart' show StateStore, InternalStateData; 46 - export '../types.dart' show ClientMetadata, AuthorizeOptions, CallbackOptions; 47 - 48 - /// OAuth response mode. 49 - enum OAuthResponseMode { 50 - /// Parameters in query string (default, most compatible) 51 - query('query'), 52 - 53 - /// Parameters in URL fragment (for single-page apps) 54 - fragment('fragment'); 55 - 56 - final String value; 57 - const OAuthResponseMode(this.value); 58 - 59 - @override 60 - String toString() => value; 61 - } 62 - 63 - /// Options for constructing an OAuthClient. 64 - /// 65 - /// This includes all configuration, storage, and service dependencies 66 - /// needed to implement the complete OAuth flow. 67 - class OAuthClientOptions { 68 - // Config 69 - /// Response mode for OAuth (query or fragment) 70 - final OAuthResponseMode responseMode; 71 - 72 - /// Client metadata (validated before use) 73 - final Map<String, dynamic> clientMetadata; 74 - 75 - /// Optional keyset for confidential clients (private_key_jwt) 76 - final Keyset? keyset; 77 - 78 - /// Whether to allow HTTP connections (for development only) 79 - /// 80 - /// This affects: 81 - /// - OAuth authorization/resource servers 82 - /// - did:web document fetching 83 - /// 84 - /// Note: PLC directory connections are controlled separately. 85 - final bool allowHttp; 86 - 87 - // Stores 88 - /// Storage for OAuth state during authorization flow 89 - final StateStore stateStore; 90 - 91 - /// Storage for session tokens 92 - final SessionStore sessionStore; 93 - 94 - /// Optional cache for authorization server metadata 95 - final auth_resolver.AuthorizationServerMetadataCache? 96 - authorizationServerMetadataCache; 97 - 98 - /// Optional cache for protected resource metadata 99 - final ProtectedResourceMetadataCache? protectedResourceMetadataCache; 100 - 101 - /// Optional cache for DPoP nonces 102 - final DpopNonceCache? dpopNonceCache; 103 - 104 - /// Optional cache for DID documents 105 - final DidCache? didCache; 106 - 107 - /// Optional cache for handle → DID resolutions 108 - final HandleCache? handleCache; 109 - 110 - // Services 111 - /// Platform-specific cryptographic operations 112 - final RuntimeImplementation runtimeImplementation; 113 - 114 - /// Optional HTTP client (Dio instance) 115 - final Dio? dio; 116 - 117 - /// Optional custom identity resolver 118 - final IdentityResolver? identityResolver; 119 - 120 - /// PLC directory URL (for DID resolution) 121 - final String? plcDirectoryUrl; 122 - 123 - /// Handle resolver URL (for handle → DID resolution) 124 - final String? handleResolverUrl; 125 - 126 - const OAuthClientOptions({ 127 - required this.responseMode, 128 - required this.clientMetadata, 129 - this.keyset, 130 - this.allowHttp = false, 131 - required this.stateStore, 132 - required this.sessionStore, 133 - this.authorizationServerMetadataCache, 134 - this.protectedResourceMetadataCache, 135 - this.dpopNonceCache, 136 - this.didCache, 137 - this.handleCache, 138 - required this.runtimeImplementation, 139 - this.dio, 140 - this.identityResolver, 141 - this.plcDirectoryUrl, 142 - this.handleResolverUrl, 143 - }); 144 - } 145 - 146 - /// Result of a successful OAuth callback. 147 - class CallbackResult { 148 - /// The authenticated session 149 - final OAuthSession session; 150 - 151 - /// The application state from the original authorize call 152 - final String? state; 153 - 154 - const CallbackResult({required this.session, this.state}); 155 - } 156 - 157 - /// Options for fetching client metadata from a discoverable client ID. 158 - class OAuthClientFetchMetadataOptions { 159 - /// The discoverable client ID (HTTPS URL) 160 - final String clientId; 161 - 162 - /// Optional HTTP client 163 - final Dio? dio; 164 - 165 - /// Optional cancellation token 166 - final CancelToken? cancelToken; 167 - 168 - const OAuthClientFetchMetadataOptions({ 169 - required this.clientId, 170 - this.dio, 171 - this.cancelToken, 172 - }); 173 - } 174 - 175 - /// Main OAuth client for atProto OAuth flows. 176 - /// 177 - /// This is the primary class that developers interact with. It orchestrates: 178 - /// - Authorization flow (authorize → callback) 179 - /// - Session restoration (restore) 180 - /// - Token revocation (revoke) 181 - /// - Session lifecycle events 182 - /// 183 - /// Usage: 184 - /// ```dart 185 - /// final client = OAuthClient( 186 - /// clientMetadata: { 187 - /// 'client_id': 'https://example.com/client-metadata.json', 188 - /// 'redirect_uris': ['myapp://oauth/callback'], 189 - /// 'scope': 'atproto', 190 - /// }, 191 - /// responseMode: OAuthResponseMode.query, 192 - /// stateStore: MyStateStore(), 193 - /// sessionStore: MySessionStore(), 194 - /// runtimeImplementation: MyRuntimeImplementation(), 195 - /// ); 196 - /// 197 - /// // Start authorization 198 - /// final authUrl = await client.authorize('alice.bsky.social'); 199 - /// 200 - /// // Handle callback 201 - /// final result = await client.callback(callbackParams); 202 - /// print('Signed in as: ${result.session.sub}'); 203 - /// 204 - /// // Restore session later 205 - /// final session = await client.restore('did:plc:abc123'); 206 - /// 207 - /// // Revoke session 208 - /// await client.revoke('did:plc:abc123'); 209 - /// ``` 210 - class OAuthClient extends CustomEventTarget<Map<String, dynamic>> { 211 - // Config 212 - /// Validated client metadata 213 - final ClientMetadata clientMetadata; 214 - 215 - /// OAuth response mode (query or fragment) 216 - final OAuthResponseMode responseMode; 217 - 218 - /// Optional keyset for confidential clients 219 - final Keyset? keyset; 220 - 221 - // Services 222 - /// Runtime for cryptographic operations 223 - final runtime_lib.Runtime runtime; 224 - 225 - /// HTTP client 226 - final Dio dio; 227 - 228 - /// OAuth resolver for identity → metadata 229 - final OAuthResolver oauthResolver; 230 - 231 - /// Factory for creating OAuth server agents 232 - final OAuthServerFactory serverFactory; 233 - 234 - // Stores 235 - /// Session management with automatic refresh 236 - final SessionGetter _sessionGetter; 237 - 238 - /// OAuth state storage 239 - final StateStore _stateStore; 240 - 241 - // Event streams 242 - final StreamController<SessionUpdatedEvent> _updatedController = 243 - StreamController<SessionUpdatedEvent>.broadcast(); 244 - final StreamController<SessionDeletedEvent> _deletedController = 245 - StreamController<SessionDeletedEvent>.broadcast(); 246 - 247 - /// Stream of session update events 248 - Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream; 249 - 250 - /// Stream of session deletion events 251 - Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream; 252 - 253 - /// Constructs an OAuthClient with the given options. 254 - /// 255 - /// Throws [FormatException] if client metadata is invalid. 256 - /// Throws [TypeError] if keyset configuration is incorrect. 257 - OAuthClient(OAuthClientOptions options) 258 - : keyset = options.keyset, 259 - responseMode = options.responseMode, 260 - runtime = runtime_lib.Runtime(options.runtimeImplementation), 261 - dio = options.dio ?? Dio(), 262 - _stateStore = options.stateStore, 263 - clientMetadata = validateClientMetadata( 264 - options.clientMetadata, 265 - options.keyset, 266 - ), 267 - oauthResolver = _createOAuthResolver(options), 268 - serverFactory = _createServerFactory(options), 269 - _sessionGetter = _createSessionGetter(options) { 270 - // Proxy session events from SessionGetter 271 - _sessionGetter.onUpdated.listen((event) { 272 - _updatedController.add(event); 273 - dispatchCustomEvent('updated', event); 274 - }); 275 - 276 - _sessionGetter.onDeleted.listen((event) { 277 - _deletedController.add(event); 278 - dispatchCustomEvent('deleted', event); 279 - }); 280 - } 281 - 282 - /// Creates the OAuth resolver. 283 - static OAuthResolver _createOAuthResolver(OAuthClientOptions options) { 284 - final dio = options.dio ?? Dio(); 285 - 286 - return OAuthResolver( 287 - identityResolver: 288 - options.identityResolver ?? 289 - AtprotoIdentityResolver.withDefaults( 290 - handleResolverUrl: 291 - options.handleResolverUrl ?? 'https://bsky.social', 292 - plcDirectoryUrl: options.plcDirectoryUrl, 293 - dio: dio, 294 - didCache: options.didCache, 295 - handleCache: options.handleCache, 296 - ), 297 - protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver( 298 - options.protectedResourceMetadataCache ?? 299 - InMemoryStore<String, Map<String, dynamic>>(), 300 - dio: dio, 301 - config: OAuthProtectedResourceMetadataResolverConfig( 302 - allowHttpResource: options.allowHttp, 303 - ), 304 - ), 305 - authorizationServerMetadataResolver: 306 - auth_resolver.OAuthAuthorizationServerMetadataResolver( 307 - options.authorizationServerMetadataCache ?? 308 - InMemoryStore<String, Map<String, dynamic>>(), 309 - dio: dio, 310 - config: 311 - auth_resolver.OAuthAuthorizationServerMetadataResolverConfig( 312 - allowHttpIssuer: options.allowHttp, 313 - ), 314 - ), 315 - ); 316 - } 317 - 318 - /// Creates the OAuth server factory. 319 - static OAuthServerFactory _createServerFactory(OAuthClientOptions options) { 320 - return OAuthServerFactory( 321 - clientMetadata: validateClientMetadata( 322 - options.clientMetadata, 323 - options.keyset, 324 - ), 325 - runtime: runtime_lib.Runtime(options.runtimeImplementation), 326 - resolver: _createOAuthResolver(options), 327 - dio: options.dio ?? Dio(), 328 - keyset: options.keyset, 329 - dpopNonceCache: options.dpopNonceCache ?? InMemoryStore<String, String>(), 330 - ); 331 - } 332 - 333 - /// Creates the session getter. 334 - static SessionGetter _createSessionGetter(OAuthClientOptions options) { 335 - return SessionGetter( 336 - sessionStore: options.sessionStore, 337 - serverFactory: _createServerFactory(options), 338 - runtime: runtime_lib.Runtime(options.runtimeImplementation), 339 - ); 340 - } 341 - 342 - /// Fetches client metadata from a discoverable client ID URL. 343 - /// 344 - /// This is a static helper method for fetching metadata before 345 - /// constructing the OAuthClient. 346 - /// 347 - /// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ 348 - static Future<Map<String, dynamic>> fetchMetadata( 349 - OAuthClientFetchMetadataOptions options, 350 - ) async { 351 - final dio = options.dio ?? Dio(); 352 - final clientId = options.clientId; 353 - 354 - try { 355 - final response = await dio.getUri<Map<String, dynamic>>( 356 - Uri.parse(clientId), 357 - options: Options( 358 - followRedirects: false, 359 - validateStatus: (status) => status == 200, 360 - responseType: ResponseType.json, 361 - ), 362 - cancelToken: options.cancelToken, 363 - ); 364 - 365 - // Validate content type 366 - final contentType = response.headers.value('content-type'); 367 - final mime = contentType?.split(';')[0].trim(); 368 - if (mime != 'application/json') { 369 - throw FormatException('Invalid client metadata content type: $mime'); 370 - } 371 - 372 - final data = response.data; 373 - if (data == null) { 374 - throw FormatException('Empty client metadata response'); 375 - } 376 - 377 - return data; 378 - } catch (e) { 379 - if (e is DioException) { 380 - throw Exception('Failed to fetch client metadata: ${e.message}'); 381 - } 382 - rethrow; 383 - } 384 - } 385 - 386 - /// Exposes the identity resolver for convenience. 387 - IdentityResolver get identityResolver => oauthResolver.identityResolver; 388 - 389 - /// Returns the public JWKS for this client (for confidential clients). 390 - /// 391 - /// This is the JWKS that should be published at the client's jwks_uri 392 - /// or included in the client metadata. 393 - Map<String, dynamic> get jwks { 394 - if (keyset == null) { 395 - return {'keys': <Map<String, dynamic>>[]}; 396 - } 397 - return keyset!.toJSON(); 398 - } 399 - 400 - /// Initiates an OAuth authorization flow. 401 - /// 402 - /// This method: 403 - /// 1. Resolves the input (handle, DID, or URL) to OAuth metadata 404 - /// 2. Generates PKCE parameters 405 - /// 3. Generates DPoP key 406 - /// 4. Negotiates client authentication method 407 - /// 5. Stores internal state 408 - /// 6. Uses PAR (Pushed Authorization Request) if supported 409 - /// 7. Returns the authorization URL to open in a browser 410 - /// 411 - /// The [input] can be: 412 - /// - An atProto handle (e.g., "alice.bsky.social") 413 - /// - A DID (e.g., "did:plc:...") 414 - /// - A PDS URL (e.g., "https://pds.example.com") 415 - /// - An authorization server URL (e.g., "https://auth.example.com") 416 - /// 417 - /// The [options] can specify: 418 - /// - redirectUri: Override the default redirect URI 419 - /// - state: Application state to preserve 420 - /// - scope: Override the default scope 421 - /// - Other OIDC parameters (prompt, display, etc.) 422 - /// 423 - /// Throws [FormatException] if parameters are invalid. 424 - /// Throws [OAuthResolverError] if resolution fails. 425 - Future<Uri> authorize( 426 - String input, { 427 - AuthorizeOptions? options, 428 - CancelToken? cancelToken, 429 - }) async { 430 - final opts = options ?? const AuthorizeOptions(); 431 - 432 - // Validate redirect URI 433 - final redirectUri = opts.redirectUri ?? clientMetadata.redirectUris.first; 434 - if (!clientMetadata.redirectUris.contains(redirectUri)) { 435 - throw FormatException('Invalid redirect_uri: $redirectUri'); 436 - } 437 - 438 - // Resolve input to OAuth metadata 439 - final resolved = await oauthResolver.resolve( 440 - input, 441 - auth_resolver.GetCachedOptions(cancelToken: cancelToken), 442 - ); 443 - 444 - final metadata = resolved.metadata; 445 - 446 - // Generate PKCE 447 - final pkce = await runtime.generatePKCE(); 448 - 449 - // Generate DPoP key 450 - final dpopAlgs = metadata['dpop_signing_alg_values_supported'] as List?; 451 - final dpopKey = await runtime.generateKey( 452 - dpopAlgs?.cast<String>() ?? [fallbackAlg], 453 - ); 454 - 455 - // Compute DPoP JWK thumbprint for authorization requests. 456 - // Required by RFC 9449 §7 to bind the subsequently issued code to this key. 457 - final bareJwk = dpopKey.bareJwk; 458 - if (bareJwk == null) { 459 - throw StateError('DPoP key must provide a public JWK representation'); 460 - } 461 - final generatedDpopJkt = await runtime.calculateJwkThumbprint(bareJwk); 462 - 463 - // Negotiate client authentication method 464 - final authMethod = negotiateClientAuthMethod( 465 - metadata, 466 - clientMetadata, 467 - keyset, 468 - ); 469 - 470 - // Generate state parameter 471 - final state = await runtime.generateNonce(); 472 - 473 - // Store internal state for callback validation 474 - // IMPORTANT: Store the FULL private JWK, not just bareJwk (public key only) 475 - // We need the private key to restore the DPoP key during token exchange 476 - final dpopKeyJwk = (dpopKey as dynamic).privateJwk ?? dpopKey.bareJwk ?? {}; 477 - 478 - if (kDebugMode) { 479 - print('🔑 Storing DPoP key for authorization flow'); 480 - } 481 - 482 - await _stateStore.set( 483 - state, 484 - InternalStateData( 485 - iss: metadata['issuer'] as String, 486 - dpopKey: dpopKeyJwk, 487 - authMethod: authMethod.toJson(), 488 - verifier: pkce['verifier'] as String, 489 - redirectUri: redirectUri, // Store the exact redirectUri used in PAR 490 - appState: opts.state, 491 - ), 492 - ); 493 - 494 - // Build authorization request parameters 495 - final parameters = <String, String>{ 496 - 'client_id': clientMetadata.clientId!, 497 - 'redirect_uri': redirectUri, 498 - 'code_challenge': pkce['challenge'] as String, 499 - 'code_challenge_method': pkce['method'] as String, 500 - 'state': state, 501 - 'response_mode': responseMode.value, 502 - 'response_type': 'code', 503 - 'scope': opts.scope ?? clientMetadata.scope ?? 'atproto', 504 - 'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt, 505 - }; 506 - 507 - // Add login hint if we have identity info 508 - if (resolved.identityInfo != null) { 509 - final handle = resolved.identityInfo!.handle; 510 - final did = resolved.identityInfo!.did; 511 - if (handle != handleInvalid) { 512 - parameters['login_hint'] = handle; 513 - } else { 514 - parameters['login_hint'] = did; 515 - } 516 - } 517 - 518 - // Add optional parameters from options 519 - if (opts.nonce != null) parameters['nonce'] = opts.nonce!; 520 - if (opts.display != null) parameters['display'] = opts.display!; 521 - if (opts.prompt != null) parameters['prompt'] = opts.prompt!; 522 - if (opts.maxAge != null) parameters['max_age'] = opts.maxAge.toString(); 523 - if (opts.uiLocales != null) parameters['ui_locales'] = opts.uiLocales!; 524 - if (opts.idTokenHint != null) { 525 - parameters['id_token_hint'] = opts.idTokenHint!; 526 - } 527 - 528 - // Build authorization URL 529 - final authorizationUrl = Uri.parse( 530 - metadata['authorization_endpoint'] as String, 531 - ); 532 - 533 - // Validate authorization endpoint protocol 534 - if (authorizationUrl.scheme != 'https' && 535 - authorizationUrl.scheme != 'http') { 536 - throw FormatException( 537 - 'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}', 538 - ); 539 - } 540 - 541 - // Use PAR (Pushed Authorization Request) if supported 542 - final parEndpoint = 543 - metadata['pushed_authorization_request_endpoint'] as String?; 544 - final requiresPar = 545 - metadata['require_pushed_authorization_requests'] as bool? ?? false; 546 - 547 - if (parEndpoint != null) { 548 - // Server supports PAR, use it 549 - final server = await serverFactory.fromMetadata( 550 - metadata, 551 - authMethod, 552 - dpopKey, 553 - ); 554 - 555 - final parResponse = await server.request( 556 - 'pushed_authorization_request', 557 - parameters, 558 - ); 559 - 560 - final requestUri = parResponse['request_uri'] as String; 561 - 562 - // Return simplified URL with just request_uri 563 - return authorizationUrl.replace( 564 - queryParameters: { 565 - 'client_id': clientMetadata.clientId!, 566 - 'request_uri': requestUri, 567 - }, 568 - ); 569 - } else if (requiresPar) { 570 - throw Exception( 571 - 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available', 572 - ); 573 - } else { 574 - // No PAR support, use direct authorization request 575 - final fullUrl = authorizationUrl.replace(queryParameters: parameters); 576 - 577 - // Check URL length (2048 byte limit for some browsers) 578 - final urlLength = fullUrl.toString().length; 579 - if (urlLength >= 2048) { 580 - throw Exception('Login URL too long ($urlLength bytes)'); 581 - } 582 - 583 - return fullUrl; 584 - } 585 - } 586 - 587 - /// Handles the OAuth callback after user authorization. 588 - /// 589 - /// This method: 590 - /// 1. Validates the state parameter 591 - /// 2. Retrieves stored internal state 592 - /// 3. Checks for error responses 593 - /// 4. Validates issuer (if provided) 594 - /// 5. Exchanges authorization code for tokens 595 - /// 6. Creates and stores session 596 - /// 7. Cleans up state 597 - /// 598 - /// The [params] should be the query parameters from the callback URL. 599 - /// 600 - /// The [options] can specify: 601 - /// - redirectUri: Must match the one used in authorize() 602 - /// 603 - /// Returns a [CallbackResult] with the session and application state. 604 - /// 605 - /// Throws [OAuthCallbackError] if the callback contains errors or is invalid. 606 - Future<CallbackResult> callback( 607 - Map<String, String> params, { 608 - CallbackOptions? options, 609 - CancelToken? cancelToken, 610 - }) async { 611 - final opts = options ?? const CallbackOptions(); 612 - 613 - // Check for JARM (not supported) 614 - final responseJwt = params['response']; 615 - if (responseJwt != null) { 616 - throw OAuthCallbackError(params, message: 'JARM not supported'); 617 - } 618 - 619 - // Extract parameters 620 - final issuerParam = params['iss']; 621 - final stateParam = params['state']; 622 - final errorParam = params['error']; 623 - final codeParam = params['code']; 624 - 625 - // Validate state parameter 626 - if (stateParam == null) { 627 - throw OAuthCallbackError(params, message: 'Missing "state" parameter'); 628 - } 629 - 630 - // Retrieve internal state 631 - final stateData = await _stateStore.get(stateParam); 632 - if (stateData == null) { 633 - throw OAuthCallbackError( 634 - params, 635 - message: 'Unknown authorization session "$stateParam"', 636 - ); 637 - } 638 - 639 - // Prevent replay attacks - delete state immediately 640 - await _stateStore.del(stateParam); 641 - 642 - try { 643 - // Check for error response 644 - if (errorParam != null) { 645 - throw OAuthCallbackError(params, state: stateData.appState); 646 - } 647 - 648 - // Validate authorization code 649 - if (codeParam == null) { 650 - throw OAuthCallbackError( 651 - params, 652 - message: 'Missing "code" query param', 653 - state: stateData.appState, 654 - ); 655 - } 656 - 657 - // Create OAuth server agent 658 - final authMethod = 659 - stateData.authMethod != null 660 - ? ClientAuthMethod.fromJson( 661 - stateData.authMethod as Map<String, dynamic>, 662 - ) 663 - : const ClientAuthMethod.none(); // Legacy fallback 664 - 665 - // Restore dpopKey from stored private JWK 666 - // Restore DPoP key with error handling for corrupted JWK data 667 - final FlutterKey dpopKey; 668 - try { 669 - dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map<String, dynamic>); 670 - if (kDebugMode) { 671 - print('🔓 DPoP key restored successfully for token exchange'); 672 - } 673 - } catch (e) { 674 - throw Exception( 675 - 'Failed to restore DPoP key from stored state: $e. ' 676 - 'The stored key may be corrupted. Please try authenticating again.', 677 - ); 678 - } 679 - 680 - final server = await serverFactory.fromIssuer( 681 - stateData.iss, 682 - authMethod, 683 - dpopKey, 684 - auth_resolver.GetCachedOptions(cancelToken: cancelToken), 685 - ); 686 - 687 - // Validate issuer if provided 688 - if (issuerParam != null) { 689 - if (server.issuer.isEmpty) { 690 - throw OAuthCallbackError( 691 - params, 692 - message: 'Issuer not found in metadata', 693 - state: stateData.appState, 694 - ); 695 - } 696 - if (server.issuer != issuerParam) { 697 - throw OAuthCallbackError( 698 - params, 699 - message: 'Issuer mismatch', 700 - state: stateData.appState, 701 - ); 702 - } 703 - } else if (server 704 - .serverMetadata['authorization_response_iss_parameter_supported'] == 705 - true) { 706 - throw OAuthCallbackError( 707 - params, 708 - message: 'iss missing from the response', 709 - state: stateData.appState, 710 - ); 711 - } 712 - 713 - // Exchange authorization code for tokens 714 - // CRITICAL: Use the EXACT same redirectUri that was used during authorization 715 - // The redirectUri in the token exchange MUST match the one in the PAR request 716 - final redirectUriForExchange = 717 - stateData.redirectUri ?? 718 - opts.redirectUri ?? 719 - clientMetadata.redirectUris.first; 720 - 721 - if (kDebugMode) { 722 - print('🔄 Exchanging authorization code for tokens:'); 723 - print(' Code: ${codeParam.substring(0, 20)}...'); 724 - print( 725 - ' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...', 726 - ); 727 - print(' Redirect URI: $redirectUriForExchange'); 728 - print( 729 - ' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}', 730 - ); 731 - print(' Issuer: ${server.issuer}'); 732 - } 733 - 734 - final tokenSet = await server.exchangeCode( 735 - codeParam, 736 - codeVerifier: stateData.verifier, 737 - redirectUri: redirectUriForExchange, 738 - ); 739 - 740 - try { 741 - if (kDebugMode) { 742 - print('💾 Storing session for: ${tokenSet.sub}'); 743 - } 744 - 745 - // Store session 746 - await _sessionGetter.setStored( 747 - tokenSet.sub, 748 - Session( 749 - dpopKey: stateData.dpopKey, 750 - authMethod: authMethod.toJson(), 751 - tokenSet: tokenSet, 752 - ), 753 - ); 754 - 755 - if (kDebugMode) { 756 - print('✅ Session stored successfully'); 757 - print('🎯 Creating session wrapper...'); 758 - } 759 - 760 - // Create session wrapper 761 - final session = _createSession(server, tokenSet.sub); 762 - 763 - if (kDebugMode) { 764 - print('✅ Session wrapper created'); 765 - print('🎉 OAuth callback complete!'); 766 - } 767 - 768 - return CallbackResult(session: session, state: stateData.appState); 769 - } catch (err, stackTrace) { 770 - // If session storage failed, revoke the tokens 771 - if (kDebugMode) { 772 - print('❌ Session storage/creation failed:'); 773 - print(' Error: $err'); 774 - print(' Stack trace: $stackTrace'); 775 - } 776 - await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken); 777 - rethrow; 778 - } 779 - } catch (err, stackTrace) { 780 - // Ensure appState is available in error 781 - if (kDebugMode) { 782 - print('❌ Callback error (outer catch):'); 783 - print(' Error type: ${err.runtimeType}'); 784 - print(' Error: $err'); 785 - print(' Stack trace: $stackTrace'); 786 - } 787 - throw OAuthCallbackError.from(err, params, stateData.appState); 788 - } 789 - } 790 - 791 - /// Restores a stored session. 792 - /// 793 - /// This method: 794 - /// 1. Retrieves session from storage 795 - /// 2. Checks if tokens are expired 796 - /// 3. Automatically refreshes tokens if needed (based on [refresh]) 797 - /// 4. Creates OAuthServerAgent 798 - /// 5. Returns live OAuthSession 799 - /// 800 - /// The [sub] is the user's DID. 801 - /// 802 - /// The [refresh] parameter controls token refresh: 803 - /// - `true`: Force refresh even if not expired 804 - /// - `false`: Use cached tokens even if expired 805 - /// - `'auto'`: Refresh only if expired (default) 806 - /// 807 - /// Throws [Exception] if session doesn't exist. 808 - /// Throws [TokenRefreshError] if refresh fails. 809 - /// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied. 810 - Future<OAuthSession> restore( 811 - String sub, { 812 - dynamic refresh = 'auto', 813 - CancelToken? cancelToken, 814 - }) async { 815 - // Validate DID format 816 - assertAtprotoDid(sub); 817 - 818 - // Get session (automatically refreshes if needed based on refresh param) 819 - final session = await _sessionGetter.getSession(sub, refresh); 820 - 821 - try { 822 - // Determine auth method (with legacy fallback) 823 - final authMethod = 824 - session.authMethod != null 825 - ? ClientAuthMethod.fromJson( 826 - session.authMethod as Map<String, dynamic>, 827 - ) 828 - : const ClientAuthMethod.none(); // Legacy 829 - 830 - // Restore dpopKey from stored private JWK with error handling 831 - // CRITICAL FIX: Use the stored key instead of generating a new one 832 - // This ensures DPoP proofs match the token binding 833 - final FlutterKey dpopKey; 834 - try { 835 - dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>); 836 - } catch (e) { 837 - // If key is corrupted, delete the session and force re-authentication 838 - await _sessionGetter.delStored( 839 - sub, 840 - Exception('Corrupted DPoP key in stored session: $e'), 841 - ); 842 - throw Exception( 843 - 'Failed to restore DPoP key for session. The stored key is corrupted. ' 844 - 'Please authenticate again.', 845 - ); 846 - } 847 - 848 - // Create server agent 849 - final server = await serverFactory.fromIssuer( 850 - session.tokenSet.iss, 851 - authMethod, 852 - dpopKey, 853 - auth_resolver.GetCachedOptions( 854 - noCache: refresh == true, 855 - allowStale: refresh == false, 856 - cancelToken: cancelToken, 857 - ), 858 - ); 859 - 860 - return _createSession(server, sub); 861 - } catch (err) { 862 - // If auth method can't be satisfied, delete the session 863 - if (err is AuthMethodUnsatisfiableError) { 864 - await _sessionGetter.delStored(sub, err); 865 - } 866 - rethrow; 867 - } 868 - } 869 - 870 - /// Revokes a session. 871 - /// 872 - /// This method: 873 - /// 1. Retrieves session from storage 874 - /// 2. Calls token revocation endpoint 875 - /// 3. Deletes session from storage 876 - /// 877 - /// The [sub] is the user's DID. 878 - /// 879 - /// Token revocation is best-effort - even if the revocation request fails, 880 - /// the local session is still deleted. 881 - Future<void> revoke(String sub, {CancelToken? cancelToken}) async { 882 - // Validate DID format 883 - assertAtprotoDid(sub); 884 - 885 - // Get session (allow stale tokens for revocation) 886 - final session = await _sessionGetter.get( 887 - sub, 888 - const GetCachedOptions(allowStale: true), 889 - ); 890 - 891 - // Try to revoke tokens on the server 892 - try { 893 - final authMethod = 894 - session.authMethod != null 895 - ? ClientAuthMethod.fromJson( 896 - session.authMethod as Map<String, dynamic>, 897 - ) 898 - : const ClientAuthMethod.none(); // Legacy 899 - 900 - // Restore dpopKey from stored private JWK with error handling 901 - // CRITICAL FIX: Use the stored key instead of generating a new one 902 - // This ensures DPoP proofs match the token binding 903 - final FlutterKey dpopKey; 904 - try { 905 - dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>); 906 - } catch (e) { 907 - // If key is corrupted, skip server-side revocation 908 - // The finally block will still delete the local session 909 - if (kDebugMode) { 910 - print('⚠️ Cannot revoke on server: corrupted DPoP key ($e)'); 911 - print(' Local session will still be deleted'); 912 - } 913 - return; 914 - } 915 - 916 - final server = await serverFactory.fromIssuer( 917 - session.tokenSet.iss, 918 - authMethod, 919 - dpopKey, 920 - auth_resolver.GetCachedOptions(cancelToken: cancelToken), 921 - ); 922 - 923 - await server.revoke(session.tokenSet.accessToken); 924 - } finally { 925 - // Always delete local session, even if revocation failed 926 - await _sessionGetter.delStored(sub, TokenRevokedError(sub)); 927 - } 928 - } 929 - 930 - /// Creates an OAuthSession wrapper. 931 - /// 932 - /// Internal helper for creating session objects from server agents. 933 - OAuthSession _createSession(OAuthServerAgent server, String sub) { 934 - // Create a wrapper that implements SessionGetterInterface 935 - final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter); 936 - 937 - return OAuthSession( 938 - server: server, 939 - sub: sub, 940 - sessionGetter: sessionGetterWrapper, 941 - ); 942 - } 943 - 944 - /// Disposes of resources used by this client. 945 - /// 946 - /// Call this when the client is no longer needed to prevent memory leaks. 947 - @override 948 - void dispose() { 949 - _updatedController.close(); 950 - _deletedController.close(); 951 - _sessionGetter.dispose(); 952 - super.dispose(); 953 - } 954 - } 955 - 956 - /// Wrapper to adapt SessionGetter to SessionGetterInterface 957 - class _SessionGetterWrapper implements SessionGetterInterface { 958 - final SessionGetter _getter; 959 - 960 - _SessionGetterWrapper(this._getter); 961 - 962 - @override 963 - Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async { 964 - return _getter.get( 965 - sub, 966 - GetCachedOptions( 967 - noCache: noCache ?? false, 968 - allowStale: allowStale ?? false, 969 - ), 970 - ); 971 - } 972 - 973 - @override 974 - Future<void> delStored(String sub, [Object? cause]) { 975 - return _getter.delStored(sub, cause); 976 - } 977 - }
-2
packages/atproto_oauth_flutter/lib/src/constants.dart
··· 1 - /// Per ATProto spec (OpenID uses RS256) 2 - const String fallbackAlg = 'ES256';
-593
packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart
··· 1 - import 'dart:async'; 2 - import 'dart:convert'; 3 - 4 - import 'package:dio/dio.dart'; 5 - import 'package:flutter/foundation.dart' hide Key; 6 - 7 - import '../runtime/runtime_implementation.dart'; 8 - 9 - /// A simple key-value store interface for storing DPoP nonces. 10 - /// 11 - /// This is a simplified Dart version of @atproto-labs/simple-store. 12 - /// Implementations can use: 13 - /// - In-memory Map (for testing) 14 - /// - SharedPreferences (for persistence) 15 - /// - Secure storage (for sensitive data) 16 - abstract class SimpleStore<K, V> { 17 - /// Get a value by key. Returns null if not found. 18 - FutureOr<V?> get(K key); 19 - 20 - /// Set a value for a key. 21 - FutureOr<void> set(K key, V value); 22 - 23 - /// Delete a value by key. 24 - FutureOr<void> del(K key); 25 - 26 - /// Clear all values (optional). 27 - FutureOr<void> clear(); 28 - } 29 - 30 - /// In-memory implementation of SimpleStore for DPoP nonces. 31 - /// 32 - /// This is used as the default nonce store. Nonces are ephemeral and 33 - /// don't need to be persisted across app restarts. 34 - class InMemoryStore<K, V> implements SimpleStore<K, V> { 35 - final Map<K, V> _store = {}; 36 - 37 - @override 38 - V? get(K key) => _store[key]; 39 - 40 - @override 41 - void set(K key, V value) => _store[key] = value; 42 - 43 - @override 44 - void del(K key) => _store.remove(key); 45 - 46 - @override 47 - void clear() => _store.clear(); 48 - } 49 - 50 - /// Options for configuring the DPoP fetch wrapper. 51 - class DpopFetchWrapperOptions { 52 - /// The cryptographic key used to sign DPoP proofs. 53 - final Key key; 54 - 55 - /// Store for caching DPoP nonces per origin. 56 - final SimpleStore<String, String> nonces; 57 - 58 - /// List of algorithms supported by the server (optional). 59 - /// If not provided, the key's first algorithm will be used. 60 - final List<String>? supportedAlgs; 61 - 62 - /// Function to compute SHA-256 hash (required for DPoP). 63 - /// Should return base64url-encoded hash. 64 - final Future<String> Function(String input) sha256; 65 - 66 - /// Whether the target server is an authorization server (true) 67 - /// or resource server (false). 68 - /// 69 - /// This affects how "use_dpop_nonce" errors are detected: 70 - /// - Authorization servers return 400 with JSON error 71 - /// - Resource servers return 401 with WWW-Authenticate header 72 - /// 73 - /// If null, both patterns will be checked. 74 - final bool? isAuthServer; 75 - 76 - const DpopFetchWrapperOptions({ 77 - required this.key, 78 - required this.nonces, 79 - this.supportedAlgs, 80 - required this.sha256, 81 - this.isAuthServer, 82 - }); 83 - } 84 - 85 - /// Creates a Dio interceptor that adds DPoP (Demonstrating Proof of Possession) 86 - /// headers to HTTP requests. 87 - /// 88 - /// DPoP is a security mechanism that binds access tokens to cryptographic keys, 89 - /// preventing token theft and replay attacks. It works by: 90 - /// 91 - /// 1. Creating a JWT proof signed with a private key 92 - /// 2. Including the proof in a DPoP header 93 - /// 3. Including the access token hash (ath) in the proof 94 - /// 4. Handling nonce-based replay protection 95 - /// 96 - /// The interceptor automatically: 97 - /// - Generates DPoP proofs for each request 98 - /// - Caches and reuses server-provided nonces 99 - /// - Retries requests when server requires a fresh nonce 100 - /// - Handles both authorization and resource server error formats 101 - /// 102 - /// See: https://datatracker.ietf.org/doc/html/rfc9449 103 - /// 104 - /// Example: 105 - /// ```dart 106 - /// final dio = Dio(); 107 - /// final options = DpopFetchWrapperOptions( 108 - /// key: myKey, 109 - /// nonces: InMemoryStore(), 110 - /// sha256: runtime.sha256, 111 - /// ); 112 - /// dio.interceptors.add(createDpopInterceptor(options)); 113 - /// ``` 114 - Interceptor createDpopInterceptor(DpopFetchWrapperOptions options) { 115 - // Negotiate algorithm once at creation time 116 - final alg = _negotiateAlg(options.key, options.supportedAlgs); 117 - 118 - return InterceptorsWrapper( 119 - onRequest: (requestOptions, handler) async { 120 - try { 121 - // Extract authorization header for ath calculation 122 - final authHeader = requestOptions.headers['Authorization'] as String?; 123 - final String? ath; 124 - if (authHeader != null && authHeader.startsWith('DPoP ')) { 125 - ath = await options.sha256(authHeader.substring(5)); 126 - } else { 127 - ath = null; 128 - } 129 - 130 - final uri = requestOptions.uri; 131 - final origin = 132 - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; 133 - 134 - final htm = requestOptions.method; 135 - final htu = _buildHtu(uri.toString()); 136 - 137 - // Try to get cached nonce for this origin 138 - String? initNonce; 139 - try { 140 - initNonce = await options.nonces.get(origin); 141 - } catch (_) { 142 - // Ignore nonce retrieval errors 143 - } 144 - 145 - // Build and add DPoP proof 146 - final initProof = await _buildProof( 147 - options.key, 148 - alg, 149 - htm, 150 - htu, 151 - initNonce, 152 - ath, 153 - ); 154 - requestOptions.headers['DPoP'] = initProof; 155 - 156 - handler.next(requestOptions); 157 - } catch (e) { 158 - handler.reject( 159 - DioException( 160 - requestOptions: requestOptions, 161 - error: 'Failed to create DPoP proof: $e', 162 - type: DioExceptionType.unknown, 163 - ), 164 - ); 165 - } 166 - }, 167 - onResponse: (response, handler) async { 168 - try { 169 - final uri = response.requestOptions.uri; 170 - 171 - if (kDebugMode && uri.path.contains('/token')) { 172 - print('🟢 DPoP interceptor onResponse triggered'); 173 - print(' URL: ${uri.path}'); 174 - print(' Status: ${response.statusCode}'); 175 - } 176 - 177 - // Check for DPoP-Nonce header in response 178 - final nextNonce = response.headers.value('dpop-nonce'); 179 - 180 - if (nextNonce != null) { 181 - // Extract origin from request 182 - final origin = 183 - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; 184 - 185 - // Store the fresh nonce for future requests 186 - try { 187 - await options.nonces.set(origin, nextNonce); 188 - if (kDebugMode && uri.path.contains('/token')) { 189 - print(' Cached nonce: ${nextNonce.substring(0, 20)}...'); 190 - } 191 - } catch (_) { 192 - // Ignore nonce storage errors 193 - } 194 - } else if (kDebugMode && uri.path.contains('/token')) { 195 - print(' No nonce in response'); 196 - } 197 - 198 - // Check for nonce errors in successful responses (when validateStatus: true) 199 - // This handles the case where Dio returns 401 as a successful response 200 - if (nextNonce != null && 201 - await _isUseDpopNonceError(response, options.isAuthServer)) { 202 - final isTokenEndpoint = 203 - uri.path.contains('/token') || uri.path.endsWith('/token'); 204 - 205 - if (kDebugMode) { 206 - print( 207 - '⚠️ DPoP nonce error in response (status ${response.statusCode})', 208 - ); 209 - print(' Is token endpoint: $isTokenEndpoint'); 210 - } 211 - 212 - if (isTokenEndpoint) { 213 - // Don't retry token endpoint - just pass through with nonce cached 214 - if (kDebugMode) { 215 - print( 216 - ' NOT retrying token endpoint (nonce cached for next attempt)', 217 - ); 218 - } 219 - handler.next(response); 220 - return; 221 - } 222 - 223 - // For non-token endpoints, retry is safe 224 - if (kDebugMode) { 225 - print('🔄 Retrying request with fresh nonce'); 226 - } 227 - 228 - try { 229 - final authHeader = 230 - response.requestOptions.headers['Authorization'] as String?; 231 - final String? ath; 232 - if (authHeader != null && authHeader.startsWith('DPoP ')) { 233 - ath = await options.sha256(authHeader.substring(5)); 234 - } else { 235 - ath = null; 236 - } 237 - 238 - final htm = response.requestOptions.method; 239 - final htu = _buildHtu(uri.toString()); 240 - 241 - final nextProof = await _buildProof( 242 - options.key, 243 - alg, 244 - htm, 245 - htu, 246 - nextNonce, 247 - ath, 248 - ); 249 - 250 - // Clone request options and update DPoP header 251 - // Note: We preserve validateStatus to match original request behavior 252 - final retryOptions = Options( 253 - method: response.requestOptions.method, 254 - headers: {...response.requestOptions.headers, 'DPoP': nextProof}, 255 - validateStatus: response.requestOptions.validateStatus, 256 - ); 257 - 258 - // DESIGN NOTE: We create a fresh Dio instance for retry to avoid 259 - // re-triggering this interceptor (which would cause infinite loops). 260 - // This means base options (timeouts, etc.) are not preserved, but 261 - // this is acceptable for DPoP nonce retry scenarios which should be fast. 262 - // If this becomes an issue, we could inject a Dio factory function. 263 - final dio = Dio(); 264 - final retryResponse = await dio.requestUri( 265 - uri, 266 - options: retryOptions, 267 - data: response.requestOptions.data, 268 - ); 269 - 270 - handler.resolve(retryResponse); 271 - return; 272 - } catch (retryError) { 273 - if (kDebugMode) { 274 - print('❌ Retry failed: $retryError'); 275 - } 276 - // If retry fails, return the original response 277 - handler.next(response); 278 - return; 279 - } 280 - } 281 - 282 - handler.next(response); 283 - } catch (e) { 284 - handler.reject( 285 - DioException( 286 - requestOptions: response.requestOptions, 287 - response: response, 288 - error: 'Failed to process DPoP nonce: $e', 289 - type: DioExceptionType.unknown, 290 - ), 291 - ); 292 - } 293 - }, 294 - onError: (error, handler) async { 295 - final response = error.response; 296 - if (response == null) { 297 - handler.next(error); 298 - return; 299 - } 300 - 301 - final uri = response.requestOptions.uri; 302 - 303 - if (kDebugMode && uri.path.contains('/token')) { 304 - print('🔴 DPoP interceptor onError triggered'); 305 - print(' URL: ${uri.path}'); 306 - print(' Status: ${response.statusCode}'); 307 - print( 308 - ' Has validateStatus: ${response.requestOptions.validateStatus != null}', 309 - ); 310 - } 311 - 312 - // Check for DPoP-Nonce in error response 313 - final nextNonce = response.headers.value('dpop-nonce'); 314 - 315 - if (nextNonce != null) { 316 - // Extract origin 317 - final origin = 318 - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; 319 - 320 - // Store the fresh nonce for future requests 321 - try { 322 - await options.nonces.set(origin, nextNonce); 323 - if (kDebugMode && uri.path.contains('/token')) { 324 - print(' Cached nonce: ${nextNonce.substring(0, 20)}...'); 325 - } 326 - } catch (_) { 327 - // Ignore nonce storage errors 328 - } 329 - 330 - // Check if this is a "use_dpop_nonce" error 331 - final isNonceError = await _isUseDpopNonceError( 332 - response, 333 - options.isAuthServer, 334 - ); 335 - 336 - if (kDebugMode && uri.path.contains('/token')) { 337 - print(' Is use_dpop_nonce error: $isNonceError'); 338 - } 339 - 340 - if (isNonceError) { 341 - // IMPORTANT: Do NOT retry for token endpoint! 342 - // Retrying the token exchange can consume the authorization code, 343 - // causing "Invalid code" errors on the retry. 344 - // 345 - // Instead, we rely on pre-fetching the nonce before critical operations 346 - // (like authorization code exchange) to ensure we have a valid nonce 347 - // from the start. 348 - // 349 - // We still cache the nonce for future requests, but we don't retry 350 - // this particular request. 351 - final isTokenEndpoint = 352 - uri.path.contains('/token') || uri.path.endsWith('/token'); 353 - 354 - if (kDebugMode && isTokenEndpoint) { 355 - print('⚠️ DPoP nonce error on token endpoint - NOT retrying'); 356 - print(' Cached fresh nonce for future requests'); 357 - } 358 - 359 - if (isTokenEndpoint) { 360 - // Don't retry - just pass through the error with the nonce cached 361 - handler.next(error); 362 - return; 363 - } 364 - 365 - // For non-token endpoints, retry is safe 366 - if (kDebugMode) { 367 - print('🔄 DPoP retry for non-token endpoint: ${uri.path}'); 368 - } 369 - 370 - try { 371 - final authHeader = 372 - response.requestOptions.headers['Authorization'] as String?; 373 - final String? ath; 374 - if (authHeader != null && authHeader.startsWith('DPoP ')) { 375 - ath = await options.sha256(authHeader.substring(5)); 376 - } else { 377 - ath = null; 378 - } 379 - 380 - final htm = response.requestOptions.method; 381 - final htu = _buildHtu(uri.toString()); 382 - 383 - final nextProof = await _buildProof( 384 - options.key, 385 - alg, 386 - htm, 387 - htu, 388 - nextNonce, 389 - ath, 390 - ); 391 - 392 - // Clone request options and update DPoP header 393 - // Note: We preserve validateStatus to match original request behavior 394 - final retryOptions = Options( 395 - method: response.requestOptions.method, 396 - headers: {...response.requestOptions.headers, 'DPoP': nextProof}, 397 - validateStatus: response.requestOptions.validateStatus, 398 - ); 399 - 400 - // DESIGN NOTE: We create a fresh Dio instance for retry to avoid 401 - // re-triggering this interceptor (which would cause infinite loops). 402 - // This means base options (timeouts, etc.) are not preserved, but 403 - // this is acceptable for DPoP nonce retry scenarios which should be fast. 404 - // If this becomes an issue, we could inject a Dio factory function. 405 - final dio = Dio(); 406 - final retryResponse = await dio.requestUri( 407 - uri, 408 - options: retryOptions, 409 - data: response.requestOptions.data, 410 - ); 411 - 412 - handler.resolve(retryResponse); 413 - return; 414 - } catch (retryError) { 415 - // If retry fails, return the retry error 416 - if (retryError is DioException) { 417 - handler.next(retryError); 418 - } else { 419 - handler.next( 420 - DioException( 421 - requestOptions: response.requestOptions, 422 - error: retryError, 423 - type: DioExceptionType.unknown, 424 - ), 425 - ); 426 - } 427 - return; 428 - } 429 - } 430 - } 431 - 432 - if (kDebugMode && uri.path.contains('/token')) { 433 - print('🔴 DPoP interceptor passing error through (no retry)'); 434 - } 435 - 436 - handler.next(error); 437 - }, 438 - ); 439 - } 440 - 441 - /// Strips query string and fragment from URL. 442 - /// 443 - /// Per RFC 9449, the htu (HTTP URI) claim must not include query or fragment. 444 - /// 445 - /// See: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6 446 - String _buildHtu(String url) { 447 - final fragmentIndex = url.indexOf('#'); 448 - final queryIndex = url.indexOf('?'); 449 - 450 - final int end; 451 - if (fragmentIndex == -1) { 452 - end = queryIndex; 453 - } else if (queryIndex == -1) { 454 - end = fragmentIndex; 455 - } else { 456 - end = fragmentIndex < queryIndex ? fragmentIndex : queryIndex; 457 - } 458 - 459 - return end == -1 ? url : url.substring(0, end); 460 - } 461 - 462 - /// Builds a DPoP proof JWT. 463 - /// 464 - /// The proof is a JWT with: 465 - /// - Header: typ="dpop+jwt", alg, jwk (public key) 466 - /// - Payload: iat, jti, htm, htu, nonce?, ath? 467 - /// 468 - /// See: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 469 - Future<String> _buildProof( 470 - Key key, 471 - String alg, 472 - String htm, 473 - String htu, 474 - String? nonce, 475 - String? ath, 476 - ) async { 477 - final jwk = key.bareJwk; 478 - if (jwk == null) { 479 - throw StateError('Only asymmetric keys can be used for DPoP proofs'); 480 - } 481 - 482 - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; 483 - 484 - // Create header 485 - final header = {'alg': alg, 'typ': 'dpop+jwt', 'jwk': jwk}; 486 - 487 - // Create payload 488 - final payload = { 489 - 'iat': now, 490 - // Random jti to prevent replay attacks 491 - // Any collision will cause server rejection, which is acceptable 492 - 'jti': DateTime.now().microsecondsSinceEpoch.toString(), 493 - 'htm': htm, 494 - 'htu': htu, 495 - if (nonce != null) 'nonce': nonce, 496 - if (ath != null) 'ath': ath, 497 - }; 498 - 499 - if (kDebugMode && htu.contains('/token')) { 500 - print('🔐 Creating DPoP proof for token request:'); 501 - print(' htm: $htm'); 502 - print(' htu: $htu'); 503 - print(' nonce: ${nonce ?? "none"}'); 504 - print(' ath: ${ath ?? "none"}'); 505 - print(' jwk keys: ${jwk?.keys.toList()}'); 506 - } 507 - 508 - final jwt = await key.createJwt(header, payload); 509 - 510 - if (kDebugMode && htu.contains('/token')) { 511 - print(' ✅ DPoP proof created: ${jwt.substring(0, 50)}...'); 512 - } 513 - 514 - return jwt; 515 - } 516 - 517 - /// Checks if a response indicates a "use_dpop_nonce" error. 518 - /// 519 - /// There are multiple error formats depending on server implementation: 520 - /// 521 - /// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header 522 - /// WWW-Authenticate: DPoP error="use_dpop_nonce" 523 - /// 524 - /// 2. Authorization Server: 400 with JSON body 525 - /// {"error": "use_dpop_nonce"} 526 - /// 527 - /// 3. Resource Server (JSON variant): 401 with JSON body 528 - /// {"error": "use_dpop_nonce"} 529 - /// 530 - /// See: 531 - /// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 532 - /// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 533 - Future<bool> _isUseDpopNonceError(Response response, bool? isAuthServer) async { 534 - // Check WWW-Authenticate header format (401 + header) 535 - if (response.statusCode == 401) { 536 - final wwwAuth = response.headers.value('www-authenticate'); 537 - if (wwwAuth != null && wwwAuth.startsWith('DPoP')) { 538 - if (wwwAuth.contains('error="use_dpop_nonce"')) { 539 - return true; 540 - } 541 - } 542 - } 543 - 544 - // Check JSON body format (400 or 401 + JSON) 545 - // Some servers use 401 + JSON instead of WWW-Authenticate header 546 - if (response.statusCode == 400 || response.statusCode == 401) { 547 - try { 548 - final data = response.data; 549 - if (data is Map<String, dynamic>) { 550 - return data['error'] == 'use_dpop_nonce'; 551 - } else if (data is String) { 552 - // Try to parse as JSON 553 - final json = jsonDecode(data); 554 - if (json is Map<String, dynamic>) { 555 - return json['error'] == 'use_dpop_nonce'; 556 - } 557 - } 558 - } catch (_) { 559 - // Invalid JSON or response too large, not a use_dpop_nonce error 560 - return false; 561 - } 562 - } 563 - 564 - return false; 565 - } 566 - 567 - /// Negotiates the algorithm to use for DPoP proofs. 568 - /// 569 - /// If supportedAlgs is provided, uses the first algorithm that the key supports. 570 - /// Otherwise, uses the key's first algorithm. 571 - /// 572 - /// Throws if the key doesn't support any of the server's algorithms. 573 - String _negotiateAlg(Key key, List<String>? supportedAlgs) { 574 - if (supportedAlgs != null) { 575 - // Use order of supportedAlgs as preference 576 - for (final alg in supportedAlgs) { 577 - if (key.algorithms.contains(alg)) { 578 - return alg; 579 - } 580 - } 581 - throw StateError( 582 - 'Key does not match any algorithm supported by the server. ' 583 - 'Key supports: ${key.algorithms}, server supports: $supportedAlgs', 584 - ); 585 - } 586 - 587 - // No server preference, use key's first algorithm 588 - if (key.algorithms.isEmpty) { 589 - throw StateError('Key does not support any algorithms'); 590 - } 591 - 592 - return key.algorithms.first; 593 - }
-14
packages/atproto_oauth_flutter/lib/src/errors/auth_method_unsatisfiable_error.dart
··· 1 - /// Exception thrown when the requested authentication method cannot be satisfied. 2 - class AuthMethodUnsatisfiableError implements Exception { 3 - final String? message; 4 - 5 - AuthMethodUnsatisfiableError([this.message]); 6 - 7 - @override 8 - String toString() { 9 - if (message != null) { 10 - return 'AuthMethodUnsatisfiableError: $message'; 11 - } 12 - return 'AuthMethodUnsatisfiableError'; 13 - } 14 - }
-10
packages/atproto_oauth_flutter/lib/src/errors/errors.dart
··· 1 - /// OAuth error types for the atproto_oauth_flutter package. 2 - library; 3 - 4 - export 'auth_method_unsatisfiable_error.dart'; 5 - export 'oauth_callback_error.dart'; 6 - export 'oauth_resolver_error.dart'; 7 - export 'oauth_response_error.dart'; 8 - export 'token_invalid_error.dart'; 9 - export 'token_refresh_error.dart'; 10 - export 'token_revoked_error.dart';
-51
packages/atproto_oauth_flutter/lib/src/errors/oauth_callback_error.dart
··· 1 - /// Error class for OAuth callback failures. 2 - /// 3 - /// This error is thrown when an OAuth authorization callback contains 4 - /// error parameters or fails to parse correctly. 5 - /// 6 - /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 7 - class OAuthCallbackError implements Exception { 8 - /// The URL parameters from the callback 9 - final Map<String, String> params; 10 - 11 - /// The state parameter from the callback (if present) 12 - final String? state; 13 - 14 - /// The error message 15 - final String message; 16 - 17 - /// Optional underlying cause 18 - final Object? cause; 19 - 20 - /// Creates an OAuth callback error from parameters. 21 - /// 22 - /// The [params] should contain the parsed query parameters from the callback URL. 23 - /// The [message] defaults to the error_description from params, or a generic message. 24 - OAuthCallbackError(this.params, {String? message, this.state, this.cause}) 25 - : message = 26 - message ?? params['error_description'] ?? 'OAuth callback error'; 27 - 28 - /// Creates an OAuthCallbackError from another error. 29 - /// 30 - /// If [err] is already an OAuthCallbackError, returns it unchanged. 31 - /// Otherwise, wraps the error with the given params and state. 32 - static OAuthCallbackError from( 33 - Object err, 34 - Map<String, String> params, [ 35 - String? state, 36 - ]) { 37 - if (err is OAuthCallbackError) return err; 38 - final message = err is Exception ? err.toString() : null; 39 - return OAuthCallbackError( 40 - params, 41 - message: message, 42 - state: state, 43 - cause: err, 44 - ); 45 - } 46 - 47 - @override 48 - String toString() { 49 - return 'OAuthCallbackError: $message'; 50 - } 51 - }
-47
packages/atproto_oauth_flutter/lib/src/errors/oauth_resolver_error.dart
··· 1 - /// Error class for OAuth resolution failures. 2 - /// 3 - /// This error is thrown when OAuth metadata resolution fails, including: 4 - /// - Authorization server metadata discovery 5 - /// - Protected resource metadata discovery 6 - /// - Identity resolution (handle → DID → PDS) 7 - class OAuthResolverError implements Exception { 8 - /// The error message 9 - final String message; 10 - 11 - /// Optional underlying cause 12 - final Object? cause; 13 - 14 - /// Creates an OAuth resolver error. 15 - OAuthResolverError(this.message, {this.cause}); 16 - 17 - /// Creates an OAuthResolverError from another error. 18 - /// 19 - /// If [cause] is already an OAuthResolverError, returns it unchanged. 20 - /// Otherwise, wraps the error with an appropriate message. 21 - /// 22 - /// For validation errors, extracts the first error details. 23 - static OAuthResolverError from(Object cause, [String? message]) { 24 - if (cause is OAuthResolverError) return cause; 25 - 26 - String? validationReason; 27 - 28 - // Check if it's a validation error (would be FormatException or similar in Dart) 29 - if (cause is FormatException) { 30 - validationReason = cause.message; 31 - } 32 - 33 - final fullMessage = 34 - (message ?? 'Unable to resolve OAuth metadata') + 35 - (validationReason != null ? ' ($validationReason)' : ''); 36 - 37 - return OAuthResolverError(fullMessage, cause: cause); 38 - } 39 - 40 - @override 41 - String toString() { 42 - if (cause != null) { 43 - return 'OAuthResolverError: $message (caused by: $cause)'; 44 - } 45 - return 'OAuthResolverError: $message'; 46 - } 47 - }
-62
packages/atproto_oauth_flutter/lib/src/errors/oauth_response_error.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import '../util.dart'; 4 - 5 - /// Error class for OAuth protocol errors returned by the server. 6 - /// 7 - /// OAuth servers return errors as JSON with standard fields: 8 - /// - error: The error code (required) 9 - /// - error_description: Human-readable description (optional) 10 - /// - error_uri: URI with more information (optional) 11 - /// 12 - /// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 13 - class OAuthResponseError implements Exception { 14 - /// The HTTP response that contained the error 15 - final Response response; 16 - 17 - /// The parsed response body (usually JSON) 18 - final dynamic payload; 19 - 20 - /// The OAuth error code (e.g., "invalid_request", "invalid_grant") 21 - final String? error; 22 - 23 - /// The human-readable error description 24 - final String? errorDescription; 25 - 26 - /// Creates an OAuth response error from a Dio response. 27 - /// 28 - /// Automatically extracts the error and error_description fields 29 - /// from the response payload if it's a JSON object. 30 - OAuthResponseError(this.response, this.payload) 31 - : error = _extractError(payload), 32 - errorDescription = _extractErrorDescription(payload); 33 - 34 - /// HTTP status code from the response 35 - int get status => response.statusCode ?? 0; 36 - 37 - /// HTTP headers from the response 38 - Headers get headers => response.headers; 39 - 40 - /// Extracts the error code from the payload 41 - static String? _extractError(dynamic payload) { 42 - if (payload is Map<String, dynamic>) { 43 - return ifString(payload['error']); 44 - } 45 - return null; 46 - } 47 - 48 - /// Extracts the error description from the payload 49 - static String? _extractErrorDescription(dynamic payload) { 50 - if (payload is Map<String, dynamic>) { 51 - return ifString(payload['error_description']); 52 - } 53 - return null; 54 - } 55 - 56 - @override 57 - String toString() { 58 - final errorCode = error ?? 'unknown'; 59 - final description = errorDescription != null ? ': $errorDescription' : ''; 60 - return 'OAuth "$errorCode" error$description'; 61 - } 62 - }
-22
packages/atproto_oauth_flutter/lib/src/errors/token_invalid_error.dart
··· 1 - /// Exception thrown when a token is invalid. 2 - class TokenInvalidError implements Exception { 3 - /// Subject identifier for the invalid token 4 - final String sub; 5 - 6 - /// Error message 7 - final String message; 8 - 9 - /// Optional cause of the error 10 - final Object? cause; 11 - 12 - TokenInvalidError(this.sub, {String? message, this.cause}) 13 - : message = message ?? 'The session for "$sub" is invalid'; 14 - 15 - @override 16 - String toString() { 17 - if (cause != null) { 18 - return 'TokenInvalidError: $message (caused by: $cause)'; 19 - } 20 - return 'TokenInvalidError: $message'; 21 - } 22 - }
-21
packages/atproto_oauth_flutter/lib/src/errors/token_refresh_error.dart
··· 1 - /// Exception thrown when a token refresh operation fails. 2 - class TokenRefreshError implements Exception { 3 - /// Subject identifier for the token that failed to refresh 4 - final String sub; 5 - 6 - /// Error message 7 - final String message; 8 - 9 - /// Optional cause of the error 10 - final Object? cause; 11 - 12 - TokenRefreshError(this.sub, this.message, {this.cause}); 13 - 14 - @override 15 - String toString() { 16 - if (cause != null) { 17 - return 'TokenRefreshError: $message (caused by: $cause)'; 18 - } 19 - return 'TokenRefreshError: $message'; 20 - } 21 - }
-22
packages/atproto_oauth_flutter/lib/src/errors/token_revoked_error.dart
··· 1 - /// Exception thrown when a token has been successfully revoked. 2 - class TokenRevokedError implements Exception { 3 - /// Subject identifier for the revoked token 4 - final String sub; 5 - 6 - /// Error message 7 - final String message; 8 - 9 - /// Optional cause of the error 10 - final Object? cause; 11 - 12 - TokenRevokedError(this.sub, {String? message, this.cause}) 13 - : message = message ?? 'The session for "$sub" was successfully revoked'; 14 - 15 - @override 16 - String toString() { 17 - if (cause != null) { 18 - return 'TokenRevokedError: $message (caused by: $cause)'; 19 - } 20 - return 'TokenRevokedError: $message'; 21 - } 22 - }
-263
packages/atproto_oauth_flutter/lib/src/identity/README.md
··· 1 - # atProto Identity Resolution Layer 2 - 3 - ## Overview 4 - 5 - This module implements the **critical identity resolution functionality** for atProto decentralization. It resolves atProto handles and DIDs to discover where user data is actually stored (their Personal Data Server). 6 - 7 - ## Why This Matters 8 - 9 - **This is the most important code for decentralization in atProto.** 10 - 11 - Without this layer: 12 - - Apps hardcode `bsky.social` as the only server 13 - - Users can't use custom domains 14 - - Self-hosting is impossible 15 - - atProto becomes centralized 16 - 17 - With this layer: 18 - - ✅ Users host data on any PDS they choose 19 - - ✅ Custom domain handles work (e.g., `alice.example.com`) 20 - - ✅ Identity is portable (change PDS without losing DID) 21 - - ✅ True decentralization is achieved 22 - 23 - ## Architecture 24 - 25 - ### Resolution Flow 26 - 27 - ``` 28 - Handle/DID Input 29 - 30 - Is it a DID? ──Yes──→ DID Resolution 31 - ↓ ↓ 32 - No DID Document 33 - ↓ ↓ 34 - Handle Resolution Extract Handle 35 - ↓ ↓ 36 - DID Validate Handle ←→ DID 37 - ↓ ↓ 38 - DID Resolution Return IdentityInfo 39 - 40 - DID Document 41 - 42 - Validate Handle in Doc 43 - 44 - Extract PDS URL 45 - 46 - Return IdentityInfo 47 - ``` 48 - 49 - ### Key Components 50 - 51 - #### 1. IdentityResolver 52 - Main interface for resolving identities. Use `AtprotoIdentityResolver` for the standard implementation. 53 - 54 - ```dart 55 - final resolver = AtprotoIdentityResolver.withDefaults( 56 - handleResolverUrl: 'https://bsky.social', 57 - ); 58 - 59 - // Resolve to PDS URL (most common use case) 60 - final pdsUrl = await resolver.resolveToPds('alice.example.com'); 61 - 62 - // Get full identity info 63 - final info = await resolver.resolve('alice.example.com'); 64 - print('DID: ${info.did}'); 65 - print('Handle: ${info.handle}'); 66 - print('PDS: ${info.pdsUrl}'); 67 - ``` 68 - 69 - #### 2. HandleResolver 70 - Resolves atProto handles (e.g., `alice.bsky.social`) to DIDs using XRPC. 71 - 72 - **Resolution Methods:** 73 - - XRPC: Uses `com.atproto.identity.resolveHandle` endpoint 74 - - DNS TXT record: Checks `_atproto.{handle}` (not implemented yet) 75 - - .well-known: Checks `https://{handle}/.well-known/atproto-did` (not implemented yet) 76 - 77 - Current implementation uses XRPC, which works for all handles. 78 - 79 - #### 3. DidResolver 80 - Resolves DIDs to DID documents. 81 - 82 - **Supported Methods:** 83 - - `did:plc`: Queries PLC directory (https://plc.directory) 84 - - `did:web`: Fetches from HTTPS URLs 85 - 86 - #### 4. DidDocument 87 - Represents a W3C DID document with atProto-specific helpers: 88 - - `extractPdsUrl()`: Gets the PDS endpoint 89 - - `extractNormalizedHandle()`: Gets the validated handle 90 - 91 - ### Bi-directional Resolution 92 - 93 - For security, we enforce **bi-directional resolution**: 94 - 95 - 1. Handle → DID resolution must succeed 96 - 2. DID document must contain the original handle 97 - 3. Both directions must agree 98 - 99 - This prevents: 100 - - Handle hijacking 101 - - DID spoofing 102 - - MITM attacks 103 - 104 - ### Caching 105 - 106 - Built-in caching with configurable TTLs: 107 - - **Handles**: 1 hour default (handles can change) 108 - - **DIDs**: 24 hours default (DID docs are more stable) 109 - 110 - Caching is automatic but can be bypassed with `noCache: true`. 111 - 112 - ## File Structure 113 - 114 - ``` 115 - identity/ 116 - ├── constants.dart # atProto constants 117 - ├── did_document.dart # DID document representation 118 - ├── did_helpers.dart # DID validation utilities 119 - ├── did_resolver.dart # DID → DID document resolution 120 - ├── handle_helpers.dart # Handle validation utilities 121 - ├── handle_resolver.dart # Handle → DID resolution 122 - ├── identity_resolver.dart # Main resolver (orchestrates everything) 123 - ├── identity_resolver_error.dart # Error types 124 - ├── identity.dart # Public exports 125 - └── README.md # This file 126 - ``` 127 - 128 - ## Usage Examples 129 - 130 - ### Basic Resolution 131 - 132 - ```dart 133 - import 'package:atproto_oauth_flutter/src/identity/identity.dart'; 134 - 135 - final resolver = AtprotoIdentityResolver.withDefaults( 136 - handleResolverUrl: 'https://bsky.social', 137 - ); 138 - 139 - // Simple PDS lookup 140 - final pdsUrl = await resolver.resolveToPds('alice.bsky.social'); 141 - print('PDS: $pdsUrl'); 142 - ``` 143 - 144 - ### Custom Configuration 145 - 146 - ```dart 147 - // With custom caching and PLC directory 148 - final resolver = AtprotoIdentityResolver.withDefaults( 149 - handleResolverUrl: 'https://bsky.social', 150 - plcDirectoryUrl: 'https://plc.directory/', 151 - didCache: InMemoryDidCache(ttl: Duration(hours: 12)), 152 - handleCache: InMemoryHandleCache(ttl: Duration(minutes: 30)), 153 - ); 154 - ``` 155 - 156 - ### Manual Component Construction 157 - 158 - ```dart 159 - // Build your own resolver with custom components 160 - final dio = Dio(); 161 - 162 - final didResolver = CachedDidResolver( 163 - AtprotoDidResolver(dio: dio), 164 - ); 165 - 166 - final handleResolver = CachedHandleResolver( 167 - XrpcHandleResolver('https://bsky.social', dio: dio), 168 - ); 169 - 170 - final resolver = AtprotoIdentityResolver( 171 - didResolver: didResolver, 172 - handleResolver: handleResolver, 173 - ); 174 - ``` 175 - 176 - ### Error Handling 177 - 178 - ```dart 179 - try { 180 - final info = await resolver.resolve('invalid-handle'); 181 - } on InvalidHandleError catch (e) { 182 - print('Invalid handle format: $e'); 183 - } on HandleResolverError catch (e) { 184 - print('Handle resolution failed: $e'); 185 - } on DidResolverError catch (e) { 186 - print('DID resolution failed: $e'); 187 - } on IdentityResolverError catch (e) { 188 - print('Identity resolution failed: $e'); 189 - } 190 - ``` 191 - 192 - ## Implementation Notes 193 - 194 - ### Ported from TypeScript 195 - 196 - This implementation is a 1:1 port from the official atProto TypeScript packages: 197 - - `@atproto-labs/identity-resolver` 198 - - `@atproto-labs/did-resolver` 199 - - `@atproto-labs/handle-resolver` 200 - 201 - Source: `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts` 202 - 203 - ### Differences from TypeScript 204 - 205 - 1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups. We use XRPC only. 206 - 2. **Simplified Caching**: In-memory only (TypeScript has more cache backends). 207 - 3. **Dio instead of Fetch**: Using Dio HTTP client instead of global fetch. 208 - 4. **Explicit Types**: Dart's type system is more explicit than TypeScript's. 209 - 210 - ### Future Improvements 211 - 212 - - [ ] Add DNS-over-HTTPS for handle resolution 213 - - [ ] Implement .well-known handle resolution 214 - - [ ] Add persistent cache backends (SQLite, Hive) 215 - - [ ] Support custom DID methods beyond plc/web 216 - - [ ] Add metrics and observability 217 - - [ ] Implement resolver timeouts and retries 218 - 219 - ## Testing 220 - 221 - Test the implementation with real handles: 222 - 223 - ```dart 224 - // Test custom PDS 225 - final pds1 = await resolver.resolveToPds('bretton.dev'); 226 - assert(pds1.contains('pds.bretton.dev')); 227 - 228 - // Test Bluesky user 229 - final pds2 = await resolver.resolveToPds('pfrazee.com'); 230 - print('Paul Frazee PDS: $pds2'); 231 - 232 - // Test from DID 233 - final info = await resolver.resolveFromDid('did:plc:ragtjsm2j2vknwkz3zp4oxrd'); 234 - assert(info.handle == 'pfrazee.com'); 235 - ``` 236 - 237 - ## Security Considerations 238 - 239 - 1. **Bi-directional Validation**: Always enforced to prevent spoofing 240 - 2. **HTTPS Only**: All HTTP requests use HTTPS (except localhost for testing) 241 - 3. **No Redirects**: HTTP redirects are rejected to prevent attacks 242 - 4. **Input Validation**: All handles and DIDs are validated before use 243 - 5. **Cache Poisoning**: TTLs prevent stale data, noCache option available 244 - 245 - ## Performance 246 - 247 - Typical resolution times (with cold cache): 248 - - Handle → PDS: ~200-500ms (1 handle lookup + 1 DID fetch) 249 - - DID → PDS: ~100-200ms (1 DID fetch only) 250 - - Cached resolution: <1ms (in-memory lookup) 251 - 252 - For production apps: 253 - - Enable caching (default) 254 - - Use connection pooling (Dio does this) 255 - - Consider warming cache for known users 256 - - Monitor resolver errors and timeouts 257 - 258 - ## References 259 - 260 - - [atProto DID Spec](https://atproto.com/specs/did) 261 - - [atProto Handle Spec](https://atproto.com/specs/handle) 262 - - [W3C DID Core](https://www.w3.org/TR/did-core/) 263 - - [PLC Directory](https://plc.directory/)
-29
packages/atproto_oauth_flutter/lib/src/identity/constants.dart
··· 1 - /// Constants used in atProto identity resolution. 2 - library; 3 - 4 - /// Placeholder handle used when handle is invalid or doesn't match DID. 5 - const String handleInvalid = 'handle.invalid'; 6 - 7 - /// DID prefix for all decentralized identifiers. 8 - const String didPrefix = 'did:'; 9 - 10 - /// DID PLC (Placeholder) prefix. 11 - const String didPlcPrefix = 'did:plc:'; 12 - 13 - /// DID Web prefix. 14 - const String didWebPrefix = 'did:web:'; 15 - 16 - /// Length of a complete did:plc identifier (including prefix). 17 - const int didPlcLength = 32; 18 - 19 - /// Default PLC directory URL for resolving did:plc identifiers. 20 - const String defaultPlcDirectoryUrl = 'https://plc.directory/'; 21 - 22 - /// Maximum length for a DID (per spec). 23 - const int maxDidLength = 2048; 24 - 25 - /// atProto service type in DID documents. 26 - const String atprotoServiceType = 'AtprotoPersonalDataServer'; 27 - 28 - /// atProto service ID prefix in DID documents. 29 - const String atprotoServiceId = '#atproto_pds';
-156
packages/atproto_oauth_flutter/lib/src/identity/did_document.dart
··· 1 - import 'constants.dart'; 2 - import 'handle_helpers.dart'; 3 - 4 - /// Represents a DID document as defined by W3C DID Core spec. 5 - /// 6 - /// This is a simplified version focused on atProto needs. 7 - /// See: https://www.w3.org/TR/did-core/ 8 - class DidDocument { 9 - /// The DID subject (the DID itself) 10 - final String id; 11 - 12 - /// Alternative identifiers (used for atProto handles: at://handle) 13 - final List<String>? alsoKnownAs; 14 - 15 - /// Service endpoints (used to find PDS URL) 16 - final List<DidService>? service; 17 - 18 - /// Verification methods for authentication 19 - final List<dynamic>? verificationMethod; 20 - 21 - /// Authentication methods 22 - final List<dynamic>? authentication; 23 - 24 - /// Optional controller DIDs 25 - final dynamic controller; // Can be String or List<String> 26 - 27 - /// The @context field 28 - final dynamic context; 29 - 30 - const DidDocument({ 31 - required this.id, 32 - this.alsoKnownAs, 33 - this.service, 34 - this.verificationMethod, 35 - this.authentication, 36 - this.controller, 37 - this.context, 38 - }); 39 - 40 - /// Parses a DID document from JSON. 41 - factory DidDocument.fromJson(Map<String, dynamic> json) { 42 - return DidDocument( 43 - id: json['id'] as String, 44 - alsoKnownAs: 45 - (json['alsoKnownAs'] as List<dynamic>?) 46 - ?.map((e) => e as String) 47 - .toList(), 48 - service: 49 - (json['service'] as List<dynamic>?) 50 - ?.map((e) => DidService.fromJson(e as Map<String, dynamic>)) 51 - .toList(), 52 - verificationMethod: json['verificationMethod'] as List<dynamic>?, 53 - authentication: json['authentication'] as List<dynamic>?, 54 - controller: json['controller'], 55 - context: json['@context'], 56 - ); 57 - } 58 - 59 - /// Converts the DID document to JSON. 60 - Map<String, dynamic> toJson() { 61 - final map = <String, dynamic>{'id': id}; 62 - 63 - if (context != null) map['@context'] = context; 64 - if (alsoKnownAs != null) map['alsoKnownAs'] = alsoKnownAs; 65 - if (service != null) { 66 - map['service'] = service!.map((s) => s.toJson()).toList(); 67 - } 68 - if (verificationMethod != null) { 69 - map['verificationMethod'] = verificationMethod; 70 - } 71 - if (authentication != null) map['authentication'] = authentication; 72 - if (controller != null) map['controller'] = controller; 73 - 74 - return map; 75 - } 76 - 77 - /// Extracts the atProto PDS URL from the DID document. 78 - /// 79 - /// Returns null if no PDS service is found. 80 - String? extractPdsUrl() { 81 - if (service == null) return null; 82 - 83 - for (final s in service!) { 84 - // Check for standard atproto_pds service 85 - if (s.id == atprotoServiceId && s.type == atprotoServiceType) { 86 - if (s.serviceEndpoint is String) { 87 - return s.serviceEndpoint as String; 88 - } 89 - } 90 - 91 - // Also check if type matches (some implementations may vary on id) 92 - if (s.type == atprotoServiceType && s.serviceEndpoint is String) { 93 - return s.serviceEndpoint as String; 94 - } 95 - } 96 - 97 - return null; 98 - } 99 - 100 - /// Extracts the raw atProto handle from the DID document. 101 - /// 102 - /// Returns null if no handle is found in alsoKnownAs. 103 - String? extractAtprotoHandle() { 104 - if (alsoKnownAs == null) return null; 105 - 106 - for (final aka in alsoKnownAs!) { 107 - if (aka.startsWith('at://')) { 108 - // Strip off "at://" prefix 109 - return aka.substring(5); 110 - } 111 - } 112 - 113 - return null; 114 - } 115 - 116 - /// Extracts a validated, normalized atProto handle from the DID document. 117 - /// 118 - /// Returns null if no valid handle is found. 119 - String? extractNormalizedHandle() { 120 - final handle = extractAtprotoHandle(); 121 - if (handle == null) return null; 122 - return asNormalizedHandle(handle); 123 - } 124 - } 125 - 126 - /// Represents a service endpoint in a DID document. 127 - class DidService { 128 - /// Service ID (e.g., "#atproto_pds") 129 - final String id; 130 - 131 - /// Service type (e.g., "AtprotoPersonalDataServer") 132 - final String type; 133 - 134 - /// Service endpoint URL 135 - final dynamic serviceEndpoint; // Can be String, Map, or List 136 - 137 - const DidService({ 138 - required this.id, 139 - required this.type, 140 - required this.serviceEndpoint, 141 - }); 142 - 143 - /// Parses a service from JSON. 144 - factory DidService.fromJson(Map<String, dynamic> json) { 145 - return DidService( 146 - id: json['id'] as String, 147 - type: json['type'] as String, 148 - serviceEndpoint: json['serviceEndpoint'], 149 - ); 150 - } 151 - 152 - /// Converts the service to JSON. 153 - Map<String, dynamic> toJson() { 154 - return {'id': id, 'type': type, 'serviceEndpoint': serviceEndpoint}; 155 - } 156 - }
-251
packages/atproto_oauth_flutter/lib/src/identity/did_helpers.dart
··· 1 - import 'constants.dart'; 2 - import 'identity_resolver_error.dart'; 3 - 4 - /// Checks if a string is a valid DID. 5 - /// 6 - /// A valid DID follows the format: did:method:method-specific-id 7 - /// where method is lowercase alphanumeric and method-specific-id 8 - /// contains only allowed characters. 9 - bool isDid(String input) { 10 - try { 11 - assertDid(input); 12 - return true; 13 - } catch (e) { 14 - if (e is IdentityResolverError) { 15 - return false; 16 - } 17 - rethrow; 18 - } 19 - } 20 - 21 - /// Asserts that a string is a valid DID, throwing if not. 22 - void assertDid(String input) { 23 - if (input.length > maxDidLength) { 24 - throw InvalidDidError(input, 'DID is too long ($maxDidLength chars max)'); 25 - } 26 - 27 - if (!input.startsWith(didPrefix)) { 28 - throw InvalidDidError(input, 'DID requires "$didPrefix" prefix'); 29 - } 30 - 31 - final methodEndIndex = input.indexOf(':', didPrefix.length); 32 - if (methodEndIndex == -1) { 33 - throw InvalidDidError(input, 'Missing colon after method name'); 34 - } 35 - 36 - _assertDidMethod(input, didPrefix.length, methodEndIndex); 37 - _assertDidMsid(input, methodEndIndex + 1, input.length); 38 - } 39 - 40 - /// Validates DID method name (lowercase alphanumeric). 41 - void _assertDidMethod(String input, int start, int end) { 42 - if (end == start) { 43 - throw InvalidDidError(input, 'Empty method name'); 44 - } 45 - 46 - for (int i = start; i < end; i++) { 47 - final c = input.codeUnitAt(i); 48 - if (!((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39))) { 49 - // Not a-z or 0-9 50 - throw InvalidDidError( 51 - input, 52 - 'Invalid character at position $i in DID method name', 53 - ); 54 - } 55 - } 56 - } 57 - 58 - /// Validates DID method-specific identifier. 59 - void _assertDidMsid(String input, int start, int end) { 60 - if (end == start) { 61 - throw InvalidDidError(input, 'DID method-specific id must not be empty'); 62 - } 63 - 64 - for (int i = start; i < end; i++) { 65 - final c = input.codeUnitAt(i); 66 - 67 - // Check for frequent chars first (a-z, A-Z, 0-9, ., -, _) 68 - if ((c >= 0x61 && c <= 0x7a) || // a-z 69 - (c >= 0x41 && c <= 0x5a) || // A-Z 70 - (c >= 0x30 && c <= 0x39) || // 0-9 71 - c == 0x2e || // . 72 - c == 0x2d || // - 73 - c == 0x5f) { 74 - // _ 75 - continue; 76 - } 77 - 78 - // ":" 79 - if (c == 0x3a) { 80 - if (i == end - 1) { 81 - throw InvalidDidError(input, 'DID cannot end with ":"'); 82 - } 83 - continue; 84 - } 85 - 86 - // pct-encoded: %HEXDIG HEXDIG 87 - if (c == 0x25) { 88 - // % 89 - if (i + 2 >= end) { 90 - throw InvalidDidError( 91 - input, 92 - 'Incomplete pct-encoded character at position $i', 93 - ); 94 - } 95 - 96 - i++; 97 - final c1 = input.codeUnitAt(i); 98 - if (!((c1 >= 0x30 && c1 <= 0x39) || (c1 >= 0x41 && c1 <= 0x46))) { 99 - // Not 0-9 or A-F 100 - throw InvalidDidError( 101 - input, 102 - 'Invalid pct-encoded character at position $i', 103 - ); 104 - } 105 - 106 - i++; 107 - final c2 = input.codeUnitAt(i); 108 - if (!((c2 >= 0x30 && c2 <= 0x39) || (c2 >= 0x41 && c2 <= 0x46))) { 109 - // Not 0-9 or A-F 110 - throw InvalidDidError( 111 - input, 112 - 'Invalid pct-encoded character at position $i', 113 - ); 114 - } 115 - 116 - continue; 117 - } 118 - 119 - throw InvalidDidError(input, 'Disallowed character in DID at position $i'); 120 - } 121 - } 122 - 123 - /// Extracts the method name from a DID. 124 - /// 125 - /// Example: extractDidMethod('did:plc:abc123') returns 'plc' 126 - String extractDidMethod(String did) { 127 - final methodEndIndex = did.indexOf(':', didPrefix.length); 128 - return did.substring(didPrefix.length, methodEndIndex); 129 - } 130 - 131 - /// Checks if a string is a valid did:plc identifier. 132 - bool isDidPlc(String input) { 133 - if (input.length != didPlcLength) return false; 134 - if (!input.startsWith(didPlcPrefix)) return false; 135 - 136 - // Check that all characters after prefix are base32 [a-z2-7] 137 - for (int i = didPlcPrefix.length; i < didPlcLength; i++) { 138 - if (!_isBase32Char(input.codeUnitAt(i))) return false; 139 - } 140 - 141 - return true; 142 - } 143 - 144 - /// Checks if a string is a valid did:web identifier. 145 - bool isDidWeb(String input) { 146 - if (!input.startsWith(didWebPrefix)) return false; 147 - if (input.length <= didWebPrefix.length) return false; 148 - 149 - // Check if next char after prefix is ":" 150 - if (input.codeUnitAt(didWebPrefix.length) == 0x3a) return false; 151 - 152 - try { 153 - _assertDidMsid(input, didWebPrefix.length, input.length); 154 - return true; 155 - } catch (e) { 156 - return false; 157 - } 158 - } 159 - 160 - /// Checks if a DID uses an atProto-blessed method (plc or web). 161 - bool isAtprotoDid(String input) { 162 - return isDidPlc(input) || isDidWeb(input); 163 - } 164 - 165 - /// Asserts that a string is a valid atProto DID (did:plc or did:web). 166 - /// 167 - /// Throws [InvalidDidError] if the DID is not a valid atProto DID. 168 - void assertAtprotoDid(String input) { 169 - if (!isAtprotoDid(input)) { 170 - throw InvalidDidError( 171 - input, 172 - 'DID must use atProto-blessed method (did:plc or did:web)', 173 - ); 174 - } 175 - } 176 - 177 - /// Asserts that a string is a valid did:plc identifier. 178 - void assertDidPlc(String input) { 179 - if (!input.startsWith(didPlcPrefix)) { 180 - throw InvalidDidError(input, 'Invalid did:plc prefix'); 181 - } 182 - 183 - if (input.length != didPlcLength) { 184 - throw InvalidDidError( 185 - input, 186 - 'did:plc must be $didPlcLength characters long', 187 - ); 188 - } 189 - 190 - for (int i = didPlcPrefix.length; i < didPlcLength; i++) { 191 - if (!_isBase32Char(input.codeUnitAt(i))) { 192 - throw InvalidDidError(input, 'Invalid character at position $i'); 193 - } 194 - } 195 - } 196 - 197 - /// Asserts that a string is a valid did:web identifier. 198 - void assertDidWeb(String input) { 199 - if (!input.startsWith(didWebPrefix)) { 200 - throw InvalidDidError(input, 'Invalid did:web prefix'); 201 - } 202 - 203 - if (input.codeUnitAt(didWebPrefix.length) == 0x3a) { 204 - throw InvalidDidError(input, 'did:web MSID must not start with a colon'); 205 - } 206 - 207 - _assertDidMsid(input, didWebPrefix.length, input.length); 208 - } 209 - 210 - /// Checks if a character code is a base32 character [a-z2-7]. 211 - bool _isBase32Char(int c) => 212 - (c >= 0x61 && c <= 0x7a) || (c >= 0x32 && c <= 0x37); 213 - 214 - /// Converts a did:web to an HTTPS URL. 215 - /// 216 - /// Example: 217 - /// - did:web:example.com -> https://example.com 218 - /// - did:web:example.com:user:alice -> https://example.com/user/alice 219 - /// - did:web:localhost%3A3000 -> http://localhost:3000 220 - Uri didWebToUrl(String did) { 221 - assertDidWeb(did); 222 - 223 - final hostIdx = didWebPrefix.length; 224 - final pathIdx = did.indexOf(':', hostIdx); 225 - 226 - final hostEnc = 227 - pathIdx == -1 ? did.substring(hostIdx) : did.substring(hostIdx, pathIdx); 228 - final host = hostEnc.replaceAll('%3A', ':'); 229 - final path = pathIdx == -1 ? '' : did.substring(pathIdx).replaceAll(':', '/'); 230 - 231 - // Use http for localhost, https for everything else 232 - final proto = 233 - host.startsWith('localhost') && 234 - (host.length == 9 || host.codeUnitAt(9) == 0x3a) // ':' 235 - ? 'http' 236 - : 'https'; 237 - 238 - return Uri.parse('$proto://$host$path'); 239 - } 240 - 241 - /// Converts an HTTPS URL to a did:web identifier. 242 - /// 243 - /// Example: 244 - /// - https://example.com -> did:web:example.com 245 - /// - https://example.com/user/alice -> did:web:example.com:user:alice 246 - String urlToDidWeb(Uri url) { 247 - final port = url.hasPort ? '%3A${url.port}' : ''; 248 - final path = url.path == '/' ? '' : url.path.replaceAll('/', ':'); 249 - 250 - return '$didWebPrefix${url.host}$port$path'; 251 - }
-257
packages/atproto_oauth_flutter/lib/src/identity/did_resolver.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import 'constants.dart'; 4 - import 'did_document.dart'; 5 - import 'did_helpers.dart'; 6 - import 'identity_resolver_error.dart'; 7 - 8 - /// Options for DID resolution. 9 - class ResolveDidOptions { 10 - /// Whether to bypass cache 11 - final bool noCache; 12 - 13 - /// Cancellation token for the request 14 - final CancelToken? cancelToken; 15 - 16 - const ResolveDidOptions({this.noCache = false, this.cancelToken}); 17 - } 18 - 19 - /// Interface for resolving DIDs to DID documents. 20 - abstract class DidResolver { 21 - /// Resolves a DID to its DID document. 22 - /// 23 - /// Throws [DidResolverError] if resolution fails. 24 - Future<DidDocument> resolve(String did, [ResolveDidOptions? options]); 25 - } 26 - 27 - /// DID resolver that supports both did:plc and did:web methods. 28 - class AtprotoDidResolver implements DidResolver { 29 - final DidPlcMethod _plcMethod; 30 - final DidWebMethod _webMethod; 31 - 32 - AtprotoDidResolver({String? plcDirectoryUrl, Dio? dio}) 33 - : _plcMethod = DidPlcMethod(plcDirectoryUrl: plcDirectoryUrl, dio: dio), 34 - _webMethod = DidWebMethod(dio: dio); 35 - 36 - @override 37 - Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async { 38 - if (isDidPlc(did)) { 39 - return _plcMethod.resolve(did, options); 40 - } else if (isDidWeb(did)) { 41 - return _webMethod.resolve(did, options); 42 - } else { 43 - throw DidResolverError( 44 - 'Unsupported DID method: ${extractDidMethod(did)}', 45 - ); 46 - } 47 - } 48 - } 49 - 50 - /// Resolver for did:plc identifiers using the PLC directory. 51 - class DidPlcMethod { 52 - final Uri plcDirectoryUrl; 53 - final Dio dio; 54 - 55 - DidPlcMethod({String? plcDirectoryUrl, Dio? dio}) 56 - : plcDirectoryUrl = Uri.parse(plcDirectoryUrl ?? defaultPlcDirectoryUrl), 57 - dio = dio ?? Dio(); 58 - 59 - Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async { 60 - assertDidPlc(did); 61 - 62 - final url = plcDirectoryUrl.resolve('/${Uri.encodeComponent(did)}'); 63 - 64 - try { 65 - final response = await dio.getUri( 66 - url, 67 - options: Options( 68 - headers: { 69 - 'Accept': 'application/did+ld+json,application/json', 70 - if (options?.noCache ?? false) 'Cache-Control': 'no-cache', 71 - }, 72 - followRedirects: false, 73 - validateStatus: (status) => status == 200, 74 - ), 75 - cancelToken: options?.cancelToken, 76 - ); 77 - 78 - if (response.data is! Map<String, dynamic>) { 79 - throw DidResolverError( 80 - 'Invalid response format from PLC directory for $did', 81 - ); 82 - } 83 - 84 - return DidDocument.fromJson(response.data as Map<String, dynamic>); 85 - } on DioException catch (e) { 86 - if (e.type == DioExceptionType.cancel) { 87 - throw DidResolverError('DID resolution was cancelled'); 88 - } 89 - 90 - if (e.response?.statusCode == 404) { 91 - throw DidResolverError('DID not found: $did'); 92 - } 93 - 94 - throw DidResolverError( 95 - 'Failed to resolve DID from PLC directory: ${e.message}', 96 - e, 97 - ); 98 - } catch (e) { 99 - if (e is DidResolverError) rethrow; 100 - 101 - throw DidResolverError('Unexpected error resolving DID: $e', e); 102 - } 103 - } 104 - } 105 - 106 - /// Resolver for did:web identifiers using HTTPS. 107 - class DidWebMethod { 108 - final Dio dio; 109 - 110 - DidWebMethod({Dio? dio}) : dio = dio ?? Dio(); 111 - 112 - Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async { 113 - assertDidWeb(did); 114 - 115 - final baseUrl = didWebToUrl(did); 116 - 117 - // Try /.well-known/did.json first, then /did.json 118 - final urls = [ 119 - baseUrl.resolve('/.well-known/did.json'), 120 - baseUrl.resolve('/did.json'), 121 - ]; 122 - 123 - DioException? lastError; 124 - 125 - for (final url in urls) { 126 - try { 127 - final response = await dio.getUri( 128 - url, 129 - options: Options( 130 - headers: { 131 - 'Accept': 'application/did+ld+json,application/json', 132 - if (options?.noCache ?? false) 'Cache-Control': 'no-cache', 133 - }, 134 - followRedirects: false, 135 - validateStatus: (status) => status == 200, 136 - ), 137 - cancelToken: options?.cancelToken, 138 - ); 139 - 140 - if (response.data is! Map<String, dynamic>) { 141 - throw DidResolverError( 142 - 'Invalid response format from did:web for $did', 143 - ); 144 - } 145 - 146 - final doc = DidDocument.fromJson(response.data as Map<String, dynamic>); 147 - 148 - // Verify the DID in the document matches 149 - if (doc.id != did) { 150 - throw DidResolverError( 151 - 'DID mismatch: expected $did but got ${doc.id}', 152 - ); 153 - } 154 - 155 - return doc; 156 - } on DioException catch (e) { 157 - if (e.type == DioExceptionType.cancel) { 158 - throw DidResolverError('DID resolution was cancelled'); 159 - } 160 - 161 - // If not found, try the next URL 162 - if (e.response?.statusCode == 404) { 163 - lastError = e; 164 - continue; 165 - } 166 - 167 - // Any other error, throw immediately 168 - throw DidResolverError('Failed to resolve did:web: ${e.message}', e); 169 - } catch (e) { 170 - if (e is DidResolverError) rethrow; 171 - 172 - throw DidResolverError('Unexpected error resolving did:web: $e', e); 173 - } 174 - } 175 - 176 - // If we get here, all URLs failed 177 - throw DidResolverError('DID document not found for $did', lastError); 178 - } 179 - } 180 - 181 - /// Cached DID resolver that wraps another resolver with caching. 182 - class CachedDidResolver implements DidResolver { 183 - final DidResolver _resolver; 184 - final DidCache _cache; 185 - 186 - CachedDidResolver(this._resolver, [DidCache? cache]) 187 - : _cache = cache ?? InMemoryDidCache(); 188 - 189 - @override 190 - Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async { 191 - // Check cache first unless noCache is set 192 - if (!(options?.noCache ?? false)) { 193 - final cached = await _cache.get(did); 194 - if (cached != null) { 195 - return cached; 196 - } 197 - } 198 - 199 - // Resolve and cache 200 - final doc = await _resolver.resolve(did, options); 201 - await _cache.set(did, doc); 202 - 203 - return doc; 204 - } 205 - 206 - /// Clears the cache 207 - Future<void> clearCache() => _cache.clear(); 208 - } 209 - 210 - /// Interface for caching DID documents. 211 - abstract class DidCache { 212 - Future<DidDocument?> get(String did); 213 - Future<void> set(String did, DidDocument document); 214 - Future<void> clear(); 215 - } 216 - 217 - /// Simple in-memory DID cache with expiration. 218 - class InMemoryDidCache implements DidCache { 219 - final Map<String, _CacheEntry> _cache = {}; 220 - final Duration _ttl; 221 - 222 - InMemoryDidCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 24); 223 - 224 - @override 225 - Future<DidDocument?> get(String did) async { 226 - final entry = _cache[did]; 227 - if (entry == null) return null; 228 - 229 - // Check if expired 230 - if (DateTime.now().isAfter(entry.expiresAt)) { 231 - _cache.remove(did); 232 - return null; 233 - } 234 - 235 - return entry.document; 236 - } 237 - 238 - @override 239 - Future<void> set(String did, DidDocument document) async { 240 - _cache[did] = _CacheEntry( 241 - document: document, 242 - expiresAt: DateTime.now().add(_ttl), 243 - ); 244 - } 245 - 246 - @override 247 - Future<void> clear() async { 248 - _cache.clear(); 249 - } 250 - } 251 - 252 - class _CacheEntry { 253 - final DidDocument document; 254 - final DateTime expiresAt; 255 - 256 - _CacheEntry({required this.document, required this.expiresAt}); 257 - }
-35
packages/atproto_oauth_flutter/lib/src/identity/handle_helpers.dart
··· 1 - import 'identity_resolver_error.dart'; 2 - 3 - /// Normalizes a handle to lowercase. 4 - String normalizeHandle(String handle) => handle.toLowerCase(); 5 - 6 - /// Checks if a handle is valid according to atProto spec. 7 - /// 8 - /// A valid handle must: 9 - /// - Be between 1 and 253 characters 10 - /// - Match the pattern: subdomain.domain.tld 11 - /// - Each label must start and end with alphanumeric 12 - /// - Labels can contain hyphens but not at boundaries 13 - bool isValidHandle(String handle) { 14 - if (handle.isEmpty || handle.length >= 254) return false; 15 - 16 - // Pattern: ([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])? 17 - final pattern = RegExp( 18 - r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$', 19 - ); 20 - 21 - return pattern.hasMatch(handle); 22 - } 23 - 24 - /// Returns a normalized handle if valid, null otherwise. 25 - String? asNormalizedHandle(String input) { 26 - final handle = normalizeHandle(input); 27 - return isValidHandle(handle) ? handle : null; 28 - } 29 - 30 - /// Asserts that a handle is valid. 31 - void assertValidHandle(String handle) { 32 - if (!isValidHandle(handle)) { 33 - throw InvalidHandleError(handle, 'Invalid handle format'); 34 - } 35 - }
-202
packages/atproto_oauth_flutter/lib/src/identity/handle_resolver.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import 'did_helpers.dart'; 4 - import 'identity_resolver_error.dart'; 5 - 6 - /// Options for handle resolution. 7 - class ResolveHandleOptions { 8 - /// Whether to bypass cache 9 - final bool noCache; 10 - 11 - /// Cancellation token for the request 12 - final CancelToken? cancelToken; 13 - 14 - const ResolveHandleOptions({this.noCache = false, this.cancelToken}); 15 - } 16 - 17 - /// Interface for resolving atProto handles to DIDs. 18 - abstract class HandleResolver { 19 - /// Resolves an atProto handle to a DID. 20 - /// 21 - /// Returns null if the handle doesn't resolve to a DID (but no error occurred). 22 - /// Throws [HandleResolverError] if an unexpected error occurs during resolution. 23 - Future<String?> resolve(String handle, [ResolveHandleOptions? options]); 24 - } 25 - 26 - /// XRPC-based handle resolver that uses com.atproto.identity.resolveHandle. 27 - /// 28 - /// This resolver makes HTTP requests to an atProto XRPC service (typically 29 - /// a PDS or entryway service) to resolve handles. 30 - class XrpcHandleResolver implements HandleResolver { 31 - /// The base URL of the XRPC service 32 - final Uri serviceUrl; 33 - 34 - /// HTTP client for making requests 35 - final Dio dio; 36 - 37 - XrpcHandleResolver(String serviceUrl, {Dio? dio}) 38 - : serviceUrl = Uri.parse(serviceUrl), 39 - dio = dio ?? Dio(); 40 - 41 - @override 42 - Future<String?> resolve( 43 - String handle, [ 44 - ResolveHandleOptions? options, 45 - ]) async { 46 - final url = serviceUrl.resolve('/xrpc/com.atproto.identity.resolveHandle'); 47 - final uri = url.replace(queryParameters: {'handle': handle}); 48 - 49 - try { 50 - final response = await dio.getUri( 51 - uri, 52 - options: Options( 53 - headers: {if (options?.noCache ?? false) 'Cache-Control': 'no-cache'}, 54 - validateStatus: (status) { 55 - // Allow 400 and 200 status codes 56 - return status == 200 || status == 400; 57 - }, 58 - ), 59 - cancelToken: options?.cancelToken, 60 - ); 61 - 62 - final data = response.data; 63 - 64 - // Handle 400 Bad Request (expected for invalid/unresolvable handles) 65 - if (response.statusCode == 400) { 66 - if (data is Map<String, dynamic>) { 67 - final error = data['error'] as String?; 68 - final message = data['message'] as String?; 69 - 70 - // Expected response for handle that doesn't exist 71 - if (error == 'InvalidRequest' && 72 - message == 'Unable to resolve handle') { 73 - return null; 74 - } 75 - } 76 - 77 - throw HandleResolverError( 78 - 'Invalid response from resolveHandle method: ${response.data}', 79 - ); 80 - } 81 - 82 - // Handle successful response 83 - if (response.statusCode == 200) { 84 - if (data is! Map<String, dynamic>) { 85 - throw HandleResolverError( 86 - 'Invalid response format from resolveHandle method', 87 - ); 88 - } 89 - 90 - final did = data['did']; 91 - if (did is! String) { 92 - throw HandleResolverError( 93 - 'Missing or invalid DID in resolveHandle response', 94 - ); 95 - } 96 - 97 - // Validate that it's a proper atProto DID 98 - if (!isAtprotoDid(did)) { 99 - throw HandleResolverError( 100 - 'Invalid DID returned from resolveHandle method: $did', 101 - ); 102 - } 103 - 104 - return did; 105 - } 106 - 107 - throw HandleResolverError( 108 - 'Unexpected status code from resolveHandle method: ${response.statusCode}', 109 - ); 110 - } on DioException catch (e) { 111 - if (e.type == DioExceptionType.cancel) { 112 - throw HandleResolverError('Handle resolution was cancelled'); 113 - } 114 - 115 - throw HandleResolverError('Failed to resolve handle: ${e.message}', e); 116 - } catch (e) { 117 - if (e is HandleResolverError) rethrow; 118 - 119 - throw HandleResolverError('Unexpected error resolving handle: $e', e); 120 - } 121 - } 122 - } 123 - 124 - /// Cached handle resolver that wraps another resolver with caching. 125 - class CachedHandleResolver implements HandleResolver { 126 - final HandleResolver _resolver; 127 - final HandleCache _cache; 128 - 129 - CachedHandleResolver(this._resolver, [HandleCache? cache]) 130 - : _cache = cache ?? InMemoryHandleCache(); 131 - 132 - @override 133 - Future<String?> resolve( 134 - String handle, [ 135 - ResolveHandleOptions? options, 136 - ]) async { 137 - // Check cache first unless noCache is set 138 - if (!(options?.noCache ?? false)) { 139 - final cached = await _cache.get(handle); 140 - if (cached != null) { 141 - return cached; 142 - } 143 - } 144 - 145 - // Resolve and cache 146 - final did = await _resolver.resolve(handle, options); 147 - if (did != null) { 148 - await _cache.set(handle, did); 149 - } 150 - 151 - return did; 152 - } 153 - 154 - /// Clears the cache 155 - Future<void> clearCache() => _cache.clear(); 156 - } 157 - 158 - /// Interface for caching handle resolution results. 159 - abstract class HandleCache { 160 - Future<String?> get(String handle); 161 - Future<void> set(String handle, String did); 162 - Future<void> clear(); 163 - } 164 - 165 - /// Simple in-memory handle cache with expiration. 166 - class InMemoryHandleCache implements HandleCache { 167 - final Map<String, _CacheEntry> _cache = {}; 168 - final Duration _ttl; 169 - 170 - InMemoryHandleCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 1); 171 - 172 - @override 173 - Future<String?> get(String handle) async { 174 - final entry = _cache[handle]; 175 - if (entry == null) return null; 176 - 177 - // Check if expired 178 - if (DateTime.now().isAfter(entry.expiresAt)) { 179 - _cache.remove(handle); 180 - return null; 181 - } 182 - 183 - return entry.did; 184 - } 185 - 186 - @override 187 - Future<void> set(String handle, String did) async { 188 - _cache[handle] = _CacheEntry(did: did, expiresAt: DateTime.now().add(_ttl)); 189 - } 190 - 191 - @override 192 - Future<void> clear() async { 193 - _cache.clear(); 194 - } 195 - } 196 - 197 - class _CacheEntry { 198 - final String did; 199 - final DateTime expiresAt; 200 - 201 - _CacheEntry({required this.did, required this.expiresAt}); 202 - }
-47
packages/atproto_oauth_flutter/lib/src/identity/identity.dart
··· 1 - /// Identity resolution for atProto. 2 - /// 3 - /// This module provides the core identity resolution functionality for atProto, 4 - /// enabling decentralized identity through handle and DID resolution. 5 - /// 6 - /// ## Key Components 7 - /// 8 - /// - **IdentityResolver**: Main interface for resolving handles/DIDs to identity info 9 - /// - **HandleResolver**: Resolves atProto handles (e.g., "alice.bsky.social") to DIDs 10 - /// - **DidResolver**: Resolves DIDs to DID documents 11 - /// - **DidDocument**: Represents a DID document with services and handles 12 - /// 13 - /// ## Why This Matters for Decentralization 14 - /// 15 - /// This is the **most important module for atProto decentralization**. It enables: 16 - /// 1. Users to host their data on any PDS, not just bsky.social 17 - /// 2. Custom domain handles (e.g., "alice.example.com") 18 - /// 3. Portable identity (change PDS without losing identity) 19 - /// 20 - /// ## Usage 21 - /// 22 - /// ```dart 23 - /// // Create a resolver 24 - /// final resolver = AtprotoIdentityResolver.withDefaults( 25 - /// handleResolverUrl: 'https://bsky.social', 26 - /// ); 27 - /// 28 - /// // Resolve a handle to find their PDS 29 - /// final pdsUrl = await resolver.resolveToPds('alice.bsky.social'); 30 - /// print('Alice\'s PDS: $pdsUrl'); 31 - /// 32 - /// // Get full identity info 33 - /// final info = await resolver.resolve('alice.bsky.social'); 34 - /// print('DID: ${info.did}'); 35 - /// print('Handle: ${info.handle}'); 36 - /// print('PDS: ${info.pdsUrl}'); 37 - /// ``` 38 - library; 39 - 40 - export 'constants.dart'; 41 - export 'did_document.dart'; 42 - export 'did_helpers.dart'; 43 - export 'did_resolver.dart'; 44 - export 'handle_helpers.dart'; 45 - export 'handle_resolver.dart'; 46 - export 'identity_resolver.dart'; 47 - export 'identity_resolver_error.dart';
-366
packages/atproto_oauth_flutter/lib/src/identity/identity_resolver.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import 'constants.dart'; 4 - import 'did_document.dart'; 5 - import 'did_helpers.dart'; 6 - import 'did_resolver.dart'; 7 - import 'handle_helpers.dart'; 8 - import 'handle_resolver.dart'; 9 - import 'identity_resolver_error.dart'; 10 - 11 - /// Represents resolved identity information for an atProto user. 12 - /// 13 - /// This combines DID, DID document, and validated handle information. 14 - class IdentityInfo { 15 - /// The DID (Decentralized Identifier) for this identity 16 - final String did; 17 - 18 - /// The complete DID document 19 - final DidDocument didDoc; 20 - 21 - /// The validated handle, or 'handle.invalid' if handle validation failed 22 - final String handle; 23 - 24 - const IdentityInfo({ 25 - required this.did, 26 - required this.didDoc, 27 - required this.handle, 28 - }); 29 - 30 - /// Whether the handle is valid (not 'handle.invalid') 31 - bool get hasValidHandle => handle != handleInvalid; 32 - 33 - /// Extracts the PDS URL from the DID document. 34 - /// 35 - /// Returns null if no PDS service is found. 36 - String? get pdsUrl => didDoc.extractPdsUrl(); 37 - } 38 - 39 - /// Options for identity resolution. 40 - class ResolveIdentityOptions { 41 - /// Whether to bypass cache 42 - final bool noCache; 43 - 44 - /// Cancellation token for the request 45 - final CancelToken? cancelToken; 46 - 47 - const ResolveIdentityOptions({this.noCache = false, this.cancelToken}); 48 - } 49 - 50 - /// Interface for resolving atProto identities (handles or DIDs) to complete identity info. 51 - abstract class IdentityResolver { 52 - /// Resolves an identifier (handle or DID) to complete identity information. 53 - /// 54 - /// The identifier can be either: 55 - /// - An atProto handle (e.g., "alice.bsky.social") 56 - /// - A DID (e.g., "did:plc:...") 57 - /// 58 - /// Returns [IdentityInfo] with DID, DID document, and validated handle. 59 - Future<IdentityInfo> resolve( 60 - String identifier, [ 61 - ResolveIdentityOptions? options, 62 - ]); 63 - } 64 - 65 - /// Implementation of the official atProto identity resolution strategy. 66 - /// 67 - /// This resolver: 68 - /// 1. Determines if input is a handle or DID 69 - /// 2. Resolves handle → DID (if needed) 70 - /// 3. Fetches DID document 71 - /// 4. Validates bi-directional resolution (handle in DID doc matches original) 72 - /// 5. Extracts PDS URL from DID document 73 - /// 74 - /// This is the **critical piece for decentralization** - it ensures users can 75 - /// host their data on any PDS, not just bsky.social. 76 - class AtprotoIdentityResolver implements IdentityResolver { 77 - final DidResolver didResolver; 78 - final HandleResolver handleResolver; 79 - 80 - AtprotoIdentityResolver({ 81 - required this.didResolver, 82 - required this.handleResolver, 83 - }); 84 - 85 - /// Factory constructor with defaults for typical usage. 86 - /// 87 - /// [handleResolverUrl] should point to an atProto XRPC service that 88 - /// implements com.atproto.identity.resolveHandle. Typically this is 89 - /// https://bsky.social for public resolution, or your own PDS. 90 - factory AtprotoIdentityResolver.withDefaults({ 91 - required String handleResolverUrl, 92 - String? plcDirectoryUrl, 93 - Dio? dio, 94 - DidCache? didCache, 95 - HandleCache? handleCache, 96 - }) { 97 - final dioInstance = dio ?? Dio(); 98 - 99 - final baseDidResolver = AtprotoDidResolver( 100 - plcDirectoryUrl: plcDirectoryUrl, 101 - dio: dioInstance, 102 - ); 103 - 104 - final baseHandleResolver = XrpcHandleResolver( 105 - handleResolverUrl, 106 - dio: dioInstance, 107 - ); 108 - 109 - return AtprotoIdentityResolver( 110 - didResolver: CachedDidResolver(baseDidResolver, didCache), 111 - handleResolver: CachedHandleResolver(baseHandleResolver, handleCache), 112 - ); 113 - } 114 - 115 - @override 116 - Future<IdentityInfo> resolve( 117 - String identifier, [ 118 - ResolveIdentityOptions? options, 119 - ]) async { 120 - return isDid(identifier) 121 - ? resolveFromDid(identifier, options) 122 - : resolveFromHandle(identifier, options); 123 - } 124 - 125 - /// Resolves identity starting from a DID. 126 - /// 127 - /// This: 128 - /// 1. Fetches the DID document 129 - /// 2. Extracts the handle from alsoKnownAs 130 - /// 3. Validates that the handle resolves back to the same DID 131 - Future<IdentityInfo> resolveFromDid( 132 - String did, [ 133 - ResolveIdentityOptions? options, 134 - ]) async { 135 - final document = await getDocumentFromDid(did, options); 136 - 137 - // We will only return the document's handle alias if it resolves to the 138 - // same DID as the input (bi-directional validation) 139 - final handle = document.extractNormalizedHandle(); 140 - String? resolvedDid; 141 - 142 - if (handle != null) { 143 - try { 144 - resolvedDid = await handleResolver.resolve( 145 - handle, 146 - ResolveHandleOptions( 147 - noCache: options?.noCache ?? false, 148 - cancelToken: options?.cancelToken, 149 - ), 150 - ); 151 - } catch (e) { 152 - // Ignore errors (handle might be temporarily unavailable) 153 - resolvedDid = null; 154 - } 155 - } 156 - 157 - return IdentityInfo( 158 - did: document.id, 159 - didDoc: document, 160 - handle: handle != null && resolvedDid == did ? handle : handleInvalid, 161 - ); 162 - } 163 - 164 - /// Resolves identity starting from a handle. 165 - /// 166 - /// This: 167 - /// 1. Resolves handle → DID 168 - /// 2. Fetches DID document 169 - /// 3. Validates that the DID document contains the original handle 170 - Future<IdentityInfo> resolveFromHandle( 171 - String handle, [ 172 - ResolveIdentityOptions? options, 173 - ]) async { 174 - final document = await getDocumentFromHandle(handle, options); 175 - 176 - // Bi-directional resolution is enforced in getDocumentFromHandle() 177 - return IdentityInfo( 178 - did: document.id, 179 - didDoc: document, 180 - handle: document.extractNormalizedHandle() ?? handleInvalid, 181 - ); 182 - } 183 - 184 - /// Fetches a DID document from a DID. 185 - Future<DidDocument> getDocumentFromDid( 186 - String did, [ 187 - ResolveIdentityOptions? options, 188 - ]) async { 189 - return didResolver.resolve( 190 - did, 191 - ResolveDidOptions( 192 - noCache: options?.noCache ?? false, 193 - cancelToken: options?.cancelToken, 194 - ), 195 - ); 196 - } 197 - 198 - /// Fetches a DID document from a handle with bi-directional validation. 199 - /// 200 - /// This method: 201 - /// 1. Normalizes and validates the handle 202 - /// 2. Resolves handle → DID 203 - /// 3. Fetches DID document 204 - /// 4. Verifies the DID document contains the original handle 205 - Future<DidDocument> getDocumentFromHandle( 206 - String input, [ 207 - ResolveIdentityOptions? options, 208 - ]) async { 209 - final handle = asNormalizedHandle(input); 210 - if (handle == null) { 211 - throw InvalidHandleError(input, 'Invalid handle format'); 212 - } 213 - 214 - final did = await handleResolver.resolve( 215 - handle, 216 - ResolveHandleOptions( 217 - noCache: options?.noCache ?? false, 218 - cancelToken: options?.cancelToken, 219 - ), 220 - ); 221 - 222 - if (did == null) { 223 - throw IdentityResolverError('Handle "$handle" does not resolve to a DID'); 224 - } 225 - 226 - // Fetch the DID document 227 - final document = await didResolver.resolve( 228 - did, 229 - ResolveDidOptions( 230 - noCache: options?.noCache ?? false, 231 - cancelToken: options?.cancelToken, 232 - ), 233 - ); 234 - 235 - // Enforce bi-directional resolution 236 - final docHandle = document.extractNormalizedHandle(); 237 - if (handle != docHandle) { 238 - throw IdentityResolverError( 239 - 'DID document for "$did" does not include the handle "$handle" ' 240 - '(found: ${docHandle ?? "none"})', 241 - ); 242 - } 243 - 244 - return document; 245 - } 246 - 247 - /// Convenience method to resolve directly to PDS URL. 248 - /// 249 - /// This is the most common use case: given a handle or DID, find the PDS URL. 250 - Future<String> resolveToPds( 251 - String identifier, [ 252 - ResolveIdentityOptions? options, 253 - ]) async { 254 - final info = await resolve(identifier, options); 255 - final pdsUrl = info.pdsUrl; 256 - 257 - if (pdsUrl == null) { 258 - throw IdentityResolverError( 259 - 'No PDS endpoint found in DID document for $identifier', 260 - ); 261 - } 262 - 263 - return pdsUrl; 264 - } 265 - } 266 - 267 - /// Options for creating an identity resolver. 268 - class IdentityResolverOptions { 269 - /// Custom identity resolver (if not provided, AtprotoIdentityResolver is used) 270 - final IdentityResolver? identityResolver; 271 - 272 - /// Custom DID resolver 273 - final DidResolver? didResolver; 274 - 275 - /// Custom handle resolver (or URL string for XRPC resolver) 276 - final dynamic handleResolver; // HandleResolver, String, or Uri 277 - 278 - /// Custom DID cache 279 - final DidCache? didCache; 280 - 281 - /// Custom handle cache 282 - final HandleCache? handleCache; 283 - 284 - /// Custom Dio instance for HTTP requests 285 - final Dio? dio; 286 - 287 - /// PLC directory URL (defaults to https://plc.directory/) 288 - final String? plcDirectoryUrl; 289 - 290 - const IdentityResolverOptions({ 291 - this.identityResolver, 292 - this.didResolver, 293 - this.handleResolver, 294 - this.didCache, 295 - this.handleCache, 296 - this.dio, 297 - this.plcDirectoryUrl, 298 - }); 299 - } 300 - 301 - /// Creates an identity resolver with the given options. 302 - /// 303 - /// This is the main entry point for creating an identity resolver. 304 - /// It handles setting up default implementations with proper caching. 305 - IdentityResolver createIdentityResolver(IdentityResolverOptions options) { 306 - // If a custom identity resolver is provided, use it 307 - if (options.identityResolver != null) { 308 - return options.identityResolver!; 309 - } 310 - 311 - final dioInstance = options.dio ?? Dio(); 312 - 313 - // Create DID resolver 314 - final didResolver = _createDidResolver(options, dioInstance); 315 - 316 - // Create handle resolver 317 - final handleResolver = _createHandleResolver(options, dioInstance); 318 - 319 - return AtprotoIdentityResolver( 320 - didResolver: didResolver, 321 - handleResolver: handleResolver, 322 - ); 323 - } 324 - 325 - DidResolver _createDidResolver(IdentityResolverOptions options, Dio dio) { 326 - final didResolver = 327 - options.didResolver ?? 328 - AtprotoDidResolver(plcDirectoryUrl: options.plcDirectoryUrl, dio: dio); 329 - 330 - // Wrap with cache if not already cached 331 - if (didResolver is CachedDidResolver && options.didCache == null) { 332 - return didResolver; 333 - } 334 - 335 - return CachedDidResolver(didResolver, options.didCache); 336 - } 337 - 338 - HandleResolver _createHandleResolver(IdentityResolverOptions options, Dio dio) { 339 - final handleResolverInput = options.handleResolver; 340 - 341 - if (handleResolverInput == null) { 342 - throw ArgumentError( 343 - 'handleResolver is required. Provide either a HandleResolver instance, ' 344 - 'a URL string, or a Uri pointing to an XRPC service.', 345 - ); 346 - } 347 - 348 - HandleResolver baseResolver; 349 - 350 - if (handleResolverInput is HandleResolver) { 351 - baseResolver = handleResolverInput; 352 - } else if (handleResolverInput is String || handleResolverInput is Uri) { 353 - baseResolver = XrpcHandleResolver(handleResolverInput.toString(), dio: dio); 354 - } else { 355 - throw ArgumentError( 356 - 'handleResolver must be a HandleResolver, String, or Uri', 357 - ); 358 - } 359 - 360 - // Wrap with cache if not already cached 361 - if (baseResolver is CachedHandleResolver && options.handleCache == null) { 362 - return baseResolver; 363 - } 364 - 365 - return CachedHandleResolver(baseResolver, options.handleCache); 366 - }
-53
packages/atproto_oauth_flutter/lib/src/identity/identity_resolver_error.dart
··· 1 - /// Error thrown when identity resolution fails. 2 - /// 3 - /// This error is thrown when resolving an atProto handle or DID fails, 4 - /// including cases such as: 5 - /// - Invalid handle format 6 - /// - Handle doesn't resolve to a DID 7 - /// - DID document is malformed or missing required fields 8 - /// - Bi-directional resolution fails (handle in DID doc doesn't match) 9 - class IdentityResolverError extends Error { 10 - /// The error message describing what went wrong 11 - final String message; 12 - 13 - /// Optional underlying cause of the error 14 - final Object? cause; 15 - 16 - IdentityResolverError(this.message, [this.cause]); 17 - 18 - @override 19 - String toString() { 20 - if (cause != null) { 21 - return 'IdentityResolverError: $message\nCaused by: $cause'; 22 - } 23 - return 'IdentityResolverError: $message'; 24 - } 25 - } 26 - 27 - /// Error thrown when a DID is invalid or malformed. 28 - class InvalidDidError extends IdentityResolverError { 29 - /// The invalid DID that was provided 30 - final String did; 31 - 32 - InvalidDidError(this.did, String message, [Object? cause]) 33 - : super('Invalid DID "$did": $message', cause); 34 - } 35 - 36 - /// Error thrown when a handle is invalid or malformed. 37 - class InvalidHandleError extends IdentityResolverError { 38 - /// The invalid handle that was provided 39 - final String handle; 40 - 41 - InvalidHandleError(this.handle, String message, [Object? cause]) 42 - : super('Invalid handle "$handle": $message', cause); 43 - } 44 - 45 - /// Error thrown when handle resolution fails. 46 - class HandleResolverError extends IdentityResolverError { 47 - HandleResolverError(super.message, [super.cause]); 48 - } 49 - 50 - /// Error thrown when DID resolution fails. 51 - class DidResolverError extends IdentityResolverError { 52 - DidResolverError(super.message, [super.cause]); 53 - }
-248
packages/atproto_oauth_flutter/lib/src/oauth/authorization_server_metadata_resolver.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import '../dpop/fetch_dpop.dart'; 4 - import '../util.dart'; 5 - 6 - /// Options for getting cached values. 7 - class GetCachedOptions { 8 - /// Whether to bypass cache and force a fresh fetch 9 - final bool noCache; 10 - 11 - /// Whether to allow returning stale cached values 12 - final bool allowStale; 13 - 14 - /// Optional cancellation token 15 - final CancelToken? cancelToken; 16 - 17 - const GetCachedOptions({ 18 - this.noCache = false, 19 - this.allowStale = true, 20 - this.cancelToken, 21 - }); 22 - } 23 - 24 - /// Cache interface for authorization server metadata. 25 - /// 26 - /// Implementations should store metadata keyed by issuer URL. 27 - typedef AuthorizationServerMetadataCache = 28 - SimpleStore<String, Map<String, dynamic>>; 29 - 30 - /// Configuration for the authorization server metadata resolver. 31 - class OAuthAuthorizationServerMetadataResolverConfig { 32 - /// Whether to allow HTTP (non-HTTPS) issuer URLs. 33 - /// 34 - /// Should only be true in development/test environments. 35 - /// Production MUST use HTTPS. 36 - final bool allowHttpIssuer; 37 - 38 - const OAuthAuthorizationServerMetadataResolverConfig({ 39 - this.allowHttpIssuer = false, 40 - }); 41 - } 42 - 43 - /// Resolves OAuth Authorization Server Metadata via RFC 8414 discovery. 44 - /// 45 - /// This class: 46 - /// 1. Validates issuer URLs (must be HTTPS in production) 47 - /// 2. Fetches metadata from `{issuer}/.well-known/oauth-authorization-server` 48 - /// 3. Validates the metadata against the spec 49 - /// 4. Verifies issuer matches (prevents MIX-UP attacks) 50 - /// 5. Ensures ATPROTO requirements (client_id_metadata_document) 51 - /// 6. Caches metadata to avoid repeated fetches 52 - /// 53 - /// See: https://datatracker.ietf.org/doc/html/rfc8414 54 - class OAuthAuthorizationServerMetadataResolver { 55 - final AuthorizationServerMetadataCache _cache; 56 - final Dio _dio; 57 - final bool _allowHttpIssuer; 58 - 59 - /// Creates a resolver with the given cache and HTTP client. 60 - /// 61 - /// [cache] is used to store fetched metadata. Use an in-memory store for 62 - /// testing or a persistent store for production. 63 - /// 64 - /// [dio] is the HTTP client. If not provided, creates a default instance. 65 - /// 66 - /// [config] allows customizing behavior (e.g., allowing HTTP in tests). 67 - OAuthAuthorizationServerMetadataResolver( 68 - this._cache, { 69 - Dio? dio, 70 - OAuthAuthorizationServerMetadataResolverConfig? config, 71 - }) : _dio = dio ?? Dio(), 72 - _allowHttpIssuer = config?.allowHttpIssuer ?? false; 73 - 74 - /// Resolves authorization server metadata for the given issuer. 75 - /// 76 - /// The [input] should be a valid issuer identifier (typically an HTTPS URL). 77 - /// 78 - /// Returns the complete metadata as a Map. Throws if: 79 - /// - Input is not a valid issuer URL 80 - /// - HTTP is used in production (allowHttpIssuer = false) 81 - /// - Network request fails 82 - /// - Response is not valid JSON 83 - /// - Metadata validation fails 84 - /// - Issuer mismatch detected 85 - /// - ATPROTO requirements not met 86 - /// 87 - /// Example: 88 - /// ```dart 89 - /// final resolver = OAuthAuthorizationServerMetadataResolver(cache); 90 - /// final metadata = await resolver.get('https://pds.example.com'); 91 - /// print(metadata['authorization_endpoint']); 92 - /// ``` 93 - Future<Map<String, dynamic>> get( 94 - String input, [ 95 - GetCachedOptions? options, 96 - ]) async { 97 - // Validate and normalize issuer URL 98 - final issuer = _validateIssuer(input); 99 - 100 - // Security check: disallow HTTP in production 101 - if (!_allowHttpIssuer && issuer.startsWith('http:')) { 102 - throw FormatException( 103 - 'Unsecure issuer URL protocol only allowed in development and test environments', 104 - ); 105 - } 106 - 107 - // Check cache first (unless noCache is set) 108 - if (options?.noCache != true) { 109 - final cached = await _cache.get(issuer); 110 - if (cached != null) { 111 - return cached; 112 - } 113 - } 114 - 115 - // Fetch fresh metadata 116 - final metadata = await _fetchMetadata(issuer, options); 117 - 118 - // Store in cache 119 - await _cache.set(issuer, metadata); 120 - 121 - return metadata; 122 - } 123 - 124 - /// Fetches metadata from the well-known endpoint. 125 - Future<Map<String, dynamic>> _fetchMetadata( 126 - String issuer, 127 - GetCachedOptions? options, 128 - ) async { 129 - final url = 130 - Uri.parse( 131 - issuer, 132 - ).replace(path: '/.well-known/oauth-authorization-server').toString(); 133 - 134 - try { 135 - final response = await _dio.get<Map<String, dynamic>>( 136 - url, 137 - options: Options( 138 - headers: {'accept': 'application/json'}, 139 - followRedirects: false, // response must be 200 OK, no redirects 140 - validateStatus: (status) => status == 200, 141 - ), 142 - cancelToken: options?.cancelToken, 143 - ); 144 - 145 - // Verify content type 146 - final contentType = contentMime( 147 - response.headers.map.map((key, value) => MapEntry(key, value.first)), 148 - ); 149 - 150 - if (contentType != 'application/json') { 151 - throw DioException( 152 - requestOptions: response.requestOptions, 153 - response: response, 154 - type: DioExceptionType.badResponse, 155 - message: 'Unexpected content type for "$url"', 156 - ); 157 - } 158 - 159 - final metadata = response.data; 160 - if (metadata == null) { 161 - throw DioException( 162 - requestOptions: response.requestOptions, 163 - response: response, 164 - type: DioExceptionType.badResponse, 165 - message: 'Empty response body for "$url"', 166 - ); 167 - } 168 - 169 - // Validate metadata structure 170 - _validateMetadata(metadata, issuer); 171 - 172 - return metadata; 173 - } on DioException catch (e) { 174 - if (e.response?.statusCode == 200) { 175 - // Already handled above, rethrow 176 - rethrow; 177 - } 178 - throw DioException( 179 - requestOptions: e.requestOptions, 180 - response: e.response, 181 - type: e.type, 182 - message: 183 - 'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"', 184 - error: e.error, 185 - ); 186 - } 187 - } 188 - 189 - /// Validates an issuer identifier. 190 - /// 191 - /// Ensures the issuer is a valid URL without query or fragment. 192 - /// Returns the normalized issuer. 193 - String _validateIssuer(String input) { 194 - final uri = Uri.tryParse(input); 195 - if (uri == null) { 196 - throw FormatException('Invalid issuer URL: $input'); 197 - } 198 - 199 - // Issuer must not have query or fragment 200 - if (uri.hasQuery || uri.hasFragment) { 201 - throw FormatException( 202 - 'Issuer URL must not contain query or fragment: $input', 203 - ); 204 - } 205 - 206 - // Normalize: remove trailing slash 207 - final normalized = 208 - input.endsWith('/') ? input.substring(0, input.length - 1) : input; 209 - 210 - return normalized; 211 - } 212 - 213 - /// Validates authorization server metadata. 214 - /// 215 - /// Checks: 216 - /// - Required fields are present 217 - /// - Issuer matches expected value (MIX-UP attack prevention) 218 - /// - ATPROTO requirement: client_id_metadata_document_supported = true 219 - void _validateMetadata(Map<String, dynamic> metadata, String expectedIssuer) { 220 - // Validate issuer field (critical for security - prevents MIX-UP attacks) 221 - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks 222 - // https://datatracker.ietf.org/doc/html/rfc8414#section-2 223 - final issuer = metadata['issuer']; 224 - if (issuer != expectedIssuer) { 225 - throw FormatException( 226 - 'Invalid issuer: expected "$expectedIssuer", got "$issuer"', 227 - ); 228 - } 229 - 230 - // ATPROTO requires client_id_metadata_document support 231 - // https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ 232 - final clientIdMetadataSupported = 233 - metadata['client_id_metadata_document_supported']; 234 - if (clientIdMetadataSupported != true) { 235 - throw FormatException( 236 - 'Authorization server "$issuer" does not support client_id_metadata_document', 237 - ); 238 - } 239 - 240 - // Validate required endpoints exist 241 - if (metadata['authorization_endpoint'] == null) { 242 - throw FormatException('Missing required field: authorization_endpoint'); 243 - } 244 - if (metadata['token_endpoint'] == null) { 245 - throw FormatException('Missing required field: token_endpoint'); 246 - } 247 - } 248 - }
-285
packages/atproto_oauth_flutter/lib/src/oauth/client_auth.dart
··· 1 - import '../constants.dart'; 2 - import '../errors/auth_method_unsatisfiable_error.dart'; 3 - import '../runtime/runtime.dart'; 4 - import '../runtime/runtime_implementation.dart'; 5 - import '../types.dart'; 6 - 7 - /// Represents a client authentication method. 8 - /// 9 - /// OAuth supports different ways for clients to authenticate with the 10 - /// authorization server: 11 - /// - 'none': Public client (no secret), only client_id 12 - /// - 'private_key_jwt': Confidential client using JWT signed with private key 13 - class ClientAuthMethod { 14 - final String method; 15 - final String? kid; // Key ID for private_key_jwt method 16 - 17 - const ClientAuthMethod.none() : method = 'none', kid = null; 18 - 19 - const ClientAuthMethod.privateKeyJwt(this.kid) : method = 'private_key_jwt'; 20 - 21 - @override 22 - bool operator ==(Object other) { 23 - if (identical(this, other)) return true; 24 - return other is ClientAuthMethod && 25 - other.method == method && 26 - other.kid == kid; 27 - } 28 - 29 - @override 30 - int get hashCode => method.hashCode ^ kid.hashCode; 31 - 32 - Map<String, dynamic> toJson() { 33 - return {'method': method, if (kid != null) 'kid': kid}; 34 - } 35 - 36 - factory ClientAuthMethod.fromJson(Map<String, dynamic> json) { 37 - final method = json['method'] as String; 38 - if (method == 'none') { 39 - return const ClientAuthMethod.none(); 40 - } else if (method == 'private_key_jwt') { 41 - return ClientAuthMethod.privateKeyJwt(json['kid'] as String); 42 - } 43 - throw FormatException('Unknown auth method: $method'); 44 - } 45 - } 46 - 47 - /// Credential payload to include in OAuth requests. 48 - class OAuthClientCredentials { 49 - /// Client identifier 50 - final String clientId; 51 - 52 - /// Client assertion type (for private_key_jwt) 53 - final String? clientAssertionType; 54 - 55 - /// Client assertion JWT (for private_key_jwt) 56 - final String? clientAssertion; 57 - 58 - const OAuthClientCredentials({ 59 - required this.clientId, 60 - this.clientAssertionType, 61 - this.clientAssertion, 62 - }); 63 - 64 - Map<String, dynamic> toJson() { 65 - final map = <String, dynamic>{'client_id': clientId}; 66 - if (clientAssertionType != null) { 67 - map['client_assertion_type'] = clientAssertionType; 68 - } 69 - if (clientAssertion != null) { 70 - map['client_assertion'] = clientAssertion; 71 - } 72 - return map; 73 - } 74 - } 75 - 76 - /// Result of creating client credentials. 77 - class ClientCredentialsResult { 78 - /// Optional HTTP headers (e.g., Authorization header for client_secret_basic) 79 - final Map<String, String>? headers; 80 - 81 - /// Payload to include in the request body 82 - final OAuthClientCredentials payload; 83 - 84 - const ClientCredentialsResult({this.headers, required this.payload}); 85 - } 86 - 87 - /// Factory function that creates client credentials. 88 - typedef ClientCredentialsFactory = Future<ClientCredentialsResult> Function(); 89 - 90 - /// Negotiates the client authentication method to use. 91 - /// 92 - /// This function: 93 - /// 1. Checks that the server supports the client's auth method 94 - /// 2. For private_key_jwt, finds a suitable key from the keyset 95 - /// 3. Returns the negotiated auth method 96 - /// 97 - /// The ATPROTO spec requires that authorization servers support both 98 - /// "none" and "private_key_jwt", and clients use one or the other. 99 - /// 100 - /// Throws: 101 - /// - Error if server doesn't support client's auth method 102 - /// - Error if private_key_jwt is used but no suitable key is found 103 - ClientAuthMethod negotiateClientAuthMethod( 104 - Map<String, dynamic> serverMetadata, 105 - ClientMetadata clientMetadata, 106 - Keyset? keyset, 107 - ) { 108 - final method = clientMetadata.tokenEndpointAuthMethod; 109 - 110 - // Check that the server supports this method 111 - final methods = _supportedMethods(serverMetadata); 112 - if (!methods.contains(method)) { 113 - throw StateError( 114 - 'The server does not support "$method" authentication. ' 115 - 'Supported methods are: ${methods.join(', ')}.', 116 - ); 117 - } 118 - 119 - if (method == 'private_key_jwt') { 120 - // Invalid client configuration 121 - if (keyset == null) { 122 - throw StateError('A keyset is required for private_key_jwt'); 123 - } 124 - 125 - final algs = _supportedAlgs(serverMetadata); 126 - 127 - // Find a suitable key 128 - // We can't use keyset.findPrivateKey here because we need to ensure 129 - // the key has a "kid" property (required for JWT headers) 130 - for (final key in keyset.keys) { 131 - if (key.kid != null && 132 - key.usage == 'sign' && 133 - key.algorithms.any((a) => algs.contains(a))) { 134 - return ClientAuthMethod.privateKeyJwt(key.kid!); 135 - } 136 - } 137 - 138 - throw StateError( 139 - algs.contains(fallbackAlg) 140 - ? 'Client authentication method "$method" requires at least one "$fallbackAlg" signing key with a "kid" property' 141 - : 'Authorization server requires "$method" authentication method, but does not support "$fallbackAlg" algorithm.', 142 - ); 143 - } 144 - 145 - if (method == 'none') { 146 - return const ClientAuthMethod.none(); 147 - } 148 - 149 - throw StateError( 150 - 'The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.' + 151 - (method == 'client_secret_basic' 152 - ? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.' 153 - : ' You set "$method" which is not allowed.'), 154 - ); 155 - } 156 - 157 - /// Creates a factory that generates client credentials. 158 - /// 159 - /// The factory can be called multiple times to generate fresh credentials 160 - /// (important for private_key_jwt which includes timestamps). 161 - /// 162 - /// Throws [AuthMethodUnsatisfiableError] if: 163 - /// - Server no longer supports the auth method 164 - /// - Key is no longer available in the keyset 165 - ClientCredentialsFactory createClientCredentialsFactory( 166 - ClientAuthMethod authMethod, 167 - Map<String, dynamic> serverMetadata, 168 - ClientMetadata clientMetadata, 169 - Runtime runtime, 170 - Keyset? keyset, 171 - ) { 172 - // Ensure the AS still supports the auth method 173 - if (!_supportedMethods(serverMetadata).contains(authMethod.method)) { 174 - throw AuthMethodUnsatisfiableError( 175 - 'Client authentication method "${authMethod.method}" no longer supported', 176 - ); 177 - } 178 - 179 - if (authMethod.method == 'none') { 180 - return () async => ClientCredentialsResult( 181 - payload: OAuthClientCredentials(clientId: clientMetadata.clientId!), 182 - ); 183 - } 184 - 185 - if (authMethod.method == 'private_key_jwt') { 186 - try { 187 - // Find the key 188 - if (keyset == null) { 189 - throw StateError('A keyset is required for private_key_jwt'); 190 - } 191 - 192 - final key = keyset.keys.firstWhere( 193 - (k) => 194 - k.kid == authMethod.kid && 195 - k.usage == 'sign' && 196 - k.algorithms.any((a) => _supportedAlgs(serverMetadata).contains(a)), 197 - orElse: () => throw StateError('Key not found: ${authMethod.kid}'), 198 - ); 199 - 200 - final alg = key.algorithms.firstWhere( 201 - (a) => _supportedAlgs(serverMetadata).contains(a), 202 - orElse: () => throw StateError('No supported algorithm found'), 203 - ); 204 - 205 - // https://www.rfc-editor.org/rfc/rfc7523.html#section-3 206 - return () async { 207 - final jti = await runtime.generateNonce(); 208 - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; 209 - 210 - final jwt = await key.createJwt( 211 - {'alg': alg}, 212 - { 213 - // Issuer: the client_id 214 - 'iss': clientMetadata.clientId, 215 - // Subject: the client_id 216 - 'sub': clientMetadata.clientId, 217 - // Audience: the authorization server 218 - 'aud': serverMetadata['issuer'], 219 - // JWT ID: unique identifier 220 - 'jti': jti, 221 - // Issued at 222 - 'iat': now, 223 - // Expiration: 1 minute from now 224 - 'exp': now + 60, 225 - }, 226 - ); 227 - 228 - return ClientCredentialsResult( 229 - payload: OAuthClientCredentials( 230 - clientId: clientMetadata.clientId!, 231 - clientAssertionType: 232 - 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', 233 - clientAssertion: jwt, 234 - ), 235 - ); 236 - }; 237 - } catch (cause) { 238 - throw AuthMethodUnsatisfiableError('Failed to load private key: $cause'); 239 - } 240 - } 241 - 242 - throw AuthMethodUnsatisfiableError( 243 - 'Unsupported auth method: ${authMethod.method}', 244 - ); 245 - } 246 - 247 - /// Gets the list of supported authentication methods from server metadata. 248 - List<String> _supportedMethods(Map<String, dynamic> serverMetadata) { 249 - final methods = serverMetadata['token_endpoint_auth_methods_supported']; 250 - if (methods is List) { 251 - return methods.map((m) => m.toString()).toList(); 252 - } 253 - return []; 254 - } 255 - 256 - /// Gets the list of supported signing algorithms from server metadata. 257 - List<String> _supportedAlgs(Map<String, dynamic> serverMetadata) { 258 - final algs = 259 - serverMetadata['token_endpoint_auth_signing_alg_values_supported']; 260 - if (algs is List) { 261 - return algs.map((a) => a.toString()).toList(); 262 - } 263 - 264 - // Default to ES256 as prescribed by the ATProto spec: 265 - // > Clients and Authorization Servers currently must support the ES256 266 - // > cryptographic system [for client authentication]. 267 - // https://atproto.com/specs/oauth#confidential-client-authentication 268 - return [fallbackAlg]; 269 - } 270 - 271 - /// Placeholder for Keyset class. 272 - /// 273 - /// In the full implementation, this would come from @atproto/jwk package. 274 - /// For now, we use a simple implementation. 275 - class Keyset { 276 - final List<Key> keys; 277 - 278 - const Keyset(this.keys); 279 - 280 - int get size => keys.length; 281 - 282 - Map<String, dynamic> toJSON() { 283 - return {'keys': keys.map((k) => k.bareJwk).toList()}; 284 - } 285 - }
-307
packages/atproto_oauth_flutter/lib/src/oauth/oauth_resolver.dart
··· 1 - import '../errors/oauth_resolver_error.dart'; 2 - import '../identity/did_document.dart'; 3 - import '../identity/identity_resolver.dart'; 4 - import 'authorization_server_metadata_resolver.dart'; 5 - import 'protected_resource_metadata_resolver.dart'; 6 - 7 - /// Complete result of OAuth resolution from an identity. 8 - class ResolvedOAuthIdentityFromIdentity { 9 - /// The resolved identity information 10 - final IdentityInfo identityInfo; 11 - 12 - /// The authorization server metadata 13 - final Map<String, dynamic> metadata; 14 - 15 - /// The PDS URL 16 - final Uri pds; 17 - 18 - const ResolvedOAuthIdentityFromIdentity({ 19 - required this.identityInfo, 20 - required this.metadata, 21 - required this.pds, 22 - }); 23 - } 24 - 25 - /// Result of OAuth resolution from a service URL. 26 - class ResolvedOAuthIdentityFromService { 27 - /// The authorization server metadata 28 - final Map<String, dynamic> metadata; 29 - 30 - /// Optional identity info (only present if resolved from handle/DID) 31 - final IdentityInfo? identityInfo; 32 - 33 - const ResolvedOAuthIdentityFromService({ 34 - required this.metadata, 35 - this.identityInfo, 36 - }); 37 - } 38 - 39 - /// Options for OAuth resolution. 40 - typedef ResolveOAuthOptions = GetCachedOptions; 41 - 42 - /// Main OAuth resolver that combines identity and metadata resolution. 43 - /// 44 - /// This class orchestrates the complete OAuth discovery flow: 45 - /// 46 - /// 1. **From handle/DID** (resolveFromIdentity): 47 - /// - Resolve handle → DID (if needed) 48 - /// - Fetch DID document 49 - /// - Extract PDS URL from DID document 50 - /// - Fetch protected resource metadata from PDS 51 - /// - Extract authorization server(s) from resource metadata 52 - /// - Fetch authorization server metadata 53 - /// - Verify PDS is protected by the authorization server 54 - /// 55 - /// 2. **From URL** (resolveFromService): 56 - /// - Try as PDS URL (fetch protected resource metadata) 57 - /// - Extract authorization server from metadata 58 - /// - Fallback: try as authorization server directly 59 - /// 60 - /// This is the critical piece that enables decentralization - users can 61 - /// host their data on any PDS, and we discover the OAuth server dynamically. 62 - class OAuthResolver { 63 - final IdentityResolver identityResolver; 64 - final OAuthProtectedResourceMetadataResolver 65 - protectedResourceMetadataResolver; 66 - final OAuthAuthorizationServerMetadataResolver 67 - authorizationServerMetadataResolver; 68 - 69 - OAuthResolver({ 70 - required this.identityResolver, 71 - required this.protectedResourceMetadataResolver, 72 - required this.authorizationServerMetadataResolver, 73 - }); 74 - 75 - /// Resolves OAuth metadata from an input (handle, DID, or URL). 76 - /// 77 - /// The [input] can be: 78 - /// - An atProto handle (e.g., "alice.bsky.social") 79 - /// - A DID (e.g., "did:plc:...") 80 - /// - A PDS URL (e.g., "https://pds.example.com") 81 - /// - An authorization server URL (e.g., "https://auth.example.com") 82 - /// 83 - /// Returns metadata for the authorization server. The identityInfo 84 - /// is only present if input was a handle or DID. 85 - Future<ResolvedOAuthIdentityFromService> resolve( 86 - String input, [ 87 - ResolveOAuthOptions? options, 88 - ]) async { 89 - // Detect if input is a URL (starts with http:// or https://) 90 - if (RegExp(r'^https?://').hasMatch(input)) { 91 - return resolveFromService(input, options); 92 - } else { 93 - final result = await resolveFromIdentity(input, options); 94 - return ResolvedOAuthIdentityFromService( 95 - metadata: result.metadata, 96 - identityInfo: result.identityInfo, 97 - ); 98 - } 99 - } 100 - 101 - /// Resolves OAuth metadata from a service URL (PDS or authorization server). 102 - /// 103 - /// This method: 104 - /// 1. First tries to resolve as a PDS (protected resource) 105 - /// 2. If that fails, tries to resolve as an authorization server directly 106 - /// 107 - /// This allows both "login with PDS URL" and "login with auth server URL" 108 - /// flows, useful when users forget their handle or for compatibility. 109 - Future<ResolvedOAuthIdentityFromService> resolveFromService( 110 - String input, [ 111 - ResolveOAuthOptions? options, 112 - ]) async { 113 - try { 114 - // Assume first that input is a PDS URL (as required by ATPROTO) 115 - final metadata = await getResourceServerMetadata(input, options); 116 - return ResolvedOAuthIdentityFromService(metadata: metadata); 117 - } catch (err) { 118 - // Check if request was cancelled - note: Dio's CancelToken doesn't have throwIfCanceled() 119 - // We rely on Dio throwing CancelError automatically 120 - 121 - if (err is OAuthResolverError) { 122 - try { 123 - // Fallback to trying to fetch as an issuer (Entryway/Authorization Server) 124 - final issuerUri = Uri.tryParse(input); 125 - if (issuerUri != null && issuerUri.hasScheme) { 126 - final metadata = await getAuthorizationServerMetadata( 127 - input, 128 - options, 129 - ); 130 - return ResolvedOAuthIdentityFromService(metadata: metadata); 131 - } 132 - } catch (_) { 133 - // Fallback failed, throw original error 134 - } 135 - } 136 - 137 - rethrow; 138 - } 139 - } 140 - 141 - /// Resolves OAuth metadata from a handle or DID. 142 - /// 143 - /// This is the primary OAuth discovery flow: 144 - /// 1. Resolve handle → DID → DID document (via IdentityResolver) 145 - /// 2. Extract PDS URL from DID document 146 - /// 3. Get protected resource metadata from PDS 147 - /// 4. Extract authorization server(s) 148 - /// 5. Get authorization server metadata 149 - /// 6. Verify PDS is protected by the auth server 150 - Future<ResolvedOAuthIdentityFromIdentity> resolveFromIdentity( 151 - String input, [ 152 - ResolveOAuthOptions? options, 153 - ]) async { 154 - final identityInfo = await resolveIdentity( 155 - input, 156 - options != null 157 - ? ResolveIdentityOptions( 158 - noCache: options.noCache, 159 - cancelToken: options.cancelToken, 160 - ) 161 - : null, 162 - ); 163 - 164 - final pds = _extractPdsUrl(identityInfo.didDoc); 165 - 166 - final metadata = await getResourceServerMetadata(pds, options); 167 - 168 - return ResolvedOAuthIdentityFromIdentity( 169 - identityInfo: identityInfo, 170 - metadata: metadata, 171 - pds: pds, 172 - ); 173 - } 174 - 175 - /// Resolves an identity (handle or DID) to IdentityInfo. 176 - /// 177 - /// Wraps the IdentityResolver with proper error handling. 178 - Future<IdentityInfo> resolveIdentity( 179 - String input, [ 180 - ResolveIdentityOptions? options, 181 - ]) async { 182 - try { 183 - return await identityResolver.resolve(input, options); 184 - } catch (cause) { 185 - throw OAuthResolverError.from( 186 - cause, 187 - 'Failed to resolve identity: $input', 188 - ); 189 - } 190 - } 191 - 192 - /// Gets authorization server metadata for an issuer. 193 - /// 194 - /// Wraps the AuthorizationServerMetadataResolver with proper error handling. 195 - Future<Map<String, dynamic>> getAuthorizationServerMetadata( 196 - String issuer, [ 197 - GetCachedOptions? options, 198 - ]) async { 199 - try { 200 - return await authorizationServerMetadataResolver.get(issuer, options); 201 - } catch (cause) { 202 - throw OAuthResolverError.from( 203 - cause, 204 - 'Failed to resolve OAuth server metadata for issuer: $issuer', 205 - ); 206 - } 207 - } 208 - 209 - /// Gets authorization server metadata for a protected resource (PDS). 210 - /// 211 - /// This method: 212 - /// 1. Fetches protected resource metadata 213 - /// 2. Validates exactly one authorization server is listed (ATPROTO requirement) 214 - /// 3. Fetches authorization server metadata 215 - /// 4. Verifies the PDS is in the auth server's protected_resources list 216 - Future<Map<String, dynamic>> getResourceServerMetadata( 217 - dynamic pdsUrl, [ 218 - GetCachedOptions? options, 219 - ]) async { 220 - try { 221 - final rsMetadata = await protectedResourceMetadataResolver.get( 222 - pdsUrl, 223 - options, 224 - ); 225 - 226 - // ATPROTO requires exactly one authorization server 227 - final authServers = rsMetadata['authorization_servers']; 228 - if (authServers is! List || authServers.length != 1) { 229 - throw OAuthResolverError( 230 - authServers == null || (authServers as List).isEmpty 231 - ? 'No authorization servers found for PDS: $pdsUrl' 232 - : 'Unable to determine authorization server for PDS: $pdsUrl', 233 - ); 234 - } 235 - 236 - final issuer = authServers[0] as String; 237 - 238 - final asMetadata = await getAuthorizationServerMetadata(issuer, options); 239 - 240 - // Verify PDS is protected by this authorization server 241 - // https://www.rfc-editor.org/rfc/rfc9728.html#section-4 242 - final protectedResources = asMetadata['protected_resources']; 243 - if (protectedResources != null) { 244 - final resource = rsMetadata['resource'] as String; 245 - if (!(protectedResources as List).contains(resource)) { 246 - throw OAuthResolverError( 247 - 'PDS "$pdsUrl" not protected by issuer "$issuer"', 248 - ); 249 - } 250 - } 251 - 252 - return asMetadata; 253 - } catch (cause) { 254 - throw OAuthResolverError.from( 255 - cause, 256 - 'Failed to resolve OAuth server metadata for resource: $pdsUrl', 257 - ); 258 - } 259 - } 260 - 261 - /// Extracts the PDS URL from a DID document. 262 - /// 263 - /// Throws OAuthResolverError if no PDS URL is found. 264 - Uri _extractPdsUrl(DidDocument document) { 265 - // Find the atproto_pds service 266 - final service = document.service?.firstWhere( 267 - (s) => _isAtprotoPersonalDataServerService(s, document), 268 - orElse: 269 - () => 270 - throw OAuthResolverError( 271 - 'Identity "${document.id}" does not have a PDS URL', 272 - ), 273 - ); 274 - 275 - if (service == null) { 276 - throw OAuthResolverError( 277 - 'Identity "${document.id}" does not have a PDS URL', 278 - ); 279 - } 280 - 281 - try { 282 - return Uri.parse(service.serviceEndpoint as String); 283 - } catch (cause) { 284 - throw OAuthResolverError( 285 - 'Invalid PDS URL in DID document: ${service.serviceEndpoint}', 286 - cause: cause, 287 - ); 288 - } 289 - } 290 - 291 - /// Checks if a service is an AtprotoPersonalDataServer. 292 - bool _isAtprotoPersonalDataServerService( 293 - DidService service, 294 - DidDocument document, 295 - ) { 296 - if (service.serviceEndpoint is! String) return false; 297 - if (service.type != 'AtprotoPersonalDataServer') return false; 298 - 299 - // Check service ID 300 - final id = service.id; 301 - if (id.startsWith('#')) { 302 - return id == '#atproto_pds'; 303 - } else { 304 - return id == '${document.id}#atproto_pds'; 305 - } 306 - } 307 - }
-519
packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_agent.dart
··· 1 - import 'package:dio/dio.dart'; 2 - import 'package:flutter/foundation.dart' hide Key; 3 - 4 - import '../dpop/fetch_dpop.dart'; 5 - import '../errors/oauth_response_error.dart'; 6 - import '../errors/token_refresh_error.dart'; 7 - import '../runtime/runtime.dart'; 8 - import '../runtime/runtime_implementation.dart'; 9 - import '../types.dart'; 10 - import 'authorization_server_metadata_resolver.dart' show GetCachedOptions; 11 - import 'client_auth.dart'; 12 - import 'oauth_resolver.dart'; 13 - 14 - /// Represents a token set returned from OAuth token endpoint. 15 - class TokenSet { 16 - /// Issuer (authorization server URL) 17 - final String iss; 18 - 19 - /// Subject (DID of the user) 20 - final String sub; 21 - 22 - /// Audience (PDS URL) 23 - final String aud; 24 - 25 - /// Scope (space-separated list of scopes) 26 - final String scope; 27 - 28 - /// Refresh token (optional) 29 - final String? refreshToken; 30 - 31 - /// Access token 32 - final String accessToken; 33 - 34 - /// Token type (must be "DPoP" for ATPROTO) 35 - final String tokenType; 36 - 37 - /// Expiration time (ISO date string) 38 - final String? expiresAt; 39 - 40 - const TokenSet({ 41 - required this.iss, 42 - required this.sub, 43 - required this.aud, 44 - required this.scope, 45 - this.refreshToken, 46 - required this.accessToken, 47 - required this.tokenType, 48 - this.expiresAt, 49 - }); 50 - 51 - Map<String, dynamic> toJson() { 52 - return { 53 - 'iss': iss, 54 - 'sub': sub, 55 - 'aud': aud, 56 - 'scope': scope, 57 - if (refreshToken != null) 'refresh_token': refreshToken, 58 - 'access_token': accessToken, 59 - 'token_type': tokenType, 60 - if (expiresAt != null) 'expires_at': expiresAt, 61 - }; 62 - } 63 - 64 - factory TokenSet.fromJson(Map<String, dynamic> json) { 65 - return TokenSet( 66 - iss: json['iss'] as String, 67 - sub: json['sub'] as String, 68 - aud: json['aud'] as String, 69 - scope: json['scope'] as String, 70 - refreshToken: json['refresh_token'] as String?, 71 - accessToken: json['access_token'] as String, 72 - tokenType: json['token_type'] as String, 73 - expiresAt: json['expires_at'] as String?, 74 - ); 75 - } 76 - } 77 - 78 - /// DPoP nonce cache type. 79 - typedef DpopNonceCache = SimpleStore<String, String>; 80 - 81 - /// Agent for interacting with an OAuth authorization server. 82 - /// 83 - /// This class handles: 84 - /// - Token exchange (authorization code → tokens) 85 - /// - Token refresh (refresh token → new tokens) 86 - /// - Token revocation 87 - /// - DPoP proof generation and nonce management 88 - /// - Client authentication 89 - /// 90 - /// All token requests include DPoP proofs to bind tokens to keys. 91 - class OAuthServerAgent { 92 - final ClientAuthMethod authMethod; 93 - final Key dpopKey; 94 - final Map<String, dynamic> serverMetadata; 95 - final ClientMetadata clientMetadata; 96 - final DpopNonceCache dpopNonces; 97 - final OAuthResolver oauthResolver; 98 - final Runtime runtime; 99 - final Keyset? keyset; 100 - final Dio _dio; 101 - final ClientCredentialsFactory _clientCredentialsFactory; 102 - 103 - /// Creates an OAuth server agent. 104 - /// 105 - /// Throws [AuthMethodUnsatisfiableError] if the auth method cannot be satisfied. 106 - OAuthServerAgent({ 107 - required this.authMethod, 108 - required this.dpopKey, 109 - required this.serverMetadata, 110 - required this.clientMetadata, 111 - required this.dpopNonces, 112 - required this.oauthResolver, 113 - required this.runtime, 114 - this.keyset, 115 - Dio? dio, 116 - }) : // CRITICAL: Always create a NEW Dio instance to avoid duplicate interceptors 117 - // If we reuse a shared Dio instance, each OAuthServerAgent will add its 118 - // interceptors to the same instance, causing duplicate requests! 119 - _dio = Dio(dio?.options ?? BaseOptions()), 120 - _clientCredentialsFactory = createClientCredentialsFactory( 121 - authMethod, 122 - serverMetadata, 123 - clientMetadata, 124 - runtime, 125 - keyset, 126 - ) { 127 - // Add debug logging interceptor (runs before DPoP interceptor) 128 - if (kDebugMode) { 129 - _dio.interceptors.add( 130 - InterceptorsWrapper( 131 - onRequest: (options, handler) { 132 - if (options.uri.path.contains('/token')) { 133 - print( 134 - '📤 [BEFORE DPoP] Request headers: ${options.headers.keys.toList()}', 135 - ); 136 - } 137 - handler.next(options); 138 - }, 139 - ), 140 - ); 141 - } 142 - 143 - // Add DPoP interceptor 144 - _dio.interceptors.add( 145 - createDpopInterceptor( 146 - DpopFetchWrapperOptions( 147 - key: dpopKey, 148 - nonces: dpopNonces, 149 - sha256: runtime.sha256, 150 - isAuthServer: true, 151 - ), 152 - ), 153 - ); 154 - 155 - // Add final logging interceptor (runs after DPoP interceptor) 156 - if (kDebugMode) { 157 - _dio.interceptors.add( 158 - InterceptorsWrapper( 159 - onRequest: (options, handler) { 160 - if (options.uri.path.contains('/token')) { 161 - print( 162 - '📤 [AFTER DPoP] Request headers: ${options.headers.keys.toList()}', 163 - ); 164 - if (options.headers.containsKey('dpop')) { 165 - print( 166 - ' DPoP header present: ${options.headers['dpop']?.toString().substring(0, 50)}...', 167 - ); 168 - } else if (options.headers.containsKey('DPoP')) { 169 - print( 170 - ' DPoP header present: ${options.headers['DPoP']?.toString().substring(0, 50)}...', 171 - ); 172 - } else { 173 - print(' ⚠️ DPoP header MISSING!'); 174 - } 175 - } 176 - handler.next(options); 177 - }, 178 - onError: (error, handler) { 179 - if (error.requestOptions.uri.path.contains('/token')) { 180 - print('📥 Token request error: ${error.message}'); 181 - } 182 - handler.next(error); 183 - }, 184 - ), 185 - ); 186 - } 187 - } 188 - 189 - /// The issuer (authorization server URL). 190 - String get issuer => serverMetadata['issuer'] as String; 191 - 192 - /// Revokes a token. 193 - /// 194 - /// Errors are silently ignored as revocation is best-effort. 195 - Future<void> revoke(String token) async { 196 - try { 197 - await _request('revocation', {'token': token}); 198 - } catch (_) { 199 - // Don't care if revocation fails 200 - } 201 - } 202 - 203 - /// Pre-fetches a DPoP nonce from the token endpoint. 204 - /// 205 - /// This is critical for authorization code exchange because: 206 - /// 1. First token request without nonce → PDS consumes code + returns use_dpop_nonce error 207 - /// 2. Retry with nonce → "Invalid code" because already consumed 208 - /// 209 - /// Solution: Get a nonce BEFORE attempting code exchange. 210 - /// 211 - /// We make a lightweight invalid request that will fail but return a nonce. 212 - /// The server responds with a nonce in the DPoP-Nonce header, which the 213 - /// interceptor automatically caches for subsequent requests. 214 - Future<void> _prefetchDpopNonce() async { 215 - final tokenEndpoint = serverMetadata['token_endpoint'] as String?; 216 - if (tokenEndpoint == null) return; 217 - 218 - final origin = Uri.parse(tokenEndpoint); 219 - final originKey = 220 - '${origin.scheme}://${origin.host}${origin.hasPort ? ':${origin.port}' : ''}'; 221 - 222 - // Clear any stale nonce from previous sessions 223 - try { 224 - await dpopNonces.del(originKey); 225 - if (kDebugMode) { 226 - print('🧹 Cleared stale DPoP nonce from cache'); 227 - } 228 - } catch (_) { 229 - // Ignore deletion errors 230 - } 231 - 232 - if (kDebugMode) { 233 - print('⏱️ Pre-fetch starting at: ${DateTime.now().toIso8601String()}'); 234 - } 235 - 236 - try { 237 - // Make a minimal invalid request to trigger nonce response 238 - // Use an invalid grant_type that will fail fast without side effects 239 - await _dio.post<Map<String, dynamic>>( 240 - tokenEndpoint, 241 - data: 'grant_type=invalid_prefetch', 242 - options: Options( 243 - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 244 - validateStatus: (status) => true, // Accept any status 245 - ), 246 - ); 247 - } catch (_) { 248 - // Ignore all errors - we just want the nonce from the response headers 249 - // The DPoP interceptor will have cached it in onError or onResponse 250 - } 251 - 252 - if (kDebugMode) { 253 - print('⏱️ Pre-fetch completed at: ${DateTime.now().toIso8601String()}'); 254 - final cachedNonce = await dpopNonces.get(originKey); 255 - print('🎫 DPoP nonce pre-fetch result:'); 256 - print( 257 - ' Cached nonce: ${cachedNonce != null ? "✅ ${cachedNonce.substring(0, 20)}..." : "❌ not found"}', 258 - ); 259 - } 260 - } 261 - 262 - /// Exchanges an authorization code for tokens. 263 - /// 264 - /// This is called after the user completes authorization and you receive 265 - /// the authorization code in the callback. 266 - /// 267 - /// [code] is the authorization code from the callback. 268 - /// [codeVerifier] is the PKCE code verifier (if PKCE was used). 269 - /// [redirectUri] is the redirect URI used in the authorization request. 270 - /// 271 - /// Returns a [TokenSet] with access token, optional refresh token, and metadata. 272 - /// 273 - /// IMPORTANT: This method verifies the issuer before returning tokens. 274 - /// If verification fails, the access token is automatically revoked. 275 - Future<TokenSet> exchangeCode( 276 - String code, { 277 - String? codeVerifier, 278 - String? redirectUri, 279 - }) async { 280 - // CRITICAL: DO NOT pre-fetch! Exchange immediately! 281 - // The pre-fetch adds ~678ms delay, during which the browser re-navigates 282 - // and invalidates the authorization code. We need to exchange within ~270ms. 283 - // If we get a nonce error, we'll handle it via the interceptor (though PDS 284 - // doesn't seem to require nonces for initial token exchange). 285 - 286 - final now = DateTime.now(); 287 - 288 - final tokenResponse = await _request('token', { 289 - 'grant_type': 'authorization_code', 290 - 'redirect_uri': redirectUri ?? clientMetadata.redirectUris.first, 291 - 'code': code, 292 - if (codeVerifier != null) 'code_verifier': codeVerifier, 293 - }); 294 - 295 - try { 296 - // CRITICAL: Verify issuer before trusting the sub 297 - // The tokenResponse MUST always be valid before the "sub" can be trusted 298 - // See: https://atproto.com/specs/oauth 299 - final aud = await _verifyIssuer(tokenResponse['sub'] as String); 300 - 301 - return TokenSet( 302 - aud: aud, 303 - sub: tokenResponse['sub'] as String, 304 - iss: issuer, 305 - scope: tokenResponse['scope'] as String, 306 - refreshToken: tokenResponse['refresh_token'] as String?, 307 - accessToken: tokenResponse['access_token'] as String, 308 - tokenType: tokenResponse['token_type'] as String, 309 - expiresAt: 310 - tokenResponse['expires_in'] != null 311 - ? now 312 - .add(Duration(seconds: tokenResponse['expires_in'] as int)) 313 - .toIso8601String() 314 - : null, 315 - ); 316 - } catch (err) { 317 - // If verification fails, revoke the access token 318 - await revoke(tokenResponse['access_token'] as String); 319 - rethrow; 320 - } 321 - } 322 - 323 - /// Refreshes a token set using the refresh token. 324 - /// 325 - /// [tokenSet] is the current token set with a refresh_token. 326 - /// 327 - /// Returns a new [TokenSet] with fresh tokens. 328 - /// 329 - /// Throws [TokenRefreshError] if refresh fails or no refresh token is available. 330 - /// 331 - /// IMPORTANT: This method verifies the issuer before returning tokens. 332 - Future<TokenSet> refresh(TokenSet tokenSet) async { 333 - if (tokenSet.refreshToken == null) { 334 - throw TokenRefreshError(tokenSet.sub, 'No refresh token available'); 335 - } 336 - 337 - // CRITICAL: Verify issuer BEFORE refresh to avoid unnecessary requests 338 - // and ensure the sub is still valid for this issuer 339 - final aud = await _verifyIssuer(tokenSet.sub); 340 - 341 - final now = DateTime.now(); 342 - 343 - final tokenResponse = await _request('token', { 344 - 'grant_type': 'refresh_token', 345 - 'refresh_token': tokenSet.refreshToken, 346 - }); 347 - 348 - return TokenSet( 349 - aud: aud, 350 - sub: tokenSet.sub, 351 - iss: issuer, 352 - scope: tokenResponse['scope'] as String, 353 - refreshToken: tokenResponse['refresh_token'] as String?, 354 - accessToken: tokenResponse['access_token'] as String, 355 - tokenType: tokenResponse['token_type'] as String, 356 - expiresAt: 357 - tokenResponse['expires_in'] != null 358 - ? now 359 - .add(Duration(seconds: tokenResponse['expires_in'] as int)) 360 - .toIso8601String() 361 - : null, 362 - ); 363 - } 364 - 365 - /// Verifies that the sub (DID) is indeed issued by this authorization server. 366 - /// 367 - /// This is CRITICAL for security. We must verify that the DID's PDS 368 - /// is protected by this authorization server before trusting tokens. 369 - /// 370 - /// Returns the user's PDS URL (the resource server). 371 - /// 372 - /// Throws if: 373 - /// - DID resolution fails 374 - /// - Issuer mismatch (user may have switched PDS or attack detected) 375 - Future<String> _verifyIssuer(String sub) async { 376 - final cancelToken = CancelToken(); 377 - final resolved = await oauthResolver 378 - .resolveFromIdentity( 379 - sub, 380 - GetCachedOptions( 381 - noCache: true, 382 - allowStale: false, 383 - cancelToken: cancelToken, 384 - ), 385 - ) 386 - .timeout( 387 - const Duration(seconds: 10), 388 - onTimeout: () { 389 - cancelToken.cancel(); 390 - throw TimeoutException('Issuer verification timed out'); 391 - }, 392 - ); 393 - 394 - if (issuer != resolved.metadata['issuer']) { 395 - // Best case: user switched PDS 396 - // Worst case: attack attempt 397 - // Either way: MUST NOT allow this token to be used 398 - throw FormatException('Issuer mismatch'); 399 - } 400 - 401 - return resolved.pds.toString(); 402 - } 403 - 404 - /// Makes a request to an OAuth endpoint (public API). 405 - /// 406 - /// This is a generic method for making OAuth endpoint requests with proper typing. 407 - /// Currently supports: token, revocation, pushed_authorization_request. 408 - /// 409 - /// [endpoint] is the endpoint name. 410 - /// [payload] is the request body parameters. 411 - /// 412 - /// Returns the parsed JSON response. 413 - /// Throws [OAuthResponseError] if the server returns an error. 414 - Future<Map<String, dynamic>> request( 415 - String endpoint, 416 - Map<String, dynamic> payload, 417 - ) async { 418 - return _request(endpoint, payload); 419 - } 420 - 421 - /// Makes a request to an OAuth endpoint (internal implementation). 422 - /// 423 - /// [endpoint] is the endpoint name (e.g., 'token', 'revocation', 'pushed_authorization_request'). 424 - /// [payload] is the request body parameters. 425 - /// 426 - /// Returns the parsed JSON response. 427 - /// Throws [OAuthResponseError] if the server returns an error. 428 - Future<Map<String, dynamic>> _request( 429 - String endpoint, 430 - Map<String, dynamic> payload, 431 - ) async { 432 - final url = serverMetadata['${endpoint}_endpoint']; 433 - if (url == null) { 434 - throw StateError('No $endpoint endpoint available'); 435 - } 436 - 437 - final auth = await _clientCredentialsFactory(); 438 - 439 - final fullPayload = {...payload, ...auth.payload.toJson()}; 440 - final encodedData = _wwwFormUrlEncode(fullPayload); 441 - 442 - if (kDebugMode && endpoint == 'token') { 443 - print('🌐 Token exchange HTTP request:'); 444 - print(' ⏱️ Request starting at: ${DateTime.now().toIso8601String()}'); 445 - print(' URL: $url'); 446 - print(' Payload keys: ${fullPayload.keys.toList()}'); 447 - print(' grant_type: ${fullPayload['grant_type']}'); 448 - print(' client_id: ${fullPayload['client_id']}'); 449 - print(' redirect_uri: ${fullPayload['redirect_uri']}'); 450 - print(' code: ${fullPayload['code']?.toString().substring(0, 20)}...'); 451 - print( 452 - ' code_verifier: ${fullPayload['code_verifier']?.toString().substring(0, 20)}...', 453 - ); 454 - print(' Headers: ${auth.headers?.keys.toList() ?? []}'); 455 - } 456 - 457 - try { 458 - final response = await _dio.post<Map<String, dynamic>>( 459 - url as String, 460 - data: encodedData, 461 - options: Options( 462 - headers: { 463 - if (auth.headers != null) ...auth.headers!, 464 - 'Content-Type': 'application/x-www-form-urlencoded', 465 - }, 466 - ), 467 - ); 468 - 469 - final data = response.data; 470 - if (data == null) { 471 - throw OAuthResponseError(response, {'error': 'empty_response'}); 472 - } 473 - 474 - if (kDebugMode && endpoint == 'token') { 475 - print(' ✅ Token exchange successful!'); 476 - } 477 - 478 - return data; 479 - } on DioException catch (e) { 480 - final response = e.response; 481 - if (response != null) { 482 - if (kDebugMode && endpoint == 'token') { 483 - print(' ❌ Token exchange failed:'); 484 - print(' Status: ${response.statusCode}'); 485 - print(' Response: ${response.data}'); 486 - } 487 - throw OAuthResponseError(response, response.data); 488 - } 489 - rethrow; 490 - } 491 - } 492 - 493 - /// Encodes a map as application/x-www-form-urlencoded. 494 - String _wwwFormUrlEncode(Map<String, dynamic> payload) { 495 - final entries = payload.entries 496 - .where((e) => e.value != null) 497 - .map((e) => MapEntry(e.key, _stringifyValue(e.value))); 498 - 499 - return Uri(queryParameters: Map.fromEntries(entries)).query; 500 - } 501 - 502 - /// Converts a value to string for form encoding. 503 - String _stringifyValue(dynamic value) { 504 - if (value is String) return value; 505 - if (value is num) return value.toString(); 506 - if (value is bool) return value.toString(); 507 - // For complex types, use JSON encoding 508 - return value.toString(); 509 - } 510 - } 511 - 512 - /// Timeout exception. 513 - class TimeoutException implements Exception { 514 - final String message; 515 - TimeoutException(this.message); 516 - 517 - @override 518 - String toString() => 'TimeoutException: $message'; 519 - }
-117
packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_factory.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import '../runtime/runtime.dart'; 4 - import '../runtime/runtime_implementation.dart'; 5 - import '../types.dart'; 6 - import 'authorization_server_metadata_resolver.dart'; 7 - import 'client_auth.dart'; 8 - import 'oauth_resolver.dart'; 9 - import 'oauth_server_agent.dart'; 10 - 11 - /// Factory for creating OAuth server agents. 12 - /// 13 - /// This factory: 14 - /// 1. Stores common configuration (client metadata, runtime, resolver, etc.) 15 - /// 2. Creates OAuthServerAgent instances for specific issuers 16 - /// 3. Handles both new sessions and restored sessions (with legacy support) 17 - /// 18 - /// The factory pattern allows reusing configuration across multiple agents 19 - /// and simplifies session restoration. 20 - class OAuthServerFactory { 21 - final ClientMetadata clientMetadata; 22 - final Runtime runtime; 23 - final OAuthResolver resolver; 24 - final Dio dio; 25 - final Keyset? keyset; 26 - final DpopNonceCache dpopNonceCache; 27 - 28 - /// Creates a server factory with the given configuration. 29 - /// 30 - /// [clientMetadata] is the validated client metadata. 31 - /// [runtime] provides cryptographic operations. 32 - /// [resolver] handles OAuth metadata discovery. 33 - /// [dio] is the HTTP client. 34 - /// [keyset] is optional (only needed for confidential clients). 35 - /// [dpopNonceCache] stores DPoP nonces per origin. 36 - OAuthServerFactory({ 37 - required this.clientMetadata, 38 - required this.runtime, 39 - required this.resolver, 40 - required this.dio, 41 - this.keyset, 42 - required this.dpopNonceCache, 43 - }); 44 - 45 - /// Creates an OAuth server agent from an issuer URL. 46 - /// 47 - /// This method: 48 - /// 1. Fetches authorization server metadata for the issuer 49 - /// 2. Uses the provided authMethod or negotiates one (for legacy sessions) 50 - /// 3. Creates an OAuthServerAgent with the metadata 51 - /// 52 - /// [issuer] is the authorization server URL. 53 - /// [authMethod] is the authentication method to use. 54 - /// - For new sessions, pass the result of negotiateClientAuthMethod 55 - /// - For legacy sessions (before authMethod was stored), pass 'legacy' 56 - /// and the method will be negotiated automatically 57 - /// [dpopKey] is the DPoP signing key. 58 - /// [options] are optional cache/cancellation options. 59 - /// 60 - /// The 'legacy' authMethod is for backwards compatibility with sessions 61 - /// created before we started storing the authMethod. Support for this 62 - /// may be removed in the future. 63 - /// 64 - /// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied. 65 - Future<OAuthServerAgent> fromIssuer( 66 - String issuer, 67 - dynamic authMethod, // ClientAuthMethod or 'legacy' 68 - Key dpopKey, [ 69 - GetCachedOptions? options, 70 - ]) async { 71 - final serverMetadata = await resolver.getAuthorizationServerMetadata( 72 - issuer, 73 - options, 74 - ); 75 - 76 - ClientAuthMethod finalAuthMethod; 77 - if (authMethod == 'legacy') { 78 - // Backwards compatibility: compute auth method from metadata 79 - finalAuthMethod = negotiateClientAuthMethod( 80 - serverMetadata, 81 - clientMetadata, 82 - keyset, 83 - ); 84 - } else { 85 - finalAuthMethod = authMethod as ClientAuthMethod; 86 - } 87 - 88 - return fromMetadata(serverMetadata, finalAuthMethod, dpopKey); 89 - } 90 - 91 - /// Creates an OAuth server agent from authorization server metadata. 92 - /// 93 - /// This is useful when you already have the metadata cached. 94 - /// 95 - /// [serverMetadata] is the authorization server metadata. 96 - /// [authMethod] is the authentication method to use. 97 - /// [dpopKey] is the DPoP signing key. 98 - /// 99 - /// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied. 100 - OAuthServerAgent fromMetadata( 101 - Map<String, dynamic> serverMetadata, 102 - ClientAuthMethod authMethod, 103 - Key dpopKey, 104 - ) { 105 - return OAuthServerAgent( 106 - authMethod: authMethod, 107 - dpopKey: dpopKey, 108 - serverMetadata: serverMetadata, 109 - clientMetadata: clientMetadata, 110 - dpopNonces: dpopNonceCache, 111 - oauthResolver: resolver, 112 - runtime: runtime, 113 - keyset: keyset, 114 - dio: dio, 115 - ); 116 - } 117 - }
-196
packages/atproto_oauth_flutter/lib/src/oauth/protected_resource_metadata_resolver.dart
··· 1 - import 'package:dio/dio.dart'; 2 - 3 - import '../dpop/fetch_dpop.dart'; 4 - import '../util.dart'; 5 - import 'authorization_server_metadata_resolver.dart'; 6 - 7 - /// Cache interface for protected resource metadata. 8 - /// 9 - /// Implementations should store metadata keyed by origin (scheme://host:port). 10 - typedef ProtectedResourceMetadataCache = 11 - SimpleStore<String, Map<String, dynamic>>; 12 - 13 - /// Configuration for the protected resource metadata resolver. 14 - class OAuthProtectedResourceMetadataResolverConfig { 15 - /// Whether to allow HTTP (non-HTTPS) resource URLs. 16 - /// 17 - /// Should only be true in development/test environments. 18 - /// Production MUST use HTTPS. 19 - final bool allowHttpResource; 20 - 21 - const OAuthProtectedResourceMetadataResolverConfig({ 22 - this.allowHttpResource = false, 23 - }); 24 - } 25 - 26 - /// Resolves OAuth Protected Resource Metadata via RFC 9728 discovery. 27 - /// 28 - /// This class: 29 - /// 1. Validates resource URLs (must be HTTPS in production) 30 - /// 2. Fetches metadata from `{origin}/.well-known/oauth-protected-resource` 31 - /// 3. Validates the metadata against the spec 32 - /// 4. Verifies resource field matches origin 33 - /// 5. Caches metadata to avoid repeated fetches 34 - /// 35 - /// See: https://www.rfc-editor.org/rfc/rfc9728.html 36 - class OAuthProtectedResourceMetadataResolver { 37 - final ProtectedResourceMetadataCache _cache; 38 - final Dio _dio; 39 - final bool _allowHttpResource; 40 - 41 - /// Creates a resolver with the given cache and HTTP client. 42 - /// 43 - /// [cache] is used to store fetched metadata. Use an in-memory store for 44 - /// testing or a persistent store for production. 45 - /// 46 - /// [dio] is the HTTP client. If not provided, creates a default instance. 47 - /// 48 - /// [config] allows customizing behavior (e.g., allowing HTTP in tests). 49 - OAuthProtectedResourceMetadataResolver( 50 - this._cache, { 51 - Dio? dio, 52 - OAuthProtectedResourceMetadataResolverConfig? config, 53 - }) : _dio = dio ?? Dio(), 54 - _allowHttpResource = config?.allowHttpResource ?? false; 55 - 56 - /// Resolves protected resource metadata for the given resource URL. 57 - /// 58 - /// The [resource] can be a String URL or Uri. Only the origin is used. 59 - /// 60 - /// Returns the complete metadata as a Map. Throws if: 61 - /// - Resource is not a valid URL 62 - /// - Protocol is not HTTP/HTTPS 63 - /// - HTTP is used in production (allowHttpResource = false) 64 - /// - Network request fails 65 - /// - Response is not valid JSON 66 - /// - Metadata validation fails 67 - /// - Resource mismatch detected 68 - /// 69 - /// Example: 70 - /// ```dart 71 - /// final resolver = OAuthProtectedResourceMetadataResolver(cache); 72 - /// final metadata = await resolver.get('https://pds.example.com'); 73 - /// print(metadata['authorization_servers']); 74 - /// ``` 75 - Future<Map<String, dynamic>> get( 76 - dynamic resource, [ 77 - GetCachedOptions? options, 78 - ]) async { 79 - // Parse URL and extract origin 80 - final uri = resource is Uri ? resource : Uri.parse(resource.toString()); 81 - final protocol = uri.scheme; 82 - final origin = 83 - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; 84 - 85 - // Validate protocol 86 - if (protocol != 'https' && protocol != 'http') { 87 - throw FormatException( 88 - 'Invalid protected resource metadata URL protocol: $protocol', 89 - ); 90 - } 91 - 92 - // Security check: disallow HTTP in production 93 - if (protocol == 'http' && !_allowHttpResource) { 94 - throw FormatException( 95 - 'Unsecure resource metadata URL ($protocol) only allowed in development and test environments', 96 - ); 97 - } 98 - 99 - // Check cache first (unless noCache is set) 100 - if (options?.noCache != true) { 101 - final cached = await _cache.get(origin); 102 - if (cached != null) { 103 - return cached; 104 - } 105 - } 106 - 107 - // Fetch fresh metadata 108 - final metadata = await _fetchMetadata(origin, options); 109 - 110 - // Store in cache 111 - await _cache.set(origin, metadata); 112 - 113 - return metadata; 114 - } 115 - 116 - /// Fetches metadata from the well-known endpoint. 117 - Future<Map<String, dynamic>> _fetchMetadata( 118 - String origin, 119 - GetCachedOptions? options, 120 - ) async { 121 - final url = 122 - Uri.parse( 123 - origin, 124 - ).replace(path: '/.well-known/oauth-protected-resource').toString(); 125 - 126 - try { 127 - final response = await _dio.get<Map<String, dynamic>>( 128 - url, 129 - options: Options( 130 - headers: {'accept': 'application/json'}, 131 - followRedirects: false, // response must be 200 OK, no redirects 132 - validateStatus: (status) => status == 200, 133 - ), 134 - cancelToken: options?.cancelToken, 135 - ); 136 - 137 - // Verify content type 138 - final contentType = contentMime( 139 - response.headers.map.map((key, value) => MapEntry(key, value.first)), 140 - ); 141 - 142 - if (contentType != 'application/json') { 143 - throw DioException( 144 - requestOptions: response.requestOptions, 145 - response: response, 146 - type: DioExceptionType.badResponse, 147 - message: 'Unexpected content type for "$url"', 148 - ); 149 - } 150 - 151 - final metadata = response.data; 152 - if (metadata == null) { 153 - throw DioException( 154 - requestOptions: response.requestOptions, 155 - response: response, 156 - type: DioExceptionType.badResponse, 157 - message: 'Empty response body for "$url"', 158 - ); 159 - } 160 - 161 - // Validate metadata 162 - _validateMetadata(metadata, origin); 163 - 164 - return metadata; 165 - } on DioException catch (e) { 166 - if (e.response?.statusCode == 200) { 167 - // Already handled above, rethrow 168 - rethrow; 169 - } 170 - throw DioException( 171 - requestOptions: e.requestOptions, 172 - response: e.response, 173 - type: e.type, 174 - message: 175 - 'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"', 176 - error: e.error, 177 - ); 178 - } 179 - } 180 - 181 - /// Validates protected resource metadata. 182 - /// 183 - /// Checks: 184 - /// - Resource field matches the expected origin 185 - /// - Authorization servers list is present 186 - void _validateMetadata(Map<String, dynamic> metadata, String expectedOrigin) { 187 - // Validate resource field 188 - // https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3 189 - final resource = metadata['resource']; 190 - if (resource != expectedOrigin) { 191 - throw FormatException( 192 - 'Invalid resource: expected "$expectedOrigin", got "$resource"', 193 - ); 194 - } 195 - } 196 - }
-213
packages/atproto_oauth_flutter/lib/src/oauth/validate_client_metadata.dart
··· 1 - import '../constants.dart'; 2 - import '../types.dart'; 3 - import 'client_auth.dart'; 4 - 5 - /// Validates client metadata for OAuth compliance. 6 - /// 7 - /// This function performs comprehensive validation of client metadata to ensure: 8 - /// 1. Client ID is valid (either discoverable HTTPS or loopback) 9 - /// 2. Required ATPROTO scope is present 10 - /// 3. Required response_types and grant_types are present 11 - /// 4. Authentication method is properly configured 12 - /// 5. For private_key_jwt, keyset and JWKS are properly configured 13 - /// 14 - /// The validation enforces ATPROTO OAuth requirements on top of standard OAuth. 15 - /// 16 - /// Returns the validated ClientMetadata. 17 - /// Throws TypeError if validation fails. 18 - ClientMetadata validateClientMetadata( 19 - Map<String, dynamic> input, 20 - Keyset? keyset, 21 - ) { 22 - // Allow passing a keyset and omitting jwks/jwks_uri 23 - // The keyset will be serialized into the metadata 24 - Map<String, dynamic> enrichedInput = input; 25 - if (input['jwks'] == null && 26 - input['jwks_uri'] == null && 27 - keyset != null && 28 - keyset.size > 0) { 29 - enrichedInput = {...input, 'jwks': keyset.toJSON()}; 30 - } 31 - 32 - // Parse into ClientMetadata 33 - final metadata = ClientMetadata.fromJson(enrichedInput); 34 - 35 - // Validate client ID 36 - final clientId = metadata.clientId; 37 - if (clientId == null) { 38 - throw FormatException('Client metadata must include client_id'); 39 - } 40 - 41 - if (clientId.startsWith('http:')) { 42 - // Loopback client ID (for development) 43 - _assertOAuthLoopbackClientId(clientId); 44 - } else { 45 - // Discoverable client ID (production) 46 - _assertOAuthDiscoverableClientId(clientId); 47 - } 48 - 49 - // Validate scope includes "atproto" 50 - final scopes = metadata.scope?.split(' ') ?? []; 51 - if (!scopes.contains('atproto')) { 52 - throw FormatException('Client metadata must include the "atproto" scope'); 53 - } 54 - 55 - // Validate response_types 56 - if (!metadata.responseTypes.contains('code')) { 57 - throw FormatException('"response_types" must include "code"'); 58 - } 59 - 60 - // Validate grant_types 61 - if (!metadata.grantTypes.contains('authorization_code')) { 62 - throw FormatException('"grant_types" must include "authorization_code"'); 63 - } 64 - 65 - // Validate authentication method 66 - final method = metadata.tokenEndpointAuthMethod; 67 - final methodAlg = metadata.tokenEndpointAuthSigningAlg; 68 - 69 - switch (method) { 70 - case 'none': 71 - if (methodAlg != null) { 72 - throw FormatException( 73 - '"token_endpoint_auth_signing_alg" must not be provided when ' 74 - '"token_endpoint_auth_method" is "$method"', 75 - ); 76 - } 77 - break; 78 - 79 - case 'private_key_jwt': 80 - if (methodAlg == null) { 81 - throw FormatException( 82 - '"token_endpoint_auth_signing_alg" must be provided when ' 83 - '"token_endpoint_auth_method" is "$method"', 84 - ); 85 - } 86 - 87 - if (keyset == null) { 88 - throw FormatException( 89 - 'Client authentication method "$method" requires a keyset', 90 - ); 91 - } 92 - 93 - // Validate signing keys 94 - final signingKeys = keyset.keys.where((key) => key.kid != null).toList(); 95 - 96 - if (signingKeys.isEmpty) { 97 - throw FormatException( 98 - 'Client authentication method "$method" requires at least one ' 99 - 'active signing key with a "kid" property', 100 - ); 101 - } 102 - 103 - if (!signingKeys.any((key) => key.algorithms.contains(fallbackAlg))) { 104 - throw FormatException( 105 - 'Client authentication method "$method" requires at least one ' 106 - 'active "$fallbackAlg" signing key', 107 - ); 108 - } 109 - 110 - // Validate JWKS 111 - if (metadata.jwks != null) { 112 - // Ensure all signing keys are in the JWKS 113 - final jwksKeys = (metadata.jwks!['keys'] as List?) ?? []; 114 - for (final key in signingKeys) { 115 - final found = jwksKeys.any((k) { 116 - if (k is! Map<String, dynamic>) return false; 117 - final revoked = k['revoked'] as bool?; 118 - return k['kid'] == key.kid && revoked != true; 119 - }); 120 - 121 - if (!found) { 122 - throw FormatException( 123 - 'Missing or inactive key "${key.kid}" in jwks. ' 124 - 'Make sure that every signing key of the Keyset is declared as ' 125 - 'an active key in the Metadata\'s JWKS.', 126 - ); 127 - } 128 - } 129 - } else if (metadata.jwksUri != null) { 130 - // JWKS URI is acceptable, but we can't validate it here 131 - // (we don't want to download the file during validation) 132 - } else { 133 - throw FormatException( 134 - 'Client authentication method "$method" requires a JWKS', 135 - ); 136 - } 137 - break; 138 - 139 - default: 140 - throw FormatException( 141 - 'Unsupported "token_endpoint_auth_method" value: $method', 142 - ); 143 - } 144 - 145 - return metadata; 146 - } 147 - 148 - /// Validates that a client ID is a valid discoverable client ID. 149 - /// 150 - /// A discoverable client ID must be an HTTPS URL that can be dereferenced 151 - /// to get the client metadata document. 152 - /// 153 - /// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ 154 - void _assertOAuthDiscoverableClientId(String clientId) { 155 - final uri = Uri.tryParse(clientId); 156 - 157 - if (uri == null) { 158 - throw FormatException('Invalid client_id URL: $clientId'); 159 - } 160 - 161 - if (uri.scheme != 'https') { 162 - throw FormatException('Discoverable client_id must use HTTPS: $clientId'); 163 - } 164 - 165 - if (uri.hasFragment) { 166 - throw FormatException( 167 - 'Discoverable client_id must not contain a fragment: $clientId', 168 - ); 169 - } 170 - 171 - // Validate it's a valid URL 172 - if (!uri.hasAuthority) { 173 - throw FormatException('Invalid discoverable client_id URL: $clientId'); 174 - } 175 - } 176 - 177 - /// Validates that a client ID is a valid loopback client ID. 178 - /// 179 - /// A loopback client ID is used for development/testing and must be: 180 - /// - An HTTP URL (not HTTPS) 181 - /// - Using localhost or 127.0.0.1 182 - /// - Optionally with a port 183 - /// 184 - /// See: https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 185 - void _assertOAuthLoopbackClientId(String clientId) { 186 - final uri = Uri.tryParse(clientId); 187 - 188 - if (uri == null) { 189 - throw FormatException('Invalid client_id URL: $clientId'); 190 - } 191 - 192 - if (uri.scheme != 'http') { 193 - throw FormatException( 194 - 'Loopback client_id must use HTTP (not HTTPS): $clientId', 195 - ); 196 - } 197 - 198 - final host = uri.host.toLowerCase(); 199 - if (host != 'localhost' && 200 - host != '127.0.0.1' && 201 - host != '[::1]' && 202 - host != '::1') { 203 - throw FormatException( 204 - 'Loopback client_id must use localhost or 127.0.0.1: $clientId', 205 - ); 206 - } 207 - 208 - if (uri.hasFragment) { 209 - throw FormatException( 210 - 'Loopback client_id must not contain a fragment: $clientId', 211 - ); 212 - } 213 - }
-330
packages/atproto_oauth_flutter/lib/src/platform/README.md
··· 1 - # Flutter Platform Layer 2 - 3 - This directory contains Flutter-specific implementations of the atproto OAuth client. 4 - 5 - ## Overview 6 - 7 - The platform layer provides concrete implementations of all the abstract interfaces needed for OAuth to work on Flutter: 8 - 9 - 1. **Storage** (`flutter_stores.dart`) - Secure session storage and caching 10 - 2. **Cryptography** (`flutter_runtime.dart`) - Key generation, hashing, random values 11 - 3. **Key Management** (`flutter_key.dart`) - EC key implementation with pointycastle 12 - 4. **High-level API** (`flutter_oauth_client.dart`) - Easy-to-use Flutter OAuth client 13 - 14 - ## Architecture 15 - 16 - ``` 17 - ┌─────────────────────────────────────────────────────────────┐ 18 - │ FlutterOAuthClient │ 19 - │ (High-level API) │ 20 - └─────────────────────────────────────────────────────────────┘ 21 - 22 - 23 - ┌─────────────────────────────────────────────────────────────┐ 24 - │ OAuthClient │ 25 - │ (Core OAuth logic) │ 26 - └─────────────────────────────────────────────────────────────┘ 27 - 28 - ┌───────────────────┼───────────────────┐ 29 - ▼ ▼ ▼ 30 - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 31 - │ Storage │ │ Runtime │ │ Key │ 32 - │ (secure │ │ (crypto) │ │ (signing) │ 33 - │ storage) │ │ │ │ │ 34 - └─────────────┘ └─────────────┘ └─────────────┘ 35 - │ │ │ 36 - ▼ ▼ ▼ 37 - ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ 38 - │ flutter_ │ │ crypto/ │ │ pointycastle│ 39 - │ secure_ │ │ Random. │ │ (ECDSA) │ 40 - │ storage │ │ secure() │ │ │ 41 - └─────────────┘ └─────────────┘ └─────────────┘ 42 - ``` 43 - 44 - ## Files 45 - 46 - ### `flutter_stores.dart` 47 - 48 - Implements storage and caching: 49 - 50 - - **FlutterSessionStore**: Persists OAuth sessions in secure storage 51 - - iOS: Keychain 52 - - Android: EncryptedSharedPreferences 53 - - Stores tokens, DPoP keys, and auth methods 54 - 55 - - **FlutterStateStore**: Ephemeral OAuth state (in-memory) 56 - - PKCE verifiers 57 - - State parameters 58 - - Application state 59 - 60 - - **Cache Implementations**: In-memory caches with TTL 61 - - `InMemoryAuthorizationServerMetadataCache`: OAuth server metadata (1 min TTL) 62 - - `InMemoryProtectedResourceMetadataCache`: Resource server metadata (1 min TTL) 63 - - `InMemoryDpopNonceCache`: DPoP nonces (10 min TTL) 64 - - `FlutterDidCache`: DID documents (1 min TTL) 65 - - `FlutterHandleCache`: Handle → DID mappings (1 min TTL) 66 - 67 - ### `flutter_runtime.dart` 68 - 69 - Implements cryptographic operations: 70 - 71 - - **FlutterRuntime**: Platform-specific crypto implementation 72 - - `createKey`: EC key generation (ES256/ES384/ES512/ES256K) 73 - - `digest`: SHA-256/384/512 hashing 74 - - `getRandomValues`: Cryptographically secure random bytes 75 - - `requestLock`: Local (in-memory) locking for token refresh 76 - 77 - Uses: 78 - - `crypto` package for SHA hashing 79 - - `Random.secure()` for randomness 80 - - `utils/lock.dart` for concurrency control 81 - 82 - ### `flutter_key.dart` 83 - 84 - Implements EC key management: 85 - 86 - - **FlutterKey**: Elliptic Curve key for JWT signing 87 - - Supports ES256, ES384, ES512, ES256K 88 - - Uses `pointycastle` for ECDSA operations 89 - - Implements `Key` interface from runtime layer 90 - - Serializable (for session storage) 91 - 92 - Features: 93 - - Secure key generation with `FortunaRandom` 94 - - JWT signing (compact format) 95 - - JWK representation (public and private) 96 - - Key reconstruction from JSON 97 - 98 - ### `flutter_oauth_client.dart` 99 - 100 - High-level Flutter API: 101 - 102 - - **FlutterOAuthClient**: Easy-to-use OAuth client 103 - - Pre-configured storage and caching 104 - - Automatic FlutterWebAuth2 integration 105 - - Simplified sign-in flow 106 - - Session management helpers 107 - 108 - Key method: 109 - ```dart 110 - // One-liner sign in! 111 - final session = await client.signIn('alice.bsky.social'); 112 - ``` 113 - 114 - This handles: 115 - 1. Authorization URL generation 116 - 2. Browser launch (FlutterWebAuth2) 117 - 3. Callback handling 118 - 4. Token exchange 119 - 5. Session storage 120 - 121 - ## Security Features 122 - 123 - ### 1. Secure Storage 124 - 125 - Tokens are **never** stored in plain text: 126 - 127 - - **iOS**: Stored in Keychain with device encryption 128 - - **Android**: EncryptedSharedPreferences with AES-256 129 - 130 - ### 2. DPoP (Demonstrating Proof of Possession) 131 - 132 - Tokens are cryptographically bound to EC keys: 133 - 134 - - Prevents token theft (stolen tokens are useless without the key) 135 - - Keys stored alongside tokens in secure storage 136 - - Every API request includes a signed DPoP proof 137 - 138 - ### 3. PKCE (Proof Key for Code Exchange) 139 - 140 - Protects authorization codes from interception: 141 - 142 - - Random code verifier generated for each flow 143 - - Challenge sent to server (SHA-256 hash of verifier) 144 - - Verifier required to exchange code for tokens 145 - 146 - ### 4. Concurrency Control 147 - 148 - Prevents race conditions in token refresh: 149 - 150 - - Local lock ensures only one refresh at a time 151 - - Reduces chances of using refresh token twice 152 - - Handles concurrent requests gracefully 153 - 154 - ### 5. Automatic Cleanup 155 - 156 - Sessions are automatically deleted on errors: 157 - 158 - - Token refresh failures 159 - - Invalid token errors 160 - - Auth method unsatisfiable errors 161 - - Revocation (local and remote) 162 - 163 - ## Usage 164 - 165 - ### Basic Usage 166 - 167 - ```dart 168 - import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 169 - 170 - // Initialize 171 - final client = FlutterOAuthClient( 172 - clientMetadata: ClientMetadata( 173 - clientId: 'https://example.com/client-metadata.json', 174 - redirectUris: ['myapp://oauth/callback'], 175 - ), 176 - ); 177 - 178 - // Sign in 179 - final session = await client.signIn('alice.bsky.social'); 180 - 181 - // Use session 182 - print('Signed in as: ${session.sub}'); 183 - 184 - // Restore later 185 - final restored = await client.restore(session.sub); 186 - 187 - // Sign out 188 - await client.revoke(session.sub); 189 - ``` 190 - 191 - ### Custom Configuration 192 - 193 - ```dart 194 - final client = FlutterOAuthClient( 195 - clientMetadata: ClientMetadata( 196 - clientId: 'https://example.com/client-metadata.json', 197 - redirectUris: ['myapp://oauth/callback'], 198 - ), 199 - 200 - // Custom secure storage 201 - secureStorage: FlutterSecureStorage( 202 - aOptions: AndroidOptions( 203 - encryptedSharedPreferences: true, 204 - ), 205 - ), 206 - 207 - // Development mode 208 - allowHttp: true, 209 - 210 - // Custom PLC directory 211 - plcDirectoryUrl: 'https://plc.example.com', 212 - ); 213 - ``` 214 - 215 - ## Testing 216 - 217 - The platform layer is designed to be testable: 218 - 219 - 1. **Mock Storage**: Provide test implementation of `SessionStore` 220 - 2. **Mock Runtime**: Provide test implementation of `RuntimeImplementation` 221 - 3. **Mock Keys**: Use fixed test keys instead of random generation 222 - 223 - Example: 224 - 225 - ```dart 226 - // Test storage that uses in-memory map 227 - class TestSessionStore implements SessionStore { 228 - final Map<String, Session> _store = {}; 229 - 230 - @override 231 - Future<Session?> get(String key, {CancellationToken? signal}) async { 232 - return _store[key]; 233 - } 234 - 235 - @override 236 - Future<void> set(String key, Session value) async { 237 - _store[key] = value; 238 - } 239 - 240 - // ... etc 241 - } 242 - 243 - // Use in tests 244 - final client = OAuthClient( 245 - OAuthClientOptions( 246 - // ... other options 247 - sessionStore: TestSessionStore(), 248 - ), 249 - ); 250 - ``` 251 - 252 - ## Platform Setup 253 - 254 - ### iOS 255 - 256 - Add URL scheme to `Info.plist`: 257 - 258 - ```xml 259 - <key>CFBundleURLTypes</key> 260 - <array> 261 - <dict> 262 - <key>CFBundleURLSchemes</key> 263 - <array> 264 - <string>myapp</string> 265 - </array> 266 - </dict> 267 - </array> 268 - ``` 269 - 270 - ### Android 271 - 272 - Add intent filter to `AndroidManifest.xml`: 273 - 274 - ```xml 275 - <intent-filter> 276 - <action android:name="android.intent.action.VIEW" /> 277 - <category android:name="android.intent.category.DEFAULT" /> 278 - <category android:name="android.intent.category.BROWSABLE" /> 279 - <data android:scheme="myapp" /> 280 - </intent-filter> 281 - ``` 282 - 283 - ## Dependencies 284 - 285 - - `flutter_secure_storage: ^9.2.2` - Secure token storage 286 - - `flutter_web_auth_2: ^4.1.0` - Browser-based OAuth flow 287 - - `pointycastle: ^3.9.1` - Elliptic Curve cryptography 288 - - `crypto: ^3.0.3` - SHA hashing 289 - 290 - ## Known Limitations 291 - 292 - ### 1. Key Serialization 293 - 294 - Currently, DPoP keys are regenerated on each app restart. This works but has drawbacks: 295 - 296 - - Tokens bound to old keys become invalid (require refresh) 297 - - Slight performance impact on session restoration 298 - 299 - **Fix**: Implement proper `Key` serialization in `flutter_key.dart`: 300 - - Add `toJson()` method that includes private key components 301 - - Add `fromJson()` factory that reconstructs the key 302 - - Store serialized keys in session storage 303 - 304 - ### 2. Local Lock Only 305 - 306 - The lock implementation is in-memory and doesn't work across: 307 - - Multiple isolates 308 - - Multiple processes 309 - - Multiple app instances 310 - 311 - For most Flutter apps, this is fine. For advanced use cases, implement a platform-specific lock. 312 - 313 - ### 3. Cache TTLs 314 - 315 - Cache TTLs are fixed (1 minute for most caches). Consider making these configurable if your app has different caching requirements. 316 - 317 - ## Future Improvements 318 - 319 - 1. **Key Persistence**: Implement proper key serialization (see above) 320 - 2. **Platform Locks**: Add iOS/Android native lock implementations 321 - 3. **Configurable TTLs**: Allow cache TTL customization 322 - 4. **Background Refresh**: Support token refresh in background 323 - 5. **Biometric Auth**: Optional biometric unlock for sessions 324 - 6. **Migration Helpers**: Tools for migrating from other OAuth libraries 325 - 326 - ## See Also 327 - 328 - - [Example usage](../../example/flutter_oauth_example.dart) 329 - - [Main library docs](../../atproto_oauth_flutter.dart) 330 - - [Core OAuth client](../client/oauth_client.dart)
-435
packages/atproto_oauth_flutter/lib/src/platform/flutter_key.dart
··· 1 - import 'dart:convert'; 2 - import 'dart:math'; 3 - import 'dart:typed_data'; 4 - 5 - import 'package:pointycastle/export.dart' as pointycastle; 6 - 7 - import '../runtime/runtime_implementation.dart'; 8 - 9 - /// Flutter implementation of Key using pointycastle for cryptographic operations. 10 - /// 11 - /// Supports EC keys with the following algorithms: 12 - /// - ES256 (P-256/secp256r1) 13 - /// - ES384 (P-384/secp384r1) 14 - /// - ES512 (P-521/secp521r1) - Note: P-521, not P-512 15 - /// - ES256K (secp256k1) 16 - /// 17 - /// This class handles: 18 - /// - Key generation with secure randomness 19 - /// - JWT signing (ES256/ES384/ES512/ES256K) 20 - /// - JWK representation (public and private components) 21 - /// - Serialization/deserialization for session storage 22 - class FlutterKey implements Key { 23 - /// The EC private key (contains both private and public components) 24 - final pointycastle.ECPrivateKey privateKey; 25 - 26 - /// The EC public key 27 - final pointycastle.ECPublicKey publicKey; 28 - 29 - /// The algorithm this key supports 30 - final String algorithm; 31 - 32 - /// Optional key ID 33 - final String? _kid; 34 - 35 - /// Creates a FlutterKey from EC key components. 36 - FlutterKey({ 37 - required this.privateKey, 38 - required this.publicKey, 39 - required this.algorithm, 40 - String? kid, 41 - }) : _kid = kid; 42 - 43 - @override 44 - List<String> get algorithms => [algorithm]; 45 - 46 - @override 47 - String? get kid => _kid; 48 - 49 - @override 50 - String get usage => 'sign'; 51 - 52 - @override 53 - Map<String, dynamic>? get bareJwk { 54 - // Return public key components only (no private key 'd') 55 - final jwk = _ecPublicKeyToJwk(publicKey, algorithm); 56 - if (_kid != null) { 57 - jwk['kid'] = _kid; 58 - } 59 - return jwk; 60 - } 61 - 62 - /// Full JWK including private key components. 63 - /// 64 - /// WARNING: This contains sensitive key material. Never log or expose. 65 - /// Only use for secure storage. 66 - Map<String, dynamic> get privateJwk { 67 - final jwk = _ecPrivateKeyToJwk(privateKey, publicKey, algorithm); 68 - if (_kid != null) { 69 - jwk['kid'] = _kid; 70 - } 71 - return jwk; 72 - } 73 - 74 - @override 75 - Future<String> createJwt( 76 - Map<String, dynamic> header, 77 - Map<String, dynamic> payload, 78 - ) async { 79 - // Build JWT header 80 - final jwtHeader = <String, dynamic>{ 81 - 'typ': 'JWT', 82 - 'alg': algorithm, 83 - ...header, 84 - }; 85 - if (_kid != null) { 86 - jwtHeader['kid'] = _kid; 87 - } 88 - 89 - // Encode header and payload 90 - final headerB64 = _base64UrlEncode(utf8.encode(json.encode(jwtHeader))); 91 - final payloadB64 = _base64UrlEncode(utf8.encode(json.encode(payload))); 92 - 93 - // Create signing input 94 - final signingInput = '$headerB64.$payloadB64'; 95 - final signingBytes = utf8.encode(signingInput); 96 - 97 - // Sign with appropriate algorithm 98 - final signature = _signEcdsa(signingBytes, privateKey, algorithm); 99 - 100 - // Encode signature 101 - final signatureB64 = _base64UrlEncode(signature); 102 - 103 - // Return compact JWT 104 - return '$signingInput.$signatureB64'; 105 - } 106 - 107 - /// Generates a new FlutterKey for the given algorithms. 108 - /// 109 - /// Returns a key supporting the first compatible algorithm from the list. 110 - /// 111 - /// Throws [UnsupportedError] if no compatible algorithm is found. 112 - static Future<FlutterKey> generate(List<String> algs) async { 113 - // Try algorithms in order 114 - for (final alg in algs) { 115 - switch (alg) { 116 - case 'ES256': 117 - return _generateECKey('ES256', 'P-256'); 118 - case 'ES384': 119 - return _generateECKey('ES384', 'P-384'); 120 - case 'ES512': 121 - return _generateECKey('ES512', 'P-521'); // Note: P-521, not P-512 122 - case 'ES256K': 123 - return _generateECKey('ES256K', 'secp256k1'); 124 - } 125 - } 126 - 127 - throw UnsupportedError( 128 - 'No supported algorithm found in: ${algs.join(", ")}', 129 - ); 130 - } 131 - 132 - /// Reconstructs a FlutterKey from serialized JWK data. 133 - /// 134 - /// This is used when restoring sessions from storage. 135 - factory FlutterKey.fromJwk(Map<String, dynamic> jwk) { 136 - final kty = jwk['kty'] as String?; 137 - if (kty != 'EC') { 138 - throw FormatException('Unsupported key type: $kty'); 139 - } 140 - 141 - final crv = jwk['crv'] as String?; 142 - final alg = jwk['alg'] as String?; 143 - final kid = jwk['kid'] as String?; 144 - 145 - if (crv == null || alg == null) { 146 - throw FormatException('Missing required JWK fields'); 147 - } 148 - 149 - // Parse key components 150 - final x = _base64UrlDecode(jwk['x'] as String); 151 - final y = _base64UrlDecode(jwk['y'] as String); 152 - final d = jwk['d'] != null ? _base64UrlDecode(jwk['d'] as String) : null; 153 - 154 - if (d == null) { 155 - throw FormatException('Private key component (d) is required'); 156 - } 157 - 158 - // Get curve 159 - final curve = _getCurveForName(crv); 160 - 161 - // Reconstruct public key 162 - final publicKey = pointycastle.ECPublicKey( 163 - curve.curve.createPoint(_bytesToBigInt(x), _bytesToBigInt(y)), 164 - curve, 165 - ); 166 - 167 - // Reconstruct private key 168 - final privateKey = pointycastle.ECPrivateKey(_bytesToBigInt(d), curve); 169 - 170 - return FlutterKey( 171 - privateKey: privateKey, 172 - publicKey: publicKey, 173 - algorithm: alg, 174 - kid: kid, 175 - ); 176 - } 177 - 178 - /// Serializes this key to JSON (for session storage). 179 - /// 180 - /// WARNING: Contains private key material. Store securely. 181 - Map<String, dynamic> toJson() => privateJwk; 182 - 183 - // ============================================================================ 184 - // Private helper methods 185 - // ============================================================================ 186 - 187 - /// Generates an EC key pair for the given algorithm and curve. 188 - static Future<FlutterKey> _generateECKey( 189 - String algorithm, 190 - String curveName, 191 - ) async { 192 - final curve = _getCurveForName(curveName); 193 - 194 - // Create secure random generator 195 - final secureRandom = pointycastle.FortunaRandom(); 196 - final random = Random.secure(); 197 - final seeds = List<int>.generate(32, (_) => random.nextInt(256)); 198 - secureRandom.seed(pointycastle.KeyParameter(Uint8List.fromList(seeds))); 199 - 200 - // Generate key pair 201 - final keyGen = pointycastle.ECKeyGenerator(); 202 - keyGen.init( 203 - pointycastle.ParametersWithRandom( 204 - pointycastle.ECKeyGeneratorParameters(curve), 205 - secureRandom, 206 - ), 207 - ); 208 - 209 - final keyPair = keyGen.generateKeyPair(); 210 - final privateKey = keyPair.privateKey as pointycastle.ECPrivateKey; 211 - final publicKey = keyPair.publicKey as pointycastle.ECPublicKey; 212 - 213 - return FlutterKey( 214 - privateKey: privateKey, 215 - publicKey: publicKey, 216 - algorithm: algorithm, 217 - ); 218 - } 219 - 220 - /// Gets the EC domain parameters for a given curve name. 221 - static pointycastle.ECDomainParameters _getCurveForName(String name) { 222 - // Use pointycastle's standard curve implementations 223 - switch (name) { 224 - case 'P-256': 225 - case 'prime256v1': 226 - case 'secp256r1': 227 - return pointycastle.ECCurve_secp256r1(); 228 - case 'P-384': 229 - case 'secp384r1': 230 - return pointycastle.ECCurve_secp384r1(); 231 - case 'P-521': 232 - case 'secp521r1': 233 - return pointycastle.ECCurve_secp521r1(); 234 - case 'secp256k1': 235 - return pointycastle.ECCurve_secp256k1(); 236 - default: 237 - throw UnsupportedError('Unsupported curve: $name'); 238 - } 239 - } 240 - 241 - /// Gets the curve name for JWK representation. 242 - static String _getCurveName(String algorithm) { 243 - switch (algorithm) { 244 - case 'ES256': 245 - return 'P-256'; 246 - case 'ES384': 247 - return 'P-384'; 248 - case 'ES512': 249 - return 'P-521'; 250 - case 'ES256K': 251 - return 'secp256k1'; 252 - default: 253 - throw UnsupportedError('Unsupported algorithm: $algorithm'); 254 - } 255 - } 256 - 257 - /// Gets the hash algorithm for signing. 258 - static String _getHashAlgorithm(String algorithm) { 259 - switch (algorithm) { 260 - case 'ES256': 261 - case 'ES256K': 262 - return 'SHA-256'; 263 - case 'ES384': 264 - return 'SHA-384'; 265 - case 'ES512': 266 - return 'SHA-512'; 267 - default: 268 - throw UnsupportedError('Unsupported algorithm: $algorithm'); 269 - } 270 - } 271 - 272 - /// Signs data using ECDSA with deterministic signatures (RFC 6979). 273 - /// 274 - /// This uses deterministic ECDSA which doesn't require a source of randomness, 275 - /// making it more secure and avoiding SecureRandom initialization issues. 276 - static Uint8List _signEcdsa( 277 - List<int> data, 278 - pointycastle.ECPrivateKey privateKey, 279 - String algorithm, 280 - ) { 281 - // Get the appropriate hash algorithm for this signing algorithm 282 - final hashAlg = _getHashAlgorithm(algorithm); 283 - 284 - // Build deterministic ECDSA signer name (e.g., "SHA-256/DET-ECDSA") 285 - final signerName = '$hashAlg/DET-ECDSA'; 286 - 287 - // Use deterministic ECDSA signer (RFC 6979) - no randomness required! 288 - final signer = pointycastle.Signer(signerName); 289 - signer.init( 290 - true, // signing mode 291 - pointycastle.PrivateKeyParameter<pointycastle.ECPrivateKey>(privateKey), 292 - ); 293 - 294 - // Sign the data (signer will hash it internally) 295 - final signature = 296 - signer.generateSignature(Uint8List.fromList(data)) 297 - as pointycastle.ECSignature; 298 - 299 - // Encode as IEEE P1363 format (r || s) 300 - final r = _bigIntToBytes(signature.r, _getSignatureLength(algorithm)); 301 - final s = _bigIntToBytes(signature.s, _getSignatureLength(algorithm)); 302 - 303 - return Uint8List.fromList([...r, ...s]); 304 - } 305 - 306 - /// Creates a pointycastle Digest for the given hash algorithm. 307 - static pointycastle.Digest _createDigest(String algorithm) { 308 - switch (algorithm) { 309 - case 'SHA-256': 310 - return pointycastle.SHA256Digest(); 311 - case 'SHA-384': 312 - return pointycastle.SHA384Digest(); 313 - case 'SHA-512': 314 - return pointycastle.SHA512Digest(); 315 - default: 316 - throw UnsupportedError('Unsupported hash: $algorithm'); 317 - } 318 - } 319 - 320 - /// Gets the signature length in bytes for the algorithm. 321 - static int _getSignatureLength(String algorithm) { 322 - switch (algorithm) { 323 - case 'ES256': 324 - case 'ES256K': 325 - return 32; 326 - case 'ES384': 327 - return 48; 328 - case 'ES512': 329 - return 66; // P-521 uses 66 bytes per component 330 - default: 331 - throw UnsupportedError('Unsupported algorithm: $algorithm'); 332 - } 333 - } 334 - 335 - /// Converts an EC public key to JWK format. 336 - static Map<String, dynamic> _ecPublicKeyToJwk( 337 - pointycastle.ECPublicKey publicKey, 338 - String algorithm, 339 - ) { 340 - final q = publicKey.Q!; 341 - final curve = _getCurveName(algorithm); 342 - 343 - return { 344 - 'kty': 'EC', 345 - 'crv': curve, 346 - 'x': _base64UrlEncode(_bigIntToBytes(q.x!.toBigInteger()!)), 347 - 'y': _base64UrlEncode(_bigIntToBytes(q.y!.toBigInteger()!)), 348 - 'alg': algorithm, 349 - 'use': 'sig', 350 - 'key_ops': ['sign'], 351 - }; 352 - } 353 - 354 - /// Converts an EC private key to JWK format (includes private component). 355 - static Map<String, dynamic> _ecPrivateKeyToJwk( 356 - pointycastle.ECPrivateKey privateKey, 357 - pointycastle.ECPublicKey publicKey, 358 - String algorithm, 359 - ) { 360 - final jwk = _ecPublicKeyToJwk(publicKey, algorithm); 361 - jwk['d'] = _base64UrlEncode(_bigIntToBytes(privateKey.d!)); 362 - return jwk; 363 - } 364 - 365 - /// Converts a BigInt to bytes with optional padding. 366 - static Uint8List _bigIntToBytes(BigInt number, [int? length]) { 367 - var bytes = _encodeBigInt(number); 368 - 369 - if (length != null) { 370 - if (bytes.length > length) { 371 - // Remove leading zeros 372 - bytes = bytes.sublist(bytes.length - length); 373 - } else if (bytes.length < length) { 374 - // Add leading zeros 375 - final padded = Uint8List(length); 376 - padded.setRange(length - bytes.length, length, bytes); 377 - bytes = padded; 378 - } 379 - } 380 - 381 - return bytes; 382 - } 383 - 384 - /// Encodes a BigInt as bytes (unsigned, big-endian). 385 - static Uint8List _encodeBigInt(BigInt number) { 386 - // Handle zero 387 - if (number == BigInt.zero) { 388 - return Uint8List.fromList([0]); 389 - } 390 - 391 - // Handle negative (should not happen for EC keys) 392 - if (number.isNegative) { 393 - throw ArgumentError('Cannot encode negative BigInt'); 394 - } 395 - 396 - // Convert to bytes 397 - final bytes = <int>[]; 398 - var n = number; 399 - while (n > BigInt.zero) { 400 - bytes.insert(0, (n & BigInt.from(0xff)).toInt()); 401 - n = n >> 8; 402 - } 403 - 404 - return Uint8List.fromList(bytes); 405 - } 406 - 407 - /// Converts bytes to BigInt (unsigned, big-endian). 408 - static BigInt _bytesToBigInt(List<int> bytes) { 409 - var result = BigInt.zero; 410 - for (var byte in bytes) { 411 - result = (result << 8) | BigInt.from(byte); 412 - } 413 - return result; 414 - } 415 - 416 - /// Base64url encodes bytes (no padding). 417 - static String _base64UrlEncode(List<int> bytes) { 418 - return base64Url.encode(bytes).replaceAll('=', ''); 419 - } 420 - 421 - /// Base64url decodes a string. 422 - static Uint8List _base64UrlDecode(String str) { 423 - // Add padding if needed 424 - var s = str; 425 - switch (s.length % 4) { 426 - case 2: 427 - s += '=='; 428 - break; 429 - case 3: 430 - s += '='; 431 - break; 432 - } 433 - return base64Url.decode(s); 434 - } 435 - }
-302
packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_client.dart
··· 1 - import 'package:dio/dio.dart'; 2 - import 'package:flutter/foundation.dart'; 3 - import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 4 - import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; 5 - 6 - import '../client/oauth_client.dart'; 7 - import '../session/oauth_session.dart'; 8 - import 'flutter_runtime.dart'; 9 - import 'flutter_stores.dart'; 10 - 11 - /// Flutter-specific OAuth client with sensible defaults. 12 - /// 13 - /// This is a high-level wrapper around [OAuthClient] that provides: 14 - /// - Automatic storage configuration (flutter_secure_storage) 15 - /// - Platform-specific crypto (pointycastle + crypto package) 16 - /// - In-memory caching with TTL 17 - /// - Convenient sign-in flow (authorize + FlutterWebAuth2 + callback) 18 - /// - Session management (restore, revoke) 19 - /// 20 - /// Example usage: 21 - /// ```dart 22 - /// // Initialize client 23 - /// final client = FlutterOAuthClient( 24 - /// clientMetadata: ClientMetadata( 25 - /// clientId: 'https://example.com/client-metadata.json', 26 - /// redirectUris: ['myapp://oauth/callback'], 27 - /// scope: 'atproto transition:generic', 28 - /// ), 29 - /// ); 30 - /// 31 - /// // Sign in with handle 32 - /// try { 33 - /// final session = await client.signIn('alice.bsky.social'); 34 - /// print('Signed in as: ${session.sub}'); 35 - /// 36 - /// // Use the session for authenticated requests 37 - /// final agent = session.pdsClient; 38 - /// // ... make API calls 39 - /// } catch (e) { 40 - /// print('Sign in failed: $e'); 41 - /// } 42 - /// 43 - /// // Later: restore session 44 - /// try { 45 - /// final session = await client.restore('did:plc:abc123'); 46 - /// print('Session restored'); 47 - /// } catch (e) { 48 - /// print('Session restoration failed: $e'); 49 - /// } 50 - /// 51 - /// // Sign out 52 - /// await client.revoke('did:plc:abc123'); 53 - /// ``` 54 - class FlutterOAuthClient extends OAuthClient { 55 - /// Creates a FlutterOAuthClient with Flutter-specific defaults. 56 - /// 57 - /// Parameters: 58 - /// - [clientMetadata]: Client configuration (required) 59 - /// - [responseMode]: OAuth response mode (default: query) 60 - /// - [allowHttp]: Allow HTTP for testing (default: false) 61 - /// - [secureStorage]: Custom secure storage instance (optional) 62 - /// - [dio]: Custom HTTP client (optional) 63 - /// - [plcDirectoryUrl]: Custom PLC directory URL (optional) 64 - /// - [handleResolverUrl]: Custom handle resolver URL (optional) 65 - /// 66 - /// Throws [FormatException] if client metadata is invalid. 67 - FlutterOAuthClient({ 68 - required ClientMetadata clientMetadata, 69 - OAuthResponseMode responseMode = OAuthResponseMode.query, 70 - bool allowHttp = false, 71 - FlutterSecureStorage? secureStorage, 72 - Dio? dio, 73 - String? plcDirectoryUrl, 74 - String? handleResolverUrl, 75 - }) : super( 76 - OAuthClientOptions( 77 - // Config 78 - responseMode: responseMode, 79 - clientMetadata: clientMetadata.toJson(), 80 - keyset: null, // Mobile apps are public clients 81 - allowHttp: allowHttp, 82 - 83 - // Storage (Flutter-specific) 84 - stateStore: FlutterStateStore(), 85 - sessionStore: FlutterSessionStore(secureStorage), 86 - 87 - // Caches (in-memory with TTL) 88 - authorizationServerMetadataCache: 89 - InMemoryAuthorizationServerMetadataCache(), 90 - protectedResourceMetadataCache: 91 - InMemoryProtectedResourceMetadataCache(), 92 - dpopNonceCache: InMemoryDpopNonceCache(), 93 - didCache: FlutterDidCache(), 94 - handleCache: FlutterHandleCache(), 95 - 96 - // Platform implementation 97 - runtimeImplementation: const FlutterRuntime(), 98 - 99 - // HTTP client 100 - dio: dio, 101 - 102 - // Optional overrides 103 - plcDirectoryUrl: plcDirectoryUrl, 104 - handleResolverUrl: handleResolverUrl, 105 - ), 106 - ); 107 - 108 - /// Sign in with an atProto handle, DID, or URL. 109 - /// 110 - /// This is a convenience method that: 111 - /// 1. Initiates authorization flow ([authorize]) 112 - /// 2. Opens browser with FlutterWebAuth2 113 - /// 3. Handles OAuth callback 114 - /// 4. Returns authenticated session 115 - /// 116 - /// The [input] can be: 117 - /// - An atProto handle: "alice.bsky.social" 118 - /// - A DID: "did:plc:..." 119 - /// - A PDS URL: "https://pds.example.com" 120 - /// - An authorization server URL: "https://auth.example.com" 121 - /// 122 - /// The [options] can specify: 123 - /// - redirectUri: Override default redirect URI 124 - /// - state: Application state to preserve 125 - /// - scope: Override default scope 126 - /// - Other OIDC parameters (prompt, display, etc.) 127 - /// 128 - /// Returns an [OAuthSession] with authenticated access. 129 - /// 130 - /// Throws: 131 - /// - [FormatException] if parameters are invalid 132 - /// - [OAuthResolverError] if identity resolution fails 133 - /// - [OAuthCallbackError] if authentication fails 134 - /// - [Exception] if user cancels (flutter_web_auth_2 throws PlatformException) 135 - /// 136 - /// Example: 137 - /// ```dart 138 - /// // Simple sign in 139 - /// final session = await client.signIn('alice.bsky.social'); 140 - /// 141 - /// // With custom state 142 - /// final session = await client.signIn( 143 - /// 'alice.bsky.social', 144 - /// options: AuthorizeOptions(state: 'my-app-state'), 145 - /// ); 146 - /// ``` 147 - Future<OAuthSession> signIn( 148 - String input, { 149 - AuthorizeOptions? options, 150 - CancelToken? cancelToken, 151 - }) async { 152 - // CRITICAL: Use HTTPS redirect URI for OAuth (prevents browser retry) 153 - // but listen for CUSTOM SCHEME in FlutterWebAuth2 (only custom schemes can be intercepted) 154 - // The HTTPS page will redirect to custom scheme, triggering the callback 155 - final redirectUri = 156 - options?.redirectUri ?? clientMetadata.redirectUris.first; 157 - 158 - if (!clientMetadata.redirectUris.contains(redirectUri)) { 159 - throw FormatException('Invalid redirect_uri: $redirectUri'); 160 - } 161 - 162 - // Find the custom scheme redirect URI from the list 163 - // FlutterWebAuth2 can ONLY intercept custom schemes, not HTTPS 164 - final customSchemeUri = clientMetadata.redirectUris.firstWhere( 165 - (uri) => !uri.startsWith('http://') && !uri.startsWith('https://'), 166 - orElse: 167 - () => redirectUri, // Fallback to primary if no custom scheme found 168 - ); 169 - 170 - final callbackUrlScheme = _extractScheme(customSchemeUri); 171 - 172 - // Step 1: Start OAuth authorization flow 173 - final authUrl = await authorize( 174 - input, 175 - options: 176 - options != null 177 - ? AuthorizeOptions( 178 - redirectUri: redirectUri, 179 - state: options.state, 180 - scope: options.scope, 181 - nonce: options.nonce, 182 - dpopJkt: options.dpopJkt, 183 - maxAge: options.maxAge, 184 - claims: options.claims, 185 - uiLocales: options.uiLocales, 186 - idTokenHint: options.idTokenHint, 187 - display: options.display ?? 'touch', // Mobile-friendly default 188 - prompt: options.prompt, 189 - authorizationDetails: options.authorizationDetails, 190 - ) 191 - : AuthorizeOptions( 192 - redirectUri: redirectUri, 193 - display: 'touch', // Mobile-friendly default 194 - ), 195 - cancelToken: cancelToken, 196 - ); 197 - 198 - // Step 2: Open browser for user authentication 199 - if (kDebugMode) { 200 - print('🔐 Opening browser for OAuth...'); 201 - print(' Auth URL: $authUrl'); 202 - print(' OAuth redirect URI (PDS will redirect here): $redirectUri'); 203 - print( 204 - ' FlutterWebAuth2 callback scheme (listening for): $callbackUrlScheme', 205 - ); 206 - } 207 - 208 - String? callbackUrl; 209 - try { 210 - if (kDebugMode) { 211 - print('📱 Calling FlutterWebAuth2.authenticate()...'); 212 - } 213 - 214 - callbackUrl = await FlutterWebAuth2.authenticate( 215 - url: authUrl.toString(), 216 - callbackUrlScheme: callbackUrlScheme, 217 - options: const FlutterWebAuth2Options( 218 - // Use ephemeral session to force browser to close immediately 219 - // This prevents browser retry that can invalidate the authorization code 220 - preferEphemeral: true, 221 - timeout: 300, // 5 minutes timeout 222 - ), 223 - ); 224 - 225 - if (kDebugMode) { 226 - print('✅ FlutterWebAuth2 returned successfully!'); 227 - print(' Callback URL: $callbackUrl'); 228 - print( 229 - ' ⏱️ Callback received at: ${DateTime.now().toIso8601String()}', 230 - ); 231 - } 232 - } catch (e, stackTrace) { 233 - if (kDebugMode) { 234 - print('❌ FlutterWebAuth2.authenticate() threw an error:'); 235 - print(' Error type: ${e.runtimeType}'); 236 - print(' Error message: $e'); 237 - print(' Stack trace: $stackTrace'); 238 - } 239 - rethrow; 240 - } 241 - 242 - // Step 3: Parse callback URL parameters 243 - final uri = Uri.parse(callbackUrl); 244 - final params = 245 - responseMode == OAuthResponseMode.fragment 246 - ? _parseFragment(uri.fragment) 247 - : Map<String, String>.from(uri.queryParameters); 248 - 249 - if (kDebugMode) { 250 - print('🔄 Parsing callback parameters...'); 251 - print(' Response mode: $responseMode'); 252 - print(' Callback params: $params'); 253 - } 254 - 255 - // Step 4: Complete OAuth flow 256 - if (kDebugMode) { 257 - print('📞 Calling callback() to exchange code for tokens...'); 258 - print(' Redirect URI: $redirectUri'); 259 - } 260 - 261 - final result = await callback( 262 - params, 263 - options: CallbackOptions(redirectUri: redirectUri), 264 - cancelToken: cancelToken, 265 - ); 266 - 267 - if (kDebugMode) { 268 - print('✅ Token exchange successful!'); 269 - print(' Session DID: ${result.session.sub}'); 270 - } 271 - 272 - return result.session; 273 - } 274 - 275 - /// Extracts the URL scheme from a redirect URI. 276 - /// 277 - /// Examples: 278 - /// - "myapp://oauth/callback" → "myapp" 279 - /// - "https://example.com/callback" → "https" 280 - String _extractScheme(String redirectUri) { 281 - final uri = Uri.parse(redirectUri); 282 - return uri.scheme; 283 - } 284 - 285 - /// Parses URL fragment into a parameter map. 286 - /// 287 - /// The fragment may start with '#' which we strip. 288 - Map<String, String> _parseFragment(String fragment) { 289 - // Remove leading '#' if present 290 - final clean = fragment.startsWith('#') ? fragment.substring(1) : fragment; 291 - if (clean.isEmpty) return {}; 292 - 293 - final params = <String, String>{}; 294 - for (final pair in clean.split('&')) { 295 - final parts = pair.split('='); 296 - if (parts.length == 2) { 297 - params[Uri.decodeComponent(parts[0])] = Uri.decodeComponent(parts[1]); 298 - } 299 - } 300 - return params; 301 - } 302 - }
-141
packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_router_helper.dart
··· 1 - /// Helper for configuring Flutter routers to work with OAuth callbacks. 2 - /// 3 - /// When using declarative routing packages (go_router, auto_route, etc.), 4 - /// OAuth callback deep links may be intercepted before flutter_web_auth_2 5 - /// can handle them. This helper provides utilities to configure your router 6 - /// to ignore OAuth callback URIs. 7 - /// 8 - /// ## go_router Example 9 - /// 10 - /// ```dart 11 - /// final router = GoRouter( 12 - /// routes: [...], 13 - /// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( 14 - /// customSchemes: ['com.example.myapp'], 15 - /// ), 16 - /// ); 17 - /// ``` 18 - /// 19 - /// ## Manual Configuration 20 - /// 21 - /// ```dart 22 - /// final router = GoRouter( 23 - /// routes: [...], 24 - /// redirect: (context, state) { 25 - /// if (FlutterOAuthRouterHelper.isOAuthCallback( 26 - /// state.uri, 27 - /// customSchemes: ['com.example.myapp'], 28 - /// )) { 29 - /// return null; // Let flutter_web_auth_2 handle it 30 - /// } 31 - /// return null; // Normal routing 32 - /// }, 33 - /// ); 34 - /// ``` 35 - library; 36 - 37 - import 'dart:async'; 38 - import 'package:flutter/foundation.dart'; 39 - import 'package:flutter/widgets.dart'; 40 - 41 - /// Helper class for configuring routers to work with OAuth callbacks. 42 - class FlutterOAuthRouterHelper { 43 - /// Checks if a URI is an OAuth callback that should be ignored by the router. 44 - /// 45 - /// Returns `true` if the URI uses a custom scheme from [customSchemes], 46 - /// indicating it's an OAuth callback deep link that flutter_web_auth_2 47 - /// should handle. 48 - /// 49 - /// Example: 50 - /// ```dart 51 - /// if (FlutterOAuthRouterHelper.isOAuthCallback( 52 - /// uri, 53 - /// customSchemes: ['com.example.myapp'], 54 - /// )) { 55 - /// // This is an OAuth callback - don't route it 56 - /// return null; 57 - /// } 58 - /// ``` 59 - static bool isOAuthCallback(Uri uri, {required List<String> customSchemes}) { 60 - return customSchemes.contains(uri.scheme); 61 - } 62 - 63 - /// Creates a redirect function for go_router that ignores OAuth callbacks. 64 - /// 65 - /// This is a convenience method that returns a redirect function you can 66 - /// pass directly to GoRouter's `redirect` parameter. 67 - /// 68 - /// Parameters: 69 - /// - [customSchemes]: List of custom URL schemes used for OAuth callbacks 70 - /// (e.g., `['com.example.myapp']`) 71 - /// - [fallbackRedirect]: Optional custom redirect logic for non-OAuth URIs 72 - /// 73 - /// Example: 74 - /// ```dart 75 - /// final router = GoRouter( 76 - /// routes: [...], 77 - /// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( 78 - /// customSchemes: ['com.example.myapp'], 79 - /// ), 80 - /// ); 81 - /// ``` 82 - /// 83 - /// With custom redirect logic: 84 - /// ```dart 85 - /// final router = GoRouter( 86 - /// routes: [...], 87 - /// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( 88 - /// customSchemes: ['com.example.myapp'], 89 - /// fallbackRedirect: (context, state) { 90 - /// // Your custom auth redirect logic 91 - /// if (!isAuthenticated) return '/login'; 92 - /// return null; 93 - /// }, 94 - /// ), 95 - /// ); 96 - /// ``` 97 - static FutureOr<String?> Function(BuildContext, dynamic) 98 - createGoRouterRedirect({ 99 - required List<String> customSchemes, 100 - FutureOr<String?> Function(BuildContext, dynamic)? fallbackRedirect, 101 - }) { 102 - return (BuildContext context, dynamic state) { 103 - // Extract URI from the state object (works with any router's state object that has a 'uri' property) 104 - final uri = (state as dynamic).uri as Uri; 105 - 106 - // Check if this is an OAuth callback 107 - if (isOAuthCallback(uri, customSchemes: customSchemes)) { 108 - // Let flutter_web_auth_2 handle OAuth callbacks 109 - if (kDebugMode) { 110 - print('🔀 RouterHelper: Detected OAuth callback - allowing through'); 111 - print(' URI: $uri'); 112 - } 113 - return null; 114 - } 115 - 116 - // Apply custom redirect logic if provided 117 - if (fallbackRedirect != null) { 118 - return fallbackRedirect(context, state); 119 - } 120 - 121 - // No redirect needed 122 - return null; 123 - }; 124 - } 125 - 126 - /// Extracts the scheme from a redirect URI. 127 - /// 128 - /// This is useful for getting the custom scheme from your OAuth configuration. 129 - /// 130 - /// Example: 131 - /// ```dart 132 - /// final scheme = FlutterOAuthRouterHelper.extractScheme( 133 - /// 'com.example.myapp:/oauth/callback' 134 - /// ); 135 - /// // Returns: 'com.example.myapp' 136 - /// ``` 137 - static String extractScheme(String redirectUri) { 138 - final uri = Uri.parse(redirectUri); 139 - return uri.scheme; 140 - } 141 - }
-91
packages/atproto_oauth_flutter/lib/src/platform/flutter_runtime.dart
··· 1 - import 'dart:math'; 2 - import 'dart:typed_data'; 3 - 4 - import 'package:crypto/crypto.dart' as crypto; 5 - 6 - import '../runtime/runtime_implementation.dart'; 7 - import '../utils/lock.dart'; 8 - import 'flutter_key.dart'; 9 - 10 - /// Flutter implementation of RuntimeImplementation. 11 - /// 12 - /// Provides cryptographic operations for OAuth flows using: 13 - /// - pointycastle for EC key generation (via FlutterKey) 14 - /// - crypto package for SHA hashing 15 - /// - Random.secure() for cryptographically secure random values 16 - /// - requestLocalLock for concurrency control 17 - /// 18 - /// This implementation supports: 19 - /// - ES256, ES384, ES512, ES256K (Elliptic Curve algorithms) 20 - /// - SHA-256, SHA-384, SHA-512 (Hash algorithms) 21 - /// - Secure random number generation 22 - /// - Local (in-memory) locking for token refresh 23 - /// 24 - /// Example: 25 - /// ```dart 26 - /// final runtime = FlutterRuntime(); 27 - /// 28 - /// // Generate a key 29 - /// final key = await runtime.createKey(['ES256', 'ES384']); 30 - /// 31 - /// // Hash some data 32 - /// final hash = await runtime.digest( 33 - /// Uint8List.fromList([1, 2, 3]), 34 - /// DigestAlgorithm.sha256(), 35 - /// ); 36 - /// 37 - /// // Generate random bytes 38 - /// final random = await runtime.getRandomValues(32); 39 - /// ``` 40 - class FlutterRuntime implements RuntimeImplementation { 41 - /// Creates a FlutterRuntime instance. 42 - const FlutterRuntime(); 43 - 44 - @override 45 - RuntimeKeyFactory get createKey { 46 - return (List<String> algs) async { 47 - return FlutterKey.generate(algs); 48 - }; 49 - } 50 - 51 - @override 52 - RuntimeDigest get digest { 53 - return (Uint8List bytes, DigestAlgorithm algorithm) async { 54 - switch (algorithm.name) { 55 - case 'sha256': 56 - case 'SHA-256': 57 - return Uint8List.fromList(crypto.sha256.convert(bytes).bytes); 58 - 59 - case 'sha384': 60 - case 'SHA-384': 61 - return Uint8List.fromList(crypto.sha384.convert(bytes).bytes); 62 - 63 - case 'sha512': 64 - case 'SHA-512': 65 - return Uint8List.fromList(crypto.sha512.convert(bytes).bytes); 66 - 67 - default: 68 - throw UnsupportedError( 69 - 'Unsupported digest algorithm: ${algorithm.name}', 70 - ); 71 - } 72 - }; 73 - } 74 - 75 - @override 76 - RuntimeRandomValues get getRandomValues { 77 - return (int length) async { 78 - final random = Random.secure(); 79 - return Uint8List.fromList( 80 - List.generate(length, (_) => random.nextInt(256)), 81 - ); 82 - }; 83 - } 84 - 85 - @override 86 - RuntimeLock get requestLock { 87 - // Use the local lock implementation from utils/lock.dart 88 - // This prevents concurrent token refresh within a single isolate 89 - return requestLocalLock; 90 - } 91 - }
-341
packages/atproto_oauth_flutter/lib/src/platform/flutter_stores.dart
··· 1 - import 'dart:convert'; 2 - 3 - import 'package:flutter_secure_storage/flutter_secure_storage.dart'; 4 - 5 - import '../identity/did_document.dart'; 6 - import '../identity/did_resolver.dart'; 7 - import '../identity/handle_resolver.dart'; 8 - import '../oauth/authorization_server_metadata_resolver.dart'; 9 - import '../oauth/oauth_server_agent.dart'; 10 - import '../oauth/protected_resource_metadata_resolver.dart'; 11 - import '../session/oauth_session.dart'; 12 - import '../session/session_getter.dart'; 13 - import '../session/state_store.dart'; 14 - import '../util.dart'; 15 - 16 - // ============================================================================ 17 - // Session and State Storage (uses flutter_secure_storage) 18 - // ============================================================================ 19 - 20 - /// Flutter implementation of SessionStore using flutter_secure_storage. 21 - /// 22 - /// This stores OAuth sessions (tokens and keys) in the device's secure storage: 23 - /// - iOS: Keychain 24 - /// - Android: EncryptedSharedPreferences 25 - /// 26 - /// Sessions are persisted across app restarts and are encrypted at rest. 27 - /// 28 - /// Example: 29 - /// ```dart 30 - /// final store = FlutterSessionStore(); 31 - /// await store.set('did:plc:abc123', session); 32 - /// final restored = await store.get('did:plc:abc123'); 33 - /// ``` 34 - class FlutterSessionStore implements SessionStore { 35 - final FlutterSecureStorage _storage; 36 - static const _prefix = 'atproto_session_'; 37 - 38 - FlutterSessionStore([FlutterSecureStorage? storage]) 39 - : _storage = 40 - storage ?? 41 - const FlutterSecureStorage( 42 - aOptions: AndroidOptions(encryptedSharedPreferences: true), 43 - ); 44 - 45 - @override 46 - Future<Session?> get(String key, {CancellationToken? signal}) async { 47 - try { 48 - final json = await _storage.read(key: _prefix + key); 49 - if (json == null) return null; 50 - 51 - final data = jsonDecode(json) as Map<String, dynamic>; 52 - return Session.fromJson(data); 53 - } catch (e) { 54 - return null; 55 - } 56 - } 57 - 58 - @override 59 - Future<void> set(String key, Session value) async { 60 - final json = jsonEncode(value.toJson()); 61 - await _storage.write(key: _prefix + key, value: json); 62 - } 63 - 64 - @override 65 - Future<void> del(String key) async { 66 - await _storage.delete(key: _prefix + key); 67 - } 68 - 69 - @override 70 - Future<void> clear() async { 71 - // Delete all session keys 72 - final all = await _storage.readAll(); 73 - for (final key in all.keys) { 74 - if (key.startsWith(_prefix)) { 75 - await _storage.delete(key: key); 76 - } 77 - } 78 - } 79 - } 80 - 81 - /// Flutter implementation of StateStore for ephemeral OAuth state. 82 - /// 83 - /// This stores temporary state data during the OAuth authorization flow. 84 - /// State data includes PKCE verifiers, nonces, and application state. 85 - /// 86 - /// Uses in-memory storage since state is short-lived (only needed during the 87 - /// authorization flow, which typically completes within minutes). 88 - /// 89 - /// Example: 90 - /// ```dart 91 - /// final store = FlutterStateStore(); 92 - /// await store.set('state123', InternalStateData(...)); 93 - /// final state = await store.get('state123'); 94 - /// await store.del('state123'); // Clean up after use 95 - /// ``` 96 - class FlutterStateStore implements StateStore { 97 - final Map<String, InternalStateData> _store = {}; 98 - 99 - @override 100 - Future<InternalStateData?> get(String key) async { 101 - return _store[key]; 102 - } 103 - 104 - @override 105 - Future<void> set(String key, InternalStateData data) async { 106 - _store[key] = data; 107 - } 108 - 109 - @override 110 - Future<void> del(String key) async { 111 - _store.remove(key); 112 - } 113 - 114 - @override 115 - Future<void> clear() async { 116 - _store.clear(); 117 - } 118 - } 119 - 120 - // ============================================================================ 121 - // In-Memory Caches with TTL 122 - // ============================================================================ 123 - 124 - /// Base class for in-memory caches with time-to-live (TTL). 125 - /// 126 - /// This provides a generic caching mechanism with automatic expiration. 127 - /// Cached items are stored with a timestamp and are considered stale 128 - /// after the TTL period. 129 - class _InMemoryCache<V> { 130 - final Map<String, _CacheEntry<V>> _cache = {}; 131 - final Duration _ttl; 132 - 133 - _InMemoryCache(this._ttl); 134 - 135 - Future<V?> get(String key) async { 136 - final entry = _cache[key]; 137 - if (entry == null) return null; 138 - 139 - // Check if expired 140 - if (DateTime.now().isAfter(entry.expiresAt)) { 141 - _cache.remove(key); 142 - return null; 143 - } 144 - 145 - return entry.value; 146 - } 147 - 148 - Future<void> set(String key, V value) async { 149 - _cache[key] = _CacheEntry( 150 - value: value, 151 - expiresAt: DateTime.now().add(_ttl), 152 - ); 153 - } 154 - 155 - Future<void> del(String key) async { 156 - _cache.remove(key); 157 - } 158 - 159 - Future<void> clear() async { 160 - _cache.clear(); 161 - } 162 - 163 - /// Removes expired entries from the cache. 164 - void purge() { 165 - final now = DateTime.now(); 166 - _cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt)); 167 - } 168 - } 169 - 170 - /// Cache entry with expiration time. 171 - class _CacheEntry<V> { 172 - final V value; 173 - final DateTime expiresAt; 174 - 175 - _CacheEntry({required this.value, required this.expiresAt}); 176 - } 177 - 178 - /// In-memory cache for OAuth Authorization Server metadata. 179 - /// 180 - /// Caches metadata fetched from /.well-known/oauth-authorization-server 181 - /// to avoid redundant network requests. 182 - /// 183 - /// Default TTL: 1 minute (metadata rarely changes) 184 - /// 185 - /// Example: 186 - /// ```dart 187 - /// final cache = InMemoryAuthorizationServerMetadataCache(); 188 - /// await cache.set('https://auth.example.com', metadata); 189 - /// final cached = await cache.get('https://auth.example.com'); 190 - /// ``` 191 - class InMemoryAuthorizationServerMetadataCache 192 - implements AuthorizationServerMetadataCache { 193 - final _InMemoryCache<Map<String, dynamic>> _cache; 194 - 195 - InMemoryAuthorizationServerMetadataCache({ 196 - Duration ttl = const Duration(minutes: 1), 197 - }) : _cache = _InMemoryCache(ttl); 198 - 199 - @override 200 - Future<Map<String, dynamic>?> get(String key, {CancellationToken? signal}) => 201 - _cache.get(key); 202 - 203 - @override 204 - Future<void> set(String key, Map<String, dynamic> value) => 205 - _cache.set(key, value); 206 - 207 - @override 208 - Future<void> del(String key) => _cache.del(key); 209 - 210 - @override 211 - Future<void> clear() => _cache.clear(); 212 - } 213 - 214 - /// In-memory cache for OAuth Protected Resource metadata. 215 - /// 216 - /// Caches metadata fetched from /.well-known/oauth-protected-resource 217 - /// to avoid redundant network requests. 218 - /// 219 - /// Default TTL: 1 minute (metadata rarely changes) 220 - /// 221 - /// Example: 222 - /// ```dart 223 - /// final cache = InMemoryProtectedResourceMetadataCache(); 224 - /// await cache.set('https://pds.example.com', metadata); 225 - /// ``` 226 - class InMemoryProtectedResourceMetadataCache 227 - implements ProtectedResourceMetadataCache { 228 - final _InMemoryCache<Map<String, dynamic>> _cache; 229 - 230 - InMemoryProtectedResourceMetadataCache({ 231 - Duration ttl = const Duration(minutes: 1), 232 - }) : _cache = _InMemoryCache(ttl); 233 - 234 - @override 235 - Future<Map<String, dynamic>?> get(String key, {CancellationToken? signal}) => 236 - _cache.get(key); 237 - 238 - @override 239 - Future<void> set(String key, Map<String, dynamic> value) => 240 - _cache.set(key, value); 241 - 242 - @override 243 - Future<void> del(String key) => _cache.del(key); 244 - 245 - @override 246 - Future<void> clear() => _cache.clear(); 247 - } 248 - 249 - /// In-memory cache for DPoP nonces. 250 - /// 251 - /// DPoP nonces are server-provided values used for replay protection. 252 - /// They're cached per authorization/resource server origin. 253 - /// 254 - /// Default TTL: 10 minutes (nonces typically have short lifetimes) 255 - /// 256 - /// Example: 257 - /// ```dart 258 - /// final cache = InMemoryDpopNonceCache(); 259 - /// await cache.set('https://auth.example.com', 'nonce123'); 260 - /// final nonce = await cache.get('https://auth.example.com'); 261 - /// ``` 262 - class InMemoryDpopNonceCache implements DpopNonceCache { 263 - final _InMemoryCache<String> _cache; 264 - 265 - InMemoryDpopNonceCache({Duration ttl = const Duration(minutes: 10)}) 266 - : _cache = _InMemoryCache(ttl); 267 - 268 - @override 269 - Future<String?> get(String key, {CancellationToken? signal}) => 270 - _cache.get(key); 271 - 272 - @override 273 - Future<void> set(String key, String value) => _cache.set(key, value); 274 - 275 - @override 276 - Future<void> del(String key) => _cache.del(key); 277 - 278 - @override 279 - Future<void> clear() => _cache.clear(); 280 - } 281 - 282 - /// In-memory cache for DID documents. 283 - /// 284 - /// Caches resolved DID documents (from DidDocument class) to avoid redundant 285 - /// resolution requests. 286 - /// 287 - /// Default TTL: 1 minute (DID documents can change but not frequently) 288 - /// 289 - /// Note: DidDocument is a complex class, but it has toJson/fromJson methods. 290 - /// We store the JSON representation and reconstruct on retrieval. 291 - /// 292 - /// Example: 293 - /// ```dart 294 - /// final cache = FlutterDidCache(); 295 - /// await cache.set('did:plc:abc123', didDocument); 296 - /// final doc = await cache.get('did:plc:abc123'); 297 - /// ``` 298 - class FlutterDidCache implements DidCache { 299 - final _InMemoryCache<DidDocument> _cache; 300 - 301 - FlutterDidCache({Duration ttl = const Duration(minutes: 1)}) 302 - : _cache = _InMemoryCache(ttl); 303 - 304 - @override 305 - Future<DidDocument?> get(String key) => _cache.get(key); 306 - 307 - @override 308 - Future<void> set(String key, DidDocument value) => _cache.set(key, value); 309 - 310 - @override 311 - Future<void> clear() => _cache.clear(); 312 - } 313 - 314 - /// In-memory cache for handle → DID resolutions. 315 - /// 316 - /// Caches the resolution of atProto handles (e.g., "alice.bsky.social") to DIDs. 317 - /// The cache stores simple string mappings (handle → DID). 318 - /// 319 - /// Default TTL: 1 minute (handles can be reassigned but not frequently) 320 - /// 321 - /// Example: 322 - /// ```dart 323 - /// final cache = FlutterHandleCache(); 324 - /// await cache.set('alice.bsky.social', 'did:plc:abc123'); 325 - /// final did = await cache.get('alice.bsky.social'); 326 - /// ``` 327 - class FlutterHandleCache implements HandleCache { 328 - final _InMemoryCache<String> _cache; 329 - 330 - FlutterHandleCache({Duration ttl = const Duration(minutes: 1)}) 331 - : _cache = _InMemoryCache(ttl); 332 - 333 - @override 334 - Future<String?> get(String key) => _cache.get(key); 335 - 336 - @override 337 - Future<void> set(String key, String value) => _cache.set(key, value); 338 - 339 - @override 340 - Future<void> clear() => _cache.clear(); 341 - }
-280
packages/atproto_oauth_flutter/lib/src/runtime/runtime.dart
··· 1 - import 'dart:convert'; 2 - import 'dart:typed_data'; 3 - 4 - import '../utils/lock.dart'; 5 - import 'runtime_implementation.dart'; 6 - 7 - /// Main runtime class that wraps a RuntimeImplementation and provides 8 - /// high-level cryptographic operations for OAuth. 9 - /// 10 - /// This class handles: 11 - /// - Key generation with algorithm preference sorting 12 - /// - SHA-256 hashing with base64url encoding 13 - /// - Nonce generation 14 - /// - PKCE (Proof Key for Code Exchange) generation 15 - /// - JWK thumbprint calculation 16 - /// 17 - /// All operations use the underlying RuntimeImplementation for 18 - /// platform-specific cryptographic primitives. 19 - class Runtime { 20 - final RuntimeImplementation _implementation; 21 - 22 - /// Whether the implementation provides a custom lock mechanism. 23 - final bool hasImplementationLock; 24 - 25 - /// The lock function to use (either custom or local fallback). 26 - final RuntimeLock usingLock; 27 - 28 - Runtime(this._implementation) 29 - : hasImplementationLock = _implementation.requestLock != null, 30 - usingLock = _implementation.requestLock ?? requestLocalLock; 31 - 32 - /// Generates a cryptographic key that supports the given algorithms. 33 - /// 34 - /// The algorithms are sorted by preference before being passed to the 35 - /// key factory. This ensures consistent key selection across platforms. 36 - /// 37 - /// Algorithm preference order (most to least preferred): 38 - /// 1. ES256K (secp256k1) 39 - /// 2. ES256, ES384, ES512 (elliptic curve, shorter keys first) 40 - /// 3. PS256, PS384, PS512 (RSA-PSS, shorter keys first) 41 - /// 4. RS256, RS384, RS512 (RSA-PKCS1, shorter keys first) 42 - /// 5. Other algorithms (maintain original order) 43 - /// 44 - /// Example: 45 - /// ```dart 46 - /// final key = await runtime.generateKey(['ES256', 'RS256', 'ES384']); 47 - /// // Returns key supporting ES256 (preferred over RS256 and ES384) 48 - /// ``` 49 - Future<Key> generateKey(List<String> algs) async { 50 - final algsSorted = List<String>.from(algs)..sort(_compareAlgos); 51 - return _implementation.createKey(algsSorted); 52 - } 53 - 54 - /// Computes the SHA-256 hash of the input text and returns it as base64url. 55 - /// 56 - /// This is used extensively in OAuth for: 57 - /// - PKCE code challenge (S256 method) 58 - /// - JWK thumbprint calculation 59 - /// - DPoP access token hash (ath claim) 60 - /// 61 - /// Example: 62 - /// ```dart 63 - /// final hash = await runtime.sha256('hello world'); 64 - /// // Returns base64url-encoded SHA-256 hash 65 - /// ``` 66 - Future<String> sha256(String text) async { 67 - final bytes = utf8.encode(text); 68 - final digest = await _implementation.digest( 69 - Uint8List.fromList(bytes), 70 - const DigestAlgorithm.sha256(), 71 - ); 72 - return _base64UrlEncode(digest); 73 - } 74 - 75 - /// Generates a cryptographically secure random nonce. 76 - /// 77 - /// The nonce is base64url-encoded and has the specified byte length 78 - /// (default 16 bytes = 128 bits of entropy). 79 - /// 80 - /// Used for: 81 - /// - OAuth state parameter 82 - /// - OIDC nonce parameter 83 - /// - DPoP jti (JWT ID) claim 84 - /// 85 - /// Example: 86 - /// ```dart 87 - /// final nonce = await runtime.generateNonce(); // 16 bytes 88 - /// final longNonce = await runtime.generateNonce(32); // 32 bytes 89 - /// ``` 90 - Future<String> generateNonce([int length = 16]) async { 91 - final bytes = await _implementation.getRandomValues(length); 92 - return _base64UrlEncode(bytes); 93 - } 94 - 95 - /// Generates PKCE (Proof Key for Code Exchange) parameters. 96 - /// 97 - /// PKCE is a security extension for OAuth that prevents authorization code 98 - /// interception attacks. It's required for public clients (mobile/desktop apps). 99 - /// 100 - /// Returns a map with: 101 - /// - `verifier`: Random code verifier (base64url-encoded) 102 - /// - `challenge`: SHA-256 hash of verifier (base64url-encoded) 103 - /// - `method`: 'S256' (indicating SHA-256 hashing method) 104 - /// 105 - /// The verifier should be stored securely and sent during token exchange. 106 - /// The challenge is sent during authorization. 107 - /// 108 - /// See: https://datatracker.ietf.org/doc/html/rfc7636 109 - /// 110 - /// Example: 111 - /// ```dart 112 - /// final pkce = await runtime.generatePKCE(); 113 - /// // Use pkce['challenge'] in authorization request 114 - /// // Store pkce['verifier'] for token exchange 115 - /// ``` 116 - Future<Map<String, String>> generatePKCE([int? byteLength]) async { 117 - final verifier = await _generateVerifier(byteLength); 118 - final challenge = await sha256(verifier); 119 - return {'verifier': verifier, 'challenge': challenge, 'method': 'S256'}; 120 - } 121 - 122 - /// Calculates the JWK thumbprint (jkt) for a given JSON Web Key. 123 - /// 124 - /// The thumbprint is a hash of the key's essential components, used to 125 - /// uniquely identify a key. For DPoP, this binds tokens to specific keys. 126 - /// 127 - /// The calculation follows RFC 7638: 128 - /// 1. Extract required components based on key type (kty) 129 - /// 2. Create canonical JSON representation 130 - /// 3. Compute SHA-256 hash 131 - /// 4. Base64url-encode the result 132 - /// 133 - /// Required components by key type: 134 - /// - EC: crv, kty, x, y 135 - /// - OKP: crv, kty, x 136 - /// - RSA: e, kty, n 137 - /// - oct: k, kty 138 - /// 139 - /// See: https://datatracker.ietf.org/doc/html/rfc7638 140 - /// 141 - /// Example: 142 - /// ```dart 143 - /// final thumbprint = await runtime.calculateJwkThumbprint(jwk); 144 - /// // Returns base64url-encoded SHA-256 hash of key components 145 - /// ``` 146 - Future<String> calculateJwkThumbprint(Map<String, dynamic> jwk) async { 147 - final components = _extractJktComponents(jwk); 148 - final data = jsonEncode(components); 149 - return sha256(data); 150 - } 151 - 152 - /// Generates a PKCE code verifier. 153 - /// 154 - /// The verifier is a cryptographically random string that: 155 - /// - Has length between 43-128 characters (32-96 bytes before encoding) 156 - /// - Is base64url-encoded 157 - /// - SHOULD be 32 bytes (43 chars) per RFC 7636 recommendations 158 - /// 159 - /// See: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 160 - Future<String> _generateVerifier([int? byteLength]) async { 161 - final length = byteLength ?? 32; 162 - 163 - if (length < 32 || length > 96) { 164 - throw ArgumentError( 165 - 'Invalid code_verifier length: must be between 32 and 96 bytes', 166 - ); 167 - } 168 - 169 - final bytes = await _implementation.getRandomValues(length); 170 - return _base64UrlEncode(bytes); 171 - } 172 - 173 - /// Base64url encodes a byte array without padding. 174 - /// 175 - /// Base64url encoding is standard base64 with URL-safe characters: 176 - /// - '+' becomes '-' 177 - /// - '/' becomes '_' 178 - /// - Padding ('=') is removed 179 - /// 180 - /// This is the encoding used throughout OAuth and JWT specifications. 181 - String _base64UrlEncode(Uint8List bytes) { 182 - return base64Url.encode(bytes).replaceAll('=', ''); 183 - } 184 - } 185 - 186 - /// Extracts the required components from a JWK for thumbprint calculation. 187 - /// 188 - /// This follows RFC 7638 which specifies exactly which fields to include 189 - /// in the thumbprint hash for each key type. 190 - /// 191 - /// The components are returned in a Map that will be serialized to JSON 192 - /// in lexicographic order (Dart's jsonEncode naturally does this). 193 - /// 194 - /// Throws ArgumentError if: 195 - /// - Required fields are missing 196 - /// - Key type (kty) is unsupported 197 - Map<String, String> _extractJktComponents(Map<String, dynamic> jwk) { 198 - String getRequired(String field) { 199 - final value = jwk[field]; 200 - if (value is! String || value.isEmpty) { 201 - throw ArgumentError('"$field" parameter missing or invalid'); 202 - } 203 - return value; 204 - } 205 - 206 - final kty = getRequired('kty'); 207 - 208 - switch (kty) { 209 - case 'EC': 210 - // Elliptic Curve keys (ES256, ES384, ES512, ES256K) 211 - return { 212 - 'crv': getRequired('crv'), 213 - 'kty': kty, 214 - 'x': getRequired('x'), 215 - 'y': getRequired('y'), 216 - }; 217 - 218 - case 'OKP': 219 - // Octet Key Pair (EdDSA) 220 - return {'crv': getRequired('crv'), 'kty': kty, 'x': getRequired('x')}; 221 - 222 - case 'RSA': 223 - // RSA keys (RS256, RS384, RS512, PS256, PS384, PS512) 224 - return {'e': getRequired('e'), 'kty': kty, 'n': getRequired('n')}; 225 - 226 - case 'oct': 227 - // Symmetric keys (HS256, HS384, HS512) 228 - return {'k': getRequired('k'), 'kty': kty}; 229 - 230 - default: 231 - throw ArgumentError( 232 - '"kty" (Key Type) parameter missing or unsupported: $kty', 233 - ); 234 - } 235 - } 236 - 237 - /// Compares two algorithm strings for preference ordering. 238 - /// 239 - /// Algorithm preference order: 240 - /// 1. ES256K (secp256k1) - always most preferred 241 - /// 2. ES* (Elliptic Curve) - prefer shorter keys 242 - /// - ES256 > ES384 > ES512 243 - /// 3. PS* (RSA-PSS) - prefer shorter keys 244 - /// - PS256 > PS384 > PS512 245 - /// 4. RS* (RSA-PKCS1) - prefer shorter keys 246 - /// - RS256 > RS384 > RS512 247 - /// 5. Other algorithms - maintain original order 248 - /// 249 - /// Returns: 250 - /// - Negative if `a` is preferred over `b` 251 - /// - Positive if `b` is preferred over `a` 252 - /// - Zero if no preference (maintain order) 253 - int _compareAlgos(String a, String b) { 254 - // ES256K is always most preferred 255 - if (a == 'ES256K') return -1; 256 - if (b == 'ES256K') return 1; 257 - 258 - // Check algorithm families in preference order: ES > PS > RS 259 - for (final prefix in ['ES', 'PS', 'RS']) { 260 - if (a.startsWith(prefix)) { 261 - if (b.startsWith(prefix)) { 262 - // Both have same prefix, prefer shorter key length 263 - // Extract the number (e.g., "256" from "ES256") 264 - final aLen = int.tryParse(a.substring(2, 5)) ?? 0; 265 - final bLen = int.tryParse(b.substring(2, 5)) ?? 0; 266 - 267 - // Prefer shorter keys (256 < 384 < 512) 268 - return aLen - bLen; 269 - } 270 - // 'a' has the prefix, 'b' doesn't - prefer 'a' 271 - return -1; 272 - } else if (b.startsWith(prefix)) { 273 - // 'b' has the prefix, 'a' doesn't - prefer 'b' 274 - return 1; 275 - } 276 - } 277 - 278 - // No known preference, maintain original order 279 - return 0; 280 - }
-167
packages/atproto_oauth_flutter/lib/src/runtime/runtime_implementation.dart
··· 1 - import 'dart:async'; 2 - import 'dart:typed_data'; 3 - 4 - /// Represents a cryptographic key that can sign and verify JWTs. 5 - /// 6 - /// This is a placeholder for the Key class from @atproto/jwk. 7 - /// In the full implementation, this should be imported from the jwk package. 8 - /// 9 - /// The Key class contains: 10 - /// - JWK representation (public and private) 11 - /// - Supported algorithms 12 - /// - createJwt() method for signing 13 - /// - verifyJwt() method for verification 14 - /// 15 - /// ## Key Serialization (IMPLEMENTED) 16 - /// 17 - /// DPoP keys are fully serialized and persisted in session storage via: 18 - /// 19 - /// 1. FlutterKey.toJson() / FlutterKey.privateJwk: 20 - /// - Serializes the full JWK including private key components 21 - /// - Used when storing sessions to secure storage 22 - /// 23 - /// 2. FlutterKey.fromJwk(Map<String, dynamic> jwk): 24 - /// - Reconstructs a Key from serialized JWK 25 - /// - Validates JWK structure and throws on corruption 26 - /// - Used when restoring sessions from storage 27 - /// 28 - /// This ensures DPoP keys persist across app restarts, maintaining 29 - /// token binding consistency and avoiding unnecessary token refreshes. 30 - abstract class Key { 31 - /// Create a signed JWT with the given header and payload. 32 - Future<String> createJwt( 33 - Map<String, dynamic> header, 34 - Map<String, dynamic> payload, 35 - ); 36 - 37 - /// The list of algorithms this key supports. 38 - List<String> get algorithms; 39 - 40 - /// The bare JWK (public key components only, for DPoP proofs). 41 - /// Returns null for symmetric keys. 42 - Map<String, dynamic>? get bareJwk; 43 - 44 - /// The key ID (kid) from the JWK. 45 - /// Returns null if the key doesn't have a kid. 46 - String? get kid; 47 - 48 - /// The usage of this key ('sign' or 'enc'). 49 - String get usage; 50 - 51 - // TODO: Uncomment these when implementing serialization: 52 - // Map<String, dynamic> toJson(); 53 - // static Key fromJson(Map<String, dynamic> json); 54 - } 55 - 56 - /// Factory function that creates a cryptographic key for the given algorithms. 57 - /// 58 - /// The key should support at least one of the provided algorithms. 59 - /// Algorithms are typically in order of preference. 60 - /// 61 - /// Common algorithms: 62 - /// - ES256, ES384, ES512 (Elliptic Curve) 63 - /// - ES256K (secp256k1) 64 - /// - RS256, RS384, RS512 (RSA) 65 - /// - PS256, PS384, PS512 (RSA-PSS) 66 - typedef RuntimeKeyFactory = FutureOr<Key> Function(List<String> algs); 67 - 68 - /// Generates cryptographically secure random bytes. 69 - /// 70 - /// Returns a Uint8List of the specified length filled with random bytes. 71 - /// Must use a cryptographically secure random number generator. 72 - typedef RuntimeRandomValues = FutureOr<Uint8List> Function(int length); 73 - 74 - /// Digest algorithm specification. 75 - class DigestAlgorithm { 76 - /// The hash algorithm name: 'sha256', 'sha384', or 'sha512'. 77 - final String name; 78 - 79 - const DigestAlgorithm({required this.name}); 80 - 81 - const DigestAlgorithm.sha256() : name = 'sha256'; 82 - const DigestAlgorithm.sha384() : name = 'sha384'; 83 - const DigestAlgorithm.sha512() : name = 'sha512'; 84 - } 85 - 86 - /// Computes a cryptographic hash (digest) of the input data. 87 - /// 88 - /// The algorithm specifies which hash function to use (SHA-256, SHA-384, SHA-512). 89 - /// Returns the hash as a Uint8List. 90 - typedef RuntimeDigest = 91 - FutureOr<Uint8List> Function(Uint8List data, DigestAlgorithm alg); 92 - 93 - /// Acquires a lock for the given name and executes the function while holding the lock. 94 - /// 95 - /// This ensures that only one execution of the function can run at a time for a given lock name. 96 - /// This is critical for preventing race conditions during token refresh operations. 97 - /// 98 - /// Example: 99 - /// ```dart 100 - /// final result = await requestLock('token-refresh', () async { 101 - /// // Critical section - only one execution at a time 102 - /// return await refreshToken(); 103 - /// }); 104 - /// ``` 105 - typedef RuntimeLock = 106 - Future<T> Function<T>(String name, FutureOr<T> Function() fn); 107 - 108 - /// Platform-specific runtime implementation for cryptographic operations. 109 - /// 110 - /// This interface defines the core cryptographic primitives needed for OAuth: 111 - /// - Key generation (createKey) 112 - /// - Random number generation (getRandomValues) 113 - /// - Cryptographic hashing (digest) 114 - /// - Optional locking mechanism (requestLock) 115 - /// 116 - /// Implementations must use secure cryptographic libraries: 117 - /// - For Dart: pointycastle (ECDSA), crypto (SHA hashing) 118 - /// - Random values must come from dart:math.Random.secure() 119 - /// 120 - /// Security considerations: 121 - /// - Keys must be generated using cryptographically secure randomness 122 - /// - Private keys must never be logged or exposed 123 - /// - Hash functions must be collision-resistant (SHA-256 minimum) 124 - /// - Lock implementation should prevent race conditions in token refresh 125 - abstract class RuntimeImplementation { 126 - /// Creates a cryptographic key that supports at least one of the given algorithms. 127 - /// 128 - /// The algorithms list is typically sorted by preference, with the most preferred first. 129 - /// 130 - /// For OAuth DPoP, common algorithm preferences are: 131 - /// - ES256K (secp256k1) - preferred for atproto 132 - /// - ES256, ES384, ES512 (NIST curves) 133 - /// - PS256, PS384, PS512 (RSA-PSS) 134 - /// - RS256, RS384, RS512 (RSA-PKCS1) 135 - /// 136 - /// Throws if no suitable key can be generated for any of the algorithms. 137 - RuntimeKeyFactory get createKey; 138 - 139 - /// Generates cryptographically secure random bytes. 140 - /// 141 - /// MUST use a cryptographically secure random number generator. 142 - /// In Dart, use Random.secure() from dart:math. 143 - /// 144 - /// Never use a regular Random() - this is a security vulnerability. 145 - RuntimeRandomValues get getRandomValues; 146 - 147 - /// Computes a cryptographic hash of the input data. 148 - /// 149 - /// Supported algorithms: SHA-256, SHA-384, SHA-512 150 - /// 151 - /// Implementation should use the crypto package's sha256, sha384, sha512. 152 - RuntimeDigest get digest; 153 - 154 - /// Optional platform-specific lock implementation. 155 - /// 156 - /// If provided, this will be used to prevent concurrent token refresh operations. 157 - /// If not provided, a local (in-memory) lock implementation will be used as fallback. 158 - /// 159 - /// The lock should be: 160 - /// - Re-entrant safe (same isolate can acquire multiple times) 161 - /// - Fair (FIFO order) 162 - /// - Automatically released on error 163 - /// 164 - /// For Flutter apps, the default local lock is usually sufficient. 165 - /// For multi-process scenarios, you may need a platform-specific implementation. 166 - RuntimeLock? get requestLock; 167 - }
-395
packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart
··· 1 - import 'dart:async'; 2 - import 'package:dio/dio.dart'; 3 - import 'package:http/http.dart' as http; 4 - 5 - import '../dpop/fetch_dpop.dart'; 6 - import '../errors/token_invalid_error.dart'; 7 - import '../errors/token_revoked_error.dart'; 8 - import '../oauth/oauth_server_agent.dart'; 9 - 10 - /// Type alias for AtprotoDid (user's DID) 11 - typedef AtprotoDid = String; 12 - 13 - /// Type alias for AtprotoOAuthScope 14 - typedef AtprotoOAuthScope = String; 15 - 16 - /// Placeholder for OAuthAuthorizationServerMetadata 17 - /// Will be properly typed in later chunks 18 - typedef OAuthAuthorizationServerMetadata = Map<String, dynamic>; 19 - 20 - /// Information about the current token. 21 - class TokenInfo { 22 - /// When the token expires (null if no expiration) 23 - final DateTime? expiresAt; 24 - 25 - /// Whether the token is expired (null if no expiration) 26 - final bool? expired; 27 - 28 - /// The scope of access granted 29 - final AtprotoOAuthScope scope; 30 - 31 - /// The issuer URL 32 - final String iss; 33 - 34 - /// The audience (resource server) 35 - final String aud; 36 - 37 - /// The subject (user's DID) 38 - final AtprotoDid sub; 39 - 40 - TokenInfo({ 41 - this.expiresAt, 42 - this.expired, 43 - required this.scope, 44 - required this.iss, 45 - required this.aud, 46 - required this.sub, 47 - }); 48 - } 49 - 50 - /// Abstract interface for session management. 51 - /// 52 - /// This will be implemented by SessionGetter in session_getter.dart. 53 - /// We define it here to avoid circular dependencies. 54 - abstract class SessionGetterInterface { 55 - Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale}); 56 - 57 - Future<void> delStored(AtprotoDid sub, [Object? cause]); 58 - } 59 - 60 - /// Represents an active OAuth session. 61 - /// 62 - /// A session is created after successful authentication and provides methods 63 - /// for making authenticated requests and managing the session lifecycle. 64 - class Session { 65 - /// The DPoP key used for this session (serialized as Map for storage) 66 - final Map<String, dynamic> dpopKey; 67 - 68 - /// The client authentication method (serialized as Map or String for storage). 69 - /// Can be: 70 - /// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT 71 - /// - A Map containing {method: 'none'} for no authentication 72 - /// - A String 'legacy' for backwards compatibility 73 - /// - null (defaults to 'legacy' when loading) 74 - final dynamic authMethod; 75 - 76 - /// The token set containing access and refresh tokens 77 - final TokenSet tokenSet; 78 - 79 - const Session({ 80 - required this.dpopKey, 81 - this.authMethod, 82 - required this.tokenSet, 83 - }); 84 - 85 - /// Creates a Session from JSON. 86 - factory Session.fromJson(Map<String, dynamic> json) { 87 - return Session( 88 - dpopKey: json['dpopKey'] as Map<String, dynamic>, 89 - authMethod: json['authMethod'], // Can be Map or String 90 - tokenSet: TokenSet.fromJson(json['tokenSet'] as Map<String, dynamic>), 91 - ); 92 - } 93 - 94 - /// Converts this Session to JSON. 95 - Map<String, dynamic> toJson() { 96 - final json = <String, dynamic>{ 97 - 'dpopKey': dpopKey, 98 - 'tokenSet': tokenSet.toJson(), 99 - }; 100 - 101 - if (authMethod != null) json['authMethod'] = authMethod; 102 - 103 - return json; 104 - } 105 - } 106 - 107 - /// Represents an active OAuth session with methods for authenticated requests. 108 - /// 109 - /// This class wraps an OAuth session and provides: 110 - /// - Automatic token refresh on expiry 111 - /// - DPoP-protected requests 112 - /// - Session lifecycle management (sign out) 113 - /// 114 - /// Example: 115 - /// ```dart 116 - /// final session = OAuthSession( 117 - /// server: oauthServer, 118 - /// sub: 'did:plc:abc123', 119 - /// sessionGetter: sessionGetter, 120 - /// ); 121 - /// 122 - /// // Make an authenticated request 123 - /// final response = await session.fetchHandler('/api/posts'); 124 - /// 125 - /// // Get token information 126 - /// final info = await session.getTokenInfo(); 127 - /// print('Token expires at: ${info.expiresAt}'); 128 - /// 129 - /// // Sign out 130 - /// await session.signOut(); 131 - /// ``` 132 - class OAuthSession { 133 - /// The OAuth server agent 134 - final OAuthServerAgent server; 135 - 136 - /// The subject (user's DID) 137 - final AtprotoDid sub; 138 - 139 - /// The session getter for retrieving and refreshing tokens 140 - final SessionGetterInterface sessionGetter; 141 - 142 - /// Dio instance with DPoP interceptor for authenticated requests 143 - final Dio _dio; 144 - 145 - /// Creates a new OAuth session. 146 - /// 147 - /// Parameters: 148 - /// - [server]: The OAuth server agent 149 - /// - [sub]: The subject (user's DID) 150 - /// - [sessionGetter]: The session getter for token management 151 - OAuthSession({ 152 - required this.server, 153 - required this.sub, 154 - required this.sessionGetter, 155 - }) : _dio = Dio() { 156 - // Add DPoP interceptor for authenticated requests to resource servers 157 - _dio.interceptors.add( 158 - createDpopInterceptor( 159 - DpopFetchWrapperOptions( 160 - key: server.dpopKey, 161 - nonces: server.dpopNonces, 162 - sha256: server.runtime.sha256, 163 - isAuthServer: false, // Resource server requests (PDS) 164 - ), 165 - ), 166 - ); 167 - } 168 - 169 - /// Alias for [sub] 170 - AtprotoDid get did => sub; 171 - 172 - /// The server metadata 173 - OAuthAuthorizationServerMetadata get serverMetadata => server.serverMetadata; 174 - 175 - /// Gets the current token set. 176 - /// 177 - /// Parameters: 178 - /// - [refresh]: When `true`, forces a token refresh even if not expired. 179 - /// When `false`, uses cached tokens even if expired. 180 - /// When `'auto'`, refreshes only if expired (default). 181 - Future<TokenSet> _getTokenSet(dynamic refresh) async { 182 - final session = await sessionGetter.get( 183 - sub, 184 - noCache: refresh == true, 185 - allowStale: refresh == false, 186 - ); 187 - 188 - return session.tokenSet; 189 - } 190 - 191 - /// Gets information about the current token. 192 - /// 193 - /// Parameters: 194 - /// - [refresh]: When `true`, forces a token refresh even if not expired. 195 - /// When `false`, uses cached tokens even if expired. 196 - /// When `'auto'`, refreshes only if expired (default). 197 - Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']) async { 198 - final tokenSet = await _getTokenSet(refresh); 199 - final expiresAtStr = tokenSet.expiresAt; 200 - final expiresAt = 201 - expiresAtStr != null ? DateTime.parse(expiresAtStr) : null; 202 - 203 - return TokenInfo( 204 - expiresAt: expiresAt, 205 - expired: 206 - expiresAt != null 207 - ? expiresAt.isBefore( 208 - DateTime.now().subtract(Duration(seconds: 5)), 209 - ) 210 - : null, 211 - scope: tokenSet.scope, 212 - iss: tokenSet.iss, 213 - aud: tokenSet.aud, 214 - sub: tokenSet.sub, 215 - ); 216 - } 217 - 218 - /// Signs out the user. 219 - /// 220 - /// This revokes the access token and deletes the session from storage. 221 - /// Even if revocation fails, the session is removed locally. 222 - Future<void> signOut() async { 223 - try { 224 - final tokenSet = await _getTokenSet(false); 225 - await server.revoke(tokenSet.accessToken); 226 - } finally { 227 - await sessionGetter.delStored(sub, TokenRevokedError(sub)); 228 - } 229 - } 230 - 231 - /// Makes an authenticated HTTP request to the given pathname. 232 - /// 233 - /// This method: 234 - /// 1. Automatically refreshes tokens if they're expired 235 - /// 2. Adds DPoP and Authorization headers 236 - /// 3. Retries once with a fresh token if the initial request fails with 401 237 - /// 238 - /// Parameters: 239 - /// - [pathname]: The pathname to request (relative to the audience URL) 240 - /// - [method]: HTTP method (default: 'GET') 241 - /// - [headers]: Additional headers to include 242 - /// - [body]: Request body 243 - /// 244 - /// Returns the HTTP response. 245 - /// 246 - /// Example: 247 - /// ```dart 248 - /// final response = await session.fetchHandler( 249 - /// '/xrpc/com.atproto.repo.createRecord', 250 - /// method: 'POST', 251 - /// headers: {'Content-Type': 'application/json'}, 252 - /// body: jsonEncode({'repo': did, 'collection': 'app.bsky.feed.post', ...}), 253 - /// ); 254 - /// ``` 255 - Future<http.Response> fetchHandler( 256 - String pathname, { 257 - String method = 'GET', 258 - Map<String, String>? headers, 259 - dynamic body, 260 - }) async { 261 - // Try to refresh the token if it's known to be expired 262 - final tokenSet = await _getTokenSet('auto'); 263 - 264 - final initialUrl = Uri.parse(tokenSet.aud).resolve(pathname); 265 - final initialAuth = '${tokenSet.tokenType} ${tokenSet.accessToken}'; 266 - 267 - final initialHeaders = <String, String>{ 268 - ...?headers, 269 - 'Authorization': initialAuth, 270 - }; 271 - 272 - // Make request with DPoP - the interceptor will automatically add DPoP header 273 - final initialResponse = await _makeDpopRequest( 274 - initialUrl, 275 - method: method, 276 - headers: initialHeaders, 277 - body: body, 278 - ); 279 - 280 - // If the token is not expired, we don't need to refresh it 281 - if (!_isInvalidTokenResponse(initialResponse)) { 282 - return initialResponse; 283 - } 284 - 285 - // Token is invalid, try to refresh 286 - TokenSet tokenSetFresh; 287 - try { 288 - // Force a refresh 289 - tokenSetFresh = await _getTokenSet(true); 290 - } catch (err) { 291 - // If refresh fails, return the original response 292 - return initialResponse; 293 - } 294 - 295 - // Retry with fresh token 296 - final finalAuth = '${tokenSetFresh.tokenType} ${tokenSetFresh.accessToken}'; 297 - final finalUrl = Uri.parse(tokenSetFresh.aud).resolve(pathname); 298 - 299 - final finalHeaders = <String, String>{ 300 - ...?headers, 301 - 'Authorization': finalAuth, 302 - }; 303 - 304 - final finalResponse = await _makeDpopRequest( 305 - finalUrl, 306 - method: method, 307 - headers: finalHeaders, 308 - body: body, 309 - ); 310 - 311 - // The token was successfully refreshed, but is still not accepted by the 312 - // resource server. This might be due to the resource server not accepting 313 - // credentials from the authorization server (e.g. because some migration 314 - // occurred). Any ways, there is no point in keeping the session. 315 - if (_isInvalidTokenResponse(finalResponse)) { 316 - await sessionGetter.delStored(sub, TokenInvalidError(sub)); 317 - } 318 - 319 - return finalResponse; 320 - } 321 - 322 - /// Makes an HTTP request with DPoP authentication. 323 - /// 324 - /// Uses Dio with DPoP interceptor which automatically adds: 325 - /// - DPoP header with proof JWT 326 - /// - Access token hash (ath) binding 327 - /// 328 - /// Throws [DioException] for network errors, timeouts, and cancellations. 329 - Future<http.Response> _makeDpopRequest( 330 - Uri url, { 331 - required String method, 332 - Map<String, String>? headers, 333 - dynamic body, 334 - }) async { 335 - try { 336 - // Make request with Dio - interceptor will add DPoP header 337 - final response = await _dio.requestUri( 338 - url, 339 - options: Options( 340 - method: method, 341 - headers: headers, 342 - responseType: ResponseType.bytes, // Get raw bytes for compatibility 343 - validateStatus: (status) => true, // Don't throw on any status code 344 - ), 345 - data: body, 346 - ); 347 - 348 - // Convert Dio Response to http.Response for compatibility 349 - return http.Response.bytes( 350 - response.data as List<int>, 351 - response.statusCode!, 352 - headers: response.headers.map.map( 353 - (key, value) => MapEntry(key, value.join(', ')), 354 - ), 355 - reasonPhrase: response.statusMessage, 356 - ); 357 - } on DioException catch (e) { 358 - // If we have a response (4xx/5xx), convert it to http.Response 359 - if (e.response != null) { 360 - final errorResponse = e.response!; 361 - return http.Response.bytes( 362 - errorResponse.data is List<int> 363 - ? errorResponse.data as List<int> 364 - : (errorResponse.data?.toString() ?? '').codeUnits, 365 - errorResponse.statusCode!, 366 - headers: errorResponse.headers.map.map( 367 - (key, value) => MapEntry(key, value.join(', ')), 368 - ), 369 - reasonPhrase: errorResponse.statusMessage, 370 - ); 371 - } 372 - // Network errors, timeouts, cancellations - rethrow 373 - rethrow; 374 - } 375 - } 376 - 377 - /// Checks if a response indicates an invalid token. 378 - /// 379 - /// See: 380 - /// - https://datatracker.ietf.org/doc/html/rfc6750#section-3 381 - /// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 382 - bool _isInvalidTokenResponse(http.Response response) { 383 - if (response.statusCode != 401) return false; 384 - 385 - final wwwAuth = response.headers['www-authenticate']; 386 - return wwwAuth != null && 387 - (wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) && 388 - wwwAuth.contains('error="invalid_token"'); 389 - } 390 - 391 - /// Disposes of resources used by this session. 392 - void dispose() { 393 - _dio.close(); 394 - } 395 - }
-42
packages/atproto_oauth_flutter/lib/src/session/session.dart
··· 1 - /// Session management layer for atproto OAuth. 2 - /// 3 - /// This module provides session storage, retrieval, and lifecycle management 4 - /// for OAuth sessions. It includes: 5 - /// 6 - /// - [StateStore] - Stores ephemeral OAuth state during authorization 7 - /// - [SessionStore] - Stores persistent session data 8 - /// - [Session] - Represents an authenticated session with tokens 9 - /// - [TokenSet] - Contains OAuth tokens and metadata 10 - /// - [OAuthSession] - High-level API for authenticated requests 11 - /// - [SessionGetter] - Manages session caching and token refresh 12 - /// 13 - /// Example: 14 - /// ```dart 15 - /// // Create a session store implementation 16 - /// final sessionStore = MySessionStore(); 17 - /// 18 - /// // Create a session getter 19 - /// final sessionGetter = SessionGetter( 20 - /// sessionStore: sessionStore, 21 - /// serverFactory: serverFactory, 22 - /// runtime: runtime, 23 - /// ); 24 - /// 25 - /// // Get a session (automatically refreshes if needed) 26 - /// final session = await sessionGetter.getSession('did:plc:abc123'); 27 - /// 28 - /// // Create an OAuthSession for making requests 29 - /// final oauthSession = OAuthSession( 30 - /// server: server, 31 - /// sub: 'did:plc:abc123', 32 - /// sessionGetter: sessionGetter, 33 - /// ); 34 - /// 35 - /// // Make authenticated requests 36 - /// final response = await oauthSession.fetchHandler('/api/posts'); 37 - /// ``` 38 - library; 39 - 40 - export 'state_store.dart'; 41 - export 'oauth_session.dart'; 42 - export 'session_getter.dart';
-644
packages/atproto_oauth_flutter/lib/src/session/session_getter.dart
··· 1 - import 'dart:async'; 2 - import 'dart:convert'; 3 - import 'dart:math' as math; 4 - 5 - import '../errors/auth_method_unsatisfiable_error.dart'; 6 - import '../errors/token_invalid_error.dart'; 7 - import '../errors/token_refresh_error.dart'; 8 - import '../errors/token_revoked_error.dart'; 9 - import '../oauth/client_auth.dart' show ClientAuthMethod; 10 - import '../oauth/oauth_server_agent.dart'; 11 - import '../oauth/oauth_server_factory.dart'; 12 - import '../platform/flutter_key.dart'; 13 - import '../runtime/runtime.dart'; 14 - import '../util.dart'; 15 - import 'oauth_session.dart'; 16 - 17 - /// Options for getting a cached value. 18 - class GetCachedOptions { 19 - /// Cancellation token for aborting the operation 20 - final CancellationToken? signal; 21 - 22 - /// Do not use the cache to get the value. Always get a new value. 23 - final bool? noCache; 24 - 25 - /// Allow returning stale values from the cache. 26 - final bool? allowStale; 27 - 28 - const GetCachedOptions({this.signal, this.noCache, this.allowStale}); 29 - } 30 - 31 - /// Abstract storage interface for values. 32 - /// 33 - /// This is a generic key-value store interface. 34 - abstract class SimpleStore<K, V> { 35 - /// Gets a value from the store. 36 - /// 37 - /// Returns `null` if the key doesn't exist. 38 - Future<V?> get(K key, {CancellationToken? signal}); 39 - 40 - /// Sets a value in the store. 41 - Future<void> set(K key, V value); 42 - 43 - /// Deletes a value from the store. 44 - Future<void> del(K key); 45 - 46 - /// Optionally clears all values from the store. 47 - Future<void> clear() async {} 48 - } 49 - 50 - /// Type alias for session storage 51 - typedef SessionStore = SimpleStore<String, Session>; 52 - 53 - /// Details of a session update event. 54 - class SessionUpdatedEvent { 55 - /// The subject (user's DID) 56 - final String sub; 57 - 58 - /// The DPoP key 59 - final Map<String, dynamic> dpopKey; 60 - 61 - /// The authentication method 62 - final String? authMethod; 63 - 64 - /// The token set 65 - final TokenSet tokenSet; 66 - 67 - const SessionUpdatedEvent({ 68 - required this.sub, 69 - required this.dpopKey, 70 - this.authMethod, 71 - required this.tokenSet, 72 - }); 73 - } 74 - 75 - /// Details of a session deletion event. 76 - class SessionDeletedEvent { 77 - /// The subject (user's DID) 78 - final String sub; 79 - 80 - /// The cause of deletion 81 - final Object cause; 82 - 83 - const SessionDeletedEvent({required this.sub, required this.cause}); 84 - } 85 - 86 - /// Manages session retrieval, caching, and refreshing. 87 - /// 88 - /// The SessionGetter wraps a session store and provides: 89 - /// - Automatic token refresh when tokens are stale/expired 90 - /// - Caching to avoid redundant refresh operations 91 - /// - Events for session updates and deletions 92 - /// - Concurrency control to prevent multiple simultaneous refreshes 93 - /// 94 - /// This is a critical component that ensures at most one token refresh 95 - /// is happening at a time for a given user, even across multiple tabs 96 - /// or app instances. 97 - /// 98 - /// Example: 99 - /// ```dart 100 - /// final sessionGetter = SessionGetter( 101 - /// sessionStore: mySessionStore, 102 - /// serverFactory: myServerFactory, 103 - /// runtime: myRuntime, 104 - /// ); 105 - /// 106 - /// // Listen for session updates 107 - /// sessionGetter.onUpdated.listen((event) { 108 - /// print('Session updated for ${event.sub}'); 109 - /// }); 110 - /// 111 - /// // Listen for session deletions 112 - /// sessionGetter.onDeleted.listen((event) { 113 - /// print('Session deleted for ${event.sub}: ${event.cause}'); 114 - /// }); 115 - /// 116 - /// // Get a session (automatically refreshes if expired) 117 - /// final session = await sessionGetter.getSession('did:plc:abc123'); 118 - /// 119 - /// // Force refresh 120 - /// final freshSession = await sessionGetter.getSession('did:plc:abc123', true); 121 - /// ``` 122 - class SessionGetter extends CachedGetter<AtprotoDid, Session> { 123 - final OAuthServerFactory _serverFactory; 124 - final Runtime _runtime; 125 - 126 - final _eventTarget = CustomEventTarget<Map<String, dynamic>>(); 127 - final _updatedController = StreamController<SessionUpdatedEvent>.broadcast(); 128 - final _deletedController = StreamController<SessionDeletedEvent>.broadcast(); 129 - 130 - /// Stream of session update events. 131 - Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream; 132 - 133 - /// Stream of session deletion events. 134 - Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream; 135 - 136 - SessionGetter({ 137 - required super.sessionStore, 138 - required OAuthServerFactory serverFactory, 139 - required Runtime runtime, 140 - }) : _serverFactory = serverFactory, 141 - _runtime = runtime, 142 - super( 143 - getter: null, // Will be set in _createGetter 144 - options: CachedGetterOptions( 145 - isStale: (sub, session) { 146 - final tokenSet = session.tokenSet; 147 - if (tokenSet.expiresAt == null) return false; 148 - 149 - final expiresAt = DateTime.parse(tokenSet.expiresAt!); 150 - final now = DateTime.now(); 151 - 152 - // Add some lee way to ensure the token is not expired when it 153 - // reaches the server (10 seconds) 154 - // Add some randomness to reduce the chances of multiple 155 - // instances trying to refresh the token at the same time (0-30 seconds) 156 - final buffer = Duration( 157 - milliseconds: 158 - 10000 + (math.Random().nextDouble() * 30000).toInt(), 159 - ); 160 - 161 - return expiresAt.isBefore(now.add(buffer)); 162 - }, 163 - onStoreError: (err, sub, session) async { 164 - if (err is! AuthMethodUnsatisfiableError) { 165 - // If the error was an AuthMethodUnsatisfiableError, there is no 166 - // point in trying to call `fromIssuer`. 167 - try { 168 - // Parse authMethod 169 - final authMethodValue = session.authMethod; 170 - final authMethod = 171 - authMethodValue is Map<String, dynamic> 172 - ? ClientAuthMethod.fromJson(authMethodValue) 173 - : (authMethodValue as String?) ?? 'legacy'; 174 - 175 - // Restore DPoP key from session for revocation 176 - // CRITICAL FIX: Use the stored key instead of generating a new one 177 - // This ensures DPoP proofs match the token binding 178 - final dpopKey = FlutterKey.fromJwk( 179 - session.dpopKey as Map<String, dynamic>, 180 - ); 181 - 182 - // If the token data cannot be stored, let's revoke it 183 - final server = await serverFactory.fromIssuer( 184 - session.tokenSet.iss, 185 - authMethod, 186 - dpopKey, 187 - ); 188 - await server.revoke( 189 - session.tokenSet.refreshToken ?? 190 - session.tokenSet.accessToken, 191 - ); 192 - } catch (_) { 193 - // Let the original error propagate 194 - } 195 - } 196 - 197 - throw err; 198 - }, 199 - deleteOnError: (err) async { 200 - return err is TokenRefreshError || 201 - err is TokenRevokedError || 202 - err is TokenInvalidError || 203 - err is AuthMethodUnsatisfiableError; 204 - }, 205 - ), 206 - ) { 207 - // Set the getter function after construction 208 - _getter = _createGetter(); 209 - } 210 - 211 - /// Creates the getter function for refreshing sessions. 212 - Future<Session> Function(AtprotoDid, GetCachedOptions, Session?) 213 - _createGetter() { 214 - return (sub, options, storedSession) async { 215 - // There needs to be a previous session to be able to refresh. If 216 - // storedSession is null, it means that the store does not contain 217 - // a session for the given sub. 218 - if (storedSession == null) { 219 - // Because the session is not in the store, delStored() method 220 - // will not be called by the CachedGetter class (because there is 221 - // nothing to delete). This would typically happen if there is no 222 - // synchronization mechanism between instances of this class. Let's 223 - // make sure an event is dispatched here if this occurs. 224 - const msg = 'The session was deleted by another process'; 225 - final cause = TokenRefreshError(sub, msg); 226 - _dispatchDeletedEvent(sub, cause); 227 - throw cause; 228 - } 229 - 230 - // From this point forward, throwing a TokenRefreshError will result in 231 - // delStored() being called, resulting in an event being dispatched, 232 - // even if the session was removed from the store through a concurrent 233 - // access (which, normally, should not happen if a proper runtime lock 234 - // was provided). 235 - 236 - // authMethod can be a Map (serialized ClientAuthMethod) or String ('legacy') 237 - final authMethodValue = storedSession.authMethod; 238 - final authMethod = 239 - authMethodValue is Map<String, dynamic> 240 - ? ClientAuthMethod.fromJson(authMethodValue) 241 - : (authMethodValue as String?) ?? 'legacy'; 242 - final tokenSet = storedSession.tokenSet; 243 - 244 - if (sub != tokenSet.sub) { 245 - // Fool-proofing (e.g. against invalid session storage) 246 - throw TokenRefreshError(sub, 'Stored session sub mismatch'); 247 - } 248 - 249 - if (tokenSet.refreshToken == null) { 250 - throw TokenRefreshError(sub, 'No refresh token available'); 251 - } 252 - 253 - // Since refresh tokens can only be used once, we might run into 254 - // concurrency issues if multiple instances (e.g. browser tabs) are 255 - // trying to refresh the same token simultaneously. The chances of this 256 - // happening when multiple instances are started simultaneously is 257 - // reduced by randomizing the expiry time (see isStale above). The 258 - // best solution is to use a mutex/lock to ensure that only one instance 259 - // is refreshing the token at a time (runtime.usingLock) but that is not 260 - // always possible. If no lock implementation is provided, we will use 261 - // the store to check if a concurrent refresh occurred. 262 - 263 - // Restore dpopKey from stored private JWK with error handling 264 - // CRITICAL FIX: Use the stored key instead of generating a new one 265 - // This ensures DPoP proofs match the token binding during refresh 266 - final FlutterKey dpopKey; 267 - try { 268 - dpopKey = FlutterKey.fromJwk( 269 - storedSession.dpopKey as Map<String, dynamic>, 270 - ); 271 - } catch (e) { 272 - // If key is corrupted, the session is unusable - force re-authentication 273 - throw TokenRefreshError( 274 - sub, 275 - 'Corrupted DPoP key in stored session: $e. Re-authentication required.', 276 - ); 277 - } 278 - 279 - final server = await _serverFactory.fromIssuer( 280 - tokenSet.iss, 281 - authMethod, 282 - dpopKey, 283 - ); 284 - 285 - // Because refresh tokens can only be used once, we must not use the 286 - // "signal" to abort the refresh, or throw any abort error beyond this 287 - // point. Any thrown error beyond this point will prevent the 288 - // SessionGetter from obtaining, and storing, the new token set, 289 - // effectively rendering the currently saved session unusable. 290 - options.signal?.throwIfCancelled(); 291 - 292 - try { 293 - final newTokenSet = await server.refresh(tokenSet); 294 - 295 - if (sub != newTokenSet.sub) { 296 - // The server returned another sub. Was the tokenSet manipulated? 297 - throw TokenRefreshError(sub, 'Token set sub mismatch'); 298 - } 299 - 300 - // CRITICAL FIX: Preserve the stored DPoP key (full private JWK) 301 - // This ensures the same key is used across token refreshes 302 - return Session( 303 - dpopKey: storedSession.dpopKey, 304 - tokenSet: newTokenSet, 305 - authMethod: server.authMethod.toJson(), 306 - ); 307 - } catch (cause) { 308 - // If the refresh token is invalid, let's try to recover from 309 - // concurrency issues, or make sure the session is deleted by throwing 310 - // a TokenRefreshError. 311 - if (cause is OAuthResponseError && 312 - cause.status == 400 && 313 - cause.error == 'invalid_grant') { 314 - // In case there is no lock implementation in the runtime, we will 315 - // wait for a short time to give the other concurrent instances a 316 - // chance to finish their refreshing of the token. If a concurrent 317 - // refresh did occur, we will pretend that this one succeeded. 318 - if (!_runtime.hasImplementationLock) { 319 - await Future.delayed(Duration(seconds: 1)); 320 - 321 - final stored = await getStored(sub); 322 - if (stored == null) { 323 - // A concurrent refresh occurred and caused the session to be 324 - // deleted (for a reason we can't know at this point). 325 - 326 - // Using a distinct error message mainly for debugging 327 - // purposes. Also, throwing a TokenRefreshError to trigger 328 - // deletion through the deleteOnError callback. 329 - const msg = 'The session was deleted by another process'; 330 - throw TokenRefreshError(sub, msg, cause: cause); 331 - } else if (stored.tokenSet.accessToken != tokenSet.accessToken || 332 - stored.tokenSet.refreshToken != tokenSet.refreshToken) { 333 - // A concurrent refresh occurred. Pretend this one succeeded. 334 - return stored; 335 - } else { 336 - // There were no concurrent refresh. The token is (likely) 337 - // simply no longer valid. 338 - } 339 - } 340 - 341 - // Make sure the session gets deleted from the store 342 - final msg = cause.errorDescription ?? 'The session was revoked'; 343 - throw TokenRefreshError(sub, msg, cause: cause); 344 - } 345 - 346 - // Re-throw the original exception if it wasn't an invalid_grant error 347 - if (cause is Exception) { 348 - throw cause; 349 - } else { 350 - throw Exception('Token refresh failed: $cause'); 351 - } 352 - } 353 - }; 354 - } 355 - 356 - @override 357 - Future<void> setStored(String key, Session value) async { 358 - // Prevent tampering with the stored value 359 - if (key != value.tokenSet.sub) { 360 - throw TypeError(); 361 - } 362 - 363 - await super.setStored(key, value); 364 - 365 - // Serialize authMethod to String for the event 366 - // authMethod can be Map<String, dynamic>, String, or null 367 - String? authMethodString; 368 - if (value.authMethod is Map) { 369 - authMethodString = jsonEncode(value.authMethod); 370 - } else if (value.authMethod is String) { 371 - authMethodString = value.authMethod as String; 372 - } else { 373 - authMethodString = null; 374 - } 375 - 376 - _dispatchUpdatedEvent(key, value.dpopKey, authMethodString, value.tokenSet); 377 - } 378 - 379 - @override 380 - Future<void> delStored(AtprotoDid key, [Object? cause]) async { 381 - await super.delStored(key, cause); 382 - _dispatchDeletedEvent(key, cause ?? Exception('Session deleted')); 383 - } 384 - 385 - /// Gets a session, optionally refreshing it. 386 - /// 387 - /// Parameters: 388 - /// - [sub]: The subject (user's DID) 389 - /// - [refresh]: When `true`, forces a token refresh even if not expired. 390 - /// When `false`, uses cached tokens even if expired. 391 - /// When `'auto'`, refreshes only if expired (default). 392 - Future<Session> getSession(AtprotoDid sub, [dynamic refresh = 'auto']) { 393 - return get( 394 - sub, 395 - GetCachedOptions(noCache: refresh == true, allowStale: refresh == false), 396 - ); 397 - } 398 - 399 - @override 400 - Future<Session> get(AtprotoDid key, [GetCachedOptions? options]) async { 401 - final session = await _runtime.usingLock( 402 - '@atproto-oauth-client-$key', 403 - () async { 404 - // Make sure, even if there is no signal in the options, that the 405 - // request will be cancelled after at most 30 seconds. 406 - final timeoutToken = CancellationToken(); 407 - final timeoutTimer = Timer(Duration(seconds: 30), () => timeoutToken.cancel()); 408 - 409 - final combinedSignal = 410 - options?.signal != null 411 - ? combineSignals([options!.signal, timeoutToken]) 412 - : CombinedCancellationToken([timeoutToken]); 413 - 414 - try { 415 - return await super.get( 416 - key, 417 - GetCachedOptions( 418 - signal: CancellationToken(), // Use combined signal 419 - noCache: options?.noCache, 420 - allowStale: options?.allowStale, 421 - ), 422 - ); 423 - } finally { 424 - timeoutTimer.cancel(); // Cancel timer before disposing token 425 - combinedSignal.dispose(); 426 - timeoutToken.dispose(); 427 - } 428 - }, 429 - ); 430 - 431 - if (key != session.tokenSet.sub) { 432 - // Fool-proofing (e.g. against invalid session storage) 433 - throw Exception('Token set does not match the expected sub'); 434 - } 435 - 436 - return session; 437 - } 438 - 439 - void _dispatchUpdatedEvent( 440 - String sub, 441 - Map<String, dynamic> dpopKey, 442 - String? authMethod, 443 - TokenSet tokenSet, 444 - ) { 445 - final event = SessionUpdatedEvent( 446 - sub: sub, 447 - dpopKey: dpopKey, 448 - authMethod: authMethod, 449 - tokenSet: tokenSet, 450 - ); 451 - 452 - _updatedController.add(event); 453 - _eventTarget.dispatchCustomEvent('updated', event); 454 - } 455 - 456 - void _dispatchDeletedEvent(String sub, Object cause) { 457 - final event = SessionDeletedEvent(sub: sub, cause: cause); 458 - 459 - _deletedController.add(event); 460 - _eventTarget.dispatchCustomEvent('deleted', event); 461 - } 462 - 463 - /// Disposes of resources used by this session getter. 464 - void dispose() { 465 - _updatedController.close(); 466 - _deletedController.close(); 467 - _eventTarget.dispose(); 468 - } 469 - } 470 - 471 - /// Placeholder for OAuthResponseError 472 - /// Will be implemented in later chunks 473 - class OAuthResponseError implements Exception { 474 - final int status; 475 - final String? error; 476 - final String? errorDescription; 477 - 478 - OAuthResponseError({required this.status, this.error, this.errorDescription}); 479 - } 480 - 481 - /// Options for the CachedGetter. 482 - class CachedGetterOptions<K, V> { 483 - /// Function to determine if a cached value is stale 484 - final bool Function(K key, V value)? isStale; 485 - 486 - /// Function called when storing a value fails 487 - final Future<void> Function(Object err, K key, V value)? onStoreError; 488 - 489 - /// Function to determine if a value should be deleted on error 490 - final Future<bool> Function(Object err)? deleteOnError; 491 - 492 - const CachedGetterOptions({ 493 - this.isStale, 494 - this.onStoreError, 495 - this.deleteOnError, 496 - }); 497 - } 498 - 499 - /// A pending item in the cache. 500 - class _PendingItem<V> { 501 - final Future<({V value, bool isFresh})> future; 502 - 503 - _PendingItem(this.future); 504 - } 505 - 506 - /// Wrapper utility that uses a store to speed up the retrieval of values. 507 - /// 508 - /// The CachedGetter ensures that at most one fresh call is ever being made 509 - /// for a given key. It also contains logic for reading from the cache which, 510 - /// if the cache is based on localStorage/indexedDB, will sync across multiple 511 - /// tabs (for a given key). 512 - /// 513 - /// This is an abstract base class. Subclasses should provide the getter 514 - /// function and any additional logic. 515 - class CachedGetter<K, V> { 516 - final SimpleStore<K, V> _store; 517 - final CachedGetterOptions<K, V> _options; 518 - final Map<K, _PendingItem<V>> _pending = {}; 519 - 520 - late Future<V> Function(K, GetCachedOptions, V?) _getter; 521 - 522 - CachedGetter({ 523 - required SimpleStore<K, V> sessionStore, 524 - required Future<V> Function(K, GetCachedOptions, V?)? getter, 525 - required CachedGetterOptions<K, V> options, 526 - }) : _store = sessionStore, 527 - _options = options { 528 - if (getter != null) { 529 - _getter = getter; 530 - } 531 - } 532 - 533 - Future<V> get(K key, [GetCachedOptions? options]) async { 534 - options ??= GetCachedOptions(); 535 - final signal = options.signal; 536 - final noCache = options.noCache ?? false; 537 - final allowStale = options.allowStale ?? false; 538 - 539 - signal?.throwIfCancelled(); 540 - 541 - final isStale = _options.isStale; 542 - final deleteOnError = _options.deleteOnError; 543 - 544 - // Determine if a stored value can be used 545 - bool allowStored(V value) { 546 - if (noCache) return false; // Never allow stored values 547 - if (allowStale || isStale == null) return true; // Always allow 548 - return !isStale(key, value); // Check if stale 549 - } 550 - 551 - // As long as concurrent requests are made for the same key, only one 552 - // request will be made to the getStored & getter functions at a time. 553 - _PendingItem<V>? previousExecutionFlow; 554 - while ((previousExecutionFlow = _pending[key]) != null) { 555 - try { 556 - final result = await previousExecutionFlow!.future; 557 - final isFresh = result.isFresh; 558 - final value = result.value; 559 - 560 - // Use the concurrent request's result if it is fresh 561 - if (isFresh) return value; 562 - // Use the concurrent request's result if not fresh (loaded from the 563 - // store), and matches the conditions for using a stored value. 564 - if (allowStored(value)) return value; 565 - } catch (_) { 566 - // Ignore errors from previous execution flows (they will have been 567 - // propagated by that flow). 568 - } 569 - 570 - // Break the loop if the signal was cancelled 571 - signal?.throwIfCancelled(); 572 - } 573 - 574 - final currentExecutionFlow = _PendingItem<V>( 575 - Future(() async { 576 - final storedValue = await getStored(key, signal: signal); 577 - 578 - if (storedValue != null && allowStored(storedValue)) { 579 - // Use the stored value as return value for the current execution 580 - // flow. Notify other concurrent execution flows that we got a value, 581 - // but that it came from the store (isFresh = false). 582 - return (value: storedValue, isFresh: false); 583 - } 584 - 585 - return Future(() async { 586 - return await _getter(key, options!, storedValue); 587 - }) 588 - .catchError((err) async { 589 - if (storedValue != null) { 590 - try { 591 - if (deleteOnError != null && await deleteOnError(err)) { 592 - await delStored(key, err); 593 - } 594 - } catch (error) { 595 - throw Exception('Error while deleting stored value: $error'); 596 - } 597 - } 598 - throw err; 599 - }) 600 - .then((value) async { 601 - // The value should be stored even if the signal was cancelled. 602 - await setStored(key, value); 603 - return (value: value, isFresh: true); 604 - }); 605 - }).whenComplete(() { 606 - _pending.remove(key); 607 - }), 608 - ); 609 - 610 - if (_pending.containsKey(key)) { 611 - // This should never happen. There must not be any 'await' 612 - // statement between this and the loop iteration check. 613 - throw Exception('Concurrent request for the same key'); 614 - } 615 - 616 - _pending[key] = currentExecutionFlow; 617 - 618 - final result = await currentExecutionFlow.future; 619 - return result.value; 620 - } 621 - 622 - Future<V?> getStored(K key, {CancellationToken? signal}) async { 623 - try { 624 - return await _store.get(key, signal: signal); 625 - } catch (err) { 626 - return null; 627 - } 628 - } 629 - 630 - Future<void> setStored(K key, V value) async { 631 - try { 632 - await _store.set(key, value); 633 - } catch (err) { 634 - final onStoreError = _options.onStoreError; 635 - if (onStoreError != null) { 636 - await onStoreError(err, key, value); 637 - } 638 - } 639 - } 640 - 641 - Future<void> delStored(K key, [Object? cause]) async { 642 - await _store.del(key); 643 - } 644 - }
-112
packages/atproto_oauth_flutter/lib/src/session/state_store.dart
··· 1 - /// Internal state data stored during OAuth authorization flow. 2 - /// 3 - /// This contains ephemeral data needed to complete the OAuth flow, 4 - /// such as PKCE code verifiers, state parameters, and nonces. 5 - class InternalStateData { 6 - /// The OAuth issuer URL 7 - final String iss; 8 - 9 - /// The DPoP key used for this authorization 10 - final Map<String, dynamic> dpopKey; 11 - 12 - /// Client authentication method (serialized as Map or String) 13 - /// 14 - /// Can be: 15 - /// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT 16 - /// - A Map containing {method: 'none'} for no authentication 17 - /// - A String 'legacy' for backwards compatibility 18 - /// - null (defaults to 'legacy' when loading) 19 - final dynamic authMethod; 20 - 21 - /// PKCE code verifier for authorization code flow 22 - final String? verifier; 23 - 24 - /// The redirect URI used during authorization 25 - /// MUST match exactly during token exchange 26 - final String? redirectUri; 27 - 28 - /// Application state to preserve across the OAuth flow 29 - final String? appState; 30 - 31 - const InternalStateData({ 32 - required this.iss, 33 - required this.dpopKey, 34 - this.authMethod, 35 - this.verifier, 36 - this.redirectUri, 37 - this.appState, 38 - }); 39 - 40 - /// Creates an instance from a JSON map. 41 - factory InternalStateData.fromJson(Map<String, dynamic> json) { 42 - return InternalStateData( 43 - iss: json['iss'] as String, 44 - dpopKey: json['dpopKey'] as Map<String, dynamic>, 45 - authMethod: json['authMethod'], // Can be Map or String 46 - verifier: json['verifier'] as String?, 47 - redirectUri: json['redirectUri'] as String?, 48 - appState: json['appState'] as String?, 49 - ); 50 - } 51 - 52 - /// Converts this instance to a JSON map. 53 - Map<String, dynamic> toJson() { 54 - final json = <String, dynamic>{'iss': iss, 'dpopKey': dpopKey}; 55 - 56 - if (authMethod != null) json['authMethod'] = authMethod; 57 - if (verifier != null) json['verifier'] = verifier; 58 - if (redirectUri != null) json['redirectUri'] = redirectUri; 59 - if (appState != null) json['appState'] = appState; 60 - 61 - return json; 62 - } 63 - } 64 - 65 - /// Abstract storage interface for OAuth state data. 66 - /// 67 - /// Implementations should store state data temporarily during the OAuth flow. 68 - /// This data is typically short-lived and can be cleared after successful 69 - /// authorization or timeout. 70 - /// 71 - /// Example implementation using in-memory storage: 72 - /// ```dart 73 - /// class MemoryStateStore implements StateStore { 74 - /// final Map<String, InternalStateData> _store = {}; 75 - /// 76 - /// @override 77 - /// Future<InternalStateData?> get(String key) async => _store[key]; 78 - /// 79 - /// @override 80 - /// Future<void> set(String key, InternalStateData data) async { 81 - /// _store[key] = data; 82 - /// } 83 - /// 84 - /// @override 85 - /// Future<void> del(String key) async { 86 - /// _store.remove(key); 87 - /// } 88 - /// } 89 - /// ``` 90 - abstract class StateStore { 91 - /// Retrieves state data for the given key. 92 - /// 93 - /// Returns `null` if no data exists for the key. 94 - Future<InternalStateData?> get(String key); 95 - 96 - /// Stores state data for the given key. 97 - /// 98 - /// Overwrites any existing data for the key. 99 - Future<void> set(String key, InternalStateData data); 100 - 101 - /// Deletes state data for the given key. 102 - /// 103 - /// Does nothing if no data exists for the key. 104 - Future<void> del(String key); 105 - 106 - /// Optionally clears all state data. 107 - /// 108 - /// Implementations may choose not to implement this method. 109 - Future<void> clear() async { 110 - // Default implementation does nothing 111 - } 112 - }
-352
packages/atproto_oauth_flutter/lib/src/types.dart
··· 1 - // Note: These types are not prefixed with `OAuth` because they are not specific 2 - // to OAuth. They are specific to this package. OAuth specific types will be in 3 - // a separate oauth-types module or imported from an external package. 4 - 5 - // TODO: These types currently reference schemas from @atproto/oauth-types which 6 - // need to be ported to Dart. For now, we're using Map<String, dynamic> as placeholders. 7 - // These will be replaced with proper typed classes once oauth-types is ported. 8 - 9 - /// Options for initiating an authorization request. 10 - /// 11 - /// Omits client_id, response_mode, response_type, login_hint, 12 - /// code_challenge, and code_challenge_method from OAuthAuthorizationRequestParameters 13 - /// as these are managed internally. 14 - class AuthorizeOptions { 15 - /// Optional URI to redirect to after authorization 16 - final String? redirectUri; 17 - 18 - /// Optional state parameter for CSRF protection 19 - final String? state; 20 - 21 - /// Optional scope parameter defining requested permissions 22 - final String? scope; 23 - 24 - /// Optional nonce parameter for replay protection 25 - final String? nonce; 26 - 27 - /// Optional DPoP JKT (JSON Web Key Thumbprint) 28 - final String? dpopJkt; 29 - 30 - /// Optional max age in seconds for authentication 31 - final int? maxAge; 32 - 33 - /// Optional claims parameter 34 - final Map<String, dynamic>? claims; 35 - 36 - /// Optional UI locales 37 - final String? uiLocales; 38 - 39 - /// Optional ID token hint 40 - final String? idTokenHint; 41 - 42 - /// Optional display mode 43 - final String? display; 44 - 45 - /// Optional prompt value 46 - final String? prompt; 47 - 48 - /// Optional authorization details 49 - final Map<String, dynamic>? authorizationDetails; 50 - 51 - const AuthorizeOptions({ 52 - this.redirectUri, 53 - this.state, 54 - this.scope, 55 - this.nonce, 56 - this.dpopJkt, 57 - this.maxAge, 58 - this.claims, 59 - this.uiLocales, 60 - this.idTokenHint, 61 - this.display, 62 - this.prompt, 63 - this.authorizationDetails, 64 - }); 65 - 66 - Map<String, dynamic> toJson() { 67 - final map = <String, dynamic>{}; 68 - if (redirectUri != null) map['redirect_uri'] = redirectUri; 69 - if (state != null) map['state'] = state; 70 - if (scope != null) map['scope'] = scope; 71 - if (nonce != null) map['nonce'] = nonce; 72 - if (dpopJkt != null) map['dpop_jkt'] = dpopJkt; 73 - if (maxAge != null) map['max_age'] = maxAge; 74 - if (claims != null) map['claims'] = claims; 75 - if (uiLocales != null) map['ui_locales'] = uiLocales; 76 - if (idTokenHint != null) map['id_token_hint'] = idTokenHint; 77 - if (display != null) map['display'] = display; 78 - if (prompt != null) map['prompt'] = prompt; 79 - if (authorizationDetails != null) { 80 - map['authorization_details'] = authorizationDetails; 81 - } 82 - return map; 83 - } 84 - } 85 - 86 - /// Options for handling OAuth callback. 87 - class CallbackOptions { 88 - /// Optional redirect URI that was used in the authorization request 89 - final String? redirectUri; 90 - 91 - const CallbackOptions({this.redirectUri}); 92 - 93 - Map<String, dynamic> toJson() { 94 - final map = <String, dynamic>{}; 95 - if (redirectUri != null) map['redirect_uri'] = redirectUri; 96 - return map; 97 - } 98 - } 99 - 100 - /// Client metadata for OAuth configuration. 101 - /// 102 - /// TODO: This extends the base oauthClientMetadataSchema with specific 103 - /// client_id validation. Once oauth-types is ported, this will properly 104 - /// validate client_id as either discoverable or loopback type. 105 - class ClientMetadata { 106 - /// Client identifier (either discoverable HTTPS URI or loopback URI) 107 - final String? clientId; 108 - 109 - /// Array of redirect URIs 110 - final List<String> redirectUris; 111 - 112 - /// Response types supported by the client 113 - final List<String> responseTypes; 114 - 115 - /// Grant types supported by the client 116 - final List<String> grantTypes; 117 - 118 - /// Optional scope 119 - final String? scope; 120 - 121 - /// Token endpoint authentication method 122 - final String tokenEndpointAuthMethod; 123 - 124 - /// Optional token endpoint authentication signing algorithm 125 - final String? tokenEndpointAuthSigningAlg; 126 - 127 - /// Optional userinfo signed response algorithm 128 - final String? userinfoSignedResponseAlg; 129 - 130 - /// Optional userinfo encrypted response algorithm 131 - final String? userinfoEncryptedResponseAlg; 132 - 133 - /// Optional JWKS URI 134 - final String? jwksUri; 135 - 136 - /// Optional JWKS 137 - final Map<String, dynamic>? jwks; 138 - 139 - /// Application type (web or native) 140 - final String applicationType; 141 - 142 - /// Subject type (public or pairwise) 143 - final String subjectType; 144 - 145 - /// Optional request object signing algorithm 146 - final String? requestObjectSigningAlg; 147 - 148 - /// Optional ID token signed response algorithm 149 - final String? idTokenSignedResponseAlg; 150 - 151 - /// Authorization signed response algorithm 152 - final String authorizationSignedResponseAlg; 153 - 154 - /// Optional authorization encrypted response encoding 155 - final String? authorizationEncryptedResponseEnc; 156 - 157 - /// Optional authorization encrypted response algorithm 158 - final String? authorizationEncryptedResponseAlg; 159 - 160 - /// Optional client name 161 - final String? clientName; 162 - 163 - /// Optional client URI 164 - final String? clientUri; 165 - 166 - /// Optional policy URI 167 - final String? policyUri; 168 - 169 - /// Optional terms of service URI 170 - final String? tosUri; 171 - 172 - /// Optional logo URI 173 - final String? logoUri; 174 - 175 - /// Optional default max age 176 - final int? defaultMaxAge; 177 - 178 - /// Optional require auth time 179 - final bool? requireAuthTime; 180 - 181 - /// Optional contact emails 182 - final List<String>? contacts; 183 - 184 - /// Optional TLS client certificate bound access tokens 185 - final bool? tlsClientCertificateBoundAccessTokens; 186 - 187 - /// Optional DPoP bound access tokens 188 - final bool? dpopBoundAccessTokens; 189 - 190 - /// Optional authorization details types 191 - final List<String>? authorizationDetailsTypes; 192 - 193 - const ClientMetadata({ 194 - this.clientId, 195 - required this.redirectUris, 196 - this.responseTypes = const ['code'], 197 - this.grantTypes = const ['authorization_code'], 198 - this.scope, 199 - this.tokenEndpointAuthMethod = 'client_secret_basic', 200 - this.tokenEndpointAuthSigningAlg, 201 - this.userinfoSignedResponseAlg, 202 - this.userinfoEncryptedResponseAlg, 203 - this.jwksUri, 204 - this.jwks, 205 - this.applicationType = 'web', 206 - this.subjectType = 'public', 207 - this.requestObjectSigningAlg, 208 - this.idTokenSignedResponseAlg, 209 - this.authorizationSignedResponseAlg = 'RS256', 210 - this.authorizationEncryptedResponseEnc, 211 - this.authorizationEncryptedResponseAlg, 212 - this.clientName, 213 - this.clientUri, 214 - this.policyUri, 215 - this.tosUri, 216 - this.logoUri, 217 - this.defaultMaxAge, 218 - this.requireAuthTime, 219 - this.contacts, 220 - this.tlsClientCertificateBoundAccessTokens, 221 - this.dpopBoundAccessTokens, 222 - this.authorizationDetailsTypes, 223 - }); 224 - 225 - Map<String, dynamic> toJson() { 226 - final map = <String, dynamic>{ 227 - 'redirect_uris': redirectUris, 228 - 'response_types': responseTypes, 229 - 'grant_types': grantTypes, 230 - 'token_endpoint_auth_method': tokenEndpointAuthMethod, 231 - 'application_type': applicationType, 232 - 'subject_type': subjectType, 233 - 'authorization_signed_response_alg': authorizationSignedResponseAlg, 234 - }; 235 - 236 - if (clientId != null) map['client_id'] = clientId; 237 - if (scope != null) map['scope'] = scope; 238 - if (tokenEndpointAuthSigningAlg != null) { 239 - map['token_endpoint_auth_signing_alg'] = tokenEndpointAuthSigningAlg; 240 - } 241 - if (userinfoSignedResponseAlg != null) { 242 - map['userinfo_signed_response_alg'] = userinfoSignedResponseAlg; 243 - } 244 - if (userinfoEncryptedResponseAlg != null) { 245 - map['userinfo_encrypted_response_alg'] = userinfoEncryptedResponseAlg; 246 - } 247 - if (jwksUri != null) map['jwks_uri'] = jwksUri; 248 - if (jwks != null) map['jwks'] = jwks; 249 - if (requestObjectSigningAlg != null) { 250 - map['request_object_signing_alg'] = requestObjectSigningAlg; 251 - } 252 - if (idTokenSignedResponseAlg != null) { 253 - map['id_token_signed_response_alg'] = idTokenSignedResponseAlg; 254 - } 255 - if (authorizationEncryptedResponseEnc != null) { 256 - map['authorization_encrypted_response_enc'] = 257 - authorizationEncryptedResponseEnc; 258 - } 259 - if (authorizationEncryptedResponseAlg != null) { 260 - map['authorization_encrypted_response_alg'] = 261 - authorizationEncryptedResponseAlg; 262 - } 263 - if (clientName != null) map['client_name'] = clientName; 264 - if (clientUri != null) map['client_uri'] = clientUri; 265 - if (policyUri != null) map['policy_uri'] = policyUri; 266 - if (tosUri != null) map['tos_uri'] = tosUri; 267 - if (logoUri != null) map['logo_uri'] = logoUri; 268 - if (defaultMaxAge != null) map['default_max_age'] = defaultMaxAge; 269 - if (requireAuthTime != null) map['require_auth_time'] = requireAuthTime; 270 - if (contacts != null) map['contacts'] = contacts; 271 - if (tlsClientCertificateBoundAccessTokens != null) { 272 - map['tls_client_certificate_bound_access_tokens'] = 273 - tlsClientCertificateBoundAccessTokens; 274 - } 275 - if (dpopBoundAccessTokens != null) { 276 - map['dpop_bound_access_tokens'] = dpopBoundAccessTokens; 277 - } 278 - if (authorizationDetailsTypes != null) { 279 - map['authorization_details_types'] = authorizationDetailsTypes; 280 - } 281 - 282 - return map; 283 - } 284 - 285 - factory ClientMetadata.fromJson(Map<String, dynamic> json) { 286 - return ClientMetadata( 287 - clientId: json['client_id'] as String?, 288 - redirectUris: 289 - json['redirect_uris'] != null 290 - ? (json['redirect_uris'] as List<dynamic>) 291 - .map((e) => e as String) 292 - .toList() 293 - : [], 294 - responseTypes: 295 - json['response_types'] != null 296 - ? (json['response_types'] as List<dynamic>) 297 - .map((e) => e as String) 298 - .toList() 299 - : const ['code'], 300 - grantTypes: 301 - json['grant_types'] != null 302 - ? (json['grant_types'] as List<dynamic>) 303 - .map((e) => e as String) 304 - .toList() 305 - : const ['authorization_code'], 306 - scope: json['scope'] as String?, 307 - tokenEndpointAuthMethod: 308 - json['token_endpoint_auth_method'] as String? ?? 309 - 'client_secret_basic', 310 - tokenEndpointAuthSigningAlg: 311 - json['token_endpoint_auth_signing_alg'] as String?, 312 - userinfoSignedResponseAlg: 313 - json['userinfo_signed_response_alg'] as String?, 314 - userinfoEncryptedResponseAlg: 315 - json['userinfo_encrypted_response_alg'] as String?, 316 - jwksUri: json['jwks_uri'] as String?, 317 - jwks: json['jwks'] as Map<String, dynamic>?, 318 - applicationType: json['application_type'] as String? ?? 'web', 319 - subjectType: json['subject_type'] as String? ?? 'public', 320 - requestObjectSigningAlg: json['request_object_signing_alg'] as String?, 321 - idTokenSignedResponseAlg: json['id_token_signed_response_alg'] as String?, 322 - authorizationSignedResponseAlg: 323 - json['authorization_signed_response_alg'] as String? ?? 'RS256', 324 - authorizationEncryptedResponseEnc: 325 - json['authorization_encrypted_response_enc'] as String?, 326 - authorizationEncryptedResponseAlg: 327 - json['authorization_encrypted_response_alg'] as String?, 328 - clientName: json['client_name'] as String?, 329 - clientUri: json['client_uri'] as String?, 330 - policyUri: json['policy_uri'] as String?, 331 - tosUri: json['tos_uri'] as String?, 332 - logoUri: json['logo_uri'] as String?, 333 - defaultMaxAge: json['default_max_age'] as int?, 334 - requireAuthTime: json['require_auth_time'] as bool?, 335 - contacts: 336 - json['contacts'] != null 337 - ? (json['contacts'] as List<dynamic>) 338 - .map((e) => e as String) 339 - .toList() 340 - : null, 341 - tlsClientCertificateBoundAccessTokens: 342 - json['tls_client_certificate_bound_access_tokens'] as bool?, 343 - dpopBoundAccessTokens: json['dpop_bound_access_tokens'] as bool?, 344 - authorizationDetailsTypes: 345 - json['authorization_details_types'] != null 346 - ? (json['authorization_details_types'] as List<dynamic>) 347 - .map((e) => e as String) 348 - .toList() 349 - : null, 350 - ); 351 - } 352 - }
-195
packages/atproto_oauth_flutter/lib/src/util.dart
··· 1 - import 'dart:async'; 2 - 3 - /// Returns the input if it's a String, otherwise returns null. 4 - String? ifString<V>(V v) => v is String ? v : null; 5 - 6 - /// Extracts the MIME type from Content-Type header. 7 - /// 8 - /// Example: "application/json; charset=utf-8" -> "application/json" 9 - String? contentMime(Map<String, String> headers) { 10 - final contentType = headers['content-type']; 11 - if (contentType == null) return null; 12 - return contentType.split(';')[0].trim(); 13 - } 14 - 15 - /// Event detail map for custom event handling. 16 - /// 17 - /// This is a simplified version of TypeScript's CustomEvent pattern, 18 - /// adapted for Dart using StreamController and typed events. 19 - /// 20 - /// Example: 21 - /// ```dart 22 - /// final target = CustomEventTarget(); 23 - /// final subscription = target.addEventListener('myEvent', (String detail) { 24 - /// print('Received: $detail'); 25 - /// }); 26 - /// 27 - /// // Later, to remove the listener: 28 - /// subscription.cancel(); 29 - /// ``` 30 - class CustomEventTarget<EventDetailMap> { 31 - final Map<String, StreamController<dynamic>> _controllers = {}; 32 - 33 - /// Add an event listener for a specific event type. 34 - /// 35 - /// Returns a [StreamSubscription] that can be cancelled to remove the listener. 36 - /// 37 - /// Throws [TypeError] if an event type is already registered with a different type parameter. 38 - /// 39 - /// Example: 40 - /// ```dart 41 - /// final subscription = target.addEventListener('event', (detail) => print(detail)); 42 - /// subscription.cancel(); // Remove this specific listener 43 - /// ``` 44 - StreamSubscription<T> addEventListener<T>( 45 - String type, 46 - void Function(T detail) callback, 47 - ) { 48 - final existingController = _controllers[type]; 49 - 50 - // Check if a controller already exists with a different type 51 - if (existingController != null && 52 - existingController is! StreamController<T>) { 53 - throw TypeError(); 54 - } 55 - 56 - final controller = 57 - _controllers.putIfAbsent(type, () => StreamController<T>.broadcast()) 58 - as StreamController<T>; 59 - 60 - return controller.stream.listen(callback); 61 - } 62 - 63 - /// Dispatch a custom event with detail data. 64 - /// 65 - /// Returns true if the event was dispatched successfully. 66 - bool dispatchCustomEvent<T>(String type, T detail) { 67 - final controller = _controllers[type]; 68 - if (controller == null) return false; 69 - 70 - (controller as StreamController<T>).add(detail); 71 - return true; 72 - } 73 - 74 - /// Dispose of all stream controllers. 75 - /// 76 - /// Call this when the event target is no longer needed to prevent memory leaks. 77 - void dispose() { 78 - for (final controller in _controllers.values) { 79 - controller.close(); 80 - } 81 - _controllers.clear(); 82 - } 83 - } 84 - 85 - /// Combines multiple cancellation tokens into a single cancellable operation. 86 - /// 87 - /// This is a Dart adaptation of the TypeScript combineSignals function. 88 - /// Since Dart doesn't have AbortSignal/AbortController, we use CancellationToken 89 - /// pattern with StreamController. 90 - /// 91 - /// The returned controller will be cancelled if any of the input tokens are cancelled. 92 - class CombinedCancellationToken { 93 - final StreamController<void> _controller = StreamController<void>.broadcast(); 94 - final List<StreamSubscription<void>> _subscriptions = []; 95 - bool _isCancelled = false; 96 - Object? _reason; 97 - 98 - CombinedCancellationToken(List<CancellationToken?> tokens) { 99 - for (final token in tokens) { 100 - if (token != null) { 101 - if (token.isCancelled) { 102 - cancel(Exception('Operation was cancelled: ${token.reason}')); 103 - return; 104 - } 105 - 106 - final subscription = token.stream.listen((_) { 107 - cancel(Exception('Operation was cancelled: ${token.reason}')); 108 - }); 109 - _subscriptions.add(subscription); 110 - } 111 - } 112 - } 113 - 114 - /// Whether this operation has been cancelled. 115 - bool get isCancelled => _isCancelled; 116 - 117 - /// The reason for cancellation, if any. 118 - Object? get reason => _reason; 119 - 120 - /// Stream that emits when the operation is cancelled. 121 - Stream<void> get stream => _controller.stream; 122 - 123 - /// Cancel the operation with an optional reason. 124 - void cancel([Object? reason]) { 125 - if (_isCancelled) return; 126 - 127 - _isCancelled = true; 128 - _reason = reason ?? Exception('Operation was cancelled'); 129 - 130 - _controller.add(null); 131 - dispose(); 132 - } 133 - 134 - /// Clean up resources. 135 - void dispose() { 136 - for (final subscription in _subscriptions) { 137 - subscription.cancel(); 138 - } 139 - _subscriptions.clear(); 140 - _controller.close(); 141 - } 142 - } 143 - 144 - /// Represents a cancellable operation. 145 - /// 146 - /// This is a Dart equivalent of AbortSignal in JavaScript. 147 - class CancellationToken { 148 - final StreamController<void> _controller = StreamController<void>.broadcast(); 149 - bool _isCancelled = false; 150 - Object? _reason; 151 - 152 - CancellationToken(); 153 - 154 - /// Whether this operation has been cancelled. 155 - bool get isCancelled => _isCancelled; 156 - 157 - /// The reason for cancellation, if any. 158 - Object? get reason => _reason; 159 - 160 - /// Stream that emits when the operation is cancelled. 161 - Stream<void> get stream => _controller.stream; 162 - 163 - /// Cancel the operation with an optional reason. 164 - void cancel([Object? reason]) { 165 - if (_isCancelled) return; 166 - 167 - _isCancelled = true; 168 - _reason = reason ?? Exception('Operation was cancelled'); 169 - 170 - // Only add to stream if not already closed 171 - if (!_controller.isClosed) { 172 - _controller.add(null); 173 - } 174 - } 175 - 176 - /// Throw an exception if the operation has been cancelled. 177 - void throwIfCancelled() { 178 - if (_isCancelled) { 179 - throw _reason ?? Exception('Operation was cancelled'); 180 - } 181 - } 182 - 183 - /// Dispose of the stream controller. 184 - void dispose() { 185 - _controller.close(); 186 - } 187 - } 188 - 189 - /// Combines multiple cancellation tokens into a single token. 190 - /// 191 - /// If any of the input tokens are cancelled, the returned token will also be cancelled. 192 - /// The returned token should be disposed when no longer needed. 193 - CombinedCancellationToken combineSignals(List<CancellationToken?> signals) { 194 - return CombinedCancellationToken(signals); 195 - }
-100
packages/atproto_oauth_flutter/lib/src/utils/lock.dart
··· 1 - import 'dart:async'; 2 - 3 - import '../runtime/runtime_implementation.dart'; 4 - 5 - /// A map storing active locks by name. 6 - /// 7 - /// Each lock is represented as a Future that completes when the lock is released. 8 - /// This allows queuing of operations waiting for the same lock. 9 - final Map<Object, Future<void>> _locks = {}; 10 - 11 - /// Acquires a lock for the given name. 12 - /// 13 - /// Returns a function that releases the lock when called. 14 - /// The lock is automatically added to the queue of pending operations. 15 - /// 16 - /// This implements a fair (FIFO) mutex pattern where operations are executed 17 - /// in the order they acquire the lock. 18 - Future<void Function()> _acquireLocalLock(Object name) { 19 - final completer = Completer<void Function()>(); 20 - 21 - // Get the previous lock in the queue (or a resolved promise if none) 22 - final prev = _locks[name] ?? Future.value(); 23 - 24 - // Create a completer for the release function 25 - final releaseCompleter = Completer<void>(); 26 - 27 - // Chain onto the previous lock 28 - final next = prev.then((_) { 29 - // This runs when we've acquired the lock 30 - return releaseCompleter.future; 31 - }); 32 - 33 - // Store our lock as the new tail of the queue 34 - _locks[name] = next; 35 - 36 - // Resolve the acquire promise with the release function 37 - prev.then((_) { 38 - void release() { 39 - // Only delete the lock if it's still the current one 40 - // (it might have been replaced by a newer lock) 41 - if (_locks[name] == next) { 42 - _locks.remove(name); 43 - } 44 - 45 - // Complete the release, allowing the next operation to proceed 46 - if (!releaseCompleter.isCompleted) { 47 - releaseCompleter.complete(); 48 - } 49 - } 50 - 51 - completer.complete(release); 52 - }); 53 - 54 - return completer.future; 55 - } 56 - 57 - /// Executes a function while holding a named lock. 58 - /// 59 - /// This is a local (in-memory) lock implementation that prevents concurrent 60 - /// execution of the same operation within a single isolate/process. 61 - /// 62 - /// The lock is automatically released when the function completes or throws an error. 63 - /// 64 - /// Example: 65 - /// ```dart 66 - /// final result = await requestLocalLock('my-operation', () async { 67 - /// // Only one execution at a time for 'my-operation' 68 - /// return await performCriticalOperation(); 69 - /// }); 70 - /// ``` 71 - /// 72 - /// Use cases: 73 - /// - Token refresh (prevent multiple simultaneous refresh requests) 74 - /// - Database transactions 75 - /// - File operations 76 - /// - Any operation that must not run concurrently with itself 77 - /// 78 - /// Note: This is an in-memory lock. It does not work across: 79 - /// - Multiple isolates 80 - /// - Multiple processes 81 - /// - Multiple app instances 82 - /// 83 - /// For cross-process locking, implement a platform-specific RuntimeLock. 84 - Future<T> requestLocalLock<T>(String name, FutureOr<T> Function() fn) async { 85 - // Acquire the lock and get the release function 86 - final release = await _acquireLocalLock(name); 87 - 88 - try { 89 - // Execute the function while holding the lock 90 - return await fn(); 91 - } finally { 92 - // Always release the lock, even if the function throws 93 - release(); 94 - } 95 - } 96 - 97 - /// Convenience getter that returns the requestLocalLock function as a RuntimeLock. 98 - /// 99 - /// This can be used as the default implementation for RuntimeImplementation.requestLock. 100 - RuntimeLock get requestLocalLockImpl => requestLocalLock;
-530
packages/atproto_oauth_flutter/pubspec.lock
··· 1 - # Generated by pub 2 - # See https://dart.dev/tools/pub/glossary#lockfile 3 - packages: 4 - async: 5 - dependency: transitive 6 - description: 7 - name: async 8 - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 9 - url: "https://pub.dev" 10 - source: hosted 11 - version: "2.12.0" 12 - boolean_selector: 13 - dependency: transitive 14 - description: 15 - name: boolean_selector 16 - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" 17 - url: "https://pub.dev" 18 - source: hosted 19 - version: "2.1.2" 20 - characters: 21 - dependency: transitive 22 - description: 23 - name: characters 24 - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 25 - url: "https://pub.dev" 26 - source: hosted 27 - version: "1.4.0" 28 - clock: 29 - dependency: transitive 30 - description: 31 - name: clock 32 - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b 33 - url: "https://pub.dev" 34 - source: hosted 35 - version: "1.1.2" 36 - collection: 37 - dependency: "direct main" 38 - description: 39 - name: collection 40 - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" 41 - url: "https://pub.dev" 42 - source: hosted 43 - version: "1.19.1" 44 - convert: 45 - dependency: "direct main" 46 - description: 47 - name: convert 48 - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 49 - url: "https://pub.dev" 50 - source: hosted 51 - version: "3.1.2" 52 - crypto: 53 - dependency: "direct main" 54 - description: 55 - name: crypto 56 - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" 57 - url: "https://pub.dev" 58 - source: hosted 59 - version: "3.0.6" 60 - desktop_webview_window: 61 - dependency: transitive 62 - description: 63 - name: desktop_webview_window 64 - sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" 65 - url: "https://pub.dev" 66 - source: hosted 67 - version: "0.2.3" 68 - dio: 69 - dependency: "direct main" 70 - description: 71 - name: dio 72 - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 73 - url: "https://pub.dev" 74 - source: hosted 75 - version: "5.9.0" 76 - dio_web_adapter: 77 - dependency: transitive 78 - description: 79 - name: dio_web_adapter 80 - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" 81 - url: "https://pub.dev" 82 - source: hosted 83 - version: "2.1.1" 84 - fake_async: 85 - dependency: transitive 86 - description: 87 - name: fake_async 88 - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" 89 - url: "https://pub.dev" 90 - source: hosted 91 - version: "1.3.3" 92 - ffi: 93 - dependency: transitive 94 - description: 95 - name: ffi 96 - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" 97 - url: "https://pub.dev" 98 - source: hosted 99 - version: "2.1.4" 100 - flutter: 101 - dependency: "direct main" 102 - description: flutter 103 - source: sdk 104 - version: "0.0.0" 105 - flutter_lints: 106 - dependency: "direct dev" 107 - description: 108 - name: flutter_lints 109 - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" 110 - url: "https://pub.dev" 111 - source: hosted 112 - version: "5.0.0" 113 - flutter_secure_storage: 114 - dependency: "direct main" 115 - description: 116 - name: flutter_secure_storage 117 - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" 118 - url: "https://pub.dev" 119 - source: hosted 120 - version: "9.2.4" 121 - flutter_secure_storage_linux: 122 - dependency: transitive 123 - description: 124 - name: flutter_secure_storage_linux 125 - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 126 - url: "https://pub.dev" 127 - source: hosted 128 - version: "1.2.3" 129 - flutter_secure_storage_macos: 130 - dependency: transitive 131 - description: 132 - name: flutter_secure_storage_macos 133 - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" 134 - url: "https://pub.dev" 135 - source: hosted 136 - version: "3.1.3" 137 - flutter_secure_storage_platform_interface: 138 - dependency: transitive 139 - description: 140 - name: flutter_secure_storage_platform_interface 141 - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 142 - url: "https://pub.dev" 143 - source: hosted 144 - version: "1.1.2" 145 - flutter_secure_storage_web: 146 - dependency: transitive 147 - description: 148 - name: flutter_secure_storage_web 149 - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 150 - url: "https://pub.dev" 151 - source: hosted 152 - version: "1.2.1" 153 - flutter_secure_storage_windows: 154 - dependency: transitive 155 - description: 156 - name: flutter_secure_storage_windows 157 - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 158 - url: "https://pub.dev" 159 - source: hosted 160 - version: "3.1.2" 161 - flutter_test: 162 - dependency: "direct dev" 163 - description: flutter 164 - source: sdk 165 - version: "0.0.0" 166 - flutter_web_auth_2: 167 - dependency: "direct main" 168 - description: 169 - name: flutter_web_auth_2 170 - sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" 171 - url: "https://pub.dev" 172 - source: hosted 173 - version: "4.1.0" 174 - flutter_web_auth_2_platform_interface: 175 - dependency: transitive 176 - description: 177 - name: flutter_web_auth_2_platform_interface 178 - sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d 179 - url: "https://pub.dev" 180 - source: hosted 181 - version: "4.1.0" 182 - flutter_web_plugins: 183 - dependency: transitive 184 - description: flutter 185 - source: sdk 186 - version: "0.0.0" 187 - http: 188 - dependency: "direct main" 189 - description: 190 - name: http 191 - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 192 - url: "https://pub.dev" 193 - source: hosted 194 - version: "1.5.0" 195 - http_parser: 196 - dependency: transitive 197 - description: 198 - name: http_parser 199 - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" 200 - url: "https://pub.dev" 201 - source: hosted 202 - version: "4.1.2" 203 - js: 204 - dependency: transitive 205 - description: 206 - name: js 207 - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 208 - url: "https://pub.dev" 209 - source: hosted 210 - version: "0.6.7" 211 - leak_tracker: 212 - dependency: transitive 213 - description: 214 - name: leak_tracker 215 - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" 216 - url: "https://pub.dev" 217 - source: hosted 218 - version: "11.0.2" 219 - leak_tracker_flutter_testing: 220 - dependency: transitive 221 - description: 222 - name: leak_tracker_flutter_testing 223 - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" 224 - url: "https://pub.dev" 225 - source: hosted 226 - version: "3.0.10" 227 - leak_tracker_testing: 228 - dependency: transitive 229 - description: 230 - name: leak_tracker_testing 231 - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" 232 - url: "https://pub.dev" 233 - source: hosted 234 - version: "3.0.2" 235 - lints: 236 - dependency: transitive 237 - description: 238 - name: lints 239 - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 240 - url: "https://pub.dev" 241 - source: hosted 242 - version: "5.1.1" 243 - matcher: 244 - dependency: transitive 245 - description: 246 - name: matcher 247 - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 248 - url: "https://pub.dev" 249 - source: hosted 250 - version: "0.12.17" 251 - material_color_utilities: 252 - dependency: transitive 253 - description: 254 - name: material_color_utilities 255 - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec 256 - url: "https://pub.dev" 257 - source: hosted 258 - version: "0.11.1" 259 - meta: 260 - dependency: transitive 261 - description: 262 - name: meta 263 - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c 264 - url: "https://pub.dev" 265 - source: hosted 266 - version: "1.16.0" 267 - mime: 268 - dependency: transitive 269 - description: 270 - name: mime 271 - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" 272 - url: "https://pub.dev" 273 - source: hosted 274 - version: "2.0.0" 275 - path: 276 - dependency: transitive 277 - description: 278 - name: path 279 - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" 280 - url: "https://pub.dev" 281 - source: hosted 282 - version: "1.9.1" 283 - path_provider: 284 - dependency: transitive 285 - description: 286 - name: path_provider 287 - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" 288 - url: "https://pub.dev" 289 - source: hosted 290 - version: "2.1.5" 291 - path_provider_android: 292 - dependency: transitive 293 - description: 294 - name: path_provider_android 295 - sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" 296 - url: "https://pub.dev" 297 - source: hosted 298 - version: "2.2.19" 299 - path_provider_foundation: 300 - dependency: transitive 301 - description: 302 - name: path_provider_foundation 303 - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" 304 - url: "https://pub.dev" 305 - source: hosted 306 - version: "2.4.2" 307 - path_provider_linux: 308 - dependency: transitive 309 - description: 310 - name: path_provider_linux 311 - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 312 - url: "https://pub.dev" 313 - source: hosted 314 - version: "2.2.1" 315 - path_provider_platform_interface: 316 - dependency: transitive 317 - description: 318 - name: path_provider_platform_interface 319 - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" 320 - url: "https://pub.dev" 321 - source: hosted 322 - version: "2.1.2" 323 - path_provider_windows: 324 - dependency: transitive 325 - description: 326 - name: path_provider_windows 327 - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 328 - url: "https://pub.dev" 329 - source: hosted 330 - version: "2.3.0" 331 - platform: 332 - dependency: transitive 333 - description: 334 - name: platform 335 - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" 336 - url: "https://pub.dev" 337 - source: hosted 338 - version: "3.1.6" 339 - plugin_platform_interface: 340 - dependency: transitive 341 - description: 342 - name: plugin_platform_interface 343 - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" 344 - url: "https://pub.dev" 345 - source: hosted 346 - version: "2.1.8" 347 - pointycastle: 348 - dependency: "direct main" 349 - description: 350 - name: pointycastle 351 - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" 352 - url: "https://pub.dev" 353 - source: hosted 354 - version: "3.9.1" 355 - sky_engine: 356 - dependency: transitive 357 - description: flutter 358 - source: sdk 359 - version: "0.0.0" 360 - source_span: 361 - dependency: transitive 362 - description: 363 - name: source_span 364 - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" 365 - url: "https://pub.dev" 366 - source: hosted 367 - version: "1.10.1" 368 - stack_trace: 369 - dependency: transitive 370 - description: 371 - name: stack_trace 372 - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" 373 - url: "https://pub.dev" 374 - source: hosted 375 - version: "1.12.1" 376 - stream_channel: 377 - dependency: transitive 378 - description: 379 - name: stream_channel 380 - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" 381 - url: "https://pub.dev" 382 - source: hosted 383 - version: "2.1.4" 384 - string_scanner: 385 - dependency: transitive 386 - description: 387 - name: string_scanner 388 - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" 389 - url: "https://pub.dev" 390 - source: hosted 391 - version: "1.4.1" 392 - term_glyph: 393 - dependency: transitive 394 - description: 395 - name: term_glyph 396 - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" 397 - url: "https://pub.dev" 398 - source: hosted 399 - version: "1.2.2" 400 - test_api: 401 - dependency: transitive 402 - description: 403 - name: test_api 404 - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" 405 - url: "https://pub.dev" 406 - source: hosted 407 - version: "0.7.6" 408 - typed_data: 409 - dependency: transitive 410 - description: 411 - name: typed_data 412 - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 413 - url: "https://pub.dev" 414 - source: hosted 415 - version: "1.4.0" 416 - url_launcher: 417 - dependency: transitive 418 - description: 419 - name: url_launcher 420 - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 421 - url: "https://pub.dev" 422 - source: hosted 423 - version: "6.3.2" 424 - url_launcher_android: 425 - dependency: transitive 426 - description: 427 - name: url_launcher_android 428 - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" 429 - url: "https://pub.dev" 430 - source: hosted 431 - version: "6.3.20" 432 - url_launcher_ios: 433 - dependency: transitive 434 - description: 435 - name: url_launcher_ios 436 - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 437 - url: "https://pub.dev" 438 - source: hosted 439 - version: "6.3.4" 440 - url_launcher_linux: 441 - dependency: transitive 442 - description: 443 - name: url_launcher_linux 444 - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" 445 - url: "https://pub.dev" 446 - source: hosted 447 - version: "3.2.1" 448 - url_launcher_macos: 449 - dependency: transitive 450 - description: 451 - name: url_launcher_macos 452 - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f 453 - url: "https://pub.dev" 454 - source: hosted 455 - version: "3.2.3" 456 - url_launcher_platform_interface: 457 - dependency: transitive 458 - description: 459 - name: url_launcher_platform_interface 460 - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" 461 - url: "https://pub.dev" 462 - source: hosted 463 - version: "2.3.2" 464 - url_launcher_web: 465 - dependency: transitive 466 - description: 467 - name: url_launcher_web 468 - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" 469 - url: "https://pub.dev" 470 - source: hosted 471 - version: "2.4.1" 472 - url_launcher_windows: 473 - dependency: transitive 474 - description: 475 - name: url_launcher_windows 476 - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" 477 - url: "https://pub.dev" 478 - source: hosted 479 - version: "3.1.4" 480 - vector_math: 481 - dependency: transitive 482 - description: 483 - name: vector_math 484 - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b 485 - url: "https://pub.dev" 486 - source: hosted 487 - version: "2.2.0" 488 - vm_service: 489 - dependency: transitive 490 - description: 491 - name: vm_service 492 - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" 493 - url: "https://pub.dev" 494 - source: hosted 495 - version: "14.3.1" 496 - web: 497 - dependency: transitive 498 - description: 499 - name: web 500 - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" 501 - url: "https://pub.dev" 502 - source: hosted 503 - version: "1.1.1" 504 - win32: 505 - dependency: transitive 506 - description: 507 - name: win32 508 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" 509 - url: "https://pub.dev" 510 - source: hosted 511 - version: "5.13.0" 512 - window_to_front: 513 - dependency: transitive 514 - description: 515 - name: window_to_front 516 - sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" 517 - url: "https://pub.dev" 518 - source: hosted 519 - version: "0.0.3" 520 - xdg_directories: 521 - dependency: transitive 522 - description: 523 - name: xdg_directories 524 - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" 525 - url: "https://pub.dev" 526 - source: hosted 527 - version: "1.1.0" 528 - sdks: 529 - dart: ">=3.8.0-0 <4.0.0" 530 - flutter: ">=3.29.0"
-24
packages/atproto_oauth_flutter/pubspec.yaml
··· 1 - name: atproto_oauth_flutter 2 - description: Official AT Protocol OAuth client for Flutter - 1:1 port of @atproto/oauth-client 3 - version: 0.1.0 4 - publish_to: none 5 - 6 - environment: 7 - sdk: ^3.7.2 8 - 9 - dependencies: 10 - flutter: 11 - sdk: flutter 12 - dio: ^5.9.0 13 - flutter_secure_storage: ^9.2.2 14 - flutter_web_auth_2: ^4.1.0 15 - crypto: ^3.0.3 16 - pointycastle: ^3.9.1 17 - convert: ^3.1.1 18 - collection: ^1.18.0 19 - http: ^1.2.0 20 - 21 - dev_dependencies: 22 - flutter_test: 23 - sdk: flutter 24 - flutter_lints: ^5.0.0
-245
packages/atproto_oauth_flutter/test/identity_resolver_test.dart
··· 1 - /// Unit tests for the identity resolution layer. 2 - /// 3 - /// Note: These are basic validation tests. Real integration tests would 4 - /// require network calls to live services. 5 - 6 - import 'package:flutter_test/flutter_test.dart'; 7 - import 'package:atproto_oauth_flutter/src/identity/identity.dart'; 8 - 9 - void main() { 10 - group('DID Validation', () { 11 - test('isDidPlc validates did:plc correctly', () { 12 - // did:plc must be exactly 32 chars total (8 prefix + 24 base32 [a-z2-7]) 13 - expect(isDidPlc('did:plc:z72i7hdynmk6r22z27h6abc2'), isTrue); 14 - expect(isDidPlc('did:plc:2222222222222222222222ab'), isTrue); 15 - expect(isDidPlc('did:plc:abcdefgabcdefgabcdefgabc'), isTrue); 16 - 17 - // Wrong length 18 - expect(isDidPlc('did:plc:short'), isFalse); 19 - expect(isDidPlc('did:plc:toolonggggggggggggggggggggg'), isFalse); 20 - 21 - // Wrong prefix 22 - expect(isDidPlc('did:web:example.com'), isFalse); 23 - 24 - // Invalid characters (not base32) 25 - expect(isDidPlc('did:plc:0000000000000000000000'), isFalse); // has 0 26 - expect(isDidPlc('did:plc:1111111111111111111111'), isFalse); // has 1 27 - }); 28 - 29 - test('isDidWeb validates did:web correctly', () { 30 - expect(isDidWeb('did:web:example.com'), isTrue); 31 - expect(isDidWeb('did:web:example.com:user:alice'), isTrue); 32 - expect(isDidWeb('did:web:localhost%3A3000'), isTrue); 33 - 34 - // Wrong prefix 35 - expect(isDidWeb('did:plc:abc123xyz789abc123xyz789'), isFalse); 36 - 37 - // Can't start with colon after prefix 38 - expect(isDidWeb('did:web::example.com'), isFalse); 39 - }); 40 - 41 - test('isDid validates general DIDs', () { 42 - expect(isDid('did:plc:abc123xyz789abc123xyz789'), isTrue); 43 - expect(isDid('did:web:example.com'), isTrue); 44 - expect( 45 - isDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'), 46 - isTrue, 47 - ); 48 - 49 - // Invalid 50 - expect(isDid('not-a-did'), isFalse); 51 - expect(isDid('did:'), isFalse); 52 - expect(isDid('did:method'), isFalse); 53 - expect(isDid(''), isFalse); 54 - }); 55 - 56 - test('extractDidMethod extracts method name', () { 57 - expect(extractDidMethod('did:plc:abc123'), equals('plc')); 58 - expect(extractDidMethod('did:web:example.com'), equals('web')); 59 - expect(extractDidMethod('did:key:z6Mk...'), equals('key')); 60 - }); 61 - 62 - test('didWebToUrl converts did:web to URL', () { 63 - final url1 = didWebToUrl('did:web:example.com'); 64 - expect(url1.toString(), equals('https://example.com')); 65 - 66 - final url2 = didWebToUrl('did:web:example.com:user:alice'); 67 - expect(url2.toString(), equals('https://example.com/user/alice')); 68 - 69 - final url3 = didWebToUrl('did:web:localhost%3A3000'); 70 - expect(url3.toString(), equals('http://localhost:3000')); 71 - }); 72 - 73 - test('urlToDidWeb converts URL to did:web', () { 74 - final did1 = urlToDidWeb(Uri.parse('https://example.com')); 75 - expect(did1, equals('did:web:example.com')); 76 - 77 - final did2 = urlToDidWeb(Uri.parse('https://example.com/user/alice')); 78 - expect(did2, equals('did:web:example.com:user:alice')); 79 - }); 80 - }); 81 - 82 - group('Handle Validation', () { 83 - test('isValidHandle validates handles', () { 84 - expect(isValidHandle('alice.example.com'), isTrue); 85 - expect(isValidHandle('user.bsky.social'), isTrue); 86 - expect(isValidHandle('sub.domain.example.com'), isTrue); 87 - expect(isValidHandle('a.b'), isTrue); 88 - 89 - // Invalid 90 - expect(isValidHandle(''), isFalse); 91 - expect(isValidHandle('no-tld'), isFalse); 92 - expect(isValidHandle('.starts-with-dot.com'), isFalse); 93 - expect(isValidHandle('ends-with-dot.com.'), isFalse); 94 - expect(isValidHandle('has..double-dot.com'), isFalse); 95 - expect(isValidHandle('has spaces.com'), isFalse); 96 - 97 - // Too long (254+ chars) 98 - final longHandle = '${'a' * 250}.com'; 99 - expect(isValidHandle(longHandle), isFalse); 100 - }); 101 - 102 - test('normalizeHandle converts to lowercase', () { 103 - expect(normalizeHandle('Alice.Example.Com'), equals('alice.example.com')); 104 - expect(normalizeHandle('USER.BSKY.SOCIAL'), equals('user.bsky.social')); 105 - }); 106 - 107 - test('asNormalizedHandle validates and normalizes', () { 108 - expect( 109 - asNormalizedHandle('Alice.Example.Com'), 110 - equals('alice.example.com'), 111 - ); 112 - expect(asNormalizedHandle('invalid'), isNull); 113 - expect(asNormalizedHandle(''), isNull); 114 - }); 115 - }); 116 - 117 - group('DID Document', () { 118 - test('DidDocument parses from JSON', () { 119 - final json = { 120 - 'id': 'did:plc:abc123xyz789abc123xyz789', 121 - 'alsoKnownAs': ['at://alice.bsky.social'], 122 - 'service': [ 123 - { 124 - 'id': '#atproto_pds', 125 - 'type': 'AtprotoPersonalDataServer', 126 - 'serviceEndpoint': 'https://pds.example.com', 127 - }, 128 - ], 129 - }; 130 - 131 - final doc = DidDocument.fromJson(json); 132 - 133 - expect(doc.id, equals('did:plc:abc123xyz789abc123xyz789')); 134 - expect(doc.alsoKnownAs, contains('at://alice.bsky.social')); 135 - expect(doc.service?.length, equals(1)); 136 - expect(doc.service?[0].type, equals('AtprotoPersonalDataServer')); 137 - }); 138 - 139 - test('DidDocument extracts PDS URL', () { 140 - final doc = DidDocument( 141 - id: 'did:plc:test', 142 - service: [ 143 - DidService( 144 - id: '#atproto_pds', 145 - type: 'AtprotoPersonalDataServer', 146 - serviceEndpoint: 'https://pds.example.com', 147 - ), 148 - ], 149 - ); 150 - 151 - expect(doc.extractPdsUrl(), equals('https://pds.example.com')); 152 - }); 153 - 154 - test('DidDocument extracts handle', () { 155 - final doc = DidDocument( 156 - id: 'did:plc:test', 157 - alsoKnownAs: ['at://alice.bsky.social', 'https://example.com'], 158 - ); 159 - 160 - expect(doc.extractAtprotoHandle(), equals('alice.bsky.social')); 161 - expect(doc.extractNormalizedHandle(), equals('alice.bsky.social')); 162 - }); 163 - 164 - test('DidDocument returns null for missing PDS', () { 165 - final doc = DidDocument(id: 'did:plc:test'); 166 - expect(doc.extractPdsUrl(), isNull); 167 - }); 168 - 169 - test('DidDocument returns null for missing handle', () { 170 - final doc = DidDocument(id: 'did:plc:test'); 171 - expect(doc.extractAtprotoHandle(), isNull); 172 - expect(doc.extractNormalizedHandle(), isNull); 173 - }); 174 - }); 175 - 176 - group('Cache', () { 177 - test('InMemoryDidCache stores and retrieves', () async { 178 - final cache = InMemoryDidCache(ttl: Duration(seconds: 1)); 179 - final doc = DidDocument(id: 'did:plc:test'); 180 - 181 - await cache.set('did:plc:test', doc); 182 - final retrieved = await cache.get('did:plc:test'); 183 - 184 - expect(retrieved?.id, equals('did:plc:test')); 185 - }); 186 - 187 - test('InMemoryDidCache expires entries', () async { 188 - final cache = InMemoryDidCache(ttl: Duration(milliseconds: 100)); 189 - final doc = DidDocument(id: 'did:plc:test'); 190 - 191 - await cache.set('did:plc:test', doc); 192 - 193 - // Should exist immediately 194 - expect(await cache.get('did:plc:test'), isNotNull); 195 - 196 - // Wait for expiration 197 - await Future.delayed(Duration(milliseconds: 150)); 198 - 199 - // Should be expired 200 - expect(await cache.get('did:plc:test'), isNull); 201 - }); 202 - 203 - test('InMemoryHandleCache stores and retrieves', () async { 204 - final cache = InMemoryHandleCache(ttl: Duration(seconds: 1)); 205 - 206 - await cache.set('alice.bsky.social', 'did:plc:test'); 207 - final retrieved = await cache.get('alice.bsky.social'); 208 - 209 - expect(retrieved, equals('did:plc:test')); 210 - }); 211 - 212 - test('Cache clears all entries', () async { 213 - final cache = InMemoryDidCache(); 214 - final doc = DidDocument(id: 'did:plc:test'); 215 - 216 - await cache.set('did:plc:test', doc); 217 - expect(await cache.get('did:plc:test'), isNotNull); 218 - 219 - await cache.clear(); 220 - expect(await cache.get('did:plc:test'), isNull); 221 - }); 222 - }); 223 - 224 - group('Error Types', () { 225 - test('IdentityResolverError has message', () { 226 - final error = IdentityResolverError('Test error'); 227 - expect(error.message, equals('Test error')); 228 - expect(error.toString(), contains('Test error')); 229 - }); 230 - 231 - test('InvalidDidError includes DID', () { 232 - final error = InvalidDidError('not:valid', 'Invalid format'); 233 - expect(error.did, equals('not:valid')); 234 - expect(error.toString(), contains('not:valid')); 235 - expect(error.toString(), contains('Invalid format')); 236 - }); 237 - 238 - test('InvalidHandleError includes handle', () { 239 - final error = InvalidHandleError('invalid', 'Invalid format'); 240 - expect(error.handle, equals('invalid')); 241 - expect(error.toString(), contains('invalid')); 242 - expect(error.toString(), contains('Invalid format')); 243 - }); 244 - }); 245 - }