# atproto_oauth_flutter
**Official AT Protocol OAuth client for Flutter** - A complete 1:1 port of the TypeScript `@atproto/oauth-client` package.
[](LICENSE)
## Table of Contents
- [Overview](#overview)
- [Why This Package?](#why-this-package)
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Platform Setup](#platform-setup)
- [iOS Configuration](#ios-configuration)
- [Android Configuration](#android-configuration)
- [Router Integration](#router-integration-go_router-auto_route-etc)
- [API Reference](#api-reference)
- [FlutterOAuthClient (High-Level)](#flutteroauthclient-high-level)
- [OAuthClient (Core)](#oauthclient-core)
- [Types](#types)
- [Errors](#errors)
- [Usage Guide](#usage-guide)
- [Sign In Flow](#sign-in-flow)
- [Session Restoration](#session-restoration)
- [Token Refresh](#token-refresh)
- [Sign Out (Revoke)](#sign-out-revoke)
- [Session Events](#session-events)
- [Advanced Usage](#advanced-usage)
- [Custom Storage Configuration](#custom-storage-configuration)
- [Direct OAuthClient Usage](#direct-oauthclient-usage)
- [Custom Identity Resolution](#custom-identity-resolution)
- [Decentralization Explained](#decentralization-explained)
- [Security Features](#security-features)
- [OAuth Flows](#oauth-flows)
- [Troubleshooting](#troubleshooting)
- [Migration Guide](#migration-guide)
- [Architecture](#architecture)
- [Examples](#examples)
- [Contributing](#contributing)
- [License](#license)
## 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**:
```dart
// ✅ 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`:
```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:
```bash
flutter pub get
```
## Quick Start
Here's a complete working example to get you started in 5 minutes:
```dart
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)).
## 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`:
```xml
CFBundleURLTypes
CFBundleURLSchemes
myapp
CFBundleURLName
com.example.myapp
```
**For HTTPS universal links** (production), also add:
```xml
com.apple.developer.associated-domains
applinks:example.com
```
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`:
```xml
```
**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:
```bash
# 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):
```dart
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):
```dart
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:**
```dart
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
```dart
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](#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).
```dart
Future 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](#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:**
```dart
// 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).
```dart
Future 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:**
```dart
// 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).
```dart
Future 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:**
```dart
await client.revoke('did:plc:abc123');
```
#### Properties
##### `onUpdated`
Stream of session update events (token refresh, etc.).
```dart
Stream get onUpdated
```
**Example:**
```dart
client.onUpdated.listen((event) {
print('Session ${event.sub} updated');
});
```
##### `onDeleted`
Stream of session deletion events (revoke, expiry, errors).
```dart
Stream get onDeleted
```
**Example:**
```dart
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
```dart
OAuthClient(OAuthClientOptions options)
```
See [OAuthClientOptions](#oauthclientoptions) for all parameters.
#### Methods
##### `authorize()`
Start OAuth authorization flow (returns URL to open in browser).
```dart
Future 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:**
```dart
final authUrl = await client.authorize('alice.bsky.social');
// Open authUrl in browser yourself
```
##### `callback()`
Handle OAuth callback after user authorization.
```dart
Future callback(
Map params, {
CallbackOptions? options,
CancelToken? cancelToken,
})
```
**Parameters:**
- `params` - Query/fragment parameters from callback URL
- `options` - Callback options (see [CallbackOptions](#callbackoptions))
- `cancelToken` - Dio cancellation token (optional)
**Returns:** `CallbackResult` - Contains session and app state
**Throws:**
- `OAuthCallbackError` - OAuth error or invalid callback
**Example:**
```dart
// 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.
```dart
static Future