Main coves client
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)
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**