atproto_oauth_flutter#

Official AT Protocol OAuth client for Flutter - A complete 1:1 port of the TypeScript @atproto/oauth-client package.

License: MIT

Table of Contents#

Overview#

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:

  • Complete OAuth 2.0 Flow - Authorization Code Flow with PKCE
  • Automatic Token Management - Refresh tokens automatically, handle expiration gracefully
  • Secure Storage - iOS Keychain and Android EncryptedSharedPreferences
  • DPoP Security - Token binding with cryptographic proof-of-possession
  • Decentralized Discovery - Works with ANY atProto PDS, not just bsky.social
  • Production Ready - Based on Bluesky's official TypeScript implementation

Why This Package?#

The Problem with Existing Packages#

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.

What this means:

  • ❌ Only works with Bluesky's servers
  • ❌ Can't authenticate users on custom PDS instances
  • ❌ Defeats the purpose of decentralization
  • ❌ Your app won't work with the broader atProto ecosystem

How This Package Solves It#

atproto_oauth_flutter implements proper decentralized OAuth discovery:

// ✅ Works with ANY PDS:
await client.signIn('alice.bsky.social');    // → https://bsky.app
await client.signIn('bob.custom-pds.com');   // → https://custom-pds.com
await client.signIn('bretton.dev');          // → https://pds.bretton.dev ✅

// The library automatically:
// 1. Resolves handle → DID
// 2. Fetches DID document
// 3. Discovers PDS URL
// 4. Discovers authorization server
// 5. Completes OAuth flow with the correct server

Bottom line: This is the only Flutter package that properly implements decentralized atProto OAuth.

Features#

OAuth 2.0 / OIDC Compliance#

  • ✅ Authorization Code Flow with PKCE (SHA-256)
  • ✅ Automatic token refresh with concurrency control
  • ✅ Token revocation (best-effort)
  • ✅ PAR (Pushed Authorization Request) support
  • ✅ Response modes: query, fragment
  • ✅ State parameter (CSRF protection)
  • ✅ Nonce parameter (replay protection)

atProto Specifics#

  • DID Resolution - Supports did:plc and did:web
  • Handle Resolution - XRPC-based handle → DID resolution
  • PDS Discovery - Automatic PDS discovery from DID documents
  • DPoP (Demonstrating Proof of Possession) - Cryptographic token binding
  • Multi-tenant Auth Servers - Works with any authorization server

Security#

  • Secure Storage - iOS Keychain, Android EncryptedSharedPreferences
  • DPoP Key Generation - EC keys (ES256/ES384/ES512/ES256K)
  • PKCE - SHA-256 code challenge/verifier
  • Automatic Cleanup - Sessions deleted on errors
  • Concurrency Control - Lock prevents simultaneous token refresh
  • Input Validation - All inputs validated before use

Platform Support#

  • ✅ iOS (11.0+) with Keychain storage
  • ✅ Android (API 21+) with EncryptedSharedPreferences
  • ✅ Deep linking (custom URL schemes + HTTPS)
  • ✅ Flutter 3.7.2+ with null safety

Installation#

Add this to your pubspec.yaml:

dependencies:
  atproto_oauth_flutter:
    path: packages/atproto_oauth_flutter  # For local development

    # OR (when published to pub.dev):
    # atproto_oauth_flutter: ^0.1.0

Then install:

flutter pub get

Quick Start#

Here's a complete working example to get you started in 5 minutes:

import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';

void main() async {
  // 1. Initialize the client
  final client = FlutterOAuthClient(
    clientMetadata: ClientMetadata(
      clientId: 'http://localhost',  // For development
      redirectUris: ['myapp://oauth/callback'],
      scope: 'atproto transition:generic',
    ),
  );

  // 2. Sign in with a handle
  try {
    final session = await client.signIn('alice.bsky.social');
    print('Signed in as: ${session.sub}');

    // 3. Use the session for authenticated requests
    final info = await session.getTokenInfo();
    print('Token expires: ${info.expiresAt}');

  } on OAuthCallbackError catch (e) {
    print('OAuth error: ${e.error} - ${e.errorDescription}');
  }

  // 4. Later: restore session on app restart
  final restored = await client.restore('did:plc:abc123');

  // 5. Sign out
  await client.revoke('did:plc:abc123');
}

Next step: Configure platform deep linking (see Platform Setup).

