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 58The 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: 72await client.signIn('alice.bsky.social'); // → https://bsky.app 73await client.signIn('bob.custom-pds.com'); // → https://custom-pds.com 74await 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 120Add this to your `pubspec.yaml`: 121 122```yaml 123dependencies: 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 131Then install: 132 133```bash 134flutter pub get 135``` 136 137## Quick Start 138 139Here's a complete working example to get you started in 5 minutes: 140 141```dart 142import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 143 144void 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 179OAuth requires deep linking to redirect back to your app after authentication. You must configure both platforms: 180 181### iOS Configuration 182 183Add 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 208Then 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 212Add 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 248Test that deep linking works: 249 250```bash 251# iOS (simulator) 252xcrun simctl openurl booted "myapp://oauth/callback?code=test" 253 254# Android (emulator or device) 255adb shell am start -W -a android.intent.action.VIEW -d "myapp://oauth/callback?code=test" 256``` 257 258If 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 266When 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 270We provide a helper that makes router configuration easy: 271 272**With go_router** (Recommended approach): 273 274```dart 275import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 276import 'package:go_router/go_router.dart'; 277 278final 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 292final 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 314final scheme = FlutterOAuthRouterHelper.extractScheme( 315 'myapp://oauth/callback' 316); 317// Returns: 'myapp' 318 319// Use it in your router config 320redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( 321 customSchemes: [scheme], 322), 323``` 324 325#### Other Routers 326 327The 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 333The 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 344FlutterOAuthClient({ 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 369Complete OAuth sign-in flow (authorize + browser + callback). 370 371```dart 372Future<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 397final session = await client.signIn('alice.bsky.social'); 398 399// With custom state 400final session = await client.signIn( 401 'alice.bsky.social', 402 options: AuthorizeOptions(state: 'my-app-state'), 403); 404``` 405 406##### `restore()` 407 408Restore a stored session (automatically refreshes if expired). 409 410```dart 411Future<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 438final session = await client.restore('did:plc:abc123'); 439 440// Force refresh 441final fresh = await client.restore('did:plc:abc123', refresh: true); 442``` 443 444##### `revoke()` 445 446Revoke a session (sign out). 447 448```dart 449Future<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 468await client.revoke('did:plc:abc123'); 469``` 470 471#### Properties 472 473##### `onUpdated` 474 475Stream of session update events (token refresh, etc.). 476 477```dart 478Stream<SessionUpdatedEvent> get onUpdated 479``` 480 481**Example:** 482 483```dart 484client.onUpdated.listen((event) { 485 print('Session ${event.sub} updated'); 486}); 487``` 488 489##### `onDeleted` 490 491Stream of session deletion events (revoke, expiry, errors). 492 493```dart 494Stream<SessionDeletedEvent> get onDeleted 495``` 496 497**Example:** 498 499```dart 500client.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 515OAuthClient(OAuthClientOptions options) 516``` 517 518See [OAuthClientOptions](#oauthclientoptions) for all parameters. 519 520#### Methods 521 522##### `authorize()` 523 524Start OAuth authorization flow (returns URL to open in browser). 525 526```dart 527Future<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 543final authUrl = await client.authorize('alice.bsky.social'); 544// Open authUrl in browser yourself 545``` 546 547##### `callback()` 548 549Handle OAuth callback after user authorization. 550 551```dart 552Future<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 574final uri = Uri.parse(callbackUrl); 575final params = uri.queryParameters; 576 577// Complete OAuth flow 578final result = await client.callback(params); 579print('Signed in: ${result.session.sub}'); 580print('App state: ${result.state}'); 581``` 582 583##### `restore()` and `revoke()` 584 585Same as `FlutterOAuthClient`. 586 587#### Static Methods 588 589##### `fetchMetadata()` 590 591Fetch client metadata from a discoverable client ID URL. 592 593```dart 594static 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 610final metadata = await OAuthClient.fetchMetadata( 611 OAuthClientFetchMetadataOptions( 612 clientId: 'https://example.com/client-metadata.json', 613 ), 614); 615``` 616 617#### Properties 618 619Same as `FlutterOAuthClient` (`onUpdated`, `onDeleted`). 620 621--- 622 623### Types 624 625#### ClientMetadata 626 627OAuth client configuration. 628 629```dart 630class 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) 672final metadata = ClientMetadata( 673 clientId: 'http://localhost', 674 redirectUris: ['myapp://oauth/callback'], 675 scope: 'atproto transition:generic', 676); 677 678// Production (discoverable client) 679final 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 694Additional parameters for `authorize()` / `signIn()`. 695 696```dart 697class 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 723final 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 734Options for `callback()`. 735 736```dart 737class CallbackOptions { 738 final String? redirectUri; 739} 740``` 741 742**Note:** `redirectUri` must match the one used in `authorize()`. 743 744#### OAuthSession 745 746Authenticated session with token management. 747 748```dart 749class 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 778final session = await client.signIn('alice.bsky.social'); 779 780// Get token info 781final info = await session.getTokenInfo(); 782print('Expires: ${info.expiresAt}'); 783print('Scope: ${info.scope}'); 784 785// Make authenticated request 786final response = await session.fetchHandler( 787 '/xrpc/com.atproto.repo.getRecord', 788 method: 'GET', 789); 790``` 791 792#### TokenInfo 793 794Information about the current access token. 795 796```dart 797class 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 811All errors extend `Exception` and can be caught with standard try-catch. 812 813#### OAuthCallbackError 814 815OAuth error from server or invalid callback. 816 817```dart 818class 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 835try { 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 848Failed 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 858Failed 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 868Token was revoked (intentional sign-out). 869 870#### TokenInvalidError 871 872Token is invalid (rejected by resource server). 873 874#### AuthMethodUnsatisfiableError 875 876Client authentication method not supported. 877 878--- 879 880## Usage Guide 881 882### Sign In Flow 883 884Complete example with error handling: 885 886```dart 887import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 888 889Future<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 921Restore session when app restarts: 922 923```dart 924Future<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 943Tokens are refreshed **automatically**: 944 945```dart 946// Auto-refresh (default) 947final session = await client.restore(did); 948 949// Force refresh 950final fresh = await client.restore(did, refresh: true); 951 952// Check token status 953final info = await session.getTokenInfo(); 954if (info.expired == true) { 955 print('Token will refresh on next API call'); 956} 957``` 958 959### Sign Out (Revoke) 960 961```dart 962Future<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 977void 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 996final 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 1011For full control over the OAuth flow: 1012 1013```dart 1014final 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 1025final authUrl = await client.authorize('alice.bsky.social'); 1026// Open browser yourself 1027final result = await client.callback(params); 1028``` 1029 1030--- 1031 1032## Decentralization Explained 1033 1034This 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 1040const authServer = 'https://bsky.social'; // Hardcoded! 1041``` 1042 1043### The Solution: Dynamic Discovery 1044 1045```dart 1046// ✅ CORRECT - Discovers auth server dynamically 1047await 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 1066await client.signIn('alice.bsky.social'); 1067// → https://bsky.app 1068 1069// Bob runs his own 1070await 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``` 1108App → 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``` 1116API 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 11261. Check platform configuration (Info.plist / AndroidManifest.xml) 11272. Test manually: `xcrun simctl openurl booted "myapp://..."` 11283. 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 1150final session = await client.signIn('bob.custom-pds.com'); // BROKEN 1151``` 1152 1153**After (Fixed):** 1154```dart 1155import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; 1156 1157final client = FlutterOAuthClient( 1158 clientMetadata: ClientMetadata( 1159 clientId: 'http://localhost', 1160 redirectUris: ['myapp://oauth/callback'], 1161 ), 1162); 1163 1164final session = await client.signIn('bob.custom-pds.com'); // WORKS! 1165``` 1166 1167--- 1168 1169## Architecture 1170 1171Built in **7 layers** matching TypeScript original: 1172 11731. **Foundation** - Types, constants, utilities 11742. **Runtime** - Crypto abstractions, PKCE, keys 11753. **Identity Resolution** - DID/handle → PDS discovery (**critical for decentralization**) 11764. **OAuth Discovery** - Dynamic server metadata fetching 11775. **DPoP** - Token binding proofs 11786. **OAuth Flow** - Authorization, tokens, sessions 11797. **Flutter Platform** - Secure storage, crypto implementation 1180 1181--- 1182 1183## Examples 1184 1185See `example/flutter_oauth_example.dart` for complete examples. 1186 1187### Minimal Example 1188 1189```dart 1190final client = FlutterOAuthClient( 1191 clientMetadata: ClientMetadata( 1192 clientId: 'http://localhost', 1193 redirectUris: ['myapp://oauth/callback'], 1194 ), 1195); 1196 1197final session = await client.signIn('alice.bsky.social'); 1198print('Signed in: ${session.sub}'); 1199``` 1200 1201--- 1202 1203## Contributing 1204 1205Contributions welcome! Please: 12061. Fork the repo 12072. Create feature branch 12083. Run `flutter analyze` 12094. Submit PR 1210 1211--- 1212 1213## License 1214 1215MIT 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**