Platform Setup#

OAuth requires deep linking to redirect back to your app after authentication. You must configure both platforms:

iOS Configuration#

Add a custom URL scheme to ios/Runner/Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>  <!-- Your custom scheme -->
    </array>
    <key>CFBundleURLName</key>
    <string>com.example.myapp</string>
  </dict>
</array>

For HTTPS universal links (production), also add:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:example.com</string>
</array>

Then create an apple-app-site-association file on your server at https://example.com/.well-known/apple-app-site-association.

Android Configuration#

Add an intent filter to android/app/src/main/AndroidManifest.xml:

<activity
    android:name=".MainActivity"
    ...>

    <!-- Existing intent filters -->

    <!-- OAuth callback intent filter -->
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <!-- Custom URL scheme -->
        <data android:scheme="myapp" />
    </intent-filter>

    <!-- For HTTPS universal links (production) -->
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />

        <data android:scheme="https" />
        <data android:host="example.com" />
        <data android:pathPrefix="/oauth/callback" />
    </intent-filter>
</activity>

For HTTPS universal links, also create a assetlinks.json file at https://example.com/.well-known/assetlinks.json.

Verify Deep Linking#

Test that deep linking works:

# iOS (simulator)
xcrun simctl openurl booted "myapp://oauth/callback?code=test"

# Android (emulator or device)
adb shell am start -W -a android.intent.action.VIEW -d "myapp://oauth/callback?code=test"

If your app opens, deep linking is configured correctly.

Router Integration (go_router, auto_route, etc.)#

⚠️ 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".

Why This is Needed#

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.

Solution: Use FlutterOAuthRouterHelper#

We provide a helper that makes router configuration easy:

With go_router (Recommended approach):

import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
import 'package:go_router/go_router.dart';

final router = GoRouter(
  routes: [
    // Your app routes...
  ],
  // Use the helper to automatically ignore OAuth callbacks
  redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
    customSchemes: ['myapp'], // Your custom URL scheme(s)
  ),
);

Manual configuration (if you need custom redirect logic):

final router = GoRouter(
  routes: [...],
  redirect: (context, state) {
    // Check if this is an OAuth callback
    if (FlutterOAuthRouterHelper.isOAuthCallback(
      state.uri,
      customSchemes: ['myapp'],
    )) {
      return null; // Let flutter_web_auth_2 handle it
    }

    // Your custom redirect logic here
    if (!isAuthenticated) return '/login';

    return null; // Normal routing
  },
);

Extract scheme from your OAuth config:

final scheme = FlutterOAuthRouterHelper.extractScheme(
  'myapp://oauth/callback'
);
// Returns: 'myapp'

// Use it in your router config
redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
  customSchemes: [scheme],
),

Other Routers#

The same concept applies to other routing packages:

  • auto_route: Use guards to ignore OAuth callback routes
  • beamer: Configure beamGuard to skip OAuth URIs
  • fluro: Add a custom route handler that ignores OAuth schemes

The key is to not process URIs with your custom OAuth scheme - let flutter_web_auth_2 handle them.

API Reference#

FlutterOAuthClient (High-Level)#

Recommended for most apps. Provides a simplified API with sensible defaults.

Constructor#

FlutterOAuthClient({
  required ClientMetadata clientMetadata,
  OAuthResponseMode responseMode = OAuthResponseMode.query,
  bool allowHttp = false,
  FlutterSecureStorage? secureStorage,
  Dio? dio,
  String? plcDirectoryUrl,
  String? handleResolverUrl,
})

Parameters:

  • clientMetadata (required) - Client configuration (see ClientMetadata)
  • responseMode - How OAuth parameters are returned: query (default, URL query string) or fragment (URL fragment)
  • allowHttp - Allow HTTP connections for development (default: false, never use in production)
  • secureStorage - Custom FlutterSecureStorage instance (optional)
  • dio - Custom HTTP client (optional)
  • plcDirectoryUrl - Custom PLC directory URL (default: https://plc.directory)
  • handleResolverUrl - Custom handle resolver URL (default: https://bsky.social)

Methods#

signIn()#

Complete OAuth sign-in flow (authorize + browser + callback).

Future<OAuthSession> signIn(
  String input, {
  AuthorizeOptions? options,
  CancelToken? cancelToken,
})

Parameters:

  • input - Handle (e.g., "alice.bsky.social"), DID (e.g., "did:plc:..."), PDS URL, or auth server URL
  • options - Additional OAuth parameters (optional, see AuthorizeOptions)
  • cancelToken - Dio cancellation token (optional)

Returns: OAuthSession - Authenticated session

Throws:

  • FormatException - Invalid parameters
  • OAuthResolverError - Identity/server resolution failed
  • OAuthCallbackError - OAuth error from server
  • FlutterWebAuth2UserCanceled - User cancelled browser flow

Example:

// Simple sign-in
final session = await client.signIn('alice.bsky.social');

// With custom state
final session = await client.signIn(
  'alice.bsky.social',
  options: AuthorizeOptions(state: 'my-app-state'),
);
restore()#

Restore a stored session (automatically refreshes if expired).

Future<OAuthSession> restore(
  String sub, {
  dynamic refresh = 'auto',
  CancelToken? cancelToken,
})

Parameters:

  • sub - User's DID (e.g., "did:plc:abc123")
  • refresh - Token refresh strategy:
    • 'auto' (default) - Refresh only if expired
    • true - Force refresh even if not expired
    • false - Use cached tokens even if expired
  • cancelToken - Dio cancellation token (optional)

Returns: OAuthSession - Restored session

Throws:

  • Exception - Session not found
  • TokenRefreshError - Refresh failed
  • AuthMethodUnsatisfiableError - Auth method not supported

Example:

// Auto-refresh if expired
final session = await client.restore('did:plc:abc123');

// Force refresh
final fresh = await client.restore('did:plc:abc123', refresh: true);
revoke()#

Revoke a session (sign out).

Future<void> revoke(
  String sub, {
  CancelToken? cancelToken,
})

Parameters:

  • sub - User's DID
  • cancelToken - Dio cancellation token (optional)

Behavior:

  • Calls server's token revocation endpoint (best-effort)
  • Deletes session from local storage (always)
  • Emits deleted event

Example:

await client.revoke('did:plc:abc123');

Properties#

onUpdated#

Stream of session update events (token refresh, etc.).

Stream<SessionUpdatedEvent> get onUpdated

Example:

client.onUpdated.listen((event) {
  print('Session ${event.sub} updated');
});
onDeleted#

Stream of session deletion events (revoke, expiry, errors).

Stream<SessionDeletedEvent> get onDeleted

Example:

client.onDeleted.listen((event) {
  print('Session ${event.sub} deleted: ${event.cause}');
  // Navigate to sign-in screen
});

OAuthClient (Core)#

For advanced use cases. Provides lower-level control over the OAuth flow.

Constructor#

OAuthClient(OAuthClientOptions options)

See OAuthClientOptions for all parameters.

Methods#

authorize()#

Start OAuth authorization flow (returns URL to open in browser).

Future<Uri> authorize(
  String input, {
  AuthorizeOptions? options,
  CancelToken? cancelToken,
})

Parameters: Same as signIn() but returns URL instead of completing flow.

Returns: Uri - Authorization URL to open in browser

Throws: Same as signIn()

Example:

final authUrl = await client.authorize('alice.bsky.social');
// Open authUrl in browser yourself
callback()#

Handle OAuth callback after user authorization.

Future<CallbackResult> callback(
  Map<String, String> params, {
  CallbackOptions? options,
  CancelToken? cancelToken,
})

Parameters:

  • params - Query/fragment parameters from callback URL
  • options - Callback options (see CallbackOptions)
  • cancelToken - Dio cancellation token (optional)

Returns: CallbackResult - Contains session and app state

Throws:

  • OAuthCallbackError - OAuth error or invalid callback

Example:

// Extract params from callback URL
final uri = Uri.parse(callbackUrl);
final params = uri.queryParameters;

// Complete OAuth flow
final result = await client.callback(params);
print('Signed in: ${result.session.sub}');
print('App state: ${result.state}');
restore() and revoke()#

Same as FlutterOAuthClient.

Static Methods#

fetchMetadata()#

Fetch client metadata from a discoverable client ID URL.

static Future<Map<String, dynamic>> fetchMetadata(
  OAuthClientFetchMetadataOptions options,
)

Parameters:

  • options.clientId - HTTPS URL to client metadata JSON
  • options.dio - Custom HTTP client (optional)
  • options.cancelToken - Cancellation token (optional)

Returns: Client metadata as JSON

Example:

final metadata = await OAuthClient.fetchMetadata(
  OAuthClientFetchMetadataOptions(
    clientId: 'https://example.com/client-metadata.json',
  ),
);

Properties#

Same as FlutterOAuthClient (onUpdated, onDeleted).


Types#

ClientMetadata#

OAuth client configuration.

class ClientMetadata {
  final String? clientId;
  final List<String> redirectUris;
  final List<String> responseTypes;
  final List<String> grantTypes;
  final String? scope;
  final String tokenEndpointAuthMethod;
  final String? tokenEndpointAuthSigningAlg;
  final String? jwksUri;
  final Map<String, dynamic>? jwks;
  final String applicationType;
  final String subjectType;
  final String authorizationSignedResponseAlg;
  final String? clientName;
  final String? clientUri;
  final String? policyUri;
  final String? tosUri;
  final String? logoUri;
  final int? defaultMaxAge;
  final bool? requireAuthTime;
  final List<String>? contacts;
  final bool? dpopBoundAccessTokens;
  final List<String>? authorizationDetailsTypes;

  // ... more fields
}

Key Fields:

  • clientId - Client identifier:
    • Discoverable: HTTPS URL to client metadata JSON (production)
    • Loopback: http://localhost (development only)
  • redirectUris - Array of valid redirect URIs (must match deep link configuration)
  • scope - Requested scope (default: "atproto", recommended: "atproto transition:generic")
  • clientName - Human-readable app name
  • dpopBoundAccessTokens - Enable DPoP (recommended: true)

Example:

// Development (loopback client)
final metadata = ClientMetadata(
  clientId: 'http://localhost',
  redirectUris: ['myapp://oauth/callback'],
  scope: 'atproto transition:generic',
);

// Production (discoverable client)
final metadata = ClientMetadata(
  clientId: 'https://example.com/client-metadata.json',
  redirectUris: [
    'myapp://oauth/callback',           // Custom scheme
    'https://example.com/oauth/callback' // Universal link
  ],
  scope: 'atproto transition:generic',
  clientName: 'My Awesome App',
  clientUri: 'https://example.com',
  dpopBoundAccessTokens: true,
);

AuthorizeOptions#

Additional parameters for authorize() / signIn().

class AuthorizeOptions {
  final String? redirectUri;
  final String? state;
  final String? scope;
  final String? nonce;
  final String? display;
  final String? prompt;
  final int? maxAge;
  final Map<String, dynamic>? claims;
  final String? uiLocales;
  final String? idTokenHint;
  final Map<String, dynamic>? authorizationDetails;
}

Key Fields:

  • redirectUri - Override default redirect URI
  • state - Application state to preserve (returned in callback)
  • scope - Override default scope
  • display - Display mode: "touch" (default for mobile), "page", "popup"
  • prompt - Prompt user: "none", "login", "consent", "select_account"

Example:

final session = await client.signIn(
  'alice.bsky.social',
  options: AuthorizeOptions(
    state: jsonEncode({'returnTo': '/home'}),
    prompt: 'login',  // Force re-authentication
  ),
);

CallbackOptions#

Options for callback().

class CallbackOptions {
  final String? redirectUri;
}

Note: redirectUri must match the one used in authorize().

OAuthSession#

Authenticated session with token management.

class OAuthSession {
  final OAuthServerAgent server;
  final String sub;  // User's DID

  // Properties
  String get did => sub;
  Map<String, dynamic> get serverMetadata;

  // Methods
  Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']);
  Future<void> signOut();
  Future<http.Response> fetchHandler(
    String pathname, {
    String method = 'GET',
    Map<String, String>? headers,
    dynamic body,
  });
}

Key Methods:

  • getTokenInfo() - Get current token info (automatically refreshes if expired)
  • signOut() - Revoke tokens and delete session
  • fetchHandler() - Make authenticated HTTP request (with auto-refresh and DPoP)

Example:

final session = await client.signIn('alice.bsky.social');

// Get token info
final info = await session.getTokenInfo();
print('Expires: ${info.expiresAt}');
print('Scope: ${info.scope}');

// Make authenticated request
final response = await session.fetchHandler(
  '/xrpc/com.atproto.repo.getRecord',
  method: 'GET',
);

TokenInfo#

Information about the current access token.

class TokenInfo {
  final DateTime? expiresAt;
  final bool? expired;
  final String scope;
  final String iss;  // Issuer URL
  final String aud;  // Audience (PDS URL)
  final String sub;  // User's DID
}

Errors#

All errors extend Exception and can be caught with standard try-catch.

OAuthCallbackError#

OAuth error from server or invalid callback.

class OAuthCallbackError implements Exception {
  final String? error;              // OAuth error code
  final String? errorDescription;   // Human-readable description
  final String? errorUri;            // URL with more info
  final String? state;               // App state from authorize
  final Map<String, String> params;  // All callback parameters
}

Common error codes:

  • access_denied - User denied authorization
  • invalid_request - Invalid parameters
  • server_error - Server error

Example:

try {
  final session = await client.signIn('alice.bsky.social');
} on OAuthCallbackError catch (e) {
  if (e.error == 'access_denied') {
    print('User cancelled sign-in');
  } else {
    print('OAuth error: ${e.error} - ${e.errorDescription}');
  }
}

OAuthResolverError#

Failed to resolve identity or discover OAuth server.

When thrown:

  • Handle doesn't resolve
  • DID document not found
  • PDS URL missing from DID document
  • OAuth server metadata not found

TokenRefreshError#

Failed to refresh access token.

When thrown:

  • Refresh token expired
  • Refresh token revoked
  • Network error
  • Server error

TokenRevokedError#

Token was revoked (intentional sign-out).

TokenInvalidError#

Token is invalid (rejected by resource server).

AuthMethodUnsatisfiableError#

Client authentication method not supported.


Usage Guide#

Sign In Flow#

Complete example with error handling:

import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';

Future<void> signIn(String handle) async {
  final client = FlutterOAuthClient(
    clientMetadata: ClientMetadata(
      clientId: 'http://localhost',
      redirectUris: ['myapp://oauth/callback'],
      scope: 'atproto transition:generic',
    ),
  );

  try {
    final session = await client.signIn(handle);

    print('✓ Signed in successfully!');
    print('  DID: ${session.sub}');

    final info = await session.getTokenInfo();
    print('  Expires: ${info.expiresAt}');

  } on OAuthCallbackError catch (e) {
    if (e.error == 'access_denied') {
      print('User denied authorization');
    } else {
      print('OAuth error: ${e.error}');
    }
  } catch (e) {
    print('Unexpected error: $e');
  }
}

Session Restoration#

Restore session when app restarts:

Future<OAuthSession?> restoreSession(FlutterOAuthClient client) async {
  final did = await loadSavedDid();
  if (did == null) return null;

  try {
    final session = await client.restore(did);
    print('✓ Session restored for ${session.sub}');
    return session;

  } on TokenRefreshError catch (e) {
    print('❌ Session refresh failed: ${e.message}');
    await clearSavedDid();
    return null;
  }
}

Token Refresh#

Tokens are refreshed automatically:

// Auto-refresh (default)
final session = await client.restore(did);

// Force refresh
final fresh = await client.restore(did, refresh: true);

// Check token status
final info = await session.getTokenInfo();
if (info.expired == true) {
  print('Token will refresh on next API call');
}

Sign Out (Revoke)#

Future<void> signOut(FlutterOAuthClient client, String did) async {
  try {
    await client.revoke(did);
    print('✓ Signed out successfully');
    await clearSavedDid();
  } catch (e) {
    print('⚠ Revoke failed: $e');
    await clearSavedDid();
  }
}

Session Events#

void setupSessionListeners(FlutterOAuthClient client) {
  client.onUpdated.listen((event) {
    print('Session updated: ${event.sub}');
  });

  client.onDeleted.listen((event) {
    print('Session deleted: ${event.sub}');
    navigateToSignIn();
  });
}

Advanced Usage#

Custom Storage Configuration#

final client = FlutterOAuthClient(
  clientMetadata: metadata,
  secureStorage: FlutterSecureStorage(
    iOptions: IOSOptions(
      accessibility: KeychainAccessibility.first_unlock,
    ),
    aOptions: AndroidOptions(
      encryptedSharedPreferences: true,
    ),
  ),
);

Direct OAuthClient Usage#

For full control over the OAuth flow:

final client = OAuthClient(
  OAuthClientOptions(
    responseMode: OAuthResponseMode.query,
    clientMetadata: metadata.toJson(),
    stateStore: MyCustomStateStore(),
    sessionStore: MyCustomSessionStore(),
    runtimeImplementation: FlutterRuntime(),
  ),
);

// Manual flow
final authUrl = await client.authorize('alice.bsky.social');
// Open browser yourself
final result = await client.callback(params);

Decentralization Explained#

This is the critical feature that sets this package apart.

The Problem: Hardcoded Servers#

// ❌ BROKEN - Only works with bsky.social
const authServer = 'https://bsky.social';  // Hardcoded!

The Solution: Dynamic Discovery#

// ✅ CORRECT - Discovers auth server dynamically
await client.signIn('bob.custom-pds.com');

// What happens:
// 1. Resolve handle → DID
// 2. Fetch DID document
// 3. Discover PDS URL
// 4. Fetch PDS metadata
// 5. Discover authorization server
// 6. Complete OAuth with correct server ✅

Why This Matters#

atProto is decentralized. Users can host their data on any PDS. Your app should work with ALL of them.

Real-World Example#

// Alice uses Bluesky
await client.signIn('alice.bsky.social');
// → https://bsky.app

// Bob runs his own
await client.signIn('bob.example.com');
// → https://auth.example.com

// All work! 🎉

Security Features#

Secure Token Storage#

  • iOS: Keychain with device encryption
  • Android: EncryptedSharedPreferences (AES-256)

DPoP (Token Binding)#

  • Binds tokens to cryptographic keys
  • Prevents token theft
  • Every request includes signed proof

PKCE (Code Protection)#

  • SHA-256 challenge/verifier
  • Prevents code interception

State Parameter#

  • CSRF protection
  • One-time use

OAuth Flows#

Authorization Flow#

App → Resolve identity → Discover servers → Generate PKCE/DPoP
  → Open browser → User authenticates → Callback → Exchange code
  → Store session → Return OAuthSession

Token Refresh Flow#

API call → Detect expiration → Acquire lock → Refresh tokens
  → Update storage → Release lock → Retry API call

Troubleshooting#

Deep Linking Not Working#

  1. Check platform configuration (Info.plist / AndroidManifest.xml)
  2. Test manually: xcrun simctl openurl booted "myapp://..."
  3. Verify URL scheme matches redirectUris

OAuth Errors#

  • invalid_request - Check ClientMetadata
  • access_denied - User cancelled
  • server_error - Check server status

Token Refresh Failures#

  • Token expired → User must re-authenticate
  • Session auto-deleted on failure

Migration Guide#

From atproto_oauth#

Before (Broken):

// Only works with bsky.social
final session = await client.signIn('bob.custom-pds.com');  // BROKEN

After (Fixed):

import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';

final client = FlutterOAuthClient(
  clientMetadata: ClientMetadata(
    clientId: 'http://localhost',
    redirectUris: ['myapp://oauth/callback'],
  ),
);

final session = await client.signIn('bob.custom-pds.com');  // WORKS!

Architecture#

Built in 7 layers matching TypeScript original:

  1. Foundation - Types, constants, utilities
  2. Runtime - Crypto abstractions, PKCE, keys
  3. Identity Resolution - DID/handle → PDS discovery (critical for decentralization)
  4. OAuth Discovery - Dynamic server metadata fetching
  5. DPoP - Token binding proofs
  6. OAuth Flow - Authorization, tokens, sessions
  7. Flutter Platform - Secure storage, crypto implementation

Examples#

See example/flutter_oauth_example.dart for complete examples.

Minimal Example#

final client = FlutterOAuthClient(
  clientMetadata: ClientMetadata(
    clientId: 'http://localhost',
    redirectUris: ['myapp://oauth/callback'],
  ),
);

final session = await client.signIn('alice.bsky.social');
print('Signed in: ${session.sub}');

Contributing#

Contributions welcome! Please:

  1. Fork the repo
  2. Create feature branch
  3. Run flutter analyze
  4. Submit PR

License#

MIT License - See LICENSE file


Credits#


Status#

Version: 0.1.0 Status: ✅ Complete - Ready for Testing

Next:

  • Manual testing with real servers
  • Unit/integration tests
  • Publish to pub.dev

Made with ❤️ for the decentralized web