chore: remove atproto_oauth_flutter package

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

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

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

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

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

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

-296
lib/services/oauth_service.dart
···
-
import 'dart:async';
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
import 'package:flutter/foundation.dart';
-
import '../config/environment_config.dart';
-
import '../config/oauth_config.dart';
-
-
/// OAuth Service for atProto authentication using the new
-
/// atproto_oauth_flutter package
-
///
-
/// Key improvements over the old implementation:
-
/// ✅ Proper decentralized OAuth discovery - works with ANY PDS
-
/// (not just bsky.social)
-
/// ✅ Built-in session management - no manual token storage
-
/// ✅ Automatic token refresh with concurrency control
-
/// ✅ Session event streams for updates and deletions
-
/// ✅ Secure storage handled internally
-
/// (iOS Keychain, Android EncryptedSharedPreferences)
-
///
-
/// The new package handles the complete OAuth flow:
-
/// 1. Handle/DID resolution
-
/// 2. PDS discovery from DID document
-
/// 3. Authorization server discovery
-
/// 4. PKCE + DPoP generation
-
/// 5. Browser-based authorization
-
/// 6. Token exchange and storage
-
/// 7. Automatic refresh and revocation
-
class OAuthService {
-
factory OAuthService() => _instance;
-
OAuthService._internal();
-
static final OAuthService _instance = OAuthService._internal();
-
-
FlutterOAuthClient? _client;
-
-
// Session event stream subscriptions
-
StreamSubscription<SessionUpdatedEvent>? _onUpdatedSubscription;
-
StreamSubscription<SessionDeletedEvent>? _onDeletedSubscription;
-
-
/// Initialize the OAuth client
-
///
-
/// This creates a FlutterOAuthClient with:
-
/// - Discoverable client metadata (HTTPS URL)
-
/// - Custom URL scheme for deep linking
-
/// - DPoP enabled for token security
-
/// - Automatic session management
-
Future<void> initialize() async {
-
try {
-
// Get environment configuration
-
final config = EnvironmentConfig.current;
-
-
// Create client with metadata from config
-
// For local development, use custom resolvers
-
_client = FlutterOAuthClient(
-
clientMetadata: OAuthConfig.createClientMetadata(),
-
plcDirectoryUrl: config.plcDirectoryUrl,
-
handleResolverUrl: config.handleResolverUrl,
-
allowHttp: config.isLocal, // Allow HTTP for local development
-
);
-
-
// Set up session event listeners
-
_setupEventListeners();
-
-
if (kDebugMode) {
-
print('✅ FlutterOAuthClient initialized');
-
print(' Environment: ${config.environment}');
-
print(' Client ID: ${OAuthConfig.clientId}');
-
print(' Redirect URI: ${OAuthConfig.customSchemeCallback}');
-
print(' Scope: ${OAuthConfig.scope}');
-
print(' Handle Resolver: ${config.handleResolverUrl}');
-
print(' PLC Directory: ${config.plcDirectoryUrl}');
-
print(' Allow HTTP: ${config.isLocal}');
-
}
-
} catch (e) {
-
if (kDebugMode) {
-
print('❌ Failed to initialize OAuth client: $e');
-
}
-
rethrow;
-
}
-
}
-
-
/// Set up listeners for session events
-
void _setupEventListeners() {
-
if (_client == null) {
-
return;
-
}
-
-
// Listen for session updates (token refresh, etc.)
-
_onUpdatedSubscription = _client!.onUpdated.listen((event) {
-
if (kDebugMode) {
-
print('📝 Session updated for: ${event.sub}');
-
}
-
});
-
-
// Listen for session deletions (revoke, expiry, errors)
-
_onDeletedSubscription = _client!.onDeleted.listen((event) {
-
if (kDebugMode) {
-
print('🗑️ Session deleted for: ${event.sub}');
-
print(' Cause: ${event.cause}');
-
}
-
});
-
}
-
-
/// Sign in with an atProto handle
-
///
-
/// The new package handles the complete OAuth flow:
-
/// 1. Resolves handle → DID (using any handle resolver)
-
/// 2. Fetches DID document to find the user's PDS
-
/// 3. Discovers authorization server from PDS metadata
-
/// 4. Generates PKCE challenge and DPoP keys
-
/// 5. Opens browser for user authorization
-
/// 6. Handles callback and exchanges code for tokens
-
/// 7. Stores session securely (iOS Keychain / Android EncryptedSharedPreferences)
-
///
-
/// This works with ANY PDS - not just bsky.social! 🎉
-
///
-
/// Examples:
-
/// - `signIn('alice.bsky.social')` → Bluesky PDS
-
/// - `signIn('bob.custom-pds.com')` → Custom PDS ✅
-
/// - `signIn('did:plc:abc123')` → Direct DID (skips handle resolution)
-
///
-
/// Returns the authenticated OAuthSession.
-
Future<OAuthSession> signIn(String input) async {
-
try {
-
if (_client == null) {
-
throw Exception(
-
'OAuth client not initialized. Call initialize() first.',
-
);
-
}
-
-
// Validate input
-
final trimmedInput = input.trim();
-
if (trimmedInput.isEmpty) {
-
throw Exception('Please enter a valid handle or DID');
-
}
-
-
if (kDebugMode) {
-
print('🔐 Starting sign-in for: $trimmedInput');
-
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
-
}
-
-
// Call the new package's signIn method
-
// This does EVERYTHING: handle resolution, PDS discovery, OAuth flow,
-
// token storage
-
if (kDebugMode) {
-
print('📞 Calling FlutterOAuthClient.signIn()...');
-
}
-
-
final session = await _client!.signIn(trimmedInput);
-
-
if (kDebugMode) {
-
print('✅ Sign-in successful!');
-
print(' DID: ${session.sub}');
-
print(' PDS: ${session.serverMetadata['issuer'] ?? 'unknown'}');
-
print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
-
}
-
-
return session;
-
} on OAuthCallbackError catch (e, stackTrace) {
-
// OAuth-specific errors (access denied, invalid request, etc.)
-
final errorCode = e.params['error'];
-
final errorDescription = e.params['error_description'] ?? e.message;
-
-
if (kDebugMode) {
-
print('❌ OAuth callback error details:');
-
print(' Error code: $errorCode');
-
print(' Description: $errorDescription');
-
print(' Message: ${e.message}');
-
print(' All params: ${e.params}');
-
print(' Exception type: ${e.runtimeType}');
-
print(' Exception: $e');
-
print(' Stack trace:');
-
print('$stackTrace');
-
}
-
-
if (errorCode == 'access_denied') {
-
throw Exception('Sign in cancelled by user');
-
}
-
-
throw Exception('OAuth error: $errorDescription');
-
} catch (e, stackTrace) {
-
// Catch all other errors including user cancellation
-
if (kDebugMode) {
-
print('❌ Sign in failed - detailed error:');
-
print(' Error type: ${e.runtimeType}');
-
print(' Error: $e');
-
print(' Stack trace:');
-
print('$stackTrace');
-
}
-
-
// Check if user cancelled (flutter_web_auth_2 throws
-
// PlatformException with "CANCELED" code)
-
if (e.toString().contains('CANCELED') ||
-
e.toString().contains('User cancelled')) {
-
throw Exception('Sign in cancelled by user');
-
}
-
-
throw Exception('Sign in failed: $e');
-
}
-
}
-
-
/// Restore a previous session if available
-
///
-
/// The new package handles session restoration automatically:
-
/// - Loads session from secure storage
-
/// - Checks token expiration
-
/// - Automatically refreshes if needed
-
/// - Returns null if no valid session exists
-
///
-
/// Parameters:
-
/// - `did`: 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
-
///
-
/// Returns the restored session or null if no session found.
-
Future<OAuthSession?> restoreSession(
-
String did, {
-
String refresh = 'auto',
-
}) async {
-
try {
-
if (_client == null) {
-
throw Exception(
-
'OAuth client not initialized. Call initialize() first.',
-
);
-
}
-
-
if (kDebugMode) {
-
print('🔄 Attempting to restore session for: $did');
-
}
-
-
// Call the new package's restore method
-
final session = await _client!.restore(did, refresh: refresh);
-
-
if (kDebugMode) {
-
print('✅ Session restored successfully');
-
final info = await session.getTokenInfo();
-
print(' Token expires: ${info.expiresAt}');
-
}
-
-
return session;
-
} on Exception catch (e) {
-
if (kDebugMode) {
-
print('⚠️ Failed to restore session: $e');
-
}
-
return null;
-
}
-
}
-
-
/// Sign out and revoke session
-
///
-
/// The new package handles revocation properly:
-
/// - Calls server's token revocation endpoint (best-effort)
-
/// - Deletes session from secure storage (always)
-
/// - Emits 'deleted' event
-
///
-
/// This is a complete sign-out with server-side revocation! 🎉
-
Future<void> signOut(String did) async {
-
try {
-
if (_client == null) {
-
throw Exception(
-
'OAuth client not initialized. Call initialize() first.',
-
);
-
}
-
-
if (kDebugMode) {
-
print('👋 Signing out: $did');
-
}
-
-
// Call the new package's revoke method
-
await _client!.revoke(did);
-
-
if (kDebugMode) {
-
print('✅ Sign out successful');
-
}
-
} catch (e) {
-
if (kDebugMode) {
-
print('⚠️ Sign out failed: $e');
-
}
-
// Re-throw to let caller handle
-
rethrow;
-
}
-
}
-
-
/// Get the current OAuth client instance
-
///
-
/// Useful for advanced use cases like:
-
/// - Listening to session events directly
-
/// - Using lower-level OAuth methods
-
FlutterOAuthClient? get client => _client;
-
-
/// Clean up resources
-
void dispose() {
-
_onUpdatedSubscription?.cancel();
-
_onDeletedSubscription?.cancel();
-
}
-
}
···
-337
packages/atproto_oauth_flutter/CHUNK3_IMPLEMENTATION_REPORT.md
···
-
# Chunk 3 Implementation Report: Identity Resolution Layer
-
-
## Status: ✅ COMPLETE
-
-
Implementation Date: 2025-10-27
-
Implementation Time: ~2 hours
-
Lines of Code: ~1,431 lines across 9 Dart files
-
-
## Overview
-
-
Successfully ported the **atProto Identity Resolution Layer** from TypeScript to Dart with full 1:1 API compatibility. This is the **most critical component for atProto decentralization**, enabling users to host their data on any Personal Data Server (PDS) instead of being locked to bsky.social.
-
-
## What Was Implemented
-
-
### Core Files Created
-
-
```
-
lib/src/identity/
-
├── constants.dart (30 lines) - atProto constants
-
├── did_document.dart (124 lines) - DID document parsing
-
├── did_helpers.dart (227 lines) - DID validation utilities
-
├── did_resolver.dart (269 lines) - DID → Document resolution
-
├── handle_helpers.dart (31 lines) - Handle validation
-
├── handle_resolver.dart (209 lines) - Handle → DID resolution
-
├── identity_resolver.dart (378 lines) - Main resolver (orchestrates everything)
-
├── identity_resolver_error.dart (53 lines) - Error types
-
├── identity.dart (43 lines) - Public API exports
-
└── README.md (267 lines) - Comprehensive documentation
-
```
-
-
### Additional Files
-
-
```
-
test/identity_resolver_test.dart (231 lines) - 21 passing unit tests
-
example/identity_resolver_example.dart (95 lines) - Usage examples
-
```
-
-
## Critical Functionality Implemented
-
-
### 1. Handle Resolution (Handle → DID)
-
-
Resolves atProto handles like `alice.bsky.social` to DIDs using XRPC:
-
-
```dart
-
final resolver = XrpcHandleResolver('https://bsky.social');
-
final did = await resolver.resolve('alice.bsky.social');
-
// Returns: did:plc:...
-
```
-
-
**Features:**
-
- XRPC-based resolution via `com.atproto.identity.resolveHandle`
-
- Proper error handling for invalid/non-existent handles
-
- Built-in caching with configurable TTL (1 hour default)
-
- Validates DIDs are proper atProto DIDs (plc or web)
-
-
### 2. DID Resolution (DID → DID Document)
-
-
Fetches DID documents from PLC directory or HTTPS:
-
-
```dart
-
final resolver = AtprotoDidResolver();
-
-
// Resolve did:plc from PLC directory
-
final doc = await resolver.resolve('did:plc:z72i7hdynmk6r22z27h6abc2');
-
-
// Resolve did:web via HTTPS
-
final doc2 = await resolver.resolve('did:web:example.com');
-
```
-
-
**Features:**
-
- `did:plc` method: Queries https://plc.directory/
-
- `did:web` method: Fetches from HTTPS URLs (/.well-known/did.json or /did.json)
-
- Validates DID document structure
-
- Caching with 24-hour default TTL
-
- No HTTP redirects (security)
-
-
### 3. Identity Resolution (Handle/DID → Complete Info)
-
-
Main resolver that orchestrates everything:
-
-
```dart
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Resolve handle to full identity info
-
final info = await resolver.resolve('alice.bsky.social');
-
print('DID: ${info.did}');
-
print('Handle: ${info.handle}');
-
print('PDS: ${info.pdsUrl}');
-
-
// Or resolve directly to PDS URL (most common use case)
-
final pdsUrl = await resolver.resolveToPds('alice.bsky.social');
-
```
-
-
**Features:**
-
- Accepts both handles and DIDs as input
-
- Enforces bi-directional validation (security)
-
- Extracts PDS URL from DID document
-
- Validates handle in DID document matches original
-
- Complete error handling with specific error types
-
- Configurable caching at all layers
-
-
### 4. Bi-directional Validation (CRITICAL for Security)
-
-
For every resolution, we validate both directions:
-
-
1. **Handle → DID** resolution succeeds
-
2. **DID Document** contains the original handle
-
3. **Both directions** agree
-
-
This prevents:
-
- Handle hijacking
-
- DID spoofing
-
- MITM attacks
-
-
### 5. DID Document Parsing
-
-
Full W3C DID Document support:
-
-
```dart
-
final doc = DidDocument.fromJson(json);
-
-
// Extract atProto-specific info
-
final pdsUrl = doc.extractPdsUrl();
-
final handle = doc.extractNormalizedHandle();
-
-
// Access standard DID doc fields
-
print(doc.id); // DID
-
print(doc.alsoKnownAs); // Alternative identifiers
-
print(doc.service); // Service endpoints
-
```
-
-
### 6. Validation Utilities
-
-
**DID Validation:**
-
- `isDid()` - Checks if string is valid DID
-
- `isDidPlc()` - Validates did:plc format (exactly 32 chars, base32)
-
- `isDidWeb()` - Validates did:web format
-
- `isAtprotoDid()` - Checks if DID uses blessed methods
-
- `assertDid()` - Throws detailed errors for invalid DIDs
-
-
**Handle Validation:**
-
- `isValidHandle()` - Validates handle format per spec
-
- `normalizeHandle()` - Converts to lowercase
-
- `asNormalizedHandle()` - Validates and normalizes
-
-
### 7. Caching Layer
-
-
Two-tier caching system:
-
-
**Handle Cache:**
-
- TTL: 1 hour default (handles can change)
-
- In-memory implementation
-
- Optional `noCache` bypass
-
-
**DID Document Cache:**
-
- TTL: 24 hours default (more stable)
-
- In-memory implementation
-
- Optional `noCache` bypass
-
-
### 8. Error Handling
-
-
Comprehensive error hierarchy:
-
-
```dart
-
IdentityResolverError - Base error
-
├── InvalidDidError - Malformed DID
-
├── InvalidHandleError - Malformed handle
-
├── HandleResolverError - Handle resolution failed
-
└── DidResolverError - DID resolution failed
-
```
-
-
All errors include:
-
- Detailed error messages
-
- Original cause (if any)
-
- Context about what failed
-
-
## Testing
-
-
### Unit Tests: ✅ 21 tests, all passing
-
-
```bash
-
$ flutter test test/identity_resolver_test.dart
-
All tests passed!
-
```
-
-
**Test Coverage:**
-
- DID validation (did:plc, did:web, general DIDs)
-
- DID method extraction
-
- URL ↔ did:web conversion
-
- Handle validation and normalization
-
- DID document parsing
-
- PDS URL extraction
-
- Handle extraction from DID docs
-
- Cache functionality (store, retrieve, expire)
-
- Error types and messages
-
-
### Static Analysis: ✅ No issues
-
-
```bash
-
$ flutter analyze lib/src/identity/
-
No issues found!
-
```
-
-
## Source Traceability
-
-
This implementation is a 1:1 port from official atProto TypeScript packages:
-
-
**Source Files:**
-
- `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts`
-
- `/home/bretton/Code/atproto/packages/internal/identity-resolver/src/`
-
- `/home/bretton/Code/atproto/packages/internal/did-resolver/src/`
-
- `/home/bretton/Code/atproto/packages/internal/handle-resolver/src/`
-
-
**Key Differences from TypeScript:**
-
1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups, use XRPC only
-
2. **Dio instead of Fetch**: Using Dio HTTP client
-
3. **Explicit Types**: Dart's stricter type system
-
4. **Simplified Caching**: In-memory only (TypeScript has more backends)
-
-
## Why This Is Critical for Decentralization
-
-
### Problem Without This Layer
-
-
Without proper identity resolution:
-
- Apps hardcode `bsky.social` as the only server
-
- Users can't use custom domains
-
- Self-hosting is impossible
-
- atProto becomes centralized like Twitter/X
-
-
### Solution With This Layer
-
-
✅ **Users host data on any PDS** they choose
-
✅ **Custom domain handles** work (e.g., `alice.example.com`)
-
✅ **Identity is portable** (change PDS without losing DID)
-
✅ **True decentralization** is achieved
-
-
## Real-World Usage Example
-
-
```dart
-
// Create resolver
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Resolve custom domain handle (NOT bsky.social!)
-
final info = await resolver.resolve('jay.bsky.team');
-
-
// Result:
-
// - DID: did:plc:...
-
// - Handle: jay.bsky.team (validated)
-
// - PDS: https://bsky.team (NOT hardcoded!)
-
-
// This user hosts their data on their own PDS!
-
```
-
-
## Performance Characteristics
-
-
**With Cold Cache:**
-
- Handle → PDS: ~200-500ms (1 handle lookup + 1 DID fetch)
-
- DID → PDS: ~100-200ms (1 DID fetch only)
-
-
**With Warm Cache:**
-
- Any resolution: <1ms (in-memory lookup)
-
-
**Recommendations:**
-
- Enable caching (default)
-
- Use connection pooling (Dio does this automatically)
-
- Consider warming cache for known users
-
- Monitor resolver errors and timeouts
-
-
## Security Considerations
-
-
1. ✅ **Bi-directional Validation**: Always enforced
-
2. ✅ **HTTPS Only**: All requests use HTTPS (except localhost)
-
3. ✅ **No Redirects**: HTTP redirects rejected
-
4. ✅ **Input Validation**: All handles/DIDs validated before use
-
5. ✅ **Cache Poisoning Protection**: TTLs prevent stale data
-
-
## Dependencies
-
-
**Required:**
-
- `dio: ^5.9.0` - HTTP client (already in pubspec.yaml)
-
-
**No additional dependencies needed!**
-
-
## Future Improvements
-
-
Potential enhancements (not required for MVP):
-
- [ ] Add DNS-over-HTTPS for handle resolution
-
- [ ] Implement .well-known handle resolution
-
- [ ] Add persistent cache backends (SQLite, Hive)
-
- [ ] Support custom DID methods beyond plc/web
-
- [ ] Add metrics and observability
-
- [ ] Implement resolver timeouts and retries
-
-
## Integration Checklist
-
-
To integrate this into OAuth flow:
-
-
- [x] Identity resolver implemented
-
- [x] Unit tests passing
-
- [x] Static analysis clean
-
- [x] Documentation complete
-
- [ ] Export from main package (add to lib/atproto_oauth_flutter.dart)
-
- [ ] Use in OAuth client for PDS discovery
-
- [ ] Test with real handles (bretton.dev, etc.)
-
-
## Files to Review
-
-
**Implementation:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/lib/src/identity/`
-
-
**Tests:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/test/identity_resolver_test.dart`
-
-
**Examples:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/example/identity_resolver_example.dart`
-
-
**Documentation:**
-
- `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/lib/src/identity/README.md`
-
-
## Conclusion
-
-
✅ **Chunk 3 is COMPLETE and production-ready.**
-
-
The identity resolution layer has been successfully ported from TypeScript with:
-
- Full API compatibility
-
- Comprehensive testing
-
- Detailed documentation
-
- Clean static analysis
-
- Real-world usage examples
-
-
This implementation enables true atProto decentralization by ensuring apps discover where each user's data lives, rather than hardcoding centralized servers.
-
-
**Next Steps:** Integrate this into the OAuth client (Chunk 4+) to complete the full OAuth flow with proper PDS discovery.
···
-373
packages/atproto_oauth_flutter/CHUNK_5_IMPLEMENTATION.md
···
-
# Chunk 5 Implementation: Session Management Layer
-
-
## Overview
-
-
This chunk implements the session management layer for atproto OAuth in Dart, providing a complete 1:1 port of the TypeScript implementation from `@atproto/oauth-client`.
-
-
## Files Created
-
-
### Core Session Files
-
-
1. **`lib/src/session/state_store.dart`**
-
- `InternalStateData` - Ephemeral OAuth state during authorization flow
-
- `StateStore` - Abstract interface for state storage
-
- Stores PKCE verifiers, state parameters, nonces, and other temporary OAuth data
-
-
2. **`lib/src/session/oauth_session.dart`**
-
- `TokenSet` - OAuth token container (access, refresh, metadata)
-
- `TokenInfo` - Token information for client use
-
- `Session` - Session with DPoP key and tokens
-
- `OAuthSession` - High-level API for authenticated requests
-
- `SessionGetterInterface` - Abstract interface to avoid circular dependencies
-
-
3. **`lib/src/session/session_getter.dart`**
-
- `SessionGetter` - Main session management class
-
- `CachedGetter` - Generic caching/refresh utility (base class)
-
- `SimpleStore` - Abstract key-value store interface
-
- `GetCachedOptions` - Options for cache retrieval
-
- Event types: `SessionUpdatedEvent`, `SessionDeletedEvent`
-
- Placeholder types: `OAuthServerFactory`, `Runtime`, `OAuthResponseError`
-
-
4. **`lib/src/session/session.dart`**
-
- Barrel file exporting all session-related classes
-
-
## Key Design Decisions
-
-
### 1. Avoiding Circular Dependencies
-
-
**Problem**: `OAuthSession` needs `SessionGetter`, but `SessionGetter` returns `Session` objects that are used by `OAuthSession`.
-
-
**Solution**: Created `SessionGetterInterface` in `oauth_session.dart` as an abstract interface. `SessionGetter` in `session_getter.dart` will implement this interface in later chunks when all dependencies are available.
-
-
```dart
-
// oauth_session.dart
-
abstract class SessionGetterInterface {
-
Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale});
-
Future<void> delStored(AtprotoDid sub, [Object? cause]);
-
}
-
-
// OAuthSession uses this interface
-
class OAuthSession {
-
final SessionGetterInterface sessionGetter;
-
// ...
-
}
-
```
-
-
### 2. TypeScript EventEmitter → Dart Streams
-
-
**TypeScript Pattern**:
-
```typescript
-
class SessionGetter extends EventEmitter {
-
emit('updated', session)
-
emit('deleted', sub)
-
}
-
```
-
-
**Dart Pattern**:
-
```dart
-
class SessionGetter {
-
final _updatedController = StreamController<SessionUpdatedEvent>.broadcast();
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
-
-
final _deletedController = StreamController<SessionDeletedEvent>.broadcast();
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
-
-
void dispose() {
-
_updatedController.close();
-
_deletedController.close();
-
}
-
}
-
```
-
-
### 3. CachedGetter Implementation
-
-
The `CachedGetter` is a critical component that ensures:
-
- At most one token refresh happens at a time for a given user
-
- Concurrent requests wait for in-flight refreshes
-
- Stale values are detected and refreshed automatically
-
- Errors trigger deletion when appropriate
-
-
**Key Features**:
-
- Generic `CachedGetter<K, V>` base class
-
- `SessionGetter` extends `CachedGetter<AtprotoDid, Session>`
-
- Pending request tracking prevents duplicate refreshes
-
- Configurable staleness detection with randomization (reduces thundering herd)
-
-
### 4. Placeholder Types for Future Chunks
-
-
Since this is Chunk 5 and some dependencies come from later chunks, we use placeholders:
-
-
```dart
-
// In oauth_session.dart
-
abstract class OAuthServerAgent {
-
OAuthAuthorizationServerMetadata get serverMetadata;
-
Map<String, dynamic> get dpopKey;
-
String get authMethod;
-
Future<void> revoke(String token);
-
Future<TokenSet> refresh(TokenSet tokenSet);
-
}
-
-
// In session_getter.dart
-
abstract class OAuthServerFactory {
-
Future<OAuthServerAgent> fromIssuer(
-
String issuer,
-
String authMethod,
-
Map<String, dynamic> dpopKey,
-
);
-
}
-
-
abstract class Runtime {
-
bool get hasImplementationLock;
-
Future<T> usingLock<T>(String key, Future<T> Function() callback);
-
Future<List<int>> sha256(List<int> data);
-
}
-
-
class OAuthResponseError implements Exception {
-
final int status;
-
final String? error;
-
final String? errorDescription;
-
}
-
```
-
-
These will be replaced with actual implementations in later chunks.
-
-
### 5. Token Expiration Logic
-
-
**TypeScript**:
-
```typescript
-
expires_at != null &&
-
new Date(expires_at).getTime() <
-
Date.now() + 10e3 + 30e3 * Math.random()
-
```
-
-
**Dart**:
-
```dart
-
if (tokenSet.expiresAt == null) return false;
-
-
final expiresAt = DateTime.parse(tokenSet.expiresAt!);
-
final now = DateTime.now();
-
-
// 10 seconds buffer + 0-30 seconds randomization
-
final buffer = Duration(
-
milliseconds: 10000 + (math.Random().nextDouble() * 30000).toInt(),
-
);
-
-
return expiresAt.isBefore(now.add(buffer));
-
```
-
-
The randomization prevents multiple instances from refreshing simultaneously.
-
-
### 6. HTTP Client Integration
-
-
**TypeScript** uses global `fetch`:
-
```typescript
-
const response = await fetch(url, { method: 'POST', ... })
-
```
-
-
**Dart** uses `package:http`:
-
```dart
-
import 'package:http/http.dart' as http;
-
-
final request = http.Request(method, url);
-
request.headers.addAll(headers);
-
request.body = body;
-
final streamedResponse = await _httpClient.send(request);
-
return await http.Response.fromStream(streamedResponse);
-
```
-
-
### 7. Record Types for Pending Results
-
-
**TypeScript**:
-
```typescript
-
type PendingItem<V> = Promise<{ value: V; isFresh: boolean }>
-
```
-
-
**Dart (using Dart 3.0 records)**:
-
```dart
-
class _PendingItem<V> {
-
final Future<({V value, bool isFresh})> future;
-
_PendingItem(this.future);
-
}
-
```
-
-
## API Compatibility
-
-
### Session Management
-
-
| TypeScript | Dart | Notes |
-
|------------|------|-------|
-
| `SessionGetter.getSession(sub, refresh?)` | `SessionGetter.getSession(sub, [refresh])` | Identical API |
-
| `SessionGetter.addEventListener('updated', ...)` | `SessionGetter.onUpdated.listen(...)` | Stream-based |
-
| `SessionGetter.addEventListener('deleted', ...)` | `SessionGetter.onDeleted.listen(...)` | Stream-based |
-
-
### OAuth Session
-
-
| TypeScript | Dart | Notes |
-
|------------|------|-------|
-
| `session.getTokenInfo(refresh?)` | `session.getTokenInfo([refresh])` | Identical API |
-
| `session.signOut()` | `session.signOut()` | Identical API |
-
| `session.fetchHandler(pathname, init?)` | `session.fetchHandler(pathname, {method, headers, body})` | Named parameters |
-
-
## Testing Strategy
-
-
The implementation compiles successfully with only 2 minor linting suggestions:
-
- Use null-aware operator in one place (style preference)
-
- Use `rethrow` in one catch block (style preference)
-
-
Both are cosmetic and don't affect functionality.
-
-
### Manual Testing Checklist
-
-
When later chunks provide concrete implementations:
-
-
```dart
-
// 1. Create a session
-
final session = Session(
-
dpopKey: {'kty': 'EC', ...},
-
authMethod: 'none',
-
tokenSet: TokenSet(
-
iss: 'https://bsky.social',
-
sub: 'did:plc:abc123',
-
aud: 'https://bsky.social',
-
scope: 'atproto',
-
accessToken: 'token',
-
refreshToken: 'refresh',
-
expiresAt: DateTime.now().add(Duration(hours: 1)).toIso8601String(),
-
),
-
);
-
-
// 2. Store in session getter
-
await sessionGetter.setStored('did:plc:abc123', session);
-
-
// 3. Retrieve (should not refresh)
-
final retrieved = await sessionGetter.getSession('did:plc:abc123', false);
-
assert(retrieved.tokenSet.accessToken == 'token');
-
-
// 4. Force refresh
-
final refreshed = await sessionGetter.getSession('did:plc:abc123', true);
-
// Should have new tokens
-
-
// 5. Check expiration
-
assert(!session.tokenSet.isExpired);
-
-
// 6. Delete
-
await sessionGetter.delStored('did:plc:abc123');
-
final deleted = await sessionGetter.getSession('did:plc:abc123');
-
// Should throw or return null
-
```
-
-
## Security Considerations
-
-
### 1. Token Storage
-
-
**Critical**: Tokens MUST be stored securely:
-
```dart
-
// ❌ NEVER do this
-
final prefs = await SharedPreferences.getInstance();
-
await prefs.setString('token', tokenSet.toJson().toString());
-
-
// ✅ Use flutter_secure_storage (implemented in Chunk 7)
-
final storage = FlutterSecureStorage();
-
await storage.write(
-
key: 'session_$sub',
-
value: jsonEncode(session.toJson()),
-
);
-
```
-
-
### 2. Token Logging
-
-
**Never log sensitive data**:
-
```dart
-
// ❌ NEVER
-
print('Access token: ${tokenSet.accessToken}');
-
-
// ✅ Safe logging
-
print('Token expires at: ${tokenSet.expiresAt}');
-
print('Token type: ${tokenSet.tokenType}');
-
```
-
-
### 3. Session Lifecycle
-
-
Sessions are automatically deleted when:
-
- Token refresh fails with `invalid_grant`
-
- Token is revoked by the server
-
- User explicitly signs out
-
- Token is marked invalid by resource server
-
-
### 4. Concurrency Protection
-
-
The `SessionGetter` includes multiple layers of protection:
-
1. **Runtime locks**: Prevent simultaneous refreshes across app instances
-
2. **Pending request tracking**: Coalesce concurrent requests
-
3. **Store-based detection**: Detect concurrent refreshes without locks
-
4. **Randomized expiry**: Reduce thundering herd at startup
-
-
## Integration with Other Chunks
-
-
### Dependencies (Available)
-
- ✅ Chunk 1: Error types (`TokenRefreshError`, `TokenRevokedError`, etc.)
-
- ✅ Chunk 1: Utilities (`CustomEventTarget`, `CancellationToken`)
-
- ✅ Chunk 1: Constants
-
-
### Dependencies (Future Chunks)
-
- ⏳ Chunk 6: `OAuthServerAgent` implementation
-
- ⏳ Chunk 7: `OAuthServerFactory` implementation
-
- ⏳ Chunk 7: `Runtime` implementation
-
- ⏳ Chunk 7: Concrete storage implementations (SecureSessionStore)
-
- ⏳ Chunk 8: DPoP fetch wrapper integration
-
-
## File Structure
-
-
```
-
lib/src/session/
-
├── state_store.dart # OAuth state storage (PKCE, nonce, etc.)
-
├── oauth_session.dart # Session types and OAuthSession class
-
├── session_getter.dart # SessionGetter and CachedGetter
-
└── session.dart # Barrel file
-
```
-
-
## Next Steps
-
-
For Chunk 6+:
-
1. Implement `OAuthServerAgent` with actual token refresh logic
-
2. Implement `OAuthServerFactory` for creating server agents
-
3. Implement `Runtime` with platform-specific lock mechanisms
-
4. Create concrete `SessionStore` using `flutter_secure_storage`
-
5. Create concrete `StateStore` for ephemeral OAuth state
-
6. Integrate DPoP proof generation in `fetchHandler`
-
7. Add proper error handling for network failures
-
8. Implement session migration for schema changes
-
-
## Performance Notes
-
-
### Memory Management
-
- `SessionGetter` maintains a `_pending` map for in-flight requests
-
- This map is automatically cleaned up when requests complete
-
- Stream controllers must be disposed via `dispose()`
-
- HTTP clients should be reused, not created per request
-
-
### Optimization Opportunities
-
- The randomized expiry buffer (0-30s) spreads refresh load
-
- Pending request coalescing reduces redundant network calls
-
- Cached values avoid unnecessary store reads
-
-
## Known Limitations
-
-
1. **No DPoP yet**: `fetchHandler` doesn't generate DPoP proofs (Chunk 8)
-
2. **No actual refresh**: `OAuthServerAgent.refresh()` is a placeholder
-
3. **No secure storage**: Storage implementations come in Chunk 7
-
4. **No runtime locks**: Lock implementation comes in Chunk 7
-
-
These are intentional - this chunk focuses on the session management *structure*, with concrete implementations following in later chunks.
-
-
## Conclusion
-
-
Chunk 5 successfully implements the session management layer with:
-
- ✅ Complete API compatibility with TypeScript
-
- ✅ Proper abstractions for future implementations
-
- ✅ Security-conscious design (even if storage is placeholder)
-
- ✅ Event-driven architecture using Dart streams
-
- ✅ Comprehensive error handling
-
- ✅ Zero compilation errors
-
-
The code is production-ready structurally and awaits concrete implementations from subsequent chunks.
···
-102
packages/atproto_oauth_flutter/IMPLEMENTATION_PLAN.md
···
-
# Implementation Plan: atproto_oauth_flutter
-
-
## Overview
-
1:1 port of `@atproto/oauth-client` from TypeScript to Dart/Flutter
-
-
**Source:** `/home/bretton/Code/atproto/packages/oauth/oauth-client/`
-
**Target:** `/home/bretton/Code/coves_flutter/packages/atproto_oauth_flutter/`
-
-
## Implementation Chunks
-
-
### Chunk 1: Foundation Layer ✅
-
**Files to port:**
-
- `src/constants.ts` → `lib/src/constants.dart`
-
- `src/types.ts` → `lib/src/types.dart`
-
- `src/errors/*.ts` → `lib/src/errors/*.dart`
-
- `src/util.ts` → `lib/src/util.dart`
-
-
**Dependencies:** None (pure types and utilities)
-
**Estimated LOC:** ~300 lines
-
-
### Chunk 2: Crypto & DPoP Layer
-
**Files to port:**
-
- `src/runtime-implementation.ts` → `lib/src/runtime/runtime_implementation.dart`
-
- `src/runtime.ts` → `lib/src/runtime/runtime.dart`
-
- `src/fetch-dpop.ts` → `lib/src/dpop/fetch_dpop.dart`
-
- `src/lock.ts` → `lib/src/utils/lock.dart`
-
-
**Dependencies:** Chunk 1 (types, errors)
-
**Dart packages:** `crypto`, `pointycastle`, `convert`
-
**Estimated LOC:** ~500 lines
-
-
### Chunk 3: Identity Resolution
-
**Files to port:**
-
- `src/identity-resolver.ts` → `lib/src/identity/identity_resolver.dart`
-
-
**Dependencies:** Chunk 1, Chunk 2
-
**Estimated LOC:** ~200 lines
-
-
### Chunk 4: OAuth Protocol Layer
-
**Files to port:**
-
- `src/oauth-authorization-server-metadata-resolver.ts` → `lib/src/oauth/authorization_server_metadata_resolver.dart`
-
- `src/oauth-protected-resource-metadata-resolver.ts` → `lib/src/oauth/protected_resource_metadata_resolver.dart`
-
- `src/oauth-resolver.ts` → `lib/src/oauth/oauth_resolver.dart`
-
- `src/oauth-client-auth.ts` → `lib/src/oauth/client_auth.dart`
-
- `src/validate-client-metadata.ts` → `lib/src/oauth/validate_client_metadata.dart`
-
- `src/oauth-callback-error.ts` → `lib/src/errors/oauth_callback_error.dart`
-
- `src/oauth-resolver-error.ts` → `lib/src/errors/oauth_resolver_error.dart`
-
- `src/oauth-response-error.ts` → `lib/src/errors/oauth_response_error.dart`
-
-
**Dependencies:** Chunk 1, Chunk 2, Chunk 3
-
**Estimated LOC:** ~800 lines
-
-
### Chunk 5: Session Management
-
**Files to port:**
-
- `src/session-getter.ts` → `lib/src/session/session_getter.dart`
-
- `src/state-store.ts` → `lib/src/session/state_store.dart`
-
- `src/oauth-session.ts` → `lib/src/session/oauth_session.dart`
-
-
**Dependencies:** Chunk 1, Chunk 2
-
**Estimated LOC:** ~400 lines
-
-
### Chunk 6: Core OAuth Client
-
**Files to port:**
-
- `src/oauth-server-agent.ts` → `lib/src/client/oauth_server_agent.dart`
-
- `src/oauth-server-factory.ts` → `lib/src/client/oauth_server_factory.dart`
-
- `src/oauth-client.ts` → `lib/src/client/oauth_client.dart`
-
-
**Dependencies:** All previous chunks
-
**Estimated LOC:** ~700 lines
-
-
### Chunk 7: Flutter Platform Layer (NEW)
-
**Files to create:**
-
- `lib/src/platform/flutter_stores.dart` - Secure storage implementations
-
- `lib/src/platform/flutter_runtime.dart` - Flutter crypto implementations
-
- `lib/src/platform/flutter_oauth_client.dart` - Flutter-specific client
-
- `lib/atproto_oauth_flutter.dart` - Main export file
-
-
**Dependencies:** All previous chunks, Flutter packages
-
**Estimated LOC:** ~300 lines
-
-
## Agent Execution Plan
-
-
Each chunk will be implemented by a sub-agent with:
-
1. **Implementation Agent** - Ports TypeScript to Dart
-
2. **Review Agent** - Reviews for bugs, best practices, API compatibility
-
-
## Success Criteria
-
-
- [ ] All TypeScript files ported to Dart
-
- [ ] API matches Expo package (same method signatures)
-
- [ ] Zero compilation errors
-
- [ ] Proper decentralization (PDS discovery works)
-
- [ ] Works with bretton.dev (custom PDS)
-
-
## Testing Plan
-
-
After all chunks complete:
-
1. Unit tests for each module
-
2. Integration test with bretton.dev
-
3. Integration test with bsky.social
-
4. Session persistence test
-
5. Token refresh test
···
-394
packages/atproto_oauth_flutter/IMPLEMENTATION_STATUS.md
···
-
# atproto_oauth_flutter - Implementation Status
-
-
## Overview
-
-
This is a **complete 1:1 port** of the TypeScript `@atproto/oauth-client` package to Dart/Flutter.
-
-
**Status**: ✅ **COMPLETE - Ready for Testing**
-
-
All 7 chunks have been implemented and the library compiles without errors.
-
-
## Implementation Chunks
-
-
### ✅ Chunk 1: Foundation & Type System
-
**Status**: Complete
-
**Files**: 5 files, ~800 LOC
-
**Location**: `lib/src/types.dart`, `lib/src/constants.dart`, etc.
-
-
Core types and constants:
-
- ClientMetadata, AuthorizeOptions, CallbackOptions
-
- OAuth/OIDC constants
-
- Utility functions (base64url, URL parsing, etc.)
-
-
### ✅ Chunk 2: Runtime & Crypto Abstractions
-
**Status**: Complete
-
**Files**: 4 files, ~500 LOC
-
**Location**: `lib/src/runtime/`, `lib/src/utils/`
-
-
Runtime abstractions:
-
- RuntimeImplementation interface
-
- Key interface (for JWT signing)
-
- Lock implementation (for concurrency control)
-
- PKCE generation, JWK thumbprints
-
-
### ✅ Chunk 3: Identity Resolution
-
**Status**: Complete
-
**Files**: 11 files, ~1,200 LOC
-
**Location**: `lib/src/identity/`
-
-
DID and handle resolution:
-
- DID resolver (did:plc, did:web)
-
- Handle resolver (XRPC-based)
-
- DID document parsing
-
- Caching with TTL
-
-
### ✅ Chunk 4: OAuth Metadata & Discovery
-
**Status**: Complete
-
**Files**: 5 files, ~800 LOC
-
**Location**: `lib/src/oauth/`
-
-
OAuth server discovery:
-
- Authorization server metadata (/.well-known/oauth-authorization-server)
-
- Protected resource metadata (/.well-known/oauth-protected-resource)
-
- Client authentication negotiation
-
- PAR (Pushed Authorization Request) support
-
-
### ✅ Chunk 5: DPoP (Demonstrating Proof of Possession)
-
**Status**: Complete
-
**Files**: 2 files, ~400 LOC
-
**Location**: `lib/src/dpop/`
-
-
DPoP implementation:
-
- DPoP proof generation
-
- Nonce management
-
- Access token hash (ath claim)
-
- Dio interceptor for automatic DPoP header injection
-
-
### ✅ Chunk 6: OAuth Flow & Session Management
-
**Status**: Complete
-
**Files**: 8 files, ~2,000 LOC
-
**Location**: `lib/src/client/`, `lib/src/session/`, `lib/src/oauth/`
-
-
Complete OAuth flow:
-
- OAuthClient (main API)
-
- Token management (access, refresh, ID tokens)
-
- Session storage and retrieval
-
- Automatic token refresh with concurrency control
-
- Error handling and cleanup
-
-
### ✅ Chunk 7: Flutter Platform Layer (FINAL)
-
**Status**: Complete
-
**Files**: 4 files, ~1,100 LOC
-
**Location**: `lib/src/platform/`
-
-
Flutter-specific implementations:
-
- FlutterOAuthClient (high-level API)
-
- FlutterKey (EC keys with pointycastle)
-
- FlutterRuntime (crypto operations)
-
- FlutterSessionStore (secure storage)
-
- In-memory caches with TTL
-
-
## Statistics
-
-
### Code
-
- **Total Files**: ~40 Dart files
-
- **Total Lines**: ~6,000 LOC (excluding tests)
-
- **Core Library**: ~5,000 LOC
-
- **Platform Layer**: ~1,100 LOC
-
- **Examples**: ~200 LOC
-
- **Documentation**: ~1,000 lines
-
-
### Compilation
-
- ✅ **Zero errors**
-
- ⚠️ 2 warnings (pre-existing, not from platform layer)
-
- ℹ️ 68 info messages (style suggestions)
-
-
### Dependencies
-
```yaml
-
dependencies:
-
flutter_secure_storage: ^9.2.2 # Secure token storage
-
flutter_web_auth_2: ^4.1.0 # Browser OAuth flow
-
pointycastle: ^3.9.1 # EC cryptography
-
crypto: ^3.0.3 # SHA hashing
-
dio: ^5.9.0 # HTTP client
-
```
-
-
## API Surface
-
-
### High-Level API (Recommended)
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
// Initialize
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
);
-
-
// Sign in
-
final session = await client.signIn('alice.bsky.social');
-
-
// Restore
-
final restored = await client.restore(session.sub);
-
-
// Revoke
-
await client.revoke(session.sub);
-
```
-
-
### Core API (Advanced)
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
// Lower-level control with OAuthClient
-
final client = OAuthClient(
-
OAuthClientOptions(
-
clientMetadata: {...},
-
sessionStore: CustomSessionStore(),
-
runtimeImplementation: CustomRuntime(),
-
// ... full control over all components
-
),
-
);
-
-
// Manual flow
-
final authUrl = await client.authorize('alice.bsky.social');
-
// ... open browser, handle callback
-
final result = await client.callback(params);
-
```
-
-
## Features Implemented
-
-
### OAuth 2.0 / OIDC
-
- ✅ Authorization Code Flow with PKCE
-
- ✅ Token refresh with automatic retry
-
- ✅ Token revocation
-
- ✅ PAR (Pushed Authorization Request)
-
- ✅ Response modes (query, fragment)
-
- ✅ State parameter (CSRF protection)
-
- ✅ Nonce parameter (replay protection)
-
-
### atProto Specifics
-
- ✅ DID resolution (did:plc, did:web)
-
- ✅ Handle resolution (via XRPC)
-
- ✅ PDS discovery
-
- ✅ DPoP (Demonstrating Proof of Possession)
-
- ✅ Multi-tenant authorization servers
-
-
### Security
-
- ✅ Secure token storage (Keychain/EncryptedSharedPreferences)
-
- ✅ DPoP key generation and signing
-
- ✅ PKCE (code challenge/verifier)
-
- ✅ Automatic session cleanup on errors
-
- ✅ Concurrency control (lock for token refresh)
-
- ✅ Input validation
-
-
### Platform
-
- ✅ iOS support (URL schemes, Keychain)
-
- ✅ Android support (Intent filters, EncryptedSharedPreferences)
-
- ✅ FlutterWebAuth2 integration
-
- ✅ Secure random number generation
-
- ✅ EC key generation (ES256/ES384/ES512/ES256K)
-
-
## Testing Status
-
-
### Unit Tests
-
- ❌ Not yet implemented
-
- **Next step**: Add unit tests for core logic
-
-
### Integration Tests
-
- ❌ Not yet implemented
-
- **Next step**: Test with real OAuth servers
-
-
### Manual Testing
-
- ⏳ **Ready for testing**
-
- Test with: `bretton.dev` (your own atproto identity)
-
-
## Known Limitations
-
-
### 1. Key Serialization (Minor)
-
DPoP keys are regenerated on app restart. This works but:
-
- Old tokens require refresh (bound to old keys)
-
- Slight performance impact
-
-
**Impact**: Low - Automatic refresh handles this transparently
-
**Fix**: Implement `Key.toJson()` / `Key.fromJson()` in `flutter_key.dart`
-
-
### 2. Local Lock Only (Minor)
-
Lock is in-memory, doesn't work across:
-
- Multiple isolates
-
- Multiple processes
-
-
**Impact**: Low - Most Flutter apps run in single isolate
-
**Fix**: Implement platform-specific lock if needed
-
-
### 3. No Token Caching (Minor)
-
Tokens aren't cached in memory between requests.
-
-
**Impact**: Low - Secure storage is fast enough
-
**Fix**: Add in-memory token cache if performance is critical
-
-
## Next Steps
-
-
### Immediate (Before Production)
-
1. ✅ **Complete implementation** - DONE
-
2. ⏳ **Manual testing** - Test sign-in flow with bretton.dev
-
3. ⏳ **Add unit tests** - Test core OAuth logic
-
4. ⏳ **Add integration tests** - Test with real servers
-
-
### Short-term
-
5. Fix key serialization (implement `Key.toJson()` / `fromJson()`)
-
6. Add comprehensive error handling examples
-
7. Add token introspection support
-
8. Add more example apps
-
-
### Long-term
-
9. Implement platform-specific locks (iOS/Android)
-
10. Add biometric authentication option
-
11. Add background token refresh
-
12. Performance optimizations (token caching)
-
-
## Files Created (Chunk 7)
-
-
### Core Platform Files
-
1. **`lib/src/platform/flutter_key.dart`** (429 lines)
-
- EC key implementation with pointycastle
-
- JWT signing (ES256/ES384/ES512/ES256K)
-
- Key serialization (to/from JWK)
-
-
2. **`lib/src/platform/flutter_runtime.dart`** (91 lines)
-
- RuntimeImplementation for Flutter
-
- SHA hashing with crypto package
-
- Secure random number generation
-
- Local lock integration
-
-
3. **`lib/src/platform/flutter_stores.dart`** (355 lines)
-
- FlutterSessionStore (secure storage)
-
- FlutterStateStore (ephemeral state)
-
- In-memory caches (metadata, nonces, DIDs, handles)
-
-
4. **`lib/src/platform/flutter_oauth_client.dart`** (235 lines)
-
- High-level FlutterOAuthClient
-
- Simplified sign-in API
-
- FlutterWebAuth2 integration
-
- Sensible defaults
-
-
### Documentation
-
5. **`lib/src/platform/README.md`** (~300 lines)
-
- Architecture overview
-
- Security features
-
- Usage examples
-
- Platform setup instructions
-
-
6. **`example/flutter_oauth_example.dart`** (~200 lines)
-
- Complete usage example
-
- All OAuth flows demonstrated
-
- Platform configuration examples
-
-
7. **`lib/atproto_oauth_flutter.dart`** (updated)
-
- Clean public API exports
-
- Comprehensive library documentation
-
-
## Security Review
-
-
### ✅ Secure Storage
-
- Tokens stored in flutter_secure_storage
-
- iOS: Keychain with device encryption
-
- Android: EncryptedSharedPreferences (AES-256)
-
-
### ✅ Cryptography
-
- pointycastle for EC key generation (NIST curves)
-
- crypto package for SHA hashing (FIPS 140-2 compliant)
-
- Random.secure() for randomness (cryptographically secure)
-
-
### ✅ Token Binding
-
- DPoP binds tokens to cryptographic keys
-
- Every request includes signed proof
-
- Prevents token theft
-
-
### ✅ Authorization Code Protection
-
- PKCE with SHA-256 challenge
-
- State parameter for CSRF protection
-
- Nonce parameter for replay protection
-
-
### ✅ Concurrency Safety
-
- Lock prevents concurrent token refresh
-
- Automatic retry on refresh failure
-
- Session cleanup on errors
-
-
## Production Readiness Checklist
-
-
### Code Quality
-
- ✅ Zero compilation errors
-
- ✅ Clean architecture (separation of concerns)
-
- ✅ Comprehensive documentation
-
- ✅ Type safety (null safety enabled)
-
- ✅ Error handling throughout
-
-
### Security
-
- ✅ Secure storage implementation
-
- ✅ Proper cryptography (NIST curves, SHA-256+)
-
- ✅ DPoP implementation
-
- ✅ PKCE implementation
-
- ✅ Input validation
-
-
### Functionality
-
- ✅ Complete OAuth 2.0 flow
-
- ✅ Token refresh
-
- ✅ Token revocation
-
- ✅ Session management
-
- ✅ Identity resolution
-
-
### Platform Support
-
- ✅ iOS support
-
- ✅ Android support
-
- ✅ Flutter 3.7.2+ compatible
-
- ✅ Null safety enabled
-
-
### Documentation
-
- ✅ API documentation
-
- ✅ Usage examples
-
- ✅ Platform setup guides
-
- ✅ Security documentation
-
-
### Testing (TODO)
-
- ⏳ Unit tests
-
- ⏳ Integration tests
-
- ⏳ Manual testing with real servers
-
-
## Comparison with TypeScript Original
-
-
This Dart port maintains **1:1 feature parity** with the TypeScript implementation:
-
-
| Feature | TypeScript | Dart/Flutter | Notes |
-
|---------|-----------|--------------|-------|
-
| OAuth 2.0 Core | ✅ | ✅ | Complete |
-
| PKCE | ✅ | ✅ | SHA-256 |
-
| DPoP | ✅ | ✅ | ES256/ES384/ES512/ES256K |
-
| PAR | ✅ | ✅ | Pushed Authorization |
-
| Token Refresh | ✅ | ✅ | With concurrency control |
-
| DID Resolution | ✅ | ✅ | did:plc, did:web |
-
| Handle Resolution | ✅ | ✅ | XRPC-based |
-
| Secure Storage | ✅ (MMKV) | ✅ (flutter_secure_storage) | Platform-specific |
-
| Crypto | ✅ (Web Crypto) | ✅ (pointycastle + crypto) | Platform-specific |
-
| Key Serialization | ✅ | ⏳ | Minor limitation |
-
-
## Conclusion
-
-
**The atproto_oauth_flutter library is COMPLETE and ready for testing!**
-
-
All core functionality has been implemented with:
-
- ✅ Zero errors
-
- ✅ Production-grade security
-
- ✅ Clean API
-
- ✅ Comprehensive documentation
-
-
**Next milestone**: Manual testing with bretton.dev OAuth flow.
-
-
---
-
-
Generated: 2025-10-27
-
Chunk 7 (FINAL): Flutter Platform Layer
-
Status: ✅ **COMPLETE**
···
-1238
packages/atproto_oauth_flutter/README.md
···
-
# atproto_oauth_flutter
-
-
**Official AT Protocol OAuth client for Flutter** - A complete 1:1 port of the TypeScript `@atproto/oauth-client` package.
-
-
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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
-
<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:
-
-
```xml
-
<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`:
-
-
```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:
-
-
```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<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](#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<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:**
-
-
```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<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:**
-
-
```dart
-
await client.revoke('did:plc:abc123');
-
```
-
-
#### Properties
-
-
##### `onUpdated`
-
-
Stream of session update events (token refresh, etc.).
-
-
```dart
-
Stream<SessionUpdatedEvent> get onUpdated
-
```
-
-
**Example:**
-
-
```dart
-
client.onUpdated.listen((event) {
-
print('Session ${event.sub} updated');
-
});
-
```
-
-
##### `onDeleted`
-
-
Stream of session deletion events (revoke, expiry, errors).
-
-
```dart
-
Stream<SessionDeletedEvent> 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<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:**
-
-
```dart
-
final authUrl = await client.authorize('alice.bsky.social');
-
// Open authUrl in browser yourself
-
```
-
-
##### `callback()`
-
-
Handle OAuth callback after user authorization.
-
-
```dart
-
Future<CallbackResult> callback(
-
Map<String, String> 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<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:**
-
-
```dart
-
final metadata = await OAuthClient.fetchMetadata(
-
OAuthClientFetchMetadataOptions(
-
clientId: 'https://example.com/client-metadata.json',
-
),
-
);
-
```
-
-
#### Properties
-
-
Same as `FlutterOAuthClient` (`onUpdated`, `onDeleted`).
-
-
---
-
-
### Types
-
-
#### ClientMetadata
-
-
OAuth client configuration.
-
-
```dart
-
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:**
-
-
```dart
-
// 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()`.
-
-
```dart
-
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:**
-
-
```dart
-
final session = await client.signIn(
-
'alice.bsky.social',
-
options: AuthorizeOptions(
-
state: jsonEncode({'returnTo': '/home'}),
-
prompt: 'login', // Force re-authentication
-
),
-
);
-
```
-
-
#### CallbackOptions
-
-
Options for `callback()`.
-
-
```dart
-
class CallbackOptions {
-
final String? redirectUri;
-
}
-
```
-
-
**Note:** `redirectUri` must match the one used in `authorize()`.
-
-
#### OAuthSession
-
-
Authenticated session with token management.
-
-
```dart
-
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:**
-
-
```dart
-
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.
-
-
```dart
-
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.
-
-
```dart
-
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:**
-
-
```dart
-
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:
-
-
```dart
-
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:
-
-
```dart
-
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**:
-
-
```dart
-
// 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)
-
-
```dart
-
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
-
-
```dart
-
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
-
-
```dart
-
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:
-
-
```dart
-
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
-
-
```dart
-
// ❌ BROKEN - Only works with bsky.social
-
const authServer = 'https://bsky.social'; // Hardcoded!
-
```
-
-
### The Solution: Dynamic Discovery
-
-
```dart
-
// ✅ 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
-
-
```dart
-
// 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):**
-
```dart
-
// Only works with bsky.social
-
final session = await client.signIn('bob.custom-pds.com'); // BROKEN
-
```
-
-
**After (Fixed):**
-
```dart
-
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
-
-
```dart
-
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
-
-
- **Based on:** Official Bluesky [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client)
-
- **Architecture:** 1:1 port maintaining API compatibility
-
-
---
-
-
## 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**
···
-220
packages/atproto_oauth_flutter/example/flutter_oauth_example.dart
···
-
/// Example usage of FlutterOAuthClient for atProto OAuth authentication.
-
///
-
/// This demonstrates the complete OAuth flow for a Flutter application:
-
/// 1. Initialize the client
-
/// 2. Sign in with a handle
-
/// 3. Use the authenticated session
-
/// 4. Restore session on app restart
-
/// 5. Sign out (revoke session)
-
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
void main() async {
-
// ========================================================================
-
// 1. Initialize the OAuth client
-
// ========================================================================
-
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
// For development: use loopback client (no client metadata URL needed)
-
clientId: 'http://localhost',
-
-
// For production: use discoverable client metadata
-
// clientId: 'https://example.com/client-metadata.json',
-
-
// Redirect URIs for your app
-
// - Custom URL scheme: myapp://oauth/callback
-
// - Universal links: https://example.com/oauth/callback
-
redirectUris: ['myapp://oauth/callback'],
-
-
// Scope: what permissions to request
-
// - 'atproto': Full atproto access
-
// - 'transition:generic': Additional permissions for legacy systems
-
scope: 'atproto transition:generic',
-
-
// Client metadata
-
clientName: 'My Awesome App',
-
clientUri: 'https://example.com',
-
-
// Token binding
-
dpopBoundAccessTokens: true, // Enable DPoP for security
-
),
-
-
// Response mode (query or fragment)
-
responseMode: OAuthResponseMode.query,
-
-
// Allow HTTP only for development (never in production!)
-
allowHttp: false,
-
);
-
-
// ========================================================================
-
// 2. Sign in with a handle
-
// ========================================================================
-
-
try {
-
print('Starting sign-in flow for alice.bsky.social...');
-
-
// This will:
-
// 1. Resolve the handle to find the authorization server
-
// 2. Generate PKCE code challenge/verifier
-
// 3. Generate DPoP key
-
// 4. Open browser for user authentication
-
// 5. Handle OAuth callback
-
// 6. Exchange authorization code for tokens
-
// 7. Store session securely
-
final session = await client.signIn('alice.bsky.social');
-
-
print('✓ Signed in successfully!');
-
print(' DID: ${session.sub}');
-
print(' Session info: ${session.info}');
-
-
// ========================================================================
-
// 3. Use the authenticated session
-
// ========================================================================
-
-
// The session has a PDS client you can use for authenticated requests
-
// (This requires integrating with an atproto API client library)
-
//
-
// Example:
-
// final agent = session.pdsClient;
-
// final profile = await agent.getProfile();
-
-
print('Session is ready for API calls');
-
} on OAuthCallbackError catch (e) {
-
// Handle OAuth errors (user cancelled, invalid state, etc.)
-
print('OAuth callback error: ${e.error}');
-
print('Description: ${e.errorDescription}');
-
return;
-
} catch (e) {
-
print('Sign-in error: $e');
-
return;
-
}
-
-
// ========================================================================
-
// 4. Restore session on app restart
-
// ========================================================================
-
-
// Later, when the app restarts, restore the session:
-
try {
-
final did = 'did:plc:abc123'; // Get from storage or previous session
-
-
print('Restoring session for $did...');
-
-
// This will:
-
// 1. Load session from secure storage
-
// 2. Check if tokens are expired
-
// 3. Automatically refresh if needed
-
// 4. Return authenticated session
-
final session = await client.restore(did);
-
-
print('✓ Session restored!');
-
print(' Access token expires: ${session.info['expiresAt']}');
-
} catch (e) {
-
print('Failed to restore session: $e');
-
// Session may have been revoked or expired
-
// Prompt user to sign in again
-
}
-
-
// ========================================================================
-
// 5. Sign out (revoke session)
-
// ========================================================================
-
-
try {
-
final did = 'did:plc:abc123';
-
-
print('Signing out $did...');
-
-
// This will:
-
// 1. Call token revocation endpoint (best effort)
-
// 2. Delete session from secure storage
-
// 3. Emit 'deleted' event
-
await client.revoke(did);
-
-
print('✓ Signed out successfully');
-
} catch (e) {
-
print('Sign out error: $e');
-
// Session is still deleted locally even if revocation fails
-
}
-
-
// ========================================================================
-
// Advanced: Listen to session events
-
// ========================================================================
-
-
// Listen for session updates (token refresh, etc.)
-
client.onUpdated.listen((event) {
-
print('Session updated: ${event.sub}');
-
print(' New access token received');
-
});
-
-
// Listen for session deletions (revoked, expired, etc.)
-
client.onDeleted.listen((event) {
-
print('Session deleted: ${event.sub}');
-
print(' Cause: ${event.cause}');
-
// Handle session deletion (navigate to sign-in screen, etc.)
-
});
-
-
// ========================================================================
-
// Advanced: Custom configuration
-
// ========================================================================
-
-
// You can customize storage, caching, and crypto:
-
final customClient = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
-
// Custom secure storage instance
-
secureStorage: const FlutterSecureStorage(
-
aOptions: AndroidOptions(encryptedSharedPreferences: true),
-
),
-
-
// Custom PLC directory URL (for private deployments)
-
plcDirectoryUrl: 'https://plc.example.com',
-
-
// Custom handle resolver URL
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
print('Custom client initialized');
-
-
// ========================================================================
-
// Platform configuration (iOS)
-
// ========================================================================
-
-
// iOS: Add URL scheme to Info.plist
-
// <key>CFBundleURLTypes</key>
-
// <array>
-
// <dict>
-
// <key>CFBundleURLSchemes</key>
-
// <array>
-
// <string>myapp</string>
-
// </array>
-
// </dict>
-
// </array>
-
-
// ========================================================================
-
// Platform configuration (Android)
-
// ========================================================================
-
-
// Android: Add intent filter to AndroidManifest.xml
-
// <intent-filter>
-
// <action android:name="android.intent.action.VIEW" />
-
// <category android:name="android.intent.category.DEFAULT" />
-
// <category android:name="android.intent.category.BROWSABLE" />
-
// <data android:scheme="myapp" />
-
// </intent-filter>
-
-
// ========================================================================
-
// Security best practices
-
// ========================================================================
-
-
// ✓ Tokens stored in secure storage (Keychain/EncryptedSharedPreferences)
-
// ✓ DPoP binds tokens to cryptographic keys
-
// ✓ PKCE prevents authorization code interception
-
// ✓ State parameter prevents CSRF attacks
-
// ✓ Automatic token refresh with concurrency control
-
// ✓ Session cleanup on errors
-
-
print('Example complete!');
-
}
···
-104
packages/atproto_oauth_flutter/example/identity_resolver_example.dart
···
-
/// Example usage of the atProto identity resolution layer.
-
///
-
/// This demonstrates the critical functionality for decentralization:
-
/// resolving handles and DIDs to find where user data is actually stored.
-
-
import 'package:atproto_oauth_flutter/src/identity/identity.dart';
-
-
Future<void> main() async {
-
print('=== atProto Identity Resolution Examples ===\n');
-
-
// Create an identity resolver
-
// The handleResolverUrl should point to an XRPC service that implements
-
// com.atproto.identity.resolveHandle (typically bsky.social for public resolution)
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
print('Example 1: Resolve a Bluesky handle to find their PDS');
-
print('--------------------------------------------------');
-
try {
-
// This is the most common use case: find where a user's data lives
-
final pdsUrl = await resolver.resolveToPds('pfrazee.com');
-
print('Handle: pfrazee.com');
-
print('PDS URL: $pdsUrl');
-
print('✓ This user hosts their data on: $pdsUrl\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 2: Get full identity information');
-
print('--------------------------------------------------');
-
try {
-
final info = await resolver.resolve('pfrazee.com');
-
print('Handle: ${info.handle}');
-
print('DID: ${info.did}');
-
print('PDS URL: ${info.pdsUrl}');
-
print('Has valid handle: ${info.hasValidHandle}');
-
print('Also known as: ${info.didDoc.alsoKnownAs}');
-
print('✓ Complete identity information retrieved\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 3: Resolve from a DID');
-
print('--------------------------------------------------');
-
try {
-
// You can also start from a DID
-
final info = await resolver.resolveFromDid(
-
'did:plc:ragtjsm2j2vknwkz3zp4oxrd',
-
);
-
print('DID: ${info.did}');
-
print('Handle: ${info.handle}');
-
print('PDS URL: ${info.pdsUrl}');
-
print('✓ Resolved DID to handle and PDS\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 4: Custom domain handle (CRITICAL for decentralization)');
-
print('--------------------------------------------------');
-
try {
-
// This demonstrates why this code is essential:
-
// Users can use their own domains and host on their own PDS
-
final info = await resolver.resolve('jay.bsky.team');
-
print('Handle: ${info.handle}');
-
print('DID: ${info.did}');
-
print('PDS URL: ${info.pdsUrl}');
-
print('✓ Custom domain resolves to custom PDS (not hardcoded!)\n');
-
} catch (e) {
-
print('Error: $e\n');
-
}
-
-
print('Example 5: Validation - Invalid handle');
-
print('--------------------------------------------------');
-
try {
-
await resolver.resolve('not-a-valid-handle');
-
} catch (e) {
-
print('✓ Correctly rejected invalid handle: $e\n');
-
}
-
-
print('=== Why This Matters ===');
-
print('''
-
This identity resolution layer is THE CRITICAL PIECE for atProto decentralization:
-
-
1. **No Hardcoded Servers**: Unlike broken implementations that hardcode bsky.social,
-
this correctly resolves each user's actual PDS location.
-
-
2. **Custom Domains**: Users can use their own domains (e.g., alice.example.com)
-
and host on any PDS they choose.
-
-
3. **Portability**: Users can change their PDS without losing their DID or identity.
-
The DID document always points to the current PDS location.
-
-
4. **Bi-directional Validation**: We verify that:
-
- Handle → DID resolution works
-
- DID document contains the handle
-
- Both directions match (security!)
-
-
5. **Caching**: Built-in caching prevents redundant lookups while respecting TTLs.
-
-
Without this layer, apps are locked to centralized servers. With it, atProto
-
achieves true decentralization where users control their data location.
-
''');
-
}
···
-104
packages/atproto_oauth_flutter/lib/atproto_oauth_flutter.dart
···
-
/// atproto OAuth client for Flutter.
-
///
-
/// This library provides OAuth authentication capabilities for AT Protocol
-
/// (atproto) applications on Flutter/Dart platforms.
-
///
-
/// This is a 1:1 port of the TypeScript @atproto/oauth-client package to Dart.
-
///
-
/// ## Quick Start
-
///
-
/// ```dart
-
/// import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
///
-
/// // 1. Initialize client
-
/// final client = FlutterOAuthClient(
-
/// clientMetadata: ClientMetadata(
-
/// clientId: 'https://example.com/client-metadata.json',
-
/// redirectUris: ['myapp://oauth/callback'],
-
/// scope: 'atproto transition:generic',
-
/// ),
-
/// );
-
///
-
/// // 2. Sign in with handle
-
/// final session = await client.signIn('alice.bsky.social');
-
/// print('Signed in as: ${session.sub}');
-
///
-
/// // 3. Use authenticated session
-
/// // (Integrate with your atproto API client)
-
///
-
/// // 4. Later: restore session
-
/// final restored = await client.restore(session.sub);
-
///
-
/// // 5. Sign out
-
/// await client.revoke(session.sub);
-
/// ```
-
///
-
/// ## Features
-
///
-
/// - Full OAuth 2.0 + OIDC support with PKCE
-
/// - DPoP (Demonstrating Proof of Possession) for token security
-
/// - Automatic token refresh
-
/// - Secure session storage (flutter_secure_storage)
-
/// - Handle and DID resolution
-
/// - PAR (Pushed Authorization Request) support
-
/// - Works with any atProto PDS or authorization server
-
///
-
/// ## Security
-
///
-
/// - Tokens stored in device secure storage (Keychain/EncryptedSharedPreferences)
-
/// - DPoP binds tokens to cryptographic keys
-
/// - PKCE prevents authorization code interception
-
/// - Automatic session cleanup on errors
-
///
-
library;
-
-
// ============================================================================
-
// Main API - Start here!
-
// ============================================================================
-
-
/// High-level Flutter OAuth client (recommended for most apps)
-
export 'src/platform/flutter_oauth_client.dart';
-
-
/// Router integration helpers (for go_router, auto_route, etc.)
-
export 'src/platform/flutter_oauth_router_helper.dart';
-
-
// ============================================================================
-
// Core OAuth Client
-
// ============================================================================
-
-
/// Core OAuth client and types (for advanced use cases)
-
export 'src/client/oauth_client.dart';
-
-
// ============================================================================
-
// Sessions
-
// ============================================================================
-
-
/// OAuth session types
-
export 'src/session/oauth_session.dart';
-
-
// ============================================================================
-
// Types
-
// ============================================================================
-
-
/// Core types and options
-
export 'src/types.dart';
-
-
// ============================================================================
-
// Platform Implementations (for custom configurations)
-
// ============================================================================
-
-
/// Storage implementations (for customization)
-
export 'src/platform/flutter_stores.dart';
-
-
/// Runtime implementation (cryptographic operations)
-
export 'src/platform/flutter_runtime.dart';
-
-
/// Key implementation (EC keys with pointycastle)
-
export 'src/platform/flutter_key.dart';
-
-
// ============================================================================
-
// Errors
-
// ============================================================================
-
-
/// All OAuth error types
-
export 'src/errors/errors.dart';
···
-977
packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart
···
-
import 'dart:async';
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart';
-
-
import '../constants.dart';
-
import '../dpop/fetch_dpop.dart' show InMemoryStore;
-
import '../errors/auth_method_unsatisfiable_error.dart';
-
import '../errors/oauth_callback_error.dart';
-
import '../errors/token_revoked_error.dart';
-
import '../identity/constants.dart';
-
import '../identity/did_helpers.dart' show assertAtprotoDid;
-
import '../identity/did_resolver.dart' show DidCache;
-
import '../identity/handle_resolver.dart' show HandleCache;
-
import '../identity/identity_resolver.dart';
-
import '../oauth/authorization_server_metadata_resolver.dart' as auth_resolver;
-
import '../oauth/client_auth.dart';
-
import '../oauth/oauth_resolver.dart';
-
import '../oauth/oauth_server_agent.dart';
-
import '../oauth/oauth_server_factory.dart';
-
import '../oauth/protected_resource_metadata_resolver.dart';
-
import '../oauth/validate_client_metadata.dart';
-
import '../platform/flutter_key.dart';
-
import '../runtime/runtime.dart' as runtime_lib;
-
import '../runtime/runtime_implementation.dart';
-
import '../session/oauth_session.dart'
-
show OAuthSession, Session, SessionGetterInterface;
-
import '../session/session_getter.dart';
-
import '../session/state_store.dart';
-
import '../types.dart';
-
import '../util.dart';
-
-
// Re-export types needed for OAuthClientOptions
-
export '../identity/did_resolver.dart' show DidCache, DidResolver;
-
export '../identity/handle_resolver.dart' show HandleCache, HandleResolver;
-
export '../identity/identity_resolver.dart' show IdentityResolver;
-
export '../oauth/authorization_server_metadata_resolver.dart'
-
show AuthorizationServerMetadataCache;
-
export '../oauth/oauth_server_agent.dart' show DpopNonceCache;
-
export '../oauth/protected_resource_metadata_resolver.dart'
-
show ProtectedResourceMetadataCache;
-
export '../runtime/runtime_implementation.dart' show RuntimeImplementation, Key;
-
export '../oauth/client_auth.dart' show Keyset;
-
export '../session/session_getter.dart'
-
show SessionStore, SessionUpdatedEvent, SessionDeletedEvent;
-
export '../session/state_store.dart' show StateStore, InternalStateData;
-
export '../types.dart' show ClientMetadata, AuthorizeOptions, CallbackOptions;
-
-
/// OAuth response mode.
-
enum OAuthResponseMode {
-
/// Parameters in query string (default, most compatible)
-
query('query'),
-
-
/// Parameters in URL fragment (for single-page apps)
-
fragment('fragment');
-
-
final String value;
-
const OAuthResponseMode(this.value);
-
-
@override
-
String toString() => value;
-
}
-
-
/// Options for constructing an OAuthClient.
-
///
-
/// This includes all configuration, storage, and service dependencies
-
/// needed to implement the complete OAuth flow.
-
class OAuthClientOptions {
-
// Config
-
/// Response mode for OAuth (query or fragment)
-
final OAuthResponseMode responseMode;
-
-
/// Client metadata (validated before use)
-
final Map<String, dynamic> clientMetadata;
-
-
/// Optional keyset for confidential clients (private_key_jwt)
-
final Keyset? keyset;
-
-
/// Whether to allow HTTP connections (for development only)
-
///
-
/// This affects:
-
/// - OAuth authorization/resource servers
-
/// - did:web document fetching
-
///
-
/// Note: PLC directory connections are controlled separately.
-
final bool allowHttp;
-
-
// Stores
-
/// Storage for OAuth state during authorization flow
-
final StateStore stateStore;
-
-
/// Storage for session tokens
-
final SessionStore sessionStore;
-
-
/// Optional cache for authorization server metadata
-
final auth_resolver.AuthorizationServerMetadataCache?
-
authorizationServerMetadataCache;
-
-
/// Optional cache for protected resource metadata
-
final ProtectedResourceMetadataCache? protectedResourceMetadataCache;
-
-
/// Optional cache for DPoP nonces
-
final DpopNonceCache? dpopNonceCache;
-
-
/// Optional cache for DID documents
-
final DidCache? didCache;
-
-
/// Optional cache for handle → DID resolutions
-
final HandleCache? handleCache;
-
-
// Services
-
/// Platform-specific cryptographic operations
-
final RuntimeImplementation runtimeImplementation;
-
-
/// Optional HTTP client (Dio instance)
-
final Dio? dio;
-
-
/// Optional custom identity resolver
-
final IdentityResolver? identityResolver;
-
-
/// PLC directory URL (for DID resolution)
-
final String? plcDirectoryUrl;
-
-
/// Handle resolver URL (for handle → DID resolution)
-
final String? handleResolverUrl;
-
-
const OAuthClientOptions({
-
required this.responseMode,
-
required this.clientMetadata,
-
this.keyset,
-
this.allowHttp = false,
-
required this.stateStore,
-
required this.sessionStore,
-
this.authorizationServerMetadataCache,
-
this.protectedResourceMetadataCache,
-
this.dpopNonceCache,
-
this.didCache,
-
this.handleCache,
-
required this.runtimeImplementation,
-
this.dio,
-
this.identityResolver,
-
this.plcDirectoryUrl,
-
this.handleResolverUrl,
-
});
-
}
-
-
/// Result of a successful OAuth callback.
-
class CallbackResult {
-
/// The authenticated session
-
final OAuthSession session;
-
-
/// The application state from the original authorize call
-
final String? state;
-
-
const CallbackResult({required this.session, this.state});
-
}
-
-
/// Options for fetching client metadata from a discoverable client ID.
-
class OAuthClientFetchMetadataOptions {
-
/// The discoverable client ID (HTTPS URL)
-
final String clientId;
-
-
/// Optional HTTP client
-
final Dio? dio;
-
-
/// Optional cancellation token
-
final CancelToken? cancelToken;
-
-
const OAuthClientFetchMetadataOptions({
-
required this.clientId,
-
this.dio,
-
this.cancelToken,
-
});
-
}
-
-
/// Main OAuth client for atProto OAuth flows.
-
///
-
/// This is the primary class that developers interact with. It orchestrates:
-
/// - Authorization flow (authorize → callback)
-
/// - Session restoration (restore)
-
/// - Token revocation (revoke)
-
/// - Session lifecycle events
-
///
-
/// Usage:
-
/// ```dart
-
/// final client = OAuthClient(
-
/// clientMetadata: {
-
/// 'client_id': 'https://example.com/client-metadata.json',
-
/// 'redirect_uris': ['myapp://oauth/callback'],
-
/// 'scope': 'atproto',
-
/// },
-
/// responseMode: OAuthResponseMode.query,
-
/// stateStore: MyStateStore(),
-
/// sessionStore: MySessionStore(),
-
/// runtimeImplementation: MyRuntimeImplementation(),
-
/// );
-
///
-
/// // Start authorization
-
/// final authUrl = await client.authorize('alice.bsky.social');
-
///
-
/// // Handle callback
-
/// final result = await client.callback(callbackParams);
-
/// print('Signed in as: ${result.session.sub}');
-
///
-
/// // Restore session later
-
/// final session = await client.restore('did:plc:abc123');
-
///
-
/// // Revoke session
-
/// await client.revoke('did:plc:abc123');
-
/// ```
-
class OAuthClient extends CustomEventTarget<Map<String, dynamic>> {
-
// Config
-
/// Validated client metadata
-
final ClientMetadata clientMetadata;
-
-
/// OAuth response mode (query or fragment)
-
final OAuthResponseMode responseMode;
-
-
/// Optional keyset for confidential clients
-
final Keyset? keyset;
-
-
// Services
-
/// Runtime for cryptographic operations
-
final runtime_lib.Runtime runtime;
-
-
/// HTTP client
-
final Dio dio;
-
-
/// OAuth resolver for identity → metadata
-
final OAuthResolver oauthResolver;
-
-
/// Factory for creating OAuth server agents
-
final OAuthServerFactory serverFactory;
-
-
// Stores
-
/// Session management with automatic refresh
-
final SessionGetter _sessionGetter;
-
-
/// OAuth state storage
-
final StateStore _stateStore;
-
-
// Event streams
-
final StreamController<SessionUpdatedEvent> _updatedController =
-
StreamController<SessionUpdatedEvent>.broadcast();
-
final StreamController<SessionDeletedEvent> _deletedController =
-
StreamController<SessionDeletedEvent>.broadcast();
-
-
/// Stream of session update events
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
-
-
/// Stream of session deletion events
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
-
-
/// Constructs an OAuthClient with the given options.
-
///
-
/// Throws [FormatException] if client metadata is invalid.
-
/// Throws [TypeError] if keyset configuration is incorrect.
-
OAuthClient(OAuthClientOptions options)
-
: keyset = options.keyset,
-
responseMode = options.responseMode,
-
runtime = runtime_lib.Runtime(options.runtimeImplementation),
-
dio = options.dio ?? Dio(),
-
_stateStore = options.stateStore,
-
clientMetadata = validateClientMetadata(
-
options.clientMetadata,
-
options.keyset,
-
),
-
oauthResolver = _createOAuthResolver(options),
-
serverFactory = _createServerFactory(options),
-
_sessionGetter = _createSessionGetter(options) {
-
// Proxy session events from SessionGetter
-
_sessionGetter.onUpdated.listen((event) {
-
_updatedController.add(event);
-
dispatchCustomEvent('updated', event);
-
});
-
-
_sessionGetter.onDeleted.listen((event) {
-
_deletedController.add(event);
-
dispatchCustomEvent('deleted', event);
-
});
-
}
-
-
/// Creates the OAuth resolver.
-
static OAuthResolver _createOAuthResolver(OAuthClientOptions options) {
-
final dio = options.dio ?? Dio();
-
-
return OAuthResolver(
-
identityResolver:
-
options.identityResolver ??
-
AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl:
-
options.handleResolverUrl ?? 'https://bsky.social',
-
plcDirectoryUrl: options.plcDirectoryUrl,
-
dio: dio,
-
didCache: options.didCache,
-
handleCache: options.handleCache,
-
),
-
protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver(
-
options.protectedResourceMetadataCache ??
-
InMemoryStore<String, Map<String, dynamic>>(),
-
dio: dio,
-
config: OAuthProtectedResourceMetadataResolverConfig(
-
allowHttpResource: options.allowHttp,
-
),
-
),
-
authorizationServerMetadataResolver:
-
auth_resolver.OAuthAuthorizationServerMetadataResolver(
-
options.authorizationServerMetadataCache ??
-
InMemoryStore<String, Map<String, dynamic>>(),
-
dio: dio,
-
config:
-
auth_resolver.OAuthAuthorizationServerMetadataResolverConfig(
-
allowHttpIssuer: options.allowHttp,
-
),
-
),
-
);
-
}
-
-
/// Creates the OAuth server factory.
-
static OAuthServerFactory _createServerFactory(OAuthClientOptions options) {
-
return OAuthServerFactory(
-
clientMetadata: validateClientMetadata(
-
options.clientMetadata,
-
options.keyset,
-
),
-
runtime: runtime_lib.Runtime(options.runtimeImplementation),
-
resolver: _createOAuthResolver(options),
-
dio: options.dio ?? Dio(),
-
keyset: options.keyset,
-
dpopNonceCache: options.dpopNonceCache ?? InMemoryStore<String, String>(),
-
);
-
}
-
-
/// Creates the session getter.
-
static SessionGetter _createSessionGetter(OAuthClientOptions options) {
-
return SessionGetter(
-
sessionStore: options.sessionStore,
-
serverFactory: _createServerFactory(options),
-
runtime: runtime_lib.Runtime(options.runtimeImplementation),
-
);
-
}
-
-
/// Fetches client metadata from a discoverable client ID URL.
-
///
-
/// This is a static helper method for fetching metadata before
-
/// constructing the OAuthClient.
-
///
-
/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
-
static Future<Map<String, dynamic>> fetchMetadata(
-
OAuthClientFetchMetadataOptions options,
-
) async {
-
final dio = options.dio ?? Dio();
-
final clientId = options.clientId;
-
-
try {
-
final response = await dio.getUri<Map<String, dynamic>>(
-
Uri.parse(clientId),
-
options: Options(
-
followRedirects: false,
-
validateStatus: (status) => status == 200,
-
responseType: ResponseType.json,
-
),
-
cancelToken: options.cancelToken,
-
);
-
-
// Validate content type
-
final contentType = response.headers.value('content-type');
-
final mime = contentType?.split(';')[0].trim();
-
if (mime != 'application/json') {
-
throw FormatException('Invalid client metadata content type: $mime');
-
}
-
-
final data = response.data;
-
if (data == null) {
-
throw FormatException('Empty client metadata response');
-
}
-
-
return data;
-
} catch (e) {
-
if (e is DioException) {
-
throw Exception('Failed to fetch client metadata: ${e.message}');
-
}
-
rethrow;
-
}
-
}
-
-
/// Exposes the identity resolver for convenience.
-
IdentityResolver get identityResolver => oauthResolver.identityResolver;
-
-
/// Returns the public JWKS for this client (for confidential clients).
-
///
-
/// This is the JWKS that should be published at the client's jwks_uri
-
/// or included in the client metadata.
-
Map<String, dynamic> get jwks {
-
if (keyset == null) {
-
return {'keys': <Map<String, dynamic>>[]};
-
}
-
return keyset!.toJSON();
-
}
-
-
/// Initiates an OAuth authorization flow.
-
///
-
/// This method:
-
/// 1. Resolves the input (handle, DID, or URL) to OAuth metadata
-
/// 2. Generates PKCE parameters
-
/// 3. Generates DPoP key
-
/// 4. Negotiates client authentication method
-
/// 5. Stores internal state
-
/// 6. Uses PAR (Pushed Authorization Request) if supported
-
/// 7. Returns the authorization URL to open in a browser
-
///
-
/// The [input] can be:
-
/// - An atProto handle (e.g., "alice.bsky.social")
-
/// - A DID (e.g., "did:plc:...")
-
/// - A PDS URL (e.g., "https://pds.example.com")
-
/// - An authorization server URL (e.g., "https://auth.example.com")
-
///
-
/// The [options] can specify:
-
/// - redirectUri: Override the default redirect URI
-
/// - state: Application state to preserve
-
/// - scope: Override the default scope
-
/// - Other OIDC parameters (prompt, display, etc.)
-
///
-
/// Throws [FormatException] if parameters are invalid.
-
/// Throws [OAuthResolverError] if resolution fails.
-
Future<Uri> authorize(
-
String input, {
-
AuthorizeOptions? options,
-
CancelToken? cancelToken,
-
}) async {
-
final opts = options ?? const AuthorizeOptions();
-
-
// Validate redirect URI
-
final redirectUri = opts.redirectUri ?? clientMetadata.redirectUris.first;
-
if (!clientMetadata.redirectUris.contains(redirectUri)) {
-
throw FormatException('Invalid redirect_uri: $redirectUri');
-
}
-
-
// Resolve input to OAuth metadata
-
final resolved = await oauthResolver.resolve(
-
input,
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
-
);
-
-
final metadata = resolved.metadata;
-
-
// Generate PKCE
-
final pkce = await runtime.generatePKCE();
-
-
// Generate DPoP key
-
final dpopAlgs = metadata['dpop_signing_alg_values_supported'] as List?;
-
final dpopKey = await runtime.generateKey(
-
dpopAlgs?.cast<String>() ?? [fallbackAlg],
-
);
-
-
// Compute DPoP JWK thumbprint for authorization requests.
-
// Required by RFC 9449 §7 to bind the subsequently issued code to this key.
-
final bareJwk = dpopKey.bareJwk;
-
if (bareJwk == null) {
-
throw StateError('DPoP key must provide a public JWK representation');
-
}
-
final generatedDpopJkt = await runtime.calculateJwkThumbprint(bareJwk);
-
-
// Negotiate client authentication method
-
final authMethod = negotiateClientAuthMethod(
-
metadata,
-
clientMetadata,
-
keyset,
-
);
-
-
// Generate state parameter
-
final state = await runtime.generateNonce();
-
-
// Store internal state for callback validation
-
// IMPORTANT: Store the FULL private JWK, not just bareJwk (public key only)
-
// We need the private key to restore the DPoP key during token exchange
-
final dpopKeyJwk = (dpopKey as dynamic).privateJwk ?? dpopKey.bareJwk ?? {};
-
-
if (kDebugMode) {
-
print('🔑 Storing DPoP key for authorization flow');
-
}
-
-
await _stateStore.set(
-
state,
-
InternalStateData(
-
iss: metadata['issuer'] as String,
-
dpopKey: dpopKeyJwk,
-
authMethod: authMethod.toJson(),
-
verifier: pkce['verifier'] as String,
-
redirectUri: redirectUri, // Store the exact redirectUri used in PAR
-
appState: opts.state,
-
),
-
);
-
-
// Build authorization request parameters
-
final parameters = <String, String>{
-
'client_id': clientMetadata.clientId!,
-
'redirect_uri': redirectUri,
-
'code_challenge': pkce['challenge'] as String,
-
'code_challenge_method': pkce['method'] as String,
-
'state': state,
-
'response_mode': responseMode.value,
-
'response_type': 'code',
-
'scope': opts.scope ?? clientMetadata.scope ?? 'atproto',
-
'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt,
-
};
-
-
// Add login hint if we have identity info
-
if (resolved.identityInfo != null) {
-
final handle = resolved.identityInfo!.handle;
-
final did = resolved.identityInfo!.did;
-
if (handle != handleInvalid) {
-
parameters['login_hint'] = handle;
-
} else {
-
parameters['login_hint'] = did;
-
}
-
}
-
-
// Add optional parameters from options
-
if (opts.nonce != null) parameters['nonce'] = opts.nonce!;
-
if (opts.display != null) parameters['display'] = opts.display!;
-
if (opts.prompt != null) parameters['prompt'] = opts.prompt!;
-
if (opts.maxAge != null) parameters['max_age'] = opts.maxAge.toString();
-
if (opts.uiLocales != null) parameters['ui_locales'] = opts.uiLocales!;
-
if (opts.idTokenHint != null) {
-
parameters['id_token_hint'] = opts.idTokenHint!;
-
}
-
-
// Build authorization URL
-
final authorizationUrl = Uri.parse(
-
metadata['authorization_endpoint'] as String,
-
);
-
-
// Validate authorization endpoint protocol
-
if (authorizationUrl.scheme != 'https' &&
-
authorizationUrl.scheme != 'http') {
-
throw FormatException(
-
'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}',
-
);
-
}
-
-
// Use PAR (Pushed Authorization Request) if supported
-
final parEndpoint =
-
metadata['pushed_authorization_request_endpoint'] as String?;
-
final requiresPar =
-
metadata['require_pushed_authorization_requests'] as bool? ?? false;
-
-
if (parEndpoint != null) {
-
// Server supports PAR, use it
-
final server = await serverFactory.fromMetadata(
-
metadata,
-
authMethod,
-
dpopKey,
-
);
-
-
final parResponse = await server.request(
-
'pushed_authorization_request',
-
parameters,
-
);
-
-
final requestUri = parResponse['request_uri'] as String;
-
-
// Return simplified URL with just request_uri
-
return authorizationUrl.replace(
-
queryParameters: {
-
'client_id': clientMetadata.clientId!,
-
'request_uri': requestUri,
-
},
-
);
-
} else if (requiresPar) {
-
throw Exception(
-
'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
-
);
-
} else {
-
// No PAR support, use direct authorization request
-
final fullUrl = authorizationUrl.replace(queryParameters: parameters);
-
-
// Check URL length (2048 byte limit for some browsers)
-
final urlLength = fullUrl.toString().length;
-
if (urlLength >= 2048) {
-
throw Exception('Login URL too long ($urlLength bytes)');
-
}
-
-
return fullUrl;
-
}
-
}
-
-
/// Handles the OAuth callback after user authorization.
-
///
-
/// This method:
-
/// 1. Validates the state parameter
-
/// 2. Retrieves stored internal state
-
/// 3. Checks for error responses
-
/// 4. Validates issuer (if provided)
-
/// 5. Exchanges authorization code for tokens
-
/// 6. Creates and stores session
-
/// 7. Cleans up state
-
///
-
/// The [params] should be the query parameters from the callback URL.
-
///
-
/// The [options] can specify:
-
/// - redirectUri: Must match the one used in authorize()
-
///
-
/// Returns a [CallbackResult] with the session and application state.
-
///
-
/// Throws [OAuthCallbackError] if the callback contains errors or is invalid.
-
Future<CallbackResult> callback(
-
Map<String, String> params, {
-
CallbackOptions? options,
-
CancelToken? cancelToken,
-
}) async {
-
final opts = options ?? const CallbackOptions();
-
-
// Check for JARM (not supported)
-
final responseJwt = params['response'];
-
if (responseJwt != null) {
-
throw OAuthCallbackError(params, message: 'JARM not supported');
-
}
-
-
// Extract parameters
-
final issuerParam = params['iss'];
-
final stateParam = params['state'];
-
final errorParam = params['error'];
-
final codeParam = params['code'];
-
-
// Validate state parameter
-
if (stateParam == null) {
-
throw OAuthCallbackError(params, message: 'Missing "state" parameter');
-
}
-
-
// Retrieve internal state
-
final stateData = await _stateStore.get(stateParam);
-
if (stateData == null) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Unknown authorization session "$stateParam"',
-
);
-
}
-
-
// Prevent replay attacks - delete state immediately
-
await _stateStore.del(stateParam);
-
-
try {
-
// Check for error response
-
if (errorParam != null) {
-
throw OAuthCallbackError(params, state: stateData.appState);
-
}
-
-
// Validate authorization code
-
if (codeParam == null) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Missing "code" query param',
-
state: stateData.appState,
-
);
-
}
-
-
// Create OAuth server agent
-
final authMethod =
-
stateData.authMethod != null
-
? ClientAuthMethod.fromJson(
-
stateData.authMethod as Map<String, dynamic>,
-
)
-
: const ClientAuthMethod.none(); // Legacy fallback
-
-
// Restore dpopKey from stored private JWK
-
// Restore DPoP key with error handling for corrupted JWK data
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map<String, dynamic>);
-
if (kDebugMode) {
-
print('🔓 DPoP key restored successfully for token exchange');
-
}
-
} catch (e) {
-
throw Exception(
-
'Failed to restore DPoP key from stored state: $e. '
-
'The stored key may be corrupted. Please try authenticating again.',
-
);
-
}
-
-
final server = await serverFactory.fromIssuer(
-
stateData.iss,
-
authMethod,
-
dpopKey,
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
-
);
-
-
// Validate issuer if provided
-
if (issuerParam != null) {
-
if (server.issuer.isEmpty) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Issuer not found in metadata',
-
state: stateData.appState,
-
);
-
}
-
if (server.issuer != issuerParam) {
-
throw OAuthCallbackError(
-
params,
-
message: 'Issuer mismatch',
-
state: stateData.appState,
-
);
-
}
-
} else if (server
-
.serverMetadata['authorization_response_iss_parameter_supported'] ==
-
true) {
-
throw OAuthCallbackError(
-
params,
-
message: 'iss missing from the response',
-
state: stateData.appState,
-
);
-
}
-
-
// Exchange authorization code for tokens
-
// CRITICAL: Use the EXACT same redirectUri that was used during authorization
-
// The redirectUri in the token exchange MUST match the one in the PAR request
-
final redirectUriForExchange =
-
stateData.redirectUri ??
-
opts.redirectUri ??
-
clientMetadata.redirectUris.first;
-
-
if (kDebugMode) {
-
print('🔄 Exchanging authorization code for tokens:');
-
print(' Code: ${codeParam.substring(0, 20)}...');
-
print(
-
' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...',
-
);
-
print(' Redirect URI: $redirectUriForExchange');
-
print(
-
' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}',
-
);
-
print(' Issuer: ${server.issuer}');
-
}
-
-
final tokenSet = await server.exchangeCode(
-
codeParam,
-
codeVerifier: stateData.verifier,
-
redirectUri: redirectUriForExchange,
-
);
-
-
try {
-
if (kDebugMode) {
-
print('💾 Storing session for: ${tokenSet.sub}');
-
}
-
-
// Store session
-
await _sessionGetter.setStored(
-
tokenSet.sub,
-
Session(
-
dpopKey: stateData.dpopKey,
-
authMethod: authMethod.toJson(),
-
tokenSet: tokenSet,
-
),
-
);
-
-
if (kDebugMode) {
-
print('✅ Session stored successfully');
-
print('🎯 Creating session wrapper...');
-
}
-
-
// Create session wrapper
-
final session = _createSession(server, tokenSet.sub);
-
-
if (kDebugMode) {
-
print('✅ Session wrapper created');
-
print('🎉 OAuth callback complete!');
-
}
-
-
return CallbackResult(session: session, state: stateData.appState);
-
} catch (err, stackTrace) {
-
// If session storage failed, revoke the tokens
-
if (kDebugMode) {
-
print('❌ Session storage/creation failed:');
-
print(' Error: $err');
-
print(' Stack trace: $stackTrace');
-
}
-
await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken);
-
rethrow;
-
}
-
} catch (err, stackTrace) {
-
// Ensure appState is available in error
-
if (kDebugMode) {
-
print('❌ Callback error (outer catch):');
-
print(' Error type: ${err.runtimeType}');
-
print(' Error: $err');
-
print(' Stack trace: $stackTrace');
-
}
-
throw OAuthCallbackError.from(err, params, stateData.appState);
-
}
-
}
-
-
/// Restores a stored session.
-
///
-
/// This method:
-
/// 1. Retrieves session from storage
-
/// 2. Checks if tokens are expired
-
/// 3. Automatically refreshes tokens if needed (based on [refresh])
-
/// 4. Creates OAuthServerAgent
-
/// 5. Returns live OAuthSession
-
///
-
/// The [sub] is the user's DID.
-
///
-
/// The [refresh] parameter controls token refresh:
-
/// - `true`: Force refresh even if not expired
-
/// - `false`: Use cached tokens even if expired
-
/// - `'auto'`: Refresh only if expired (default)
-
///
-
/// Throws [Exception] if session doesn't exist.
-
/// Throws [TokenRefreshError] if refresh fails.
-
/// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied.
-
Future<OAuthSession> restore(
-
String sub, {
-
dynamic refresh = 'auto',
-
CancelToken? cancelToken,
-
}) async {
-
// Validate DID format
-
assertAtprotoDid(sub);
-
-
// Get session (automatically refreshes if needed based on refresh param)
-
final session = await _sessionGetter.getSession(sub, refresh);
-
-
try {
-
// Determine auth method (with legacy fallback)
-
final authMethod =
-
session.authMethod != null
-
? ClientAuthMethod.fromJson(
-
session.authMethod as Map<String, dynamic>,
-
)
-
: const ClientAuthMethod.none(); // Legacy
-
-
// Restore dpopKey from stored private JWK with error handling
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
-
} catch (e) {
-
// If key is corrupted, delete the session and force re-authentication
-
await _sessionGetter.delStored(
-
sub,
-
Exception('Corrupted DPoP key in stored session: $e'),
-
);
-
throw Exception(
-
'Failed to restore DPoP key for session. The stored key is corrupted. '
-
'Please authenticate again.',
-
);
-
}
-
-
// Create server agent
-
final server = await serverFactory.fromIssuer(
-
session.tokenSet.iss,
-
authMethod,
-
dpopKey,
-
auth_resolver.GetCachedOptions(
-
noCache: refresh == true,
-
allowStale: refresh == false,
-
cancelToken: cancelToken,
-
),
-
);
-
-
return _createSession(server, sub);
-
} catch (err) {
-
// If auth method can't be satisfied, delete the session
-
if (err is AuthMethodUnsatisfiableError) {
-
await _sessionGetter.delStored(sub, err);
-
}
-
rethrow;
-
}
-
}
-
-
/// Revokes a session.
-
///
-
/// This method:
-
/// 1. Retrieves session from storage
-
/// 2. Calls token revocation endpoint
-
/// 3. Deletes session from storage
-
///
-
/// The [sub] is the user's DID.
-
///
-
/// Token revocation is best-effort - even if the revocation request fails,
-
/// the local session is still deleted.
-
Future<void> revoke(String sub, {CancelToken? cancelToken}) async {
-
// Validate DID format
-
assertAtprotoDid(sub);
-
-
// Get session (allow stale tokens for revocation)
-
final session = await _sessionGetter.get(
-
sub,
-
const GetCachedOptions(allowStale: true),
-
);
-
-
// Try to revoke tokens on the server
-
try {
-
final authMethod =
-
session.authMethod != null
-
? ClientAuthMethod.fromJson(
-
session.authMethod as Map<String, dynamic>,
-
)
-
: const ClientAuthMethod.none(); // Legacy
-
-
// Restore dpopKey from stored private JWK with error handling
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(session.dpopKey as Map<String, dynamic>);
-
} catch (e) {
-
// If key is corrupted, skip server-side revocation
-
// The finally block will still delete the local session
-
if (kDebugMode) {
-
print('⚠️ Cannot revoke on server: corrupted DPoP key ($e)');
-
print(' Local session will still be deleted');
-
}
-
return;
-
}
-
-
final server = await serverFactory.fromIssuer(
-
session.tokenSet.iss,
-
authMethod,
-
dpopKey,
-
auth_resolver.GetCachedOptions(cancelToken: cancelToken),
-
);
-
-
await server.revoke(session.tokenSet.accessToken);
-
} finally {
-
// Always delete local session, even if revocation failed
-
await _sessionGetter.delStored(sub, TokenRevokedError(sub));
-
}
-
}
-
-
/// Creates an OAuthSession wrapper.
-
///
-
/// Internal helper for creating session objects from server agents.
-
OAuthSession _createSession(OAuthServerAgent server, String sub) {
-
// Create a wrapper that implements SessionGetterInterface
-
final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter);
-
-
return OAuthSession(
-
server: server,
-
sub: sub,
-
sessionGetter: sessionGetterWrapper,
-
);
-
}
-
-
/// Disposes of resources used by this client.
-
///
-
/// Call this when the client is no longer needed to prevent memory leaks.
-
@override
-
void dispose() {
-
_updatedController.close();
-
_deletedController.close();
-
_sessionGetter.dispose();
-
super.dispose();
-
}
-
}
-
-
/// Wrapper to adapt SessionGetter to SessionGetterInterface
-
class _SessionGetterWrapper implements SessionGetterInterface {
-
final SessionGetter _getter;
-
-
_SessionGetterWrapper(this._getter);
-
-
@override
-
Future<Session> get(String sub, {bool? noCache, bool? allowStale}) async {
-
return _getter.get(
-
sub,
-
GetCachedOptions(
-
noCache: noCache ?? false,
-
allowStale: allowStale ?? false,
-
),
-
);
-
}
-
-
@override
-
Future<void> delStored(String sub, [Object? cause]) {
-
return _getter.delStored(sub, cause);
-
}
-
}
···
-2
packages/atproto_oauth_flutter/lib/src/constants.dart
···
-
/// Per ATProto spec (OpenID uses RS256)
-
const String fallbackAlg = 'ES256';
···
-593
packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart
···
-
import 'dart:async';
-
import 'dart:convert';
-
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart' hide Key;
-
-
import '../runtime/runtime_implementation.dart';
-
-
/// A simple key-value store interface for storing DPoP nonces.
-
///
-
/// This is a simplified Dart version of @atproto-labs/simple-store.
-
/// Implementations can use:
-
/// - In-memory Map (for testing)
-
/// - SharedPreferences (for persistence)
-
/// - Secure storage (for sensitive data)
-
abstract class SimpleStore<K, V> {
-
/// Get a value by key. Returns null if not found.
-
FutureOr<V?> get(K key);
-
-
/// Set a value for a key.
-
FutureOr<void> set(K key, V value);
-
-
/// Delete a value by key.
-
FutureOr<void> del(K key);
-
-
/// Clear all values (optional).
-
FutureOr<void> clear();
-
}
-
-
/// In-memory implementation of SimpleStore for DPoP nonces.
-
///
-
/// This is used as the default nonce store. Nonces are ephemeral and
-
/// don't need to be persisted across app restarts.
-
class InMemoryStore<K, V> implements SimpleStore<K, V> {
-
final Map<K, V> _store = {};
-
-
@override
-
V? get(K key) => _store[key];
-
-
@override
-
void set(K key, V value) => _store[key] = value;
-
-
@override
-
void del(K key) => _store.remove(key);
-
-
@override
-
void clear() => _store.clear();
-
}
-
-
/// Options for configuring the DPoP fetch wrapper.
-
class DpopFetchWrapperOptions {
-
/// The cryptographic key used to sign DPoP proofs.
-
final Key key;
-
-
/// Store for caching DPoP nonces per origin.
-
final SimpleStore<String, String> nonces;
-
-
/// List of algorithms supported by the server (optional).
-
/// If not provided, the key's first algorithm will be used.
-
final List<String>? supportedAlgs;
-
-
/// Function to compute SHA-256 hash (required for DPoP).
-
/// Should return base64url-encoded hash.
-
final Future<String> Function(String input) sha256;
-
-
/// Whether the target server is an authorization server (true)
-
/// or resource server (false).
-
///
-
/// This affects how "use_dpop_nonce" errors are detected:
-
/// - Authorization servers return 400 with JSON error
-
/// - Resource servers return 401 with WWW-Authenticate header
-
///
-
/// If null, both patterns will be checked.
-
final bool? isAuthServer;
-
-
const DpopFetchWrapperOptions({
-
required this.key,
-
required this.nonces,
-
this.supportedAlgs,
-
required this.sha256,
-
this.isAuthServer,
-
});
-
}
-
-
/// Creates a Dio interceptor that adds DPoP (Demonstrating Proof of Possession)
-
/// headers to HTTP requests.
-
///
-
/// DPoP is a security mechanism that binds access tokens to cryptographic keys,
-
/// preventing token theft and replay attacks. It works by:
-
///
-
/// 1. Creating a JWT proof signed with a private key
-
/// 2. Including the proof in a DPoP header
-
/// 3. Including the access token hash (ath) in the proof
-
/// 4. Handling nonce-based replay protection
-
///
-
/// The interceptor automatically:
-
/// - Generates DPoP proofs for each request
-
/// - Caches and reuses server-provided nonces
-
/// - Retries requests when server requires a fresh nonce
-
/// - Handles both authorization and resource server error formats
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc9449
-
///
-
/// Example:
-
/// ```dart
-
/// final dio = Dio();
-
/// final options = DpopFetchWrapperOptions(
-
/// key: myKey,
-
/// nonces: InMemoryStore(),
-
/// sha256: runtime.sha256,
-
/// );
-
/// dio.interceptors.add(createDpopInterceptor(options));
-
/// ```
-
Interceptor createDpopInterceptor(DpopFetchWrapperOptions options) {
-
// Negotiate algorithm once at creation time
-
final alg = _negotiateAlg(options.key, options.supportedAlgs);
-
-
return InterceptorsWrapper(
-
onRequest: (requestOptions, handler) async {
-
try {
-
// Extract authorization header for ath calculation
-
final authHeader = requestOptions.headers['Authorization'] as String?;
-
final String? ath;
-
if (authHeader != null && authHeader.startsWith('DPoP ')) {
-
ath = await options.sha256(authHeader.substring(5));
-
} else {
-
ath = null;
-
}
-
-
final uri = requestOptions.uri;
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
final htm = requestOptions.method;
-
final htu = _buildHtu(uri.toString());
-
-
// Try to get cached nonce for this origin
-
String? initNonce;
-
try {
-
initNonce = await options.nonces.get(origin);
-
} catch (_) {
-
// Ignore nonce retrieval errors
-
}
-
-
// Build and add DPoP proof
-
final initProof = await _buildProof(
-
options.key,
-
alg,
-
htm,
-
htu,
-
initNonce,
-
ath,
-
);
-
requestOptions.headers['DPoP'] = initProof;
-
-
handler.next(requestOptions);
-
} catch (e) {
-
handler.reject(
-
DioException(
-
requestOptions: requestOptions,
-
error: 'Failed to create DPoP proof: $e',
-
type: DioExceptionType.unknown,
-
),
-
);
-
}
-
},
-
onResponse: (response, handler) async {
-
try {
-
final uri = response.requestOptions.uri;
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print('🟢 DPoP interceptor onResponse triggered');
-
print(' URL: ${uri.path}');
-
print(' Status: ${response.statusCode}');
-
}
-
-
// Check for DPoP-Nonce header in response
-
final nextNonce = response.headers.value('dpop-nonce');
-
-
if (nextNonce != null) {
-
// Extract origin from request
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
// Store the fresh nonce for future requests
-
try {
-
await options.nonces.set(origin, nextNonce);
-
if (kDebugMode && uri.path.contains('/token')) {
-
print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
-
}
-
} catch (_) {
-
// Ignore nonce storage errors
-
}
-
} else if (kDebugMode && uri.path.contains('/token')) {
-
print(' No nonce in response');
-
}
-
-
// Check for nonce errors in successful responses (when validateStatus: true)
-
// This handles the case where Dio returns 401 as a successful response
-
if (nextNonce != null &&
-
await _isUseDpopNonceError(response, options.isAuthServer)) {
-
final isTokenEndpoint =
-
uri.path.contains('/token') || uri.path.endsWith('/token');
-
-
if (kDebugMode) {
-
print(
-
'⚠️ DPoP nonce error in response (status ${response.statusCode})',
-
);
-
print(' Is token endpoint: $isTokenEndpoint');
-
}
-
-
if (isTokenEndpoint) {
-
// Don't retry token endpoint - just pass through with nonce cached
-
if (kDebugMode) {
-
print(
-
' NOT retrying token endpoint (nonce cached for next attempt)',
-
);
-
}
-
handler.next(response);
-
return;
-
}
-
-
// For non-token endpoints, retry is safe
-
if (kDebugMode) {
-
print('🔄 Retrying request with fresh nonce');
-
}
-
-
try {
-
final authHeader =
-
response.requestOptions.headers['Authorization'] as String?;
-
final String? ath;
-
if (authHeader != null && authHeader.startsWith('DPoP ')) {
-
ath = await options.sha256(authHeader.substring(5));
-
} else {
-
ath = null;
-
}
-
-
final htm = response.requestOptions.method;
-
final htu = _buildHtu(uri.toString());
-
-
final nextProof = await _buildProof(
-
options.key,
-
alg,
-
htm,
-
htu,
-
nextNonce,
-
ath,
-
);
-
-
// Clone request options and update DPoP header
-
// Note: We preserve validateStatus to match original request behavior
-
final retryOptions = Options(
-
method: response.requestOptions.method,
-
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
-
validateStatus: response.requestOptions.validateStatus,
-
);
-
-
// DESIGN NOTE: We create a fresh Dio instance for retry to avoid
-
// re-triggering this interceptor (which would cause infinite loops).
-
// This means base options (timeouts, etc.) are not preserved, but
-
// this is acceptable for DPoP nonce retry scenarios which should be fast.
-
// If this becomes an issue, we could inject a Dio factory function.
-
final dio = Dio();
-
final retryResponse = await dio.requestUri(
-
uri,
-
options: retryOptions,
-
data: response.requestOptions.data,
-
);
-
-
handler.resolve(retryResponse);
-
return;
-
} catch (retryError) {
-
if (kDebugMode) {
-
print('❌ Retry failed: $retryError');
-
}
-
// If retry fails, return the original response
-
handler.next(response);
-
return;
-
}
-
}
-
-
handler.next(response);
-
} catch (e) {
-
handler.reject(
-
DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
error: 'Failed to process DPoP nonce: $e',
-
type: DioExceptionType.unknown,
-
),
-
);
-
}
-
},
-
onError: (error, handler) async {
-
final response = error.response;
-
if (response == null) {
-
handler.next(error);
-
return;
-
}
-
-
final uri = response.requestOptions.uri;
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print('🔴 DPoP interceptor onError triggered');
-
print(' URL: ${uri.path}');
-
print(' Status: ${response.statusCode}');
-
print(
-
' Has validateStatus: ${response.requestOptions.validateStatus != null}',
-
);
-
}
-
-
// Check for DPoP-Nonce in error response
-
final nextNonce = response.headers.value('dpop-nonce');
-
-
if (nextNonce != null) {
-
// Extract origin
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
// Store the fresh nonce for future requests
-
try {
-
await options.nonces.set(origin, nextNonce);
-
if (kDebugMode && uri.path.contains('/token')) {
-
print(' Cached nonce: ${nextNonce.substring(0, 20)}...');
-
}
-
} catch (_) {
-
// Ignore nonce storage errors
-
}
-
-
// Check if this is a "use_dpop_nonce" error
-
final isNonceError = await _isUseDpopNonceError(
-
response,
-
options.isAuthServer,
-
);
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print(' Is use_dpop_nonce error: $isNonceError');
-
}
-
-
if (isNonceError) {
-
// IMPORTANT: Do NOT retry for token endpoint!
-
// Retrying the token exchange can consume the authorization code,
-
// causing "Invalid code" errors on the retry.
-
//
-
// Instead, we rely on pre-fetching the nonce before critical operations
-
// (like authorization code exchange) to ensure we have a valid nonce
-
// from the start.
-
//
-
// We still cache the nonce for future requests, but we don't retry
-
// this particular request.
-
final isTokenEndpoint =
-
uri.path.contains('/token') || uri.path.endsWith('/token');
-
-
if (kDebugMode && isTokenEndpoint) {
-
print('⚠️ DPoP nonce error on token endpoint - NOT retrying');
-
print(' Cached fresh nonce for future requests');
-
}
-
-
if (isTokenEndpoint) {
-
// Don't retry - just pass through the error with the nonce cached
-
handler.next(error);
-
return;
-
}
-
-
// For non-token endpoints, retry is safe
-
if (kDebugMode) {
-
print('🔄 DPoP retry for non-token endpoint: ${uri.path}');
-
}
-
-
try {
-
final authHeader =
-
response.requestOptions.headers['Authorization'] as String?;
-
final String? ath;
-
if (authHeader != null && authHeader.startsWith('DPoP ')) {
-
ath = await options.sha256(authHeader.substring(5));
-
} else {
-
ath = null;
-
}
-
-
final htm = response.requestOptions.method;
-
final htu = _buildHtu(uri.toString());
-
-
final nextProof = await _buildProof(
-
options.key,
-
alg,
-
htm,
-
htu,
-
nextNonce,
-
ath,
-
);
-
-
// Clone request options and update DPoP header
-
// Note: We preserve validateStatus to match original request behavior
-
final retryOptions = Options(
-
method: response.requestOptions.method,
-
headers: {...response.requestOptions.headers, 'DPoP': nextProof},
-
validateStatus: response.requestOptions.validateStatus,
-
);
-
-
// DESIGN NOTE: We create a fresh Dio instance for retry to avoid
-
// re-triggering this interceptor (which would cause infinite loops).
-
// This means base options (timeouts, etc.) are not preserved, but
-
// this is acceptable for DPoP nonce retry scenarios which should be fast.
-
// If this becomes an issue, we could inject a Dio factory function.
-
final dio = Dio();
-
final retryResponse = await dio.requestUri(
-
uri,
-
options: retryOptions,
-
data: response.requestOptions.data,
-
);
-
-
handler.resolve(retryResponse);
-
return;
-
} catch (retryError) {
-
// If retry fails, return the retry error
-
if (retryError is DioException) {
-
handler.next(retryError);
-
} else {
-
handler.next(
-
DioException(
-
requestOptions: response.requestOptions,
-
error: retryError,
-
type: DioExceptionType.unknown,
-
),
-
);
-
}
-
return;
-
}
-
}
-
}
-
-
if (kDebugMode && uri.path.contains('/token')) {
-
print('🔴 DPoP interceptor passing error through (no retry)');
-
}
-
-
handler.next(error);
-
},
-
);
-
}
-
-
/// Strips query string and fragment from URL.
-
///
-
/// Per RFC 9449, the htu (HTTP URI) claim must not include query or fragment.
-
///
-
/// See: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6
-
String _buildHtu(String url) {
-
final fragmentIndex = url.indexOf('#');
-
final queryIndex = url.indexOf('?');
-
-
final int end;
-
if (fragmentIndex == -1) {
-
end = queryIndex;
-
} else if (queryIndex == -1) {
-
end = fragmentIndex;
-
} else {
-
end = fragmentIndex < queryIndex ? fragmentIndex : queryIndex;
-
}
-
-
return end == -1 ? url : url.substring(0, end);
-
}
-
-
/// Builds a DPoP proof JWT.
-
///
-
/// The proof is a JWT with:
-
/// - Header: typ="dpop+jwt", alg, jwk (public key)
-
/// - Payload: iat, jti, htm, htu, nonce?, ath?
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2
-
Future<String> _buildProof(
-
Key key,
-
String alg,
-
String htm,
-
String htu,
-
String? nonce,
-
String? ath,
-
) async {
-
final jwk = key.bareJwk;
-
if (jwk == null) {
-
throw StateError('Only asymmetric keys can be used for DPoP proofs');
-
}
-
-
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
-
-
// Create header
-
final header = {'alg': alg, 'typ': 'dpop+jwt', 'jwk': jwk};
-
-
// Create payload
-
final payload = {
-
'iat': now,
-
// Random jti to prevent replay attacks
-
// Any collision will cause server rejection, which is acceptable
-
'jti': DateTime.now().microsecondsSinceEpoch.toString(),
-
'htm': htm,
-
'htu': htu,
-
if (nonce != null) 'nonce': nonce,
-
if (ath != null) 'ath': ath,
-
};
-
-
if (kDebugMode && htu.contains('/token')) {
-
print('🔐 Creating DPoP proof for token request:');
-
print(' htm: $htm');
-
print(' htu: $htu');
-
print(' nonce: ${nonce ?? "none"}');
-
print(' ath: ${ath ?? "none"}');
-
print(' jwk keys: ${jwk?.keys.toList()}');
-
}
-
-
final jwt = await key.createJwt(header, payload);
-
-
if (kDebugMode && htu.contains('/token')) {
-
print(' ✅ DPoP proof created: ${jwt.substring(0, 50)}...');
-
}
-
-
return jwt;
-
}
-
-
/// Checks if a response indicates a "use_dpop_nonce" error.
-
///
-
/// There are multiple error formats depending on server implementation:
-
///
-
/// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header
-
/// WWW-Authenticate: DPoP error="use_dpop_nonce"
-
///
-
/// 2. Authorization Server: 400 with JSON body
-
/// {"error": "use_dpop_nonce"}
-
///
-
/// 3. Resource Server (JSON variant): 401 with JSON body
-
/// {"error": "use_dpop_nonce"}
-
///
-
/// See:
-
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
-
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
-
Future<bool> _isUseDpopNonceError(Response response, bool? isAuthServer) async {
-
// Check WWW-Authenticate header format (401 + header)
-
if (response.statusCode == 401) {
-
final wwwAuth = response.headers.value('www-authenticate');
-
if (wwwAuth != null && wwwAuth.startsWith('DPoP')) {
-
if (wwwAuth.contains('error="use_dpop_nonce"')) {
-
return true;
-
}
-
}
-
}
-
-
// Check JSON body format (400 or 401 + JSON)
-
// Some servers use 401 + JSON instead of WWW-Authenticate header
-
if (response.statusCode == 400 || response.statusCode == 401) {
-
try {
-
final data = response.data;
-
if (data is Map<String, dynamic>) {
-
return data['error'] == 'use_dpop_nonce';
-
} else if (data is String) {
-
// Try to parse as JSON
-
final json = jsonDecode(data);
-
if (json is Map<String, dynamic>) {
-
return json['error'] == 'use_dpop_nonce';
-
}
-
}
-
} catch (_) {
-
// Invalid JSON or response too large, not a use_dpop_nonce error
-
return false;
-
}
-
}
-
-
return false;
-
}
-
-
/// Negotiates the algorithm to use for DPoP proofs.
-
///
-
/// If supportedAlgs is provided, uses the first algorithm that the key supports.
-
/// Otherwise, uses the key's first algorithm.
-
///
-
/// Throws if the key doesn't support any of the server's algorithms.
-
String _negotiateAlg(Key key, List<String>? supportedAlgs) {
-
if (supportedAlgs != null) {
-
// Use order of supportedAlgs as preference
-
for (final alg in supportedAlgs) {
-
if (key.algorithms.contains(alg)) {
-
return alg;
-
}
-
}
-
throw StateError(
-
'Key does not match any algorithm supported by the server. '
-
'Key supports: ${key.algorithms}, server supports: $supportedAlgs',
-
);
-
}
-
-
// No server preference, use key's first algorithm
-
if (key.algorithms.isEmpty) {
-
throw StateError('Key does not support any algorithms');
-
}
-
-
return key.algorithms.first;
-
}
···
-14
packages/atproto_oauth_flutter/lib/src/errors/auth_method_unsatisfiable_error.dart
···
-
/// Exception thrown when the requested authentication method cannot be satisfied.
-
class AuthMethodUnsatisfiableError implements Exception {
-
final String? message;
-
-
AuthMethodUnsatisfiableError([this.message]);
-
-
@override
-
String toString() {
-
if (message != null) {
-
return 'AuthMethodUnsatisfiableError: $message';
-
}
-
return 'AuthMethodUnsatisfiableError';
-
}
-
}
···
-10
packages/atproto_oauth_flutter/lib/src/errors/errors.dart
···
-
/// OAuth error types for the atproto_oauth_flutter package.
-
library;
-
-
export 'auth_method_unsatisfiable_error.dart';
-
export 'oauth_callback_error.dart';
-
export 'oauth_resolver_error.dart';
-
export 'oauth_response_error.dart';
-
export 'token_invalid_error.dart';
-
export 'token_refresh_error.dart';
-
export 'token_revoked_error.dart';
···
-51
packages/atproto_oauth_flutter/lib/src/errors/oauth_callback_error.dart
···
-
/// Error class for OAuth callback failures.
-
///
-
/// This error is thrown when an OAuth authorization callback contains
-
/// error parameters or fails to parse correctly.
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
-
class OAuthCallbackError implements Exception {
-
/// The URL parameters from the callback
-
final Map<String, String> params;
-
-
/// The state parameter from the callback (if present)
-
final String? state;
-
-
/// The error message
-
final String message;
-
-
/// Optional underlying cause
-
final Object? cause;
-
-
/// Creates an OAuth callback error from parameters.
-
///
-
/// The [params] should contain the parsed query parameters from the callback URL.
-
/// The [message] defaults to the error_description from params, or a generic message.
-
OAuthCallbackError(this.params, {String? message, this.state, this.cause})
-
: message =
-
message ?? params['error_description'] ?? 'OAuth callback error';
-
-
/// Creates an OAuthCallbackError from another error.
-
///
-
/// If [err] is already an OAuthCallbackError, returns it unchanged.
-
/// Otherwise, wraps the error with the given params and state.
-
static OAuthCallbackError from(
-
Object err,
-
Map<String, String> params, [
-
String? state,
-
]) {
-
if (err is OAuthCallbackError) return err;
-
final message = err is Exception ? err.toString() : null;
-
return OAuthCallbackError(
-
params,
-
message: message,
-
state: state,
-
cause: err,
-
);
-
}
-
-
@override
-
String toString() {
-
return 'OAuthCallbackError: $message';
-
}
-
}
···
-47
packages/atproto_oauth_flutter/lib/src/errors/oauth_resolver_error.dart
···
-
/// Error class for OAuth resolution failures.
-
///
-
/// This error is thrown when OAuth metadata resolution fails, including:
-
/// - Authorization server metadata discovery
-
/// - Protected resource metadata discovery
-
/// - Identity resolution (handle → DID → PDS)
-
class OAuthResolverError implements Exception {
-
/// The error message
-
final String message;
-
-
/// Optional underlying cause
-
final Object? cause;
-
-
/// Creates an OAuth resolver error.
-
OAuthResolverError(this.message, {this.cause});
-
-
/// Creates an OAuthResolverError from another error.
-
///
-
/// If [cause] is already an OAuthResolverError, returns it unchanged.
-
/// Otherwise, wraps the error with an appropriate message.
-
///
-
/// For validation errors, extracts the first error details.
-
static OAuthResolverError from(Object cause, [String? message]) {
-
if (cause is OAuthResolverError) return cause;
-
-
String? validationReason;
-
-
// Check if it's a validation error (would be FormatException or similar in Dart)
-
if (cause is FormatException) {
-
validationReason = cause.message;
-
}
-
-
final fullMessage =
-
(message ?? 'Unable to resolve OAuth metadata') +
-
(validationReason != null ? ' ($validationReason)' : '');
-
-
return OAuthResolverError(fullMessage, cause: cause);
-
}
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'OAuthResolverError: $message (caused by: $cause)';
-
}
-
return 'OAuthResolverError: $message';
-
}
-
}
···
-62
packages/atproto_oauth_flutter/lib/src/errors/oauth_response_error.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../util.dart';
-
-
/// Error class for OAuth protocol errors returned by the server.
-
///
-
/// OAuth servers return errors as JSON with standard fields:
-
/// - error: The error code (required)
-
/// - error_description: Human-readable description (optional)
-
/// - error_uri: URI with more information (optional)
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
-
class OAuthResponseError implements Exception {
-
/// The HTTP response that contained the error
-
final Response response;
-
-
/// The parsed response body (usually JSON)
-
final dynamic payload;
-
-
/// The OAuth error code (e.g., "invalid_request", "invalid_grant")
-
final String? error;
-
-
/// The human-readable error description
-
final String? errorDescription;
-
-
/// Creates an OAuth response error from a Dio response.
-
///
-
/// Automatically extracts the error and error_description fields
-
/// from the response payload if it's a JSON object.
-
OAuthResponseError(this.response, this.payload)
-
: error = _extractError(payload),
-
errorDescription = _extractErrorDescription(payload);
-
-
/// HTTP status code from the response
-
int get status => response.statusCode ?? 0;
-
-
/// HTTP headers from the response
-
Headers get headers => response.headers;
-
-
/// Extracts the error code from the payload
-
static String? _extractError(dynamic payload) {
-
if (payload is Map<String, dynamic>) {
-
return ifString(payload['error']);
-
}
-
return null;
-
}
-
-
/// Extracts the error description from the payload
-
static String? _extractErrorDescription(dynamic payload) {
-
if (payload is Map<String, dynamic>) {
-
return ifString(payload['error_description']);
-
}
-
return null;
-
}
-
-
@override
-
String toString() {
-
final errorCode = error ?? 'unknown';
-
final description = errorDescription != null ? ': $errorDescription' : '';
-
return 'OAuth "$errorCode" error$description';
-
}
-
}
···
-22
packages/atproto_oauth_flutter/lib/src/errors/token_invalid_error.dart
···
-
/// Exception thrown when a token is invalid.
-
class TokenInvalidError implements Exception {
-
/// Subject identifier for the invalid token
-
final String sub;
-
-
/// Error message
-
final String message;
-
-
/// Optional cause of the error
-
final Object? cause;
-
-
TokenInvalidError(this.sub, {String? message, this.cause})
-
: message = message ?? 'The session for "$sub" is invalid';
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'TokenInvalidError: $message (caused by: $cause)';
-
}
-
return 'TokenInvalidError: $message';
-
}
-
}
···
-21
packages/atproto_oauth_flutter/lib/src/errors/token_refresh_error.dart
···
-
/// Exception thrown when a token refresh operation fails.
-
class TokenRefreshError implements Exception {
-
/// Subject identifier for the token that failed to refresh
-
final String sub;
-
-
/// Error message
-
final String message;
-
-
/// Optional cause of the error
-
final Object? cause;
-
-
TokenRefreshError(this.sub, this.message, {this.cause});
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'TokenRefreshError: $message (caused by: $cause)';
-
}
-
return 'TokenRefreshError: $message';
-
}
-
}
···
-22
packages/atproto_oauth_flutter/lib/src/errors/token_revoked_error.dart
···
-
/// Exception thrown when a token has been successfully revoked.
-
class TokenRevokedError implements Exception {
-
/// Subject identifier for the revoked token
-
final String sub;
-
-
/// Error message
-
final String message;
-
-
/// Optional cause of the error
-
final Object? cause;
-
-
TokenRevokedError(this.sub, {String? message, this.cause})
-
: message = message ?? 'The session for "$sub" was successfully revoked';
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'TokenRevokedError: $message (caused by: $cause)';
-
}
-
return 'TokenRevokedError: $message';
-
}
-
}
···
-263
packages/atproto_oauth_flutter/lib/src/identity/README.md
···
-
# atProto Identity Resolution Layer
-
-
## Overview
-
-
This module implements the **critical identity resolution functionality** for atProto decentralization. It resolves atProto handles and DIDs to discover where user data is actually stored (their Personal Data Server).
-
-
## Why This Matters
-
-
**This is the most important code for decentralization in atProto.**
-
-
Without this layer:
-
- Apps hardcode `bsky.social` as the only server
-
- Users can't use custom domains
-
- Self-hosting is impossible
-
- atProto becomes centralized
-
-
With this layer:
-
- ✅ Users host data on any PDS they choose
-
- ✅ Custom domain handles work (e.g., `alice.example.com`)
-
- ✅ Identity is portable (change PDS without losing DID)
-
- ✅ True decentralization is achieved
-
-
## Architecture
-
-
### Resolution Flow
-
-
```
-
Handle/DID Input
-
-
Is it a DID? ──Yes──→ DID Resolution
-
↓ ↓
-
No DID Document
-
↓ ↓
-
Handle Resolution Extract Handle
-
↓ ↓
-
DID Validate Handle ←→ DID
-
↓ ↓
-
DID Resolution Return IdentityInfo
-
-
DID Document
-
-
Validate Handle in Doc
-
-
Extract PDS URL
-
-
Return IdentityInfo
-
```
-
-
### Key Components
-
-
#### 1. IdentityResolver
-
Main interface for resolving identities. Use `AtprotoIdentityResolver` for the standard implementation.
-
-
```dart
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Resolve to PDS URL (most common use case)
-
final pdsUrl = await resolver.resolveToPds('alice.example.com');
-
-
// Get full identity info
-
final info = await resolver.resolve('alice.example.com');
-
print('DID: ${info.did}');
-
print('Handle: ${info.handle}');
-
print('PDS: ${info.pdsUrl}');
-
```
-
-
#### 2. HandleResolver
-
Resolves atProto handles (e.g., `alice.bsky.social`) to DIDs using XRPC.
-
-
**Resolution Methods:**
-
- XRPC: Uses `com.atproto.identity.resolveHandle` endpoint
-
- DNS TXT record: Checks `_atproto.{handle}` (not implemented yet)
-
- .well-known: Checks `https://{handle}/.well-known/atproto-did` (not implemented yet)
-
-
Current implementation uses XRPC, which works for all handles.
-
-
#### 3. DidResolver
-
Resolves DIDs to DID documents.
-
-
**Supported Methods:**
-
- `did:plc`: Queries PLC directory (https://plc.directory)
-
- `did:web`: Fetches from HTTPS URLs
-
-
#### 4. DidDocument
-
Represents a W3C DID document with atProto-specific helpers:
-
- `extractPdsUrl()`: Gets the PDS endpoint
-
- `extractNormalizedHandle()`: Gets the validated handle
-
-
### Bi-directional Resolution
-
-
For security, we enforce **bi-directional resolution**:
-
-
1. Handle → DID resolution must succeed
-
2. DID document must contain the original handle
-
3. Both directions must agree
-
-
This prevents:
-
- Handle hijacking
-
- DID spoofing
-
- MITM attacks
-
-
### Caching
-
-
Built-in caching with configurable TTLs:
-
- **Handles**: 1 hour default (handles can change)
-
- **DIDs**: 24 hours default (DID docs are more stable)
-
-
Caching is automatic but can be bypassed with `noCache: true`.
-
-
## File Structure
-
-
```
-
identity/
-
├── constants.dart # atProto constants
-
├── did_document.dart # DID document representation
-
├── did_helpers.dart # DID validation utilities
-
├── did_resolver.dart # DID → DID document resolution
-
├── handle_helpers.dart # Handle validation utilities
-
├── handle_resolver.dart # Handle → DID resolution
-
├── identity_resolver.dart # Main resolver (orchestrates everything)
-
├── identity_resolver_error.dart # Error types
-
├── identity.dart # Public exports
-
└── README.md # This file
-
```
-
-
## Usage Examples
-
-
### Basic Resolution
-
-
```dart
-
import 'package:atproto_oauth_flutter/src/identity/identity.dart';
-
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
);
-
-
// Simple PDS lookup
-
final pdsUrl = await resolver.resolveToPds('alice.bsky.social');
-
print('PDS: $pdsUrl');
-
```
-
-
### Custom Configuration
-
-
```dart
-
// With custom caching and PLC directory
-
final resolver = AtprotoIdentityResolver.withDefaults(
-
handleResolverUrl: 'https://bsky.social',
-
plcDirectoryUrl: 'https://plc.directory/',
-
didCache: InMemoryDidCache(ttl: Duration(hours: 12)),
-
handleCache: InMemoryHandleCache(ttl: Duration(minutes: 30)),
-
);
-
```
-
-
### Manual Component Construction
-
-
```dart
-
// Build your own resolver with custom components
-
final dio = Dio();
-
-
final didResolver = CachedDidResolver(
-
AtprotoDidResolver(dio: dio),
-
);
-
-
final handleResolver = CachedHandleResolver(
-
XrpcHandleResolver('https://bsky.social', dio: dio),
-
);
-
-
final resolver = AtprotoIdentityResolver(
-
didResolver: didResolver,
-
handleResolver: handleResolver,
-
);
-
```
-
-
### Error Handling
-
-
```dart
-
try {
-
final info = await resolver.resolve('invalid-handle');
-
} on InvalidHandleError catch (e) {
-
print('Invalid handle format: $e');
-
} on HandleResolverError catch (e) {
-
print('Handle resolution failed: $e');
-
} on DidResolverError catch (e) {
-
print('DID resolution failed: $e');
-
} on IdentityResolverError catch (e) {
-
print('Identity resolution failed: $e');
-
}
-
```
-
-
## Implementation Notes
-
-
### Ported from TypeScript
-
-
This implementation is a 1:1 port from the official atProto TypeScript packages:
-
- `@atproto-labs/identity-resolver`
-
- `@atproto-labs/did-resolver`
-
- `@atproto-labs/handle-resolver`
-
-
Source: `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts`
-
-
### Differences from TypeScript
-
-
1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups. We use XRPC only.
-
2. **Simplified Caching**: In-memory only (TypeScript has more cache backends).
-
3. **Dio instead of Fetch**: Using Dio HTTP client instead of global fetch.
-
4. **Explicit Types**: Dart's type system is more explicit than TypeScript's.
-
-
### Future Improvements
-
-
- [ ] Add DNS-over-HTTPS for handle resolution
-
- [ ] Implement .well-known handle resolution
-
- [ ] Add persistent cache backends (SQLite, Hive)
-
- [ ] Support custom DID methods beyond plc/web
-
- [ ] Add metrics and observability
-
- [ ] Implement resolver timeouts and retries
-
-
## Testing
-
-
Test the implementation with real handles:
-
-
```dart
-
// Test custom PDS
-
final pds1 = await resolver.resolveToPds('bretton.dev');
-
assert(pds1.contains('pds.bretton.dev'));
-
-
// Test Bluesky user
-
final pds2 = await resolver.resolveToPds('pfrazee.com');
-
print('Paul Frazee PDS: $pds2');
-
-
// Test from DID
-
final info = await resolver.resolveFromDid('did:plc:ragtjsm2j2vknwkz3zp4oxrd');
-
assert(info.handle == 'pfrazee.com');
-
```
-
-
## Security Considerations
-
-
1. **Bi-directional Validation**: Always enforced to prevent spoofing
-
2. **HTTPS Only**: All HTTP requests use HTTPS (except localhost for testing)
-
3. **No Redirects**: HTTP redirects are rejected to prevent attacks
-
4. **Input Validation**: All handles and DIDs are validated before use
-
5. **Cache Poisoning**: TTLs prevent stale data, noCache option available
-
-
## Performance
-
-
Typical resolution times (with cold cache):
-
- Handle → PDS: ~200-500ms (1 handle lookup + 1 DID fetch)
-
- DID → PDS: ~100-200ms (1 DID fetch only)
-
- Cached resolution: <1ms (in-memory lookup)
-
-
For production apps:
-
- Enable caching (default)
-
- Use connection pooling (Dio does this)
-
- Consider warming cache for known users
-
- Monitor resolver errors and timeouts
-
-
## References
-
-
- [atProto DID Spec](https://atproto.com/specs/did)
-
- [atProto Handle Spec](https://atproto.com/specs/handle)
-
- [W3C DID Core](https://www.w3.org/TR/did-core/)
-
- [PLC Directory](https://plc.directory/)
···
-29
packages/atproto_oauth_flutter/lib/src/identity/constants.dart
···
-
/// Constants used in atProto identity resolution.
-
library;
-
-
/// Placeholder handle used when handle is invalid or doesn't match DID.
-
const String handleInvalid = 'handle.invalid';
-
-
/// DID prefix for all decentralized identifiers.
-
const String didPrefix = 'did:';
-
-
/// DID PLC (Placeholder) prefix.
-
const String didPlcPrefix = 'did:plc:';
-
-
/// DID Web prefix.
-
const String didWebPrefix = 'did:web:';
-
-
/// Length of a complete did:plc identifier (including prefix).
-
const int didPlcLength = 32;
-
-
/// Default PLC directory URL for resolving did:plc identifiers.
-
const String defaultPlcDirectoryUrl = 'https://plc.directory/';
-
-
/// Maximum length for a DID (per spec).
-
const int maxDidLength = 2048;
-
-
/// atProto service type in DID documents.
-
const String atprotoServiceType = 'AtprotoPersonalDataServer';
-
-
/// atProto service ID prefix in DID documents.
-
const String atprotoServiceId = '#atproto_pds';
···
-156
packages/atproto_oauth_flutter/lib/src/identity/did_document.dart
···
-
import 'constants.dart';
-
import 'handle_helpers.dart';
-
-
/// Represents a DID document as defined by W3C DID Core spec.
-
///
-
/// This is a simplified version focused on atProto needs.
-
/// See: https://www.w3.org/TR/did-core/
-
class DidDocument {
-
/// The DID subject (the DID itself)
-
final String id;
-
-
/// Alternative identifiers (used for atProto handles: at://handle)
-
final List<String>? alsoKnownAs;
-
-
/// Service endpoints (used to find PDS URL)
-
final List<DidService>? service;
-
-
/// Verification methods for authentication
-
final List<dynamic>? verificationMethod;
-
-
/// Authentication methods
-
final List<dynamic>? authentication;
-
-
/// Optional controller DIDs
-
final dynamic controller; // Can be String or List<String>
-
-
/// The @context field
-
final dynamic context;
-
-
const DidDocument({
-
required this.id,
-
this.alsoKnownAs,
-
this.service,
-
this.verificationMethod,
-
this.authentication,
-
this.controller,
-
this.context,
-
});
-
-
/// Parses a DID document from JSON.
-
factory DidDocument.fromJson(Map<String, dynamic> json) {
-
return DidDocument(
-
id: json['id'] as String,
-
alsoKnownAs:
-
(json['alsoKnownAs'] as List<dynamic>?)
-
?.map((e) => e as String)
-
.toList(),
-
service:
-
(json['service'] as List<dynamic>?)
-
?.map((e) => DidService.fromJson(e as Map<String, dynamic>))
-
.toList(),
-
verificationMethod: json['verificationMethod'] as List<dynamic>?,
-
authentication: json['authentication'] as List<dynamic>?,
-
controller: json['controller'],
-
context: json['@context'],
-
);
-
}
-
-
/// Converts the DID document to JSON.
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{'id': id};
-
-
if (context != null) map['@context'] = context;
-
if (alsoKnownAs != null) map['alsoKnownAs'] = alsoKnownAs;
-
if (service != null) {
-
map['service'] = service!.map((s) => s.toJson()).toList();
-
}
-
if (verificationMethod != null) {
-
map['verificationMethod'] = verificationMethod;
-
}
-
if (authentication != null) map['authentication'] = authentication;
-
if (controller != null) map['controller'] = controller;
-
-
return map;
-
}
-
-
/// Extracts the atProto PDS URL from the DID document.
-
///
-
/// Returns null if no PDS service is found.
-
String? extractPdsUrl() {
-
if (service == null) return null;
-
-
for (final s in service!) {
-
// Check for standard atproto_pds service
-
if (s.id == atprotoServiceId && s.type == atprotoServiceType) {
-
if (s.serviceEndpoint is String) {
-
return s.serviceEndpoint as String;
-
}
-
}
-
-
// Also check if type matches (some implementations may vary on id)
-
if (s.type == atprotoServiceType && s.serviceEndpoint is String) {
-
return s.serviceEndpoint as String;
-
}
-
}
-
-
return null;
-
}
-
-
/// Extracts the raw atProto handle from the DID document.
-
///
-
/// Returns null if no handle is found in alsoKnownAs.
-
String? extractAtprotoHandle() {
-
if (alsoKnownAs == null) return null;
-
-
for (final aka in alsoKnownAs!) {
-
if (aka.startsWith('at://')) {
-
// Strip off "at://" prefix
-
return aka.substring(5);
-
}
-
}
-
-
return null;
-
}
-
-
/// Extracts a validated, normalized atProto handle from the DID document.
-
///
-
/// Returns null if no valid handle is found.
-
String? extractNormalizedHandle() {
-
final handle = extractAtprotoHandle();
-
if (handle == null) return null;
-
return asNormalizedHandle(handle);
-
}
-
}
-
-
/// Represents a service endpoint in a DID document.
-
class DidService {
-
/// Service ID (e.g., "#atproto_pds")
-
final String id;
-
-
/// Service type (e.g., "AtprotoPersonalDataServer")
-
final String type;
-
-
/// Service endpoint URL
-
final dynamic serviceEndpoint; // Can be String, Map, or List
-
-
const DidService({
-
required this.id,
-
required this.type,
-
required this.serviceEndpoint,
-
});
-
-
/// Parses a service from JSON.
-
factory DidService.fromJson(Map<String, dynamic> json) {
-
return DidService(
-
id: json['id'] as String,
-
type: json['type'] as String,
-
serviceEndpoint: json['serviceEndpoint'],
-
);
-
}
-
-
/// Converts the service to JSON.
-
Map<String, dynamic> toJson() {
-
return {'id': id, 'type': type, 'serviceEndpoint': serviceEndpoint};
-
}
-
}
···
-251
packages/atproto_oauth_flutter/lib/src/identity/did_helpers.dart
···
-
import 'constants.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Checks if a string is a valid DID.
-
///
-
/// A valid DID follows the format: did:method:method-specific-id
-
/// where method is lowercase alphanumeric and method-specific-id
-
/// contains only allowed characters.
-
bool isDid(String input) {
-
try {
-
assertDid(input);
-
return true;
-
} catch (e) {
-
if (e is IdentityResolverError) {
-
return false;
-
}
-
rethrow;
-
}
-
}
-
-
/// Asserts that a string is a valid DID, throwing if not.
-
void assertDid(String input) {
-
if (input.length > maxDidLength) {
-
throw InvalidDidError(input, 'DID is too long ($maxDidLength chars max)');
-
}
-
-
if (!input.startsWith(didPrefix)) {
-
throw InvalidDidError(input, 'DID requires "$didPrefix" prefix');
-
}
-
-
final methodEndIndex = input.indexOf(':', didPrefix.length);
-
if (methodEndIndex == -1) {
-
throw InvalidDidError(input, 'Missing colon after method name');
-
}
-
-
_assertDidMethod(input, didPrefix.length, methodEndIndex);
-
_assertDidMsid(input, methodEndIndex + 1, input.length);
-
}
-
-
/// Validates DID method name (lowercase alphanumeric).
-
void _assertDidMethod(String input, int start, int end) {
-
if (end == start) {
-
throw InvalidDidError(input, 'Empty method name');
-
}
-
-
for (int i = start; i < end; i++) {
-
final c = input.codeUnitAt(i);
-
if (!((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39))) {
-
// Not a-z or 0-9
-
throw InvalidDidError(
-
input,
-
'Invalid character at position $i in DID method name',
-
);
-
}
-
}
-
}
-
-
/// Validates DID method-specific identifier.
-
void _assertDidMsid(String input, int start, int end) {
-
if (end == start) {
-
throw InvalidDidError(input, 'DID method-specific id must not be empty');
-
}
-
-
for (int i = start; i < end; i++) {
-
final c = input.codeUnitAt(i);
-
-
// Check for frequent chars first (a-z, A-Z, 0-9, ., -, _)
-
if ((c >= 0x61 && c <= 0x7a) || // a-z
-
(c >= 0x41 && c <= 0x5a) || // A-Z
-
(c >= 0x30 && c <= 0x39) || // 0-9
-
c == 0x2e || // .
-
c == 0x2d || // -
-
c == 0x5f) {
-
// _
-
continue;
-
}
-
-
// ":"
-
if (c == 0x3a) {
-
if (i == end - 1) {
-
throw InvalidDidError(input, 'DID cannot end with ":"');
-
}
-
continue;
-
}
-
-
// pct-encoded: %HEXDIG HEXDIG
-
if (c == 0x25) {
-
// %
-
if (i + 2 >= end) {
-
throw InvalidDidError(
-
input,
-
'Incomplete pct-encoded character at position $i',
-
);
-
}
-
-
i++;
-
final c1 = input.codeUnitAt(i);
-
if (!((c1 >= 0x30 && c1 <= 0x39) || (c1 >= 0x41 && c1 <= 0x46))) {
-
// Not 0-9 or A-F
-
throw InvalidDidError(
-
input,
-
'Invalid pct-encoded character at position $i',
-
);
-
}
-
-
i++;
-
final c2 = input.codeUnitAt(i);
-
if (!((c2 >= 0x30 && c2 <= 0x39) || (c2 >= 0x41 && c2 <= 0x46))) {
-
// Not 0-9 or A-F
-
throw InvalidDidError(
-
input,
-
'Invalid pct-encoded character at position $i',
-
);
-
}
-
-
continue;
-
}
-
-
throw InvalidDidError(input, 'Disallowed character in DID at position $i');
-
}
-
}
-
-
/// Extracts the method name from a DID.
-
///
-
/// Example: extractDidMethod('did:plc:abc123') returns 'plc'
-
String extractDidMethod(String did) {
-
final methodEndIndex = did.indexOf(':', didPrefix.length);
-
return did.substring(didPrefix.length, methodEndIndex);
-
}
-
-
/// Checks if a string is a valid did:plc identifier.
-
bool isDidPlc(String input) {
-
if (input.length != didPlcLength) return false;
-
if (!input.startsWith(didPlcPrefix)) return false;
-
-
// Check that all characters after prefix are base32 [a-z2-7]
-
for (int i = didPlcPrefix.length; i < didPlcLength; i++) {
-
if (!_isBase32Char(input.codeUnitAt(i))) return false;
-
}
-
-
return true;
-
}
-
-
/// Checks if a string is a valid did:web identifier.
-
bool isDidWeb(String input) {
-
if (!input.startsWith(didWebPrefix)) return false;
-
if (input.length <= didWebPrefix.length) return false;
-
-
// Check if next char after prefix is ":"
-
if (input.codeUnitAt(didWebPrefix.length) == 0x3a) return false;
-
-
try {
-
_assertDidMsid(input, didWebPrefix.length, input.length);
-
return true;
-
} catch (e) {
-
return false;
-
}
-
}
-
-
/// Checks if a DID uses an atProto-blessed method (plc or web).
-
bool isAtprotoDid(String input) {
-
return isDidPlc(input) || isDidWeb(input);
-
}
-
-
/// Asserts that a string is a valid atProto DID (did:plc or did:web).
-
///
-
/// Throws [InvalidDidError] if the DID is not a valid atProto DID.
-
void assertAtprotoDid(String input) {
-
if (!isAtprotoDid(input)) {
-
throw InvalidDidError(
-
input,
-
'DID must use atProto-blessed method (did:plc or did:web)',
-
);
-
}
-
}
-
-
/// Asserts that a string is a valid did:plc identifier.
-
void assertDidPlc(String input) {
-
if (!input.startsWith(didPlcPrefix)) {
-
throw InvalidDidError(input, 'Invalid did:plc prefix');
-
}
-
-
if (input.length != didPlcLength) {
-
throw InvalidDidError(
-
input,
-
'did:plc must be $didPlcLength characters long',
-
);
-
}
-
-
for (int i = didPlcPrefix.length; i < didPlcLength; i++) {
-
if (!_isBase32Char(input.codeUnitAt(i))) {
-
throw InvalidDidError(input, 'Invalid character at position $i');
-
}
-
}
-
}
-
-
/// Asserts that a string is a valid did:web identifier.
-
void assertDidWeb(String input) {
-
if (!input.startsWith(didWebPrefix)) {
-
throw InvalidDidError(input, 'Invalid did:web prefix');
-
}
-
-
if (input.codeUnitAt(didWebPrefix.length) == 0x3a) {
-
throw InvalidDidError(input, 'did:web MSID must not start with a colon');
-
}
-
-
_assertDidMsid(input, didWebPrefix.length, input.length);
-
}
-
-
/// Checks if a character code is a base32 character [a-z2-7].
-
bool _isBase32Char(int c) =>
-
(c >= 0x61 && c <= 0x7a) || (c >= 0x32 && c <= 0x37);
-
-
/// Converts a did:web to an HTTPS URL.
-
///
-
/// Example:
-
/// - did:web:example.com -> https://example.com
-
/// - did:web:example.com:user:alice -> https://example.com/user/alice
-
/// - did:web:localhost%3A3000 -> http://localhost:3000
-
Uri didWebToUrl(String did) {
-
assertDidWeb(did);
-
-
final hostIdx = didWebPrefix.length;
-
final pathIdx = did.indexOf(':', hostIdx);
-
-
final hostEnc =
-
pathIdx == -1 ? did.substring(hostIdx) : did.substring(hostIdx, pathIdx);
-
final host = hostEnc.replaceAll('%3A', ':');
-
final path = pathIdx == -1 ? '' : did.substring(pathIdx).replaceAll(':', '/');
-
-
// Use http for localhost, https for everything else
-
final proto =
-
host.startsWith('localhost') &&
-
(host.length == 9 || host.codeUnitAt(9) == 0x3a) // ':'
-
? 'http'
-
: 'https';
-
-
return Uri.parse('$proto://$host$path');
-
}
-
-
/// Converts an HTTPS URL to a did:web identifier.
-
///
-
/// Example:
-
/// - https://example.com -> did:web:example.com
-
/// - https://example.com/user/alice -> did:web:example.com:user:alice
-
String urlToDidWeb(Uri url) {
-
final port = url.hasPort ? '%3A${url.port}' : '';
-
final path = url.path == '/' ? '' : url.path.replaceAll('/', ':');
-
-
return '$didWebPrefix${url.host}$port$path';
-
}
···
-257
packages/atproto_oauth_flutter/lib/src/identity/did_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import 'constants.dart';
-
import 'did_document.dart';
-
import 'did_helpers.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Options for DID resolution.
-
class ResolveDidOptions {
-
/// Whether to bypass cache
-
final bool noCache;
-
-
/// Cancellation token for the request
-
final CancelToken? cancelToken;
-
-
const ResolveDidOptions({this.noCache = false, this.cancelToken});
-
}
-
-
/// Interface for resolving DIDs to DID documents.
-
abstract class DidResolver {
-
/// Resolves a DID to its DID document.
-
///
-
/// Throws [DidResolverError] if resolution fails.
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]);
-
}
-
-
/// DID resolver that supports both did:plc and did:web methods.
-
class AtprotoDidResolver implements DidResolver {
-
final DidPlcMethod _plcMethod;
-
final DidWebMethod _webMethod;
-
-
AtprotoDidResolver({String? plcDirectoryUrl, Dio? dio})
-
: _plcMethod = DidPlcMethod(plcDirectoryUrl: plcDirectoryUrl, dio: dio),
-
_webMethod = DidWebMethod(dio: dio);
-
-
@override
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
if (isDidPlc(did)) {
-
return _plcMethod.resolve(did, options);
-
} else if (isDidWeb(did)) {
-
return _webMethod.resolve(did, options);
-
} else {
-
throw DidResolverError(
-
'Unsupported DID method: ${extractDidMethod(did)}',
-
);
-
}
-
}
-
}
-
-
/// Resolver for did:plc identifiers using the PLC directory.
-
class DidPlcMethod {
-
final Uri plcDirectoryUrl;
-
final Dio dio;
-
-
DidPlcMethod({String? plcDirectoryUrl, Dio? dio})
-
: plcDirectoryUrl = Uri.parse(plcDirectoryUrl ?? defaultPlcDirectoryUrl),
-
dio = dio ?? Dio();
-
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
assertDidPlc(did);
-
-
final url = plcDirectoryUrl.resolve('/${Uri.encodeComponent(did)}');
-
-
try {
-
final response = await dio.getUri(
-
url,
-
options: Options(
-
headers: {
-
'Accept': 'application/did+ld+json,application/json',
-
if (options?.noCache ?? false) 'Cache-Control': 'no-cache',
-
},
-
followRedirects: false,
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
if (response.data is! Map<String, dynamic>) {
-
throw DidResolverError(
-
'Invalid response format from PLC directory for $did',
-
);
-
}
-
-
return DidDocument.fromJson(response.data as Map<String, dynamic>);
-
} on DioException catch (e) {
-
if (e.type == DioExceptionType.cancel) {
-
throw DidResolverError('DID resolution was cancelled');
-
}
-
-
if (e.response?.statusCode == 404) {
-
throw DidResolverError('DID not found: $did');
-
}
-
-
throw DidResolverError(
-
'Failed to resolve DID from PLC directory: ${e.message}',
-
e,
-
);
-
} catch (e) {
-
if (e is DidResolverError) rethrow;
-
-
throw DidResolverError('Unexpected error resolving DID: $e', e);
-
}
-
}
-
}
-
-
/// Resolver for did:web identifiers using HTTPS.
-
class DidWebMethod {
-
final Dio dio;
-
-
DidWebMethod({Dio? dio}) : dio = dio ?? Dio();
-
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
assertDidWeb(did);
-
-
final baseUrl = didWebToUrl(did);
-
-
// Try /.well-known/did.json first, then /did.json
-
final urls = [
-
baseUrl.resolve('/.well-known/did.json'),
-
baseUrl.resolve('/did.json'),
-
];
-
-
DioException? lastError;
-
-
for (final url in urls) {
-
try {
-
final response = await dio.getUri(
-
url,
-
options: Options(
-
headers: {
-
'Accept': 'application/did+ld+json,application/json',
-
if (options?.noCache ?? false) 'Cache-Control': 'no-cache',
-
},
-
followRedirects: false,
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
if (response.data is! Map<String, dynamic>) {
-
throw DidResolverError(
-
'Invalid response format from did:web for $did',
-
);
-
}
-
-
final doc = DidDocument.fromJson(response.data as Map<String, dynamic>);
-
-
// Verify the DID in the document matches
-
if (doc.id != did) {
-
throw DidResolverError(
-
'DID mismatch: expected $did but got ${doc.id}',
-
);
-
}
-
-
return doc;
-
} on DioException catch (e) {
-
if (e.type == DioExceptionType.cancel) {
-
throw DidResolverError('DID resolution was cancelled');
-
}
-
-
// If not found, try the next URL
-
if (e.response?.statusCode == 404) {
-
lastError = e;
-
continue;
-
}
-
-
// Any other error, throw immediately
-
throw DidResolverError('Failed to resolve did:web: ${e.message}', e);
-
} catch (e) {
-
if (e is DidResolverError) rethrow;
-
-
throw DidResolverError('Unexpected error resolving did:web: $e', e);
-
}
-
}
-
-
// If we get here, all URLs failed
-
throw DidResolverError('DID document not found for $did', lastError);
-
}
-
}
-
-
/// Cached DID resolver that wraps another resolver with caching.
-
class CachedDidResolver implements DidResolver {
-
final DidResolver _resolver;
-
final DidCache _cache;
-
-
CachedDidResolver(this._resolver, [DidCache? cache])
-
: _cache = cache ?? InMemoryDidCache();
-
-
@override
-
Future<DidDocument> resolve(String did, [ResolveDidOptions? options]) async {
-
// Check cache first unless noCache is set
-
if (!(options?.noCache ?? false)) {
-
final cached = await _cache.get(did);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Resolve and cache
-
final doc = await _resolver.resolve(did, options);
-
await _cache.set(did, doc);
-
-
return doc;
-
}
-
-
/// Clears the cache
-
Future<void> clearCache() => _cache.clear();
-
}
-
-
/// Interface for caching DID documents.
-
abstract class DidCache {
-
Future<DidDocument?> get(String did);
-
Future<void> set(String did, DidDocument document);
-
Future<void> clear();
-
}
-
-
/// Simple in-memory DID cache with expiration.
-
class InMemoryDidCache implements DidCache {
-
final Map<String, _CacheEntry> _cache = {};
-
final Duration _ttl;
-
-
InMemoryDidCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 24);
-
-
@override
-
Future<DidDocument?> get(String did) async {
-
final entry = _cache[did];
-
if (entry == null) return null;
-
-
// Check if expired
-
if (DateTime.now().isAfter(entry.expiresAt)) {
-
_cache.remove(did);
-
return null;
-
}
-
-
return entry.document;
-
}
-
-
@override
-
Future<void> set(String did, DidDocument document) async {
-
_cache[did] = _CacheEntry(
-
document: document,
-
expiresAt: DateTime.now().add(_ttl),
-
);
-
}
-
-
@override
-
Future<void> clear() async {
-
_cache.clear();
-
}
-
}
-
-
class _CacheEntry {
-
final DidDocument document;
-
final DateTime expiresAt;
-
-
_CacheEntry({required this.document, required this.expiresAt});
-
}
···
-35
packages/atproto_oauth_flutter/lib/src/identity/handle_helpers.dart
···
-
import 'identity_resolver_error.dart';
-
-
/// Normalizes a handle to lowercase.
-
String normalizeHandle(String handle) => handle.toLowerCase();
-
-
/// Checks if a handle is valid according to atProto spec.
-
///
-
/// A valid handle must:
-
/// - Be between 1 and 253 characters
-
/// - Match the pattern: subdomain.domain.tld
-
/// - Each label must start and end with alphanumeric
-
/// - Labels can contain hyphens but not at boundaries
-
bool isValidHandle(String handle) {
-
if (handle.isEmpty || handle.length >= 254) return false;
-
-
// Pattern: ([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
-
final pattern = RegExp(
-
r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$',
-
);
-
-
return pattern.hasMatch(handle);
-
}
-
-
/// Returns a normalized handle if valid, null otherwise.
-
String? asNormalizedHandle(String input) {
-
final handle = normalizeHandle(input);
-
return isValidHandle(handle) ? handle : null;
-
}
-
-
/// Asserts that a handle is valid.
-
void assertValidHandle(String handle) {
-
if (!isValidHandle(handle)) {
-
throw InvalidHandleError(handle, 'Invalid handle format');
-
}
-
}
···
-202
packages/atproto_oauth_flutter/lib/src/identity/handle_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import 'did_helpers.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Options for handle resolution.
-
class ResolveHandleOptions {
-
/// Whether to bypass cache
-
final bool noCache;
-
-
/// Cancellation token for the request
-
final CancelToken? cancelToken;
-
-
const ResolveHandleOptions({this.noCache = false, this.cancelToken});
-
}
-
-
/// Interface for resolving atProto handles to DIDs.
-
abstract class HandleResolver {
-
/// Resolves an atProto handle to a DID.
-
///
-
/// Returns null if the handle doesn't resolve to a DID (but no error occurred).
-
/// Throws [HandleResolverError] if an unexpected error occurs during resolution.
-
Future<String?> resolve(String handle, [ResolveHandleOptions? options]);
-
}
-
-
/// XRPC-based handle resolver that uses com.atproto.identity.resolveHandle.
-
///
-
/// This resolver makes HTTP requests to an atProto XRPC service (typically
-
/// a PDS or entryway service) to resolve handles.
-
class XrpcHandleResolver implements HandleResolver {
-
/// The base URL of the XRPC service
-
final Uri serviceUrl;
-
-
/// HTTP client for making requests
-
final Dio dio;
-
-
XrpcHandleResolver(String serviceUrl, {Dio? dio})
-
: serviceUrl = Uri.parse(serviceUrl),
-
dio = dio ?? Dio();
-
-
@override
-
Future<String?> resolve(
-
String handle, [
-
ResolveHandleOptions? options,
-
]) async {
-
final url = serviceUrl.resolve('/xrpc/com.atproto.identity.resolveHandle');
-
final uri = url.replace(queryParameters: {'handle': handle});
-
-
try {
-
final response = await dio.getUri(
-
uri,
-
options: Options(
-
headers: {if (options?.noCache ?? false) 'Cache-Control': 'no-cache'},
-
validateStatus: (status) {
-
// Allow 400 and 200 status codes
-
return status == 200 || status == 400;
-
},
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
final data = response.data;
-
-
// Handle 400 Bad Request (expected for invalid/unresolvable handles)
-
if (response.statusCode == 400) {
-
if (data is Map<String, dynamic>) {
-
final error = data['error'] as String?;
-
final message = data['message'] as String?;
-
-
// Expected response for handle that doesn't exist
-
if (error == 'InvalidRequest' &&
-
message == 'Unable to resolve handle') {
-
return null;
-
}
-
}
-
-
throw HandleResolverError(
-
'Invalid response from resolveHandle method: ${response.data}',
-
);
-
}
-
-
// Handle successful response
-
if (response.statusCode == 200) {
-
if (data is! Map<String, dynamic>) {
-
throw HandleResolverError(
-
'Invalid response format from resolveHandle method',
-
);
-
}
-
-
final did = data['did'];
-
if (did is! String) {
-
throw HandleResolverError(
-
'Missing or invalid DID in resolveHandle response',
-
);
-
}
-
-
// Validate that it's a proper atProto DID
-
if (!isAtprotoDid(did)) {
-
throw HandleResolverError(
-
'Invalid DID returned from resolveHandle method: $did',
-
);
-
}
-
-
return did;
-
}
-
-
throw HandleResolverError(
-
'Unexpected status code from resolveHandle method: ${response.statusCode}',
-
);
-
} on DioException catch (e) {
-
if (e.type == DioExceptionType.cancel) {
-
throw HandleResolverError('Handle resolution was cancelled');
-
}
-
-
throw HandleResolverError('Failed to resolve handle: ${e.message}', e);
-
} catch (e) {
-
if (e is HandleResolverError) rethrow;
-
-
throw HandleResolverError('Unexpected error resolving handle: $e', e);
-
}
-
}
-
}
-
-
/// Cached handle resolver that wraps another resolver with caching.
-
class CachedHandleResolver implements HandleResolver {
-
final HandleResolver _resolver;
-
final HandleCache _cache;
-
-
CachedHandleResolver(this._resolver, [HandleCache? cache])
-
: _cache = cache ?? InMemoryHandleCache();
-
-
@override
-
Future<String?> resolve(
-
String handle, [
-
ResolveHandleOptions? options,
-
]) async {
-
// Check cache first unless noCache is set
-
if (!(options?.noCache ?? false)) {
-
final cached = await _cache.get(handle);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Resolve and cache
-
final did = await _resolver.resolve(handle, options);
-
if (did != null) {
-
await _cache.set(handle, did);
-
}
-
-
return did;
-
}
-
-
/// Clears the cache
-
Future<void> clearCache() => _cache.clear();
-
}
-
-
/// Interface for caching handle resolution results.
-
abstract class HandleCache {
-
Future<String?> get(String handle);
-
Future<void> set(String handle, String did);
-
Future<void> clear();
-
}
-
-
/// Simple in-memory handle cache with expiration.
-
class InMemoryHandleCache implements HandleCache {
-
final Map<String, _CacheEntry> _cache = {};
-
final Duration _ttl;
-
-
InMemoryHandleCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 1);
-
-
@override
-
Future<String?> get(String handle) async {
-
final entry = _cache[handle];
-
if (entry == null) return null;
-
-
// Check if expired
-
if (DateTime.now().isAfter(entry.expiresAt)) {
-
_cache.remove(handle);
-
return null;
-
}
-
-
return entry.did;
-
}
-
-
@override
-
Future<void> set(String handle, String did) async {
-
_cache[handle] = _CacheEntry(did: did, expiresAt: DateTime.now().add(_ttl));
-
}
-
-
@override
-
Future<void> clear() async {
-
_cache.clear();
-
}
-
}
-
-
class _CacheEntry {
-
final String did;
-
final DateTime expiresAt;
-
-
_CacheEntry({required this.did, required this.expiresAt});
-
}
···
-47
packages/atproto_oauth_flutter/lib/src/identity/identity.dart
···
-
/// Identity resolution for atProto.
-
///
-
/// This module provides the core identity resolution functionality for atProto,
-
/// enabling decentralized identity through handle and DID resolution.
-
///
-
/// ## Key Components
-
///
-
/// - **IdentityResolver**: Main interface for resolving handles/DIDs to identity info
-
/// - **HandleResolver**: Resolves atProto handles (e.g., "alice.bsky.social") to DIDs
-
/// - **DidResolver**: Resolves DIDs to DID documents
-
/// - **DidDocument**: Represents a DID document with services and handles
-
///
-
/// ## Why This Matters for Decentralization
-
///
-
/// This is the **most important module for atProto decentralization**. It enables:
-
/// 1. Users to host their data on any PDS, not just bsky.social
-
/// 2. Custom domain handles (e.g., "alice.example.com")
-
/// 3. Portable identity (change PDS without losing identity)
-
///
-
/// ## Usage
-
///
-
/// ```dart
-
/// // Create a resolver
-
/// final resolver = AtprotoIdentityResolver.withDefaults(
-
/// handleResolverUrl: 'https://bsky.social',
-
/// );
-
///
-
/// // Resolve a handle to find their PDS
-
/// final pdsUrl = await resolver.resolveToPds('alice.bsky.social');
-
/// print('Alice\'s PDS: $pdsUrl');
-
///
-
/// // Get full identity info
-
/// final info = await resolver.resolve('alice.bsky.social');
-
/// print('DID: ${info.did}');
-
/// print('Handle: ${info.handle}');
-
/// print('PDS: ${info.pdsUrl}');
-
/// ```
-
library;
-
-
export 'constants.dart';
-
export 'did_document.dart';
-
export 'did_helpers.dart';
-
export 'did_resolver.dart';
-
export 'handle_helpers.dart';
-
export 'handle_resolver.dart';
-
export 'identity_resolver.dart';
-
export 'identity_resolver_error.dart';
···
-366
packages/atproto_oauth_flutter/lib/src/identity/identity_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import 'constants.dart';
-
import 'did_document.dart';
-
import 'did_helpers.dart';
-
import 'did_resolver.dart';
-
import 'handle_helpers.dart';
-
import 'handle_resolver.dart';
-
import 'identity_resolver_error.dart';
-
-
/// Represents resolved identity information for an atProto user.
-
///
-
/// This combines DID, DID document, and validated handle information.
-
class IdentityInfo {
-
/// The DID (Decentralized Identifier) for this identity
-
final String did;
-
-
/// The complete DID document
-
final DidDocument didDoc;
-
-
/// The validated handle, or 'handle.invalid' if handle validation failed
-
final String handle;
-
-
const IdentityInfo({
-
required this.did,
-
required this.didDoc,
-
required this.handle,
-
});
-
-
/// Whether the handle is valid (not 'handle.invalid')
-
bool get hasValidHandle => handle != handleInvalid;
-
-
/// Extracts the PDS URL from the DID document.
-
///
-
/// Returns null if no PDS service is found.
-
String? get pdsUrl => didDoc.extractPdsUrl();
-
}
-
-
/// Options for identity resolution.
-
class ResolveIdentityOptions {
-
/// Whether to bypass cache
-
final bool noCache;
-
-
/// Cancellation token for the request
-
final CancelToken? cancelToken;
-
-
const ResolveIdentityOptions({this.noCache = false, this.cancelToken});
-
}
-
-
/// Interface for resolving atProto identities (handles or DIDs) to complete identity info.
-
abstract class IdentityResolver {
-
/// Resolves an identifier (handle or DID) to complete identity information.
-
///
-
/// The identifier can be either:
-
/// - An atProto handle (e.g., "alice.bsky.social")
-
/// - A DID (e.g., "did:plc:...")
-
///
-
/// Returns [IdentityInfo] with DID, DID document, and validated handle.
-
Future<IdentityInfo> resolve(
-
String identifier, [
-
ResolveIdentityOptions? options,
-
]);
-
}
-
-
/// Implementation of the official atProto identity resolution strategy.
-
///
-
/// This resolver:
-
/// 1. Determines if input is a handle or DID
-
/// 2. Resolves handle → DID (if needed)
-
/// 3. Fetches DID document
-
/// 4. Validates bi-directional resolution (handle in DID doc matches original)
-
/// 5. Extracts PDS URL from DID document
-
///
-
/// This is the **critical piece for decentralization** - it ensures users can
-
/// host their data on any PDS, not just bsky.social.
-
class AtprotoIdentityResolver implements IdentityResolver {
-
final DidResolver didResolver;
-
final HandleResolver handleResolver;
-
-
AtprotoIdentityResolver({
-
required this.didResolver,
-
required this.handleResolver,
-
});
-
-
/// Factory constructor with defaults for typical usage.
-
///
-
/// [handleResolverUrl] should point to an atProto XRPC service that
-
/// implements com.atproto.identity.resolveHandle. Typically this is
-
/// https://bsky.social for public resolution, or your own PDS.
-
factory AtprotoIdentityResolver.withDefaults({
-
required String handleResolverUrl,
-
String? plcDirectoryUrl,
-
Dio? dio,
-
DidCache? didCache,
-
HandleCache? handleCache,
-
}) {
-
final dioInstance = dio ?? Dio();
-
-
final baseDidResolver = AtprotoDidResolver(
-
plcDirectoryUrl: plcDirectoryUrl,
-
dio: dioInstance,
-
);
-
-
final baseHandleResolver = XrpcHandleResolver(
-
handleResolverUrl,
-
dio: dioInstance,
-
);
-
-
return AtprotoIdentityResolver(
-
didResolver: CachedDidResolver(baseDidResolver, didCache),
-
handleResolver: CachedHandleResolver(baseHandleResolver, handleCache),
-
);
-
}
-
-
@override
-
Future<IdentityInfo> resolve(
-
String identifier, [
-
ResolveIdentityOptions? options,
-
]) async {
-
return isDid(identifier)
-
? resolveFromDid(identifier, options)
-
: resolveFromHandle(identifier, options);
-
}
-
-
/// Resolves identity starting from a DID.
-
///
-
/// This:
-
/// 1. Fetches the DID document
-
/// 2. Extracts the handle from alsoKnownAs
-
/// 3. Validates that the handle resolves back to the same DID
-
Future<IdentityInfo> resolveFromDid(
-
String did, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final document = await getDocumentFromDid(did, options);
-
-
// We will only return the document's handle alias if it resolves to the
-
// same DID as the input (bi-directional validation)
-
final handle = document.extractNormalizedHandle();
-
String? resolvedDid;
-
-
if (handle != null) {
-
try {
-
resolvedDid = await handleResolver.resolve(
-
handle,
-
ResolveHandleOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
} catch (e) {
-
// Ignore errors (handle might be temporarily unavailable)
-
resolvedDid = null;
-
}
-
}
-
-
return IdentityInfo(
-
did: document.id,
-
didDoc: document,
-
handle: handle != null && resolvedDid == did ? handle : handleInvalid,
-
);
-
}
-
-
/// Resolves identity starting from a handle.
-
///
-
/// This:
-
/// 1. Resolves handle → DID
-
/// 2. Fetches DID document
-
/// 3. Validates that the DID document contains the original handle
-
Future<IdentityInfo> resolveFromHandle(
-
String handle, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final document = await getDocumentFromHandle(handle, options);
-
-
// Bi-directional resolution is enforced in getDocumentFromHandle()
-
return IdentityInfo(
-
did: document.id,
-
didDoc: document,
-
handle: document.extractNormalizedHandle() ?? handleInvalid,
-
);
-
}
-
-
/// Fetches a DID document from a DID.
-
Future<DidDocument> getDocumentFromDid(
-
String did, [
-
ResolveIdentityOptions? options,
-
]) async {
-
return didResolver.resolve(
-
did,
-
ResolveDidOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
}
-
-
/// Fetches a DID document from a handle with bi-directional validation.
-
///
-
/// This method:
-
/// 1. Normalizes and validates the handle
-
/// 2. Resolves handle → DID
-
/// 3. Fetches DID document
-
/// 4. Verifies the DID document contains the original handle
-
Future<DidDocument> getDocumentFromHandle(
-
String input, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final handle = asNormalizedHandle(input);
-
if (handle == null) {
-
throw InvalidHandleError(input, 'Invalid handle format');
-
}
-
-
final did = await handleResolver.resolve(
-
handle,
-
ResolveHandleOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
-
if (did == null) {
-
throw IdentityResolverError('Handle "$handle" does not resolve to a DID');
-
}
-
-
// Fetch the DID document
-
final document = await didResolver.resolve(
-
did,
-
ResolveDidOptions(
-
noCache: options?.noCache ?? false,
-
cancelToken: options?.cancelToken,
-
),
-
);
-
-
// Enforce bi-directional resolution
-
final docHandle = document.extractNormalizedHandle();
-
if (handle != docHandle) {
-
throw IdentityResolverError(
-
'DID document for "$did" does not include the handle "$handle" '
-
'(found: ${docHandle ?? "none"})',
-
);
-
}
-
-
return document;
-
}
-
-
/// Convenience method to resolve directly to PDS URL.
-
///
-
/// This is the most common use case: given a handle or DID, find the PDS URL.
-
Future<String> resolveToPds(
-
String identifier, [
-
ResolveIdentityOptions? options,
-
]) async {
-
final info = await resolve(identifier, options);
-
final pdsUrl = info.pdsUrl;
-
-
if (pdsUrl == null) {
-
throw IdentityResolverError(
-
'No PDS endpoint found in DID document for $identifier',
-
);
-
}
-
-
return pdsUrl;
-
}
-
}
-
-
/// Options for creating an identity resolver.
-
class IdentityResolverOptions {
-
/// Custom identity resolver (if not provided, AtprotoIdentityResolver is used)
-
final IdentityResolver? identityResolver;
-
-
/// Custom DID resolver
-
final DidResolver? didResolver;
-
-
/// Custom handle resolver (or URL string for XRPC resolver)
-
final dynamic handleResolver; // HandleResolver, String, or Uri
-
-
/// Custom DID cache
-
final DidCache? didCache;
-
-
/// Custom handle cache
-
final HandleCache? handleCache;
-
-
/// Custom Dio instance for HTTP requests
-
final Dio? dio;
-
-
/// PLC directory URL (defaults to https://plc.directory/)
-
final String? plcDirectoryUrl;
-
-
const IdentityResolverOptions({
-
this.identityResolver,
-
this.didResolver,
-
this.handleResolver,
-
this.didCache,
-
this.handleCache,
-
this.dio,
-
this.plcDirectoryUrl,
-
});
-
}
-
-
/// Creates an identity resolver with the given options.
-
///
-
/// This is the main entry point for creating an identity resolver.
-
/// It handles setting up default implementations with proper caching.
-
IdentityResolver createIdentityResolver(IdentityResolverOptions options) {
-
// If a custom identity resolver is provided, use it
-
if (options.identityResolver != null) {
-
return options.identityResolver!;
-
}
-
-
final dioInstance = options.dio ?? Dio();
-
-
// Create DID resolver
-
final didResolver = _createDidResolver(options, dioInstance);
-
-
// Create handle resolver
-
final handleResolver = _createHandleResolver(options, dioInstance);
-
-
return AtprotoIdentityResolver(
-
didResolver: didResolver,
-
handleResolver: handleResolver,
-
);
-
}
-
-
DidResolver _createDidResolver(IdentityResolverOptions options, Dio dio) {
-
final didResolver =
-
options.didResolver ??
-
AtprotoDidResolver(plcDirectoryUrl: options.plcDirectoryUrl, dio: dio);
-
-
// Wrap with cache if not already cached
-
if (didResolver is CachedDidResolver && options.didCache == null) {
-
return didResolver;
-
}
-
-
return CachedDidResolver(didResolver, options.didCache);
-
}
-
-
HandleResolver _createHandleResolver(IdentityResolverOptions options, Dio dio) {
-
final handleResolverInput = options.handleResolver;
-
-
if (handleResolverInput == null) {
-
throw ArgumentError(
-
'handleResolver is required. Provide either a HandleResolver instance, '
-
'a URL string, or a Uri pointing to an XRPC service.',
-
);
-
}
-
-
HandleResolver baseResolver;
-
-
if (handleResolverInput is HandleResolver) {
-
baseResolver = handleResolverInput;
-
} else if (handleResolverInput is String || handleResolverInput is Uri) {
-
baseResolver = XrpcHandleResolver(handleResolverInput.toString(), dio: dio);
-
} else {
-
throw ArgumentError(
-
'handleResolver must be a HandleResolver, String, or Uri',
-
);
-
}
-
-
// Wrap with cache if not already cached
-
if (baseResolver is CachedHandleResolver && options.handleCache == null) {
-
return baseResolver;
-
}
-
-
return CachedHandleResolver(baseResolver, options.handleCache);
-
}
···
-53
packages/atproto_oauth_flutter/lib/src/identity/identity_resolver_error.dart
···
-
/// Error thrown when identity resolution fails.
-
///
-
/// This error is thrown when resolving an atProto handle or DID fails,
-
/// including cases such as:
-
/// - Invalid handle format
-
/// - Handle doesn't resolve to a DID
-
/// - DID document is malformed or missing required fields
-
/// - Bi-directional resolution fails (handle in DID doc doesn't match)
-
class IdentityResolverError extends Error {
-
/// The error message describing what went wrong
-
final String message;
-
-
/// Optional underlying cause of the error
-
final Object? cause;
-
-
IdentityResolverError(this.message, [this.cause]);
-
-
@override
-
String toString() {
-
if (cause != null) {
-
return 'IdentityResolverError: $message\nCaused by: $cause';
-
}
-
return 'IdentityResolverError: $message';
-
}
-
}
-
-
/// Error thrown when a DID is invalid or malformed.
-
class InvalidDidError extends IdentityResolverError {
-
/// The invalid DID that was provided
-
final String did;
-
-
InvalidDidError(this.did, String message, [Object? cause])
-
: super('Invalid DID "$did": $message', cause);
-
}
-
-
/// Error thrown when a handle is invalid or malformed.
-
class InvalidHandleError extends IdentityResolverError {
-
/// The invalid handle that was provided
-
final String handle;
-
-
InvalidHandleError(this.handle, String message, [Object? cause])
-
: super('Invalid handle "$handle": $message', cause);
-
}
-
-
/// Error thrown when handle resolution fails.
-
class HandleResolverError extends IdentityResolverError {
-
HandleResolverError(super.message, [super.cause]);
-
}
-
-
/// Error thrown when DID resolution fails.
-
class DidResolverError extends IdentityResolverError {
-
DidResolverError(super.message, [super.cause]);
-
}
···
-248
packages/atproto_oauth_flutter/lib/src/oauth/authorization_server_metadata_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../dpop/fetch_dpop.dart';
-
import '../util.dart';
-
-
/// Options for getting cached values.
-
class GetCachedOptions {
-
/// Whether to bypass cache and force a fresh fetch
-
final bool noCache;
-
-
/// Whether to allow returning stale cached values
-
final bool allowStale;
-
-
/// Optional cancellation token
-
final CancelToken? cancelToken;
-
-
const GetCachedOptions({
-
this.noCache = false,
-
this.allowStale = true,
-
this.cancelToken,
-
});
-
}
-
-
/// Cache interface for authorization server metadata.
-
///
-
/// Implementations should store metadata keyed by issuer URL.
-
typedef AuthorizationServerMetadataCache =
-
SimpleStore<String, Map<String, dynamic>>;
-
-
/// Configuration for the authorization server metadata resolver.
-
class OAuthAuthorizationServerMetadataResolverConfig {
-
/// Whether to allow HTTP (non-HTTPS) issuer URLs.
-
///
-
/// Should only be true in development/test environments.
-
/// Production MUST use HTTPS.
-
final bool allowHttpIssuer;
-
-
const OAuthAuthorizationServerMetadataResolverConfig({
-
this.allowHttpIssuer = false,
-
});
-
}
-
-
/// Resolves OAuth Authorization Server Metadata via RFC 8414 discovery.
-
///
-
/// This class:
-
/// 1. Validates issuer URLs (must be HTTPS in production)
-
/// 2. Fetches metadata from `{issuer}/.well-known/oauth-authorization-server`
-
/// 3. Validates the metadata against the spec
-
/// 4. Verifies issuer matches (prevents MIX-UP attacks)
-
/// 5. Ensures ATPROTO requirements (client_id_metadata_document)
-
/// 6. Caches metadata to avoid repeated fetches
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc8414
-
class OAuthAuthorizationServerMetadataResolver {
-
final AuthorizationServerMetadataCache _cache;
-
final Dio _dio;
-
final bool _allowHttpIssuer;
-
-
/// Creates a resolver with the given cache and HTTP client.
-
///
-
/// [cache] is used to store fetched metadata. Use an in-memory store for
-
/// testing or a persistent store for production.
-
///
-
/// [dio] is the HTTP client. If not provided, creates a default instance.
-
///
-
/// [config] allows customizing behavior (e.g., allowing HTTP in tests).
-
OAuthAuthorizationServerMetadataResolver(
-
this._cache, {
-
Dio? dio,
-
OAuthAuthorizationServerMetadataResolverConfig? config,
-
}) : _dio = dio ?? Dio(),
-
_allowHttpIssuer = config?.allowHttpIssuer ?? false;
-
-
/// Resolves authorization server metadata for the given issuer.
-
///
-
/// The [input] should be a valid issuer identifier (typically an HTTPS URL).
-
///
-
/// Returns the complete metadata as a Map. Throws if:
-
/// - Input is not a valid issuer URL
-
/// - HTTP is used in production (allowHttpIssuer = false)
-
/// - Network request fails
-
/// - Response is not valid JSON
-
/// - Metadata validation fails
-
/// - Issuer mismatch detected
-
/// - ATPROTO requirements not met
-
///
-
/// Example:
-
/// ```dart
-
/// final resolver = OAuthAuthorizationServerMetadataResolver(cache);
-
/// final metadata = await resolver.get('https://pds.example.com');
-
/// print(metadata['authorization_endpoint']);
-
/// ```
-
Future<Map<String, dynamic>> get(
-
String input, [
-
GetCachedOptions? options,
-
]) async {
-
// Validate and normalize issuer URL
-
final issuer = _validateIssuer(input);
-
-
// Security check: disallow HTTP in production
-
if (!_allowHttpIssuer && issuer.startsWith('http:')) {
-
throw FormatException(
-
'Unsecure issuer URL protocol only allowed in development and test environments',
-
);
-
}
-
-
// Check cache first (unless noCache is set)
-
if (options?.noCache != true) {
-
final cached = await _cache.get(issuer);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Fetch fresh metadata
-
final metadata = await _fetchMetadata(issuer, options);
-
-
// Store in cache
-
await _cache.set(issuer, metadata);
-
-
return metadata;
-
}
-
-
/// Fetches metadata from the well-known endpoint.
-
Future<Map<String, dynamic>> _fetchMetadata(
-
String issuer,
-
GetCachedOptions? options,
-
) async {
-
final url =
-
Uri.parse(
-
issuer,
-
).replace(path: '/.well-known/oauth-authorization-server').toString();
-
-
try {
-
final response = await _dio.get<Map<String, dynamic>>(
-
url,
-
options: Options(
-
headers: {'accept': 'application/json'},
-
followRedirects: false, // response must be 200 OK, no redirects
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
// Verify content type
-
final contentType = contentMime(
-
response.headers.map.map((key, value) => MapEntry(key, value.first)),
-
);
-
-
if (contentType != 'application/json') {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Unexpected content type for "$url"',
-
);
-
}
-
-
final metadata = response.data;
-
if (metadata == null) {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Empty response body for "$url"',
-
);
-
}
-
-
// Validate metadata structure
-
_validateMetadata(metadata, issuer);
-
-
return metadata;
-
} on DioException catch (e) {
-
if (e.response?.statusCode == 200) {
-
// Already handled above, rethrow
-
rethrow;
-
}
-
throw DioException(
-
requestOptions: e.requestOptions,
-
response: e.response,
-
type: e.type,
-
message:
-
'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"',
-
error: e.error,
-
);
-
}
-
}
-
-
/// Validates an issuer identifier.
-
///
-
/// Ensures the issuer is a valid URL without query or fragment.
-
/// Returns the normalized issuer.
-
String _validateIssuer(String input) {
-
final uri = Uri.tryParse(input);
-
if (uri == null) {
-
throw FormatException('Invalid issuer URL: $input');
-
}
-
-
// Issuer must not have query or fragment
-
if (uri.hasQuery || uri.hasFragment) {
-
throw FormatException(
-
'Issuer URL must not contain query or fragment: $input',
-
);
-
}
-
-
// Normalize: remove trailing slash
-
final normalized =
-
input.endsWith('/') ? input.substring(0, input.length - 1) : input;
-
-
return normalized;
-
}
-
-
/// Validates authorization server metadata.
-
///
-
/// Checks:
-
/// - Required fields are present
-
/// - Issuer matches expected value (MIX-UP attack prevention)
-
/// - ATPROTO requirement: client_id_metadata_document_supported = true
-
void _validateMetadata(Map<String, dynamic> metadata, String expectedIssuer) {
-
// Validate issuer field (critical for security - prevents MIX-UP attacks)
-
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks
-
// https://datatracker.ietf.org/doc/html/rfc8414#section-2
-
final issuer = metadata['issuer'];
-
if (issuer != expectedIssuer) {
-
throw FormatException(
-
'Invalid issuer: expected "$expectedIssuer", got "$issuer"',
-
);
-
}
-
-
// ATPROTO requires client_id_metadata_document support
-
// https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
-
final clientIdMetadataSupported =
-
metadata['client_id_metadata_document_supported'];
-
if (clientIdMetadataSupported != true) {
-
throw FormatException(
-
'Authorization server "$issuer" does not support client_id_metadata_document',
-
);
-
}
-
-
// Validate required endpoints exist
-
if (metadata['authorization_endpoint'] == null) {
-
throw FormatException('Missing required field: authorization_endpoint');
-
}
-
if (metadata['token_endpoint'] == null) {
-
throw FormatException('Missing required field: token_endpoint');
-
}
-
}
-
}
···
-285
packages/atproto_oauth_flutter/lib/src/oauth/client_auth.dart
···
-
import '../constants.dart';
-
import '../errors/auth_method_unsatisfiable_error.dart';
-
import '../runtime/runtime.dart';
-
import '../runtime/runtime_implementation.dart';
-
import '../types.dart';
-
-
/// Represents a client authentication method.
-
///
-
/// OAuth supports different ways for clients to authenticate with the
-
/// authorization server:
-
/// - 'none': Public client (no secret), only client_id
-
/// - 'private_key_jwt': Confidential client using JWT signed with private key
-
class ClientAuthMethod {
-
final String method;
-
final String? kid; // Key ID for private_key_jwt method
-
-
const ClientAuthMethod.none() : method = 'none', kid = null;
-
-
const ClientAuthMethod.privateKeyJwt(this.kid) : method = 'private_key_jwt';
-
-
@override
-
bool operator ==(Object other) {
-
if (identical(this, other)) return true;
-
return other is ClientAuthMethod &&
-
other.method == method &&
-
other.kid == kid;
-
}
-
-
@override
-
int get hashCode => method.hashCode ^ kid.hashCode;
-
-
Map<String, dynamic> toJson() {
-
return {'method': method, if (kid != null) 'kid': kid};
-
}
-
-
factory ClientAuthMethod.fromJson(Map<String, dynamic> json) {
-
final method = json['method'] as String;
-
if (method == 'none') {
-
return const ClientAuthMethod.none();
-
} else if (method == 'private_key_jwt') {
-
return ClientAuthMethod.privateKeyJwt(json['kid'] as String);
-
}
-
throw FormatException('Unknown auth method: $method');
-
}
-
}
-
-
/// Credential payload to include in OAuth requests.
-
class OAuthClientCredentials {
-
/// Client identifier
-
final String clientId;
-
-
/// Client assertion type (for private_key_jwt)
-
final String? clientAssertionType;
-
-
/// Client assertion JWT (for private_key_jwt)
-
final String? clientAssertion;
-
-
const OAuthClientCredentials({
-
required this.clientId,
-
this.clientAssertionType,
-
this.clientAssertion,
-
});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{'client_id': clientId};
-
if (clientAssertionType != null) {
-
map['client_assertion_type'] = clientAssertionType;
-
}
-
if (clientAssertion != null) {
-
map['client_assertion'] = clientAssertion;
-
}
-
return map;
-
}
-
}
-
-
/// Result of creating client credentials.
-
class ClientCredentialsResult {
-
/// Optional HTTP headers (e.g., Authorization header for client_secret_basic)
-
final Map<String, String>? headers;
-
-
/// Payload to include in the request body
-
final OAuthClientCredentials payload;
-
-
const ClientCredentialsResult({this.headers, required this.payload});
-
}
-
-
/// Factory function that creates client credentials.
-
typedef ClientCredentialsFactory = Future<ClientCredentialsResult> Function();
-
-
/// Negotiates the client authentication method to use.
-
///
-
/// This function:
-
/// 1. Checks that the server supports the client's auth method
-
/// 2. For private_key_jwt, finds a suitable key from the keyset
-
/// 3. Returns the negotiated auth method
-
///
-
/// The ATPROTO spec requires that authorization servers support both
-
/// "none" and "private_key_jwt", and clients use one or the other.
-
///
-
/// Throws:
-
/// - Error if server doesn't support client's auth method
-
/// - Error if private_key_jwt is used but no suitable key is found
-
ClientAuthMethod negotiateClientAuthMethod(
-
Map<String, dynamic> serverMetadata,
-
ClientMetadata clientMetadata,
-
Keyset? keyset,
-
) {
-
final method = clientMetadata.tokenEndpointAuthMethod;
-
-
// Check that the server supports this method
-
final methods = _supportedMethods(serverMetadata);
-
if (!methods.contains(method)) {
-
throw StateError(
-
'The server does not support "$method" authentication. '
-
'Supported methods are: ${methods.join(', ')}.',
-
);
-
}
-
-
if (method == 'private_key_jwt') {
-
// Invalid client configuration
-
if (keyset == null) {
-
throw StateError('A keyset is required for private_key_jwt');
-
}
-
-
final algs = _supportedAlgs(serverMetadata);
-
-
// Find a suitable key
-
// We can't use keyset.findPrivateKey here because we need to ensure
-
// the key has a "kid" property (required for JWT headers)
-
for (final key in keyset.keys) {
-
if (key.kid != null &&
-
key.usage == 'sign' &&
-
key.algorithms.any((a) => algs.contains(a))) {
-
return ClientAuthMethod.privateKeyJwt(key.kid!);
-
}
-
}
-
-
throw StateError(
-
algs.contains(fallbackAlg)
-
? 'Client authentication method "$method" requires at least one "$fallbackAlg" signing key with a "kid" property'
-
: 'Authorization server requires "$method" authentication method, but does not support "$fallbackAlg" algorithm.',
-
);
-
}
-
-
if (method == 'none') {
-
return const ClientAuthMethod.none();
-
}
-
-
throw StateError(
-
'The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.' +
-
(method == 'client_secret_basic'
-
? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
-
: ' You set "$method" which is not allowed.'),
-
);
-
}
-
-
/// Creates a factory that generates client credentials.
-
///
-
/// The factory can be called multiple times to generate fresh credentials
-
/// (important for private_key_jwt which includes timestamps).
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if:
-
/// - Server no longer supports the auth method
-
/// - Key is no longer available in the keyset
-
ClientCredentialsFactory createClientCredentialsFactory(
-
ClientAuthMethod authMethod,
-
Map<String, dynamic> serverMetadata,
-
ClientMetadata clientMetadata,
-
Runtime runtime,
-
Keyset? keyset,
-
) {
-
// Ensure the AS still supports the auth method
-
if (!_supportedMethods(serverMetadata).contains(authMethod.method)) {
-
throw AuthMethodUnsatisfiableError(
-
'Client authentication method "${authMethod.method}" no longer supported',
-
);
-
}
-
-
if (authMethod.method == 'none') {
-
return () async => ClientCredentialsResult(
-
payload: OAuthClientCredentials(clientId: clientMetadata.clientId!),
-
);
-
}
-
-
if (authMethod.method == 'private_key_jwt') {
-
try {
-
// Find the key
-
if (keyset == null) {
-
throw StateError('A keyset is required for private_key_jwt');
-
}
-
-
final key = keyset.keys.firstWhere(
-
(k) =>
-
k.kid == authMethod.kid &&
-
k.usage == 'sign' &&
-
k.algorithms.any((a) => _supportedAlgs(serverMetadata).contains(a)),
-
orElse: () => throw StateError('Key not found: ${authMethod.kid}'),
-
);
-
-
final alg = key.algorithms.firstWhere(
-
(a) => _supportedAlgs(serverMetadata).contains(a),
-
orElse: () => throw StateError('No supported algorithm found'),
-
);
-
-
// https://www.rfc-editor.org/rfc/rfc7523.html#section-3
-
return () async {
-
final jti = await runtime.generateNonce();
-
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
-
-
final jwt = await key.createJwt(
-
{'alg': alg},
-
{
-
// Issuer: the client_id
-
'iss': clientMetadata.clientId,
-
// Subject: the client_id
-
'sub': clientMetadata.clientId,
-
// Audience: the authorization server
-
'aud': serverMetadata['issuer'],
-
// JWT ID: unique identifier
-
'jti': jti,
-
// Issued at
-
'iat': now,
-
// Expiration: 1 minute from now
-
'exp': now + 60,
-
},
-
);
-
-
return ClientCredentialsResult(
-
payload: OAuthClientCredentials(
-
clientId: clientMetadata.clientId!,
-
clientAssertionType:
-
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
-
clientAssertion: jwt,
-
),
-
);
-
};
-
} catch (cause) {
-
throw AuthMethodUnsatisfiableError('Failed to load private key: $cause');
-
}
-
}
-
-
throw AuthMethodUnsatisfiableError(
-
'Unsupported auth method: ${authMethod.method}',
-
);
-
}
-
-
/// Gets the list of supported authentication methods from server metadata.
-
List<String> _supportedMethods(Map<String, dynamic> serverMetadata) {
-
final methods = serverMetadata['token_endpoint_auth_methods_supported'];
-
if (methods is List) {
-
return methods.map((m) => m.toString()).toList();
-
}
-
return [];
-
}
-
-
/// Gets the list of supported signing algorithms from server metadata.
-
List<String> _supportedAlgs(Map<String, dynamic> serverMetadata) {
-
final algs =
-
serverMetadata['token_endpoint_auth_signing_alg_values_supported'];
-
if (algs is List) {
-
return algs.map((a) => a.toString()).toList();
-
}
-
-
// Default to ES256 as prescribed by the ATProto spec:
-
// > Clients and Authorization Servers currently must support the ES256
-
// > cryptographic system [for client authentication].
-
// https://atproto.com/specs/oauth#confidential-client-authentication
-
return [fallbackAlg];
-
}
-
-
/// Placeholder for Keyset class.
-
///
-
/// In the full implementation, this would come from @atproto/jwk package.
-
/// For now, we use a simple implementation.
-
class Keyset {
-
final List<Key> keys;
-
-
const Keyset(this.keys);
-
-
int get size => keys.length;
-
-
Map<String, dynamic> toJSON() {
-
return {'keys': keys.map((k) => k.bareJwk).toList()};
-
}
-
}
···
-307
packages/atproto_oauth_flutter/lib/src/oauth/oauth_resolver.dart
···
-
import '../errors/oauth_resolver_error.dart';
-
import '../identity/did_document.dart';
-
import '../identity/identity_resolver.dart';
-
import 'authorization_server_metadata_resolver.dart';
-
import 'protected_resource_metadata_resolver.dart';
-
-
/// Complete result of OAuth resolution from an identity.
-
class ResolvedOAuthIdentityFromIdentity {
-
/// The resolved identity information
-
final IdentityInfo identityInfo;
-
-
/// The authorization server metadata
-
final Map<String, dynamic> metadata;
-
-
/// The PDS URL
-
final Uri pds;
-
-
const ResolvedOAuthIdentityFromIdentity({
-
required this.identityInfo,
-
required this.metadata,
-
required this.pds,
-
});
-
}
-
-
/// Result of OAuth resolution from a service URL.
-
class ResolvedOAuthIdentityFromService {
-
/// The authorization server metadata
-
final Map<String, dynamic> metadata;
-
-
/// Optional identity info (only present if resolved from handle/DID)
-
final IdentityInfo? identityInfo;
-
-
const ResolvedOAuthIdentityFromService({
-
required this.metadata,
-
this.identityInfo,
-
});
-
}
-
-
/// Options for OAuth resolution.
-
typedef ResolveOAuthOptions = GetCachedOptions;
-
-
/// Main OAuth resolver that combines identity and metadata resolution.
-
///
-
/// This class orchestrates the complete OAuth discovery flow:
-
///
-
/// 1. **From handle/DID** (resolveFromIdentity):
-
/// - Resolve handle → DID (if needed)
-
/// - Fetch DID document
-
/// - Extract PDS URL from DID document
-
/// - Fetch protected resource metadata from PDS
-
/// - Extract authorization server(s) from resource metadata
-
/// - Fetch authorization server metadata
-
/// - Verify PDS is protected by the authorization server
-
///
-
/// 2. **From URL** (resolveFromService):
-
/// - Try as PDS URL (fetch protected resource metadata)
-
/// - Extract authorization server from metadata
-
/// - Fallback: try as authorization server directly
-
///
-
/// This is the critical piece that enables decentralization - users can
-
/// host their data on any PDS, and we discover the OAuth server dynamically.
-
class OAuthResolver {
-
final IdentityResolver identityResolver;
-
final OAuthProtectedResourceMetadataResolver
-
protectedResourceMetadataResolver;
-
final OAuthAuthorizationServerMetadataResolver
-
authorizationServerMetadataResolver;
-
-
OAuthResolver({
-
required this.identityResolver,
-
required this.protectedResourceMetadataResolver,
-
required this.authorizationServerMetadataResolver,
-
});
-
-
/// Resolves OAuth metadata from an input (handle, DID, or URL).
-
///
-
/// The [input] can be:
-
/// - An atProto handle (e.g., "alice.bsky.social")
-
/// - A DID (e.g., "did:plc:...")
-
/// - A PDS URL (e.g., "https://pds.example.com")
-
/// - An authorization server URL (e.g., "https://auth.example.com")
-
///
-
/// Returns metadata for the authorization server. The identityInfo
-
/// is only present if input was a handle or DID.
-
Future<ResolvedOAuthIdentityFromService> resolve(
-
String input, [
-
ResolveOAuthOptions? options,
-
]) async {
-
// Detect if input is a URL (starts with http:// or https://)
-
if (RegExp(r'^https?://').hasMatch(input)) {
-
return resolveFromService(input, options);
-
} else {
-
final result = await resolveFromIdentity(input, options);
-
return ResolvedOAuthIdentityFromService(
-
metadata: result.metadata,
-
identityInfo: result.identityInfo,
-
);
-
}
-
}
-
-
/// Resolves OAuth metadata from a service URL (PDS or authorization server).
-
///
-
/// This method:
-
/// 1. First tries to resolve as a PDS (protected resource)
-
/// 2. If that fails, tries to resolve as an authorization server directly
-
///
-
/// This allows both "login with PDS URL" and "login with auth server URL"
-
/// flows, useful when users forget their handle or for compatibility.
-
Future<ResolvedOAuthIdentityFromService> resolveFromService(
-
String input, [
-
ResolveOAuthOptions? options,
-
]) async {
-
try {
-
// Assume first that input is a PDS URL (as required by ATPROTO)
-
final metadata = await getResourceServerMetadata(input, options);
-
return ResolvedOAuthIdentityFromService(metadata: metadata);
-
} catch (err) {
-
// Check if request was cancelled - note: Dio's CancelToken doesn't have throwIfCanceled()
-
// We rely on Dio throwing CancelError automatically
-
-
if (err is OAuthResolverError) {
-
try {
-
// Fallback to trying to fetch as an issuer (Entryway/Authorization Server)
-
final issuerUri = Uri.tryParse(input);
-
if (issuerUri != null && issuerUri.hasScheme) {
-
final metadata = await getAuthorizationServerMetadata(
-
input,
-
options,
-
);
-
return ResolvedOAuthIdentityFromService(metadata: metadata);
-
}
-
} catch (_) {
-
// Fallback failed, throw original error
-
}
-
}
-
-
rethrow;
-
}
-
}
-
-
/// Resolves OAuth metadata from a handle or DID.
-
///
-
/// This is the primary OAuth discovery flow:
-
/// 1. Resolve handle → DID → DID document (via IdentityResolver)
-
/// 2. Extract PDS URL from DID document
-
/// 3. Get protected resource metadata from PDS
-
/// 4. Extract authorization server(s)
-
/// 5. Get authorization server metadata
-
/// 6. Verify PDS is protected by the auth server
-
Future<ResolvedOAuthIdentityFromIdentity> resolveFromIdentity(
-
String input, [
-
ResolveOAuthOptions? options,
-
]) async {
-
final identityInfo = await resolveIdentity(
-
input,
-
options != null
-
? ResolveIdentityOptions(
-
noCache: options.noCache,
-
cancelToken: options.cancelToken,
-
)
-
: null,
-
);
-
-
final pds = _extractPdsUrl(identityInfo.didDoc);
-
-
final metadata = await getResourceServerMetadata(pds, options);
-
-
return ResolvedOAuthIdentityFromIdentity(
-
identityInfo: identityInfo,
-
metadata: metadata,
-
pds: pds,
-
);
-
}
-
-
/// Resolves an identity (handle or DID) to IdentityInfo.
-
///
-
/// Wraps the IdentityResolver with proper error handling.
-
Future<IdentityInfo> resolveIdentity(
-
String input, [
-
ResolveIdentityOptions? options,
-
]) async {
-
try {
-
return await identityResolver.resolve(input, options);
-
} catch (cause) {
-
throw OAuthResolverError.from(
-
cause,
-
'Failed to resolve identity: $input',
-
);
-
}
-
}
-
-
/// Gets authorization server metadata for an issuer.
-
///
-
/// Wraps the AuthorizationServerMetadataResolver with proper error handling.
-
Future<Map<String, dynamic>> getAuthorizationServerMetadata(
-
String issuer, [
-
GetCachedOptions? options,
-
]) async {
-
try {
-
return await authorizationServerMetadataResolver.get(issuer, options);
-
} catch (cause) {
-
throw OAuthResolverError.from(
-
cause,
-
'Failed to resolve OAuth server metadata for issuer: $issuer',
-
);
-
}
-
}
-
-
/// Gets authorization server metadata for a protected resource (PDS).
-
///
-
/// This method:
-
/// 1. Fetches protected resource metadata
-
/// 2. Validates exactly one authorization server is listed (ATPROTO requirement)
-
/// 3. Fetches authorization server metadata
-
/// 4. Verifies the PDS is in the auth server's protected_resources list
-
Future<Map<String, dynamic>> getResourceServerMetadata(
-
dynamic pdsUrl, [
-
GetCachedOptions? options,
-
]) async {
-
try {
-
final rsMetadata = await protectedResourceMetadataResolver.get(
-
pdsUrl,
-
options,
-
);
-
-
// ATPROTO requires exactly one authorization server
-
final authServers = rsMetadata['authorization_servers'];
-
if (authServers is! List || authServers.length != 1) {
-
throw OAuthResolverError(
-
authServers == null || (authServers as List).isEmpty
-
? 'No authorization servers found for PDS: $pdsUrl'
-
: 'Unable to determine authorization server for PDS: $pdsUrl',
-
);
-
}
-
-
final issuer = authServers[0] as String;
-
-
final asMetadata = await getAuthorizationServerMetadata(issuer, options);
-
-
// Verify PDS is protected by this authorization server
-
// https://www.rfc-editor.org/rfc/rfc9728.html#section-4
-
final protectedResources = asMetadata['protected_resources'];
-
if (protectedResources != null) {
-
final resource = rsMetadata['resource'] as String;
-
if (!(protectedResources as List).contains(resource)) {
-
throw OAuthResolverError(
-
'PDS "$pdsUrl" not protected by issuer "$issuer"',
-
);
-
}
-
}
-
-
return asMetadata;
-
} catch (cause) {
-
throw OAuthResolverError.from(
-
cause,
-
'Failed to resolve OAuth server metadata for resource: $pdsUrl',
-
);
-
}
-
}
-
-
/// Extracts the PDS URL from a DID document.
-
///
-
/// Throws OAuthResolverError if no PDS URL is found.
-
Uri _extractPdsUrl(DidDocument document) {
-
// Find the atproto_pds service
-
final service = document.service?.firstWhere(
-
(s) => _isAtprotoPersonalDataServerService(s, document),
-
orElse:
-
() =>
-
throw OAuthResolverError(
-
'Identity "${document.id}" does not have a PDS URL',
-
),
-
);
-
-
if (service == null) {
-
throw OAuthResolverError(
-
'Identity "${document.id}" does not have a PDS URL',
-
);
-
}
-
-
try {
-
return Uri.parse(service.serviceEndpoint as String);
-
} catch (cause) {
-
throw OAuthResolverError(
-
'Invalid PDS URL in DID document: ${service.serviceEndpoint}',
-
cause: cause,
-
);
-
}
-
}
-
-
/// Checks if a service is an AtprotoPersonalDataServer.
-
bool _isAtprotoPersonalDataServerService(
-
DidService service,
-
DidDocument document,
-
) {
-
if (service.serviceEndpoint is! String) return false;
-
if (service.type != 'AtprotoPersonalDataServer') return false;
-
-
// Check service ID
-
final id = service.id;
-
if (id.startsWith('#')) {
-
return id == '#atproto_pds';
-
} else {
-
return id == '${document.id}#atproto_pds';
-
}
-
}
-
}
···
-519
packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_agent.dart
···
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart' hide Key;
-
-
import '../dpop/fetch_dpop.dart';
-
import '../errors/oauth_response_error.dart';
-
import '../errors/token_refresh_error.dart';
-
import '../runtime/runtime.dart';
-
import '../runtime/runtime_implementation.dart';
-
import '../types.dart';
-
import 'authorization_server_metadata_resolver.dart' show GetCachedOptions;
-
import 'client_auth.dart';
-
import 'oauth_resolver.dart';
-
-
/// Represents a token set returned from OAuth token endpoint.
-
class TokenSet {
-
/// Issuer (authorization server URL)
-
final String iss;
-
-
/// Subject (DID of the user)
-
final String sub;
-
-
/// Audience (PDS URL)
-
final String aud;
-
-
/// Scope (space-separated list of scopes)
-
final String scope;
-
-
/// Refresh token (optional)
-
final String? refreshToken;
-
-
/// Access token
-
final String accessToken;
-
-
/// Token type (must be "DPoP" for ATPROTO)
-
final String tokenType;
-
-
/// Expiration time (ISO date string)
-
final String? expiresAt;
-
-
const TokenSet({
-
required this.iss,
-
required this.sub,
-
required this.aud,
-
required this.scope,
-
this.refreshToken,
-
required this.accessToken,
-
required this.tokenType,
-
this.expiresAt,
-
});
-
-
Map<String, dynamic> toJson() {
-
return {
-
'iss': iss,
-
'sub': sub,
-
'aud': aud,
-
'scope': scope,
-
if (refreshToken != null) 'refresh_token': refreshToken,
-
'access_token': accessToken,
-
'token_type': tokenType,
-
if (expiresAt != null) 'expires_at': expiresAt,
-
};
-
}
-
-
factory TokenSet.fromJson(Map<String, dynamic> json) {
-
return TokenSet(
-
iss: json['iss'] as String,
-
sub: json['sub'] as String,
-
aud: json['aud'] as String,
-
scope: json['scope'] as String,
-
refreshToken: json['refresh_token'] as String?,
-
accessToken: json['access_token'] as String,
-
tokenType: json['token_type'] as String,
-
expiresAt: json['expires_at'] as String?,
-
);
-
}
-
}
-
-
/// DPoP nonce cache type.
-
typedef DpopNonceCache = SimpleStore<String, String>;
-
-
/// Agent for interacting with an OAuth authorization server.
-
///
-
/// This class handles:
-
/// - Token exchange (authorization code → tokens)
-
/// - Token refresh (refresh token → new tokens)
-
/// - Token revocation
-
/// - DPoP proof generation and nonce management
-
/// - Client authentication
-
///
-
/// All token requests include DPoP proofs to bind tokens to keys.
-
class OAuthServerAgent {
-
final ClientAuthMethod authMethod;
-
final Key dpopKey;
-
final Map<String, dynamic> serverMetadata;
-
final ClientMetadata clientMetadata;
-
final DpopNonceCache dpopNonces;
-
final OAuthResolver oauthResolver;
-
final Runtime runtime;
-
final Keyset? keyset;
-
final Dio _dio;
-
final ClientCredentialsFactory _clientCredentialsFactory;
-
-
/// Creates an OAuth server agent.
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if the auth method cannot be satisfied.
-
OAuthServerAgent({
-
required this.authMethod,
-
required this.dpopKey,
-
required this.serverMetadata,
-
required this.clientMetadata,
-
required this.dpopNonces,
-
required this.oauthResolver,
-
required this.runtime,
-
this.keyset,
-
Dio? dio,
-
}) : // CRITICAL: Always create a NEW Dio instance to avoid duplicate interceptors
-
// If we reuse a shared Dio instance, each OAuthServerAgent will add its
-
// interceptors to the same instance, causing duplicate requests!
-
_dio = Dio(dio?.options ?? BaseOptions()),
-
_clientCredentialsFactory = createClientCredentialsFactory(
-
authMethod,
-
serverMetadata,
-
clientMetadata,
-
runtime,
-
keyset,
-
) {
-
// Add debug logging interceptor (runs before DPoP interceptor)
-
if (kDebugMode) {
-
_dio.interceptors.add(
-
InterceptorsWrapper(
-
onRequest: (options, handler) {
-
if (options.uri.path.contains('/token')) {
-
print(
-
'📤 [BEFORE DPoP] Request headers: ${options.headers.keys.toList()}',
-
);
-
}
-
handler.next(options);
-
},
-
),
-
);
-
}
-
-
// Add DPoP interceptor
-
_dio.interceptors.add(
-
createDpopInterceptor(
-
DpopFetchWrapperOptions(
-
key: dpopKey,
-
nonces: dpopNonces,
-
sha256: runtime.sha256,
-
isAuthServer: true,
-
),
-
),
-
);
-
-
// Add final logging interceptor (runs after DPoP interceptor)
-
if (kDebugMode) {
-
_dio.interceptors.add(
-
InterceptorsWrapper(
-
onRequest: (options, handler) {
-
if (options.uri.path.contains('/token')) {
-
print(
-
'📤 [AFTER DPoP] Request headers: ${options.headers.keys.toList()}',
-
);
-
if (options.headers.containsKey('dpop')) {
-
print(
-
' DPoP header present: ${options.headers['dpop']?.toString().substring(0, 50)}...',
-
);
-
} else if (options.headers.containsKey('DPoP')) {
-
print(
-
' DPoP header present: ${options.headers['DPoP']?.toString().substring(0, 50)}...',
-
);
-
} else {
-
print(' ⚠️ DPoP header MISSING!');
-
}
-
}
-
handler.next(options);
-
},
-
onError: (error, handler) {
-
if (error.requestOptions.uri.path.contains('/token')) {
-
print('📥 Token request error: ${error.message}');
-
}
-
handler.next(error);
-
},
-
),
-
);
-
}
-
}
-
-
/// The issuer (authorization server URL).
-
String get issuer => serverMetadata['issuer'] as String;
-
-
/// Revokes a token.
-
///
-
/// Errors are silently ignored as revocation is best-effort.
-
Future<void> revoke(String token) async {
-
try {
-
await _request('revocation', {'token': token});
-
} catch (_) {
-
// Don't care if revocation fails
-
}
-
}
-
-
/// Pre-fetches a DPoP nonce from the token endpoint.
-
///
-
/// This is critical for authorization code exchange because:
-
/// 1. First token request without nonce → PDS consumes code + returns use_dpop_nonce error
-
/// 2. Retry with nonce → "Invalid code" because already consumed
-
///
-
/// Solution: Get a nonce BEFORE attempting code exchange.
-
///
-
/// We make a lightweight invalid request that will fail but return a nonce.
-
/// The server responds with a nonce in the DPoP-Nonce header, which the
-
/// interceptor automatically caches for subsequent requests.
-
Future<void> _prefetchDpopNonce() async {
-
final tokenEndpoint = serverMetadata['token_endpoint'] as String?;
-
if (tokenEndpoint == null) return;
-
-
final origin = Uri.parse(tokenEndpoint);
-
final originKey =
-
'${origin.scheme}://${origin.host}${origin.hasPort ? ':${origin.port}' : ''}';
-
-
// Clear any stale nonce from previous sessions
-
try {
-
await dpopNonces.del(originKey);
-
if (kDebugMode) {
-
print('🧹 Cleared stale DPoP nonce from cache');
-
}
-
} catch (_) {
-
// Ignore deletion errors
-
}
-
-
if (kDebugMode) {
-
print('⏱️ Pre-fetch starting at: ${DateTime.now().toIso8601String()}');
-
}
-
-
try {
-
// Make a minimal invalid request to trigger nonce response
-
// Use an invalid grant_type that will fail fast without side effects
-
await _dio.post<Map<String, dynamic>>(
-
tokenEndpoint,
-
data: 'grant_type=invalid_prefetch',
-
options: Options(
-
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
-
validateStatus: (status) => true, // Accept any status
-
),
-
);
-
} catch (_) {
-
// Ignore all errors - we just want the nonce from the response headers
-
// The DPoP interceptor will have cached it in onError or onResponse
-
}
-
-
if (kDebugMode) {
-
print('⏱️ Pre-fetch completed at: ${DateTime.now().toIso8601String()}');
-
final cachedNonce = await dpopNonces.get(originKey);
-
print('🎫 DPoP nonce pre-fetch result:');
-
print(
-
' Cached nonce: ${cachedNonce != null ? "✅ ${cachedNonce.substring(0, 20)}..." : "❌ not found"}',
-
);
-
}
-
}
-
-
/// Exchanges an authorization code for tokens.
-
///
-
/// This is called after the user completes authorization and you receive
-
/// the authorization code in the callback.
-
///
-
/// [code] is the authorization code from the callback.
-
/// [codeVerifier] is the PKCE code verifier (if PKCE was used).
-
/// [redirectUri] is the redirect URI used in the authorization request.
-
///
-
/// Returns a [TokenSet] with access token, optional refresh token, and metadata.
-
///
-
/// IMPORTANT: This method verifies the issuer before returning tokens.
-
/// If verification fails, the access token is automatically revoked.
-
Future<TokenSet> exchangeCode(
-
String code, {
-
String? codeVerifier,
-
String? redirectUri,
-
}) async {
-
// CRITICAL: DO NOT pre-fetch! Exchange immediately!
-
// The pre-fetch adds ~678ms delay, during which the browser re-navigates
-
// and invalidates the authorization code. We need to exchange within ~270ms.
-
// If we get a nonce error, we'll handle it via the interceptor (though PDS
-
// doesn't seem to require nonces for initial token exchange).
-
-
final now = DateTime.now();
-
-
final tokenResponse = await _request('token', {
-
'grant_type': 'authorization_code',
-
'redirect_uri': redirectUri ?? clientMetadata.redirectUris.first,
-
'code': code,
-
if (codeVerifier != null) 'code_verifier': codeVerifier,
-
});
-
-
try {
-
// CRITICAL: Verify issuer before trusting the sub
-
// The tokenResponse MUST always be valid before the "sub" can be trusted
-
// See: https://atproto.com/specs/oauth
-
final aud = await _verifyIssuer(tokenResponse['sub'] as String);
-
-
return TokenSet(
-
aud: aud,
-
sub: tokenResponse['sub'] as String,
-
iss: issuer,
-
scope: tokenResponse['scope'] as String,
-
refreshToken: tokenResponse['refresh_token'] as String?,
-
accessToken: tokenResponse['access_token'] as String,
-
tokenType: tokenResponse['token_type'] as String,
-
expiresAt:
-
tokenResponse['expires_in'] != null
-
? now
-
.add(Duration(seconds: tokenResponse['expires_in'] as int))
-
.toIso8601String()
-
: null,
-
);
-
} catch (err) {
-
// If verification fails, revoke the access token
-
await revoke(tokenResponse['access_token'] as String);
-
rethrow;
-
}
-
}
-
-
/// Refreshes a token set using the refresh token.
-
///
-
/// [tokenSet] is the current token set with a refresh_token.
-
///
-
/// Returns a new [TokenSet] with fresh tokens.
-
///
-
/// Throws [TokenRefreshError] if refresh fails or no refresh token is available.
-
///
-
/// IMPORTANT: This method verifies the issuer before returning tokens.
-
Future<TokenSet> refresh(TokenSet tokenSet) async {
-
if (tokenSet.refreshToken == null) {
-
throw TokenRefreshError(tokenSet.sub, 'No refresh token available');
-
}
-
-
// CRITICAL: Verify issuer BEFORE refresh to avoid unnecessary requests
-
// and ensure the sub is still valid for this issuer
-
final aud = await _verifyIssuer(tokenSet.sub);
-
-
final now = DateTime.now();
-
-
final tokenResponse = await _request('token', {
-
'grant_type': 'refresh_token',
-
'refresh_token': tokenSet.refreshToken,
-
});
-
-
return TokenSet(
-
aud: aud,
-
sub: tokenSet.sub,
-
iss: issuer,
-
scope: tokenResponse['scope'] as String,
-
refreshToken: tokenResponse['refresh_token'] as String?,
-
accessToken: tokenResponse['access_token'] as String,
-
tokenType: tokenResponse['token_type'] as String,
-
expiresAt:
-
tokenResponse['expires_in'] != null
-
? now
-
.add(Duration(seconds: tokenResponse['expires_in'] as int))
-
.toIso8601String()
-
: null,
-
);
-
}
-
-
/// Verifies that the sub (DID) is indeed issued by this authorization server.
-
///
-
/// This is CRITICAL for security. We must verify that the DID's PDS
-
/// is protected by this authorization server before trusting tokens.
-
///
-
/// Returns the user's PDS URL (the resource server).
-
///
-
/// Throws if:
-
/// - DID resolution fails
-
/// - Issuer mismatch (user may have switched PDS or attack detected)
-
Future<String> _verifyIssuer(String sub) async {
-
final cancelToken = CancelToken();
-
final resolved = await oauthResolver
-
.resolveFromIdentity(
-
sub,
-
GetCachedOptions(
-
noCache: true,
-
allowStale: false,
-
cancelToken: cancelToken,
-
),
-
)
-
.timeout(
-
const Duration(seconds: 10),
-
onTimeout: () {
-
cancelToken.cancel();
-
throw TimeoutException('Issuer verification timed out');
-
},
-
);
-
-
if (issuer != resolved.metadata['issuer']) {
-
// Best case: user switched PDS
-
// Worst case: attack attempt
-
// Either way: MUST NOT allow this token to be used
-
throw FormatException('Issuer mismatch');
-
}
-
-
return resolved.pds.toString();
-
}
-
-
/// Makes a request to an OAuth endpoint (public API).
-
///
-
/// This is a generic method for making OAuth endpoint requests with proper typing.
-
/// Currently supports: token, revocation, pushed_authorization_request.
-
///
-
/// [endpoint] is the endpoint name.
-
/// [payload] is the request body parameters.
-
///
-
/// Returns the parsed JSON response.
-
/// Throws [OAuthResponseError] if the server returns an error.
-
Future<Map<String, dynamic>> request(
-
String endpoint,
-
Map<String, dynamic> payload,
-
) async {
-
return _request(endpoint, payload);
-
}
-
-
/// Makes a request to an OAuth endpoint (internal implementation).
-
///
-
/// [endpoint] is the endpoint name (e.g., 'token', 'revocation', 'pushed_authorization_request').
-
/// [payload] is the request body parameters.
-
///
-
/// Returns the parsed JSON response.
-
/// Throws [OAuthResponseError] if the server returns an error.
-
Future<Map<String, dynamic>> _request(
-
String endpoint,
-
Map<String, dynamic> payload,
-
) async {
-
final url = serverMetadata['${endpoint}_endpoint'];
-
if (url == null) {
-
throw StateError('No $endpoint endpoint available');
-
}
-
-
final auth = await _clientCredentialsFactory();
-
-
final fullPayload = {...payload, ...auth.payload.toJson()};
-
final encodedData = _wwwFormUrlEncode(fullPayload);
-
-
if (kDebugMode && endpoint == 'token') {
-
print('🌐 Token exchange HTTP request:');
-
print(' ⏱️ Request starting at: ${DateTime.now().toIso8601String()}');
-
print(' URL: $url');
-
print(' Payload keys: ${fullPayload.keys.toList()}');
-
print(' grant_type: ${fullPayload['grant_type']}');
-
print(' client_id: ${fullPayload['client_id']}');
-
print(' redirect_uri: ${fullPayload['redirect_uri']}');
-
print(' code: ${fullPayload['code']?.toString().substring(0, 20)}...');
-
print(
-
' code_verifier: ${fullPayload['code_verifier']?.toString().substring(0, 20)}...',
-
);
-
print(' Headers: ${auth.headers?.keys.toList() ?? []}');
-
}
-
-
try {
-
final response = await _dio.post<Map<String, dynamic>>(
-
url as String,
-
data: encodedData,
-
options: Options(
-
headers: {
-
if (auth.headers != null) ...auth.headers!,
-
'Content-Type': 'application/x-www-form-urlencoded',
-
},
-
),
-
);
-
-
final data = response.data;
-
if (data == null) {
-
throw OAuthResponseError(response, {'error': 'empty_response'});
-
}
-
-
if (kDebugMode && endpoint == 'token') {
-
print(' ✅ Token exchange successful!');
-
}
-
-
return data;
-
} on DioException catch (e) {
-
final response = e.response;
-
if (response != null) {
-
if (kDebugMode && endpoint == 'token') {
-
print(' ❌ Token exchange failed:');
-
print(' Status: ${response.statusCode}');
-
print(' Response: ${response.data}');
-
}
-
throw OAuthResponseError(response, response.data);
-
}
-
rethrow;
-
}
-
}
-
-
/// Encodes a map as application/x-www-form-urlencoded.
-
String _wwwFormUrlEncode(Map<String, dynamic> payload) {
-
final entries = payload.entries
-
.where((e) => e.value != null)
-
.map((e) => MapEntry(e.key, _stringifyValue(e.value)));
-
-
return Uri(queryParameters: Map.fromEntries(entries)).query;
-
}
-
-
/// Converts a value to string for form encoding.
-
String _stringifyValue(dynamic value) {
-
if (value is String) return value;
-
if (value is num) return value.toString();
-
if (value is bool) return value.toString();
-
// For complex types, use JSON encoding
-
return value.toString();
-
}
-
}
-
-
/// Timeout exception.
-
class TimeoutException implements Exception {
-
final String message;
-
TimeoutException(this.message);
-
-
@override
-
String toString() => 'TimeoutException: $message';
-
}
···
-117
packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_factory.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../runtime/runtime.dart';
-
import '../runtime/runtime_implementation.dart';
-
import '../types.dart';
-
import 'authorization_server_metadata_resolver.dart';
-
import 'client_auth.dart';
-
import 'oauth_resolver.dart';
-
import 'oauth_server_agent.dart';
-
-
/// Factory for creating OAuth server agents.
-
///
-
/// This factory:
-
/// 1. Stores common configuration (client metadata, runtime, resolver, etc.)
-
/// 2. Creates OAuthServerAgent instances for specific issuers
-
/// 3. Handles both new sessions and restored sessions (with legacy support)
-
///
-
/// The factory pattern allows reusing configuration across multiple agents
-
/// and simplifies session restoration.
-
class OAuthServerFactory {
-
final ClientMetadata clientMetadata;
-
final Runtime runtime;
-
final OAuthResolver resolver;
-
final Dio dio;
-
final Keyset? keyset;
-
final DpopNonceCache dpopNonceCache;
-
-
/// Creates a server factory with the given configuration.
-
///
-
/// [clientMetadata] is the validated client metadata.
-
/// [runtime] provides cryptographic operations.
-
/// [resolver] handles OAuth metadata discovery.
-
/// [dio] is the HTTP client.
-
/// [keyset] is optional (only needed for confidential clients).
-
/// [dpopNonceCache] stores DPoP nonces per origin.
-
OAuthServerFactory({
-
required this.clientMetadata,
-
required this.runtime,
-
required this.resolver,
-
required this.dio,
-
this.keyset,
-
required this.dpopNonceCache,
-
});
-
-
/// Creates an OAuth server agent from an issuer URL.
-
///
-
/// This method:
-
/// 1. Fetches authorization server metadata for the issuer
-
/// 2. Uses the provided authMethod or negotiates one (for legacy sessions)
-
/// 3. Creates an OAuthServerAgent with the metadata
-
///
-
/// [issuer] is the authorization server URL.
-
/// [authMethod] is the authentication method to use.
-
/// - For new sessions, pass the result of negotiateClientAuthMethod
-
/// - For legacy sessions (before authMethod was stored), pass 'legacy'
-
/// and the method will be negotiated automatically
-
/// [dpopKey] is the DPoP signing key.
-
/// [options] are optional cache/cancellation options.
-
///
-
/// The 'legacy' authMethod is for backwards compatibility with sessions
-
/// created before we started storing the authMethod. Support for this
-
/// may be removed in the future.
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied.
-
Future<OAuthServerAgent> fromIssuer(
-
String issuer,
-
dynamic authMethod, // ClientAuthMethod or 'legacy'
-
Key dpopKey, [
-
GetCachedOptions? options,
-
]) async {
-
final serverMetadata = await resolver.getAuthorizationServerMetadata(
-
issuer,
-
options,
-
);
-
-
ClientAuthMethod finalAuthMethod;
-
if (authMethod == 'legacy') {
-
// Backwards compatibility: compute auth method from metadata
-
finalAuthMethod = negotiateClientAuthMethod(
-
serverMetadata,
-
clientMetadata,
-
keyset,
-
);
-
} else {
-
finalAuthMethod = authMethod as ClientAuthMethod;
-
}
-
-
return fromMetadata(serverMetadata, finalAuthMethod, dpopKey);
-
}
-
-
/// Creates an OAuth server agent from authorization server metadata.
-
///
-
/// This is useful when you already have the metadata cached.
-
///
-
/// [serverMetadata] is the authorization server metadata.
-
/// [authMethod] is the authentication method to use.
-
/// [dpopKey] is the DPoP signing key.
-
///
-
/// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied.
-
OAuthServerAgent fromMetadata(
-
Map<String, dynamic> serverMetadata,
-
ClientAuthMethod authMethod,
-
Key dpopKey,
-
) {
-
return OAuthServerAgent(
-
authMethod: authMethod,
-
dpopKey: dpopKey,
-
serverMetadata: serverMetadata,
-
clientMetadata: clientMetadata,
-
dpopNonces: dpopNonceCache,
-
oauthResolver: resolver,
-
runtime: runtime,
-
keyset: keyset,
-
dio: dio,
-
);
-
}
-
}
···
-196
packages/atproto_oauth_flutter/lib/src/oauth/protected_resource_metadata_resolver.dart
···
-
import 'package:dio/dio.dart';
-
-
import '../dpop/fetch_dpop.dart';
-
import '../util.dart';
-
import 'authorization_server_metadata_resolver.dart';
-
-
/// Cache interface for protected resource metadata.
-
///
-
/// Implementations should store metadata keyed by origin (scheme://host:port).
-
typedef ProtectedResourceMetadataCache =
-
SimpleStore<String, Map<String, dynamic>>;
-
-
/// Configuration for the protected resource metadata resolver.
-
class OAuthProtectedResourceMetadataResolverConfig {
-
/// Whether to allow HTTP (non-HTTPS) resource URLs.
-
///
-
/// Should only be true in development/test environments.
-
/// Production MUST use HTTPS.
-
final bool allowHttpResource;
-
-
const OAuthProtectedResourceMetadataResolverConfig({
-
this.allowHttpResource = false,
-
});
-
}
-
-
/// Resolves OAuth Protected Resource Metadata via RFC 9728 discovery.
-
///
-
/// This class:
-
/// 1. Validates resource URLs (must be HTTPS in production)
-
/// 2. Fetches metadata from `{origin}/.well-known/oauth-protected-resource`
-
/// 3. Validates the metadata against the spec
-
/// 4. Verifies resource field matches origin
-
/// 5. Caches metadata to avoid repeated fetches
-
///
-
/// See: https://www.rfc-editor.org/rfc/rfc9728.html
-
class OAuthProtectedResourceMetadataResolver {
-
final ProtectedResourceMetadataCache _cache;
-
final Dio _dio;
-
final bool _allowHttpResource;
-
-
/// Creates a resolver with the given cache and HTTP client.
-
///
-
/// [cache] is used to store fetched metadata. Use an in-memory store for
-
/// testing or a persistent store for production.
-
///
-
/// [dio] is the HTTP client. If not provided, creates a default instance.
-
///
-
/// [config] allows customizing behavior (e.g., allowing HTTP in tests).
-
OAuthProtectedResourceMetadataResolver(
-
this._cache, {
-
Dio? dio,
-
OAuthProtectedResourceMetadataResolverConfig? config,
-
}) : _dio = dio ?? Dio(),
-
_allowHttpResource = config?.allowHttpResource ?? false;
-
-
/// Resolves protected resource metadata for the given resource URL.
-
///
-
/// The [resource] can be a String URL or Uri. Only the origin is used.
-
///
-
/// Returns the complete metadata as a Map. Throws if:
-
/// - Resource is not a valid URL
-
/// - Protocol is not HTTP/HTTPS
-
/// - HTTP is used in production (allowHttpResource = false)
-
/// - Network request fails
-
/// - Response is not valid JSON
-
/// - Metadata validation fails
-
/// - Resource mismatch detected
-
///
-
/// Example:
-
/// ```dart
-
/// final resolver = OAuthProtectedResourceMetadataResolver(cache);
-
/// final metadata = await resolver.get('https://pds.example.com');
-
/// print(metadata['authorization_servers']);
-
/// ```
-
Future<Map<String, dynamic>> get(
-
dynamic resource, [
-
GetCachedOptions? options,
-
]) async {
-
// Parse URL and extract origin
-
final uri = resource is Uri ? resource : Uri.parse(resource.toString());
-
final protocol = uri.scheme;
-
final origin =
-
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
-
-
// Validate protocol
-
if (protocol != 'https' && protocol != 'http') {
-
throw FormatException(
-
'Invalid protected resource metadata URL protocol: $protocol',
-
);
-
}
-
-
// Security check: disallow HTTP in production
-
if (protocol == 'http' && !_allowHttpResource) {
-
throw FormatException(
-
'Unsecure resource metadata URL ($protocol) only allowed in development and test environments',
-
);
-
}
-
-
// Check cache first (unless noCache is set)
-
if (options?.noCache != true) {
-
final cached = await _cache.get(origin);
-
if (cached != null) {
-
return cached;
-
}
-
}
-
-
// Fetch fresh metadata
-
final metadata = await _fetchMetadata(origin, options);
-
-
// Store in cache
-
await _cache.set(origin, metadata);
-
-
return metadata;
-
}
-
-
/// Fetches metadata from the well-known endpoint.
-
Future<Map<String, dynamic>> _fetchMetadata(
-
String origin,
-
GetCachedOptions? options,
-
) async {
-
final url =
-
Uri.parse(
-
origin,
-
).replace(path: '/.well-known/oauth-protected-resource').toString();
-
-
try {
-
final response = await _dio.get<Map<String, dynamic>>(
-
url,
-
options: Options(
-
headers: {'accept': 'application/json'},
-
followRedirects: false, // response must be 200 OK, no redirects
-
validateStatus: (status) => status == 200,
-
),
-
cancelToken: options?.cancelToken,
-
);
-
-
// Verify content type
-
final contentType = contentMime(
-
response.headers.map.map((key, value) => MapEntry(key, value.first)),
-
);
-
-
if (contentType != 'application/json') {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Unexpected content type for "$url"',
-
);
-
}
-
-
final metadata = response.data;
-
if (metadata == null) {
-
throw DioException(
-
requestOptions: response.requestOptions,
-
response: response,
-
type: DioExceptionType.badResponse,
-
message: 'Empty response body for "$url"',
-
);
-
}
-
-
// Validate metadata
-
_validateMetadata(metadata, origin);
-
-
return metadata;
-
} on DioException catch (e) {
-
if (e.response?.statusCode == 200) {
-
// Already handled above, rethrow
-
rethrow;
-
}
-
throw DioException(
-
requestOptions: e.requestOptions,
-
response: e.response,
-
type: e.type,
-
message:
-
'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"',
-
error: e.error,
-
);
-
}
-
}
-
-
/// Validates protected resource metadata.
-
///
-
/// Checks:
-
/// - Resource field matches the expected origin
-
/// - Authorization servers list is present
-
void _validateMetadata(Map<String, dynamic> metadata, String expectedOrigin) {
-
// Validate resource field
-
// https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3
-
final resource = metadata['resource'];
-
if (resource != expectedOrigin) {
-
throw FormatException(
-
'Invalid resource: expected "$expectedOrigin", got "$resource"',
-
);
-
}
-
}
-
}
···
-213
packages/atproto_oauth_flutter/lib/src/oauth/validate_client_metadata.dart
···
-
import '../constants.dart';
-
import '../types.dart';
-
import 'client_auth.dart';
-
-
/// Validates client metadata for OAuth compliance.
-
///
-
/// This function performs comprehensive validation of client metadata to ensure:
-
/// 1. Client ID is valid (either discoverable HTTPS or loopback)
-
/// 2. Required ATPROTO scope is present
-
/// 3. Required response_types and grant_types are present
-
/// 4. Authentication method is properly configured
-
/// 5. For private_key_jwt, keyset and JWKS are properly configured
-
///
-
/// The validation enforces ATPROTO OAuth requirements on top of standard OAuth.
-
///
-
/// Returns the validated ClientMetadata.
-
/// Throws TypeError if validation fails.
-
ClientMetadata validateClientMetadata(
-
Map<String, dynamic> input,
-
Keyset? keyset,
-
) {
-
// Allow passing a keyset and omitting jwks/jwks_uri
-
// The keyset will be serialized into the metadata
-
Map<String, dynamic> enrichedInput = input;
-
if (input['jwks'] == null &&
-
input['jwks_uri'] == null &&
-
keyset != null &&
-
keyset.size > 0) {
-
enrichedInput = {...input, 'jwks': keyset.toJSON()};
-
}
-
-
// Parse into ClientMetadata
-
final metadata = ClientMetadata.fromJson(enrichedInput);
-
-
// Validate client ID
-
final clientId = metadata.clientId;
-
if (clientId == null) {
-
throw FormatException('Client metadata must include client_id');
-
}
-
-
if (clientId.startsWith('http:')) {
-
// Loopback client ID (for development)
-
_assertOAuthLoopbackClientId(clientId);
-
} else {
-
// Discoverable client ID (production)
-
_assertOAuthDiscoverableClientId(clientId);
-
}
-
-
// Validate scope includes "atproto"
-
final scopes = metadata.scope?.split(' ') ?? [];
-
if (!scopes.contains('atproto')) {
-
throw FormatException('Client metadata must include the "atproto" scope');
-
}
-
-
// Validate response_types
-
if (!metadata.responseTypes.contains('code')) {
-
throw FormatException('"response_types" must include "code"');
-
}
-
-
// Validate grant_types
-
if (!metadata.grantTypes.contains('authorization_code')) {
-
throw FormatException('"grant_types" must include "authorization_code"');
-
}
-
-
// Validate authentication method
-
final method = metadata.tokenEndpointAuthMethod;
-
final methodAlg = metadata.tokenEndpointAuthSigningAlg;
-
-
switch (method) {
-
case 'none':
-
if (methodAlg != null) {
-
throw FormatException(
-
'"token_endpoint_auth_signing_alg" must not be provided when '
-
'"token_endpoint_auth_method" is "$method"',
-
);
-
}
-
break;
-
-
case 'private_key_jwt':
-
if (methodAlg == null) {
-
throw FormatException(
-
'"token_endpoint_auth_signing_alg" must be provided when '
-
'"token_endpoint_auth_method" is "$method"',
-
);
-
}
-
-
if (keyset == null) {
-
throw FormatException(
-
'Client authentication method "$method" requires a keyset',
-
);
-
}
-
-
// Validate signing keys
-
final signingKeys = keyset.keys.where((key) => key.kid != null).toList();
-
-
if (signingKeys.isEmpty) {
-
throw FormatException(
-
'Client authentication method "$method" requires at least one '
-
'active signing key with a "kid" property',
-
);
-
}
-
-
if (!signingKeys.any((key) => key.algorithms.contains(fallbackAlg))) {
-
throw FormatException(
-
'Client authentication method "$method" requires at least one '
-
'active "$fallbackAlg" signing key',
-
);
-
}
-
-
// Validate JWKS
-
if (metadata.jwks != null) {
-
// Ensure all signing keys are in the JWKS
-
final jwksKeys = (metadata.jwks!['keys'] as List?) ?? [];
-
for (final key in signingKeys) {
-
final found = jwksKeys.any((k) {
-
if (k is! Map<String, dynamic>) return false;
-
final revoked = k['revoked'] as bool?;
-
return k['kid'] == key.kid && revoked != true;
-
});
-
-
if (!found) {
-
throw FormatException(
-
'Missing or inactive key "${key.kid}" in jwks. '
-
'Make sure that every signing key of the Keyset is declared as '
-
'an active key in the Metadata\'s JWKS.',
-
);
-
}
-
}
-
} else if (metadata.jwksUri != null) {
-
// JWKS URI is acceptable, but we can't validate it here
-
// (we don't want to download the file during validation)
-
} else {
-
throw FormatException(
-
'Client authentication method "$method" requires a JWKS',
-
);
-
}
-
break;
-
-
default:
-
throw FormatException(
-
'Unsupported "token_endpoint_auth_method" value: $method',
-
);
-
}
-
-
return metadata;
-
}
-
-
/// Validates that a client ID is a valid discoverable client ID.
-
///
-
/// A discoverable client ID must be an HTTPS URL that can be dereferenced
-
/// to get the client metadata document.
-
///
-
/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
-
void _assertOAuthDiscoverableClientId(String clientId) {
-
final uri = Uri.tryParse(clientId);
-
-
if (uri == null) {
-
throw FormatException('Invalid client_id URL: $clientId');
-
}
-
-
if (uri.scheme != 'https') {
-
throw FormatException('Discoverable client_id must use HTTPS: $clientId');
-
}
-
-
if (uri.hasFragment) {
-
throw FormatException(
-
'Discoverable client_id must not contain a fragment: $clientId',
-
);
-
}
-
-
// Validate it's a valid URL
-
if (!uri.hasAuthority) {
-
throw FormatException('Invalid discoverable client_id URL: $clientId');
-
}
-
}
-
-
/// Validates that a client ID is a valid loopback client ID.
-
///
-
/// A loopback client ID is used for development/testing and must be:
-
/// - An HTTP URL (not HTTPS)
-
/// - Using localhost or 127.0.0.1
-
/// - Optionally with a port
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
-
void _assertOAuthLoopbackClientId(String clientId) {
-
final uri = Uri.tryParse(clientId);
-
-
if (uri == null) {
-
throw FormatException('Invalid client_id URL: $clientId');
-
}
-
-
if (uri.scheme != 'http') {
-
throw FormatException(
-
'Loopback client_id must use HTTP (not HTTPS): $clientId',
-
);
-
}
-
-
final host = uri.host.toLowerCase();
-
if (host != 'localhost' &&
-
host != '127.0.0.1' &&
-
host != '[::1]' &&
-
host != '::1') {
-
throw FormatException(
-
'Loopback client_id must use localhost or 127.0.0.1: $clientId',
-
);
-
}
-
-
if (uri.hasFragment) {
-
throw FormatException(
-
'Loopback client_id must not contain a fragment: $clientId',
-
);
-
}
-
}
···
-330
packages/atproto_oauth_flutter/lib/src/platform/README.md
···
-
# Flutter Platform Layer
-
-
This directory contains Flutter-specific implementations of the atproto OAuth client.
-
-
## Overview
-
-
The platform layer provides concrete implementations of all the abstract interfaces needed for OAuth to work on Flutter:
-
-
1. **Storage** (`flutter_stores.dart`) - Secure session storage and caching
-
2. **Cryptography** (`flutter_runtime.dart`) - Key generation, hashing, random values
-
3. **Key Management** (`flutter_key.dart`) - EC key implementation with pointycastle
-
4. **High-level API** (`flutter_oauth_client.dart`) - Easy-to-use Flutter OAuth client
-
-
## Architecture
-
-
```
-
┌─────────────────────────────────────────────────────────────┐
-
│ FlutterOAuthClient │
-
│ (High-level API) │
-
└─────────────────────────────────────────────────────────────┘
-
-
-
┌─────────────────────────────────────────────────────────────┐
-
│ OAuthClient │
-
│ (Core OAuth logic) │
-
└─────────────────────────────────────────────────────────────┘
-
-
┌───────────────────┼───────────────────┐
-
▼ ▼ ▼
-
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
-
│ Storage │ │ Runtime │ │ Key │
-
│ (secure │ │ (crypto) │ │ (signing) │
-
│ storage) │ │ │ │ │
-
└─────────────┘ └─────────────┘ └─────────────┘
-
│ │ │
-
▼ ▼ ▼
-
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
-
│ flutter_ │ │ crypto/ │ │ pointycastle│
-
│ secure_ │ │ Random. │ │ (ECDSA) │
-
│ storage │ │ secure() │ │ │
-
└─────────────┘ └─────────────┘ └─────────────┘
-
```
-
-
## Files
-
-
### `flutter_stores.dart`
-
-
Implements storage and caching:
-
-
- **FlutterSessionStore**: Persists OAuth sessions in secure storage
-
- iOS: Keychain
-
- Android: EncryptedSharedPreferences
-
- Stores tokens, DPoP keys, and auth methods
-
-
- **FlutterStateStore**: Ephemeral OAuth state (in-memory)
-
- PKCE verifiers
-
- State parameters
-
- Application state
-
-
- **Cache Implementations**: In-memory caches with TTL
-
- `InMemoryAuthorizationServerMetadataCache`: OAuth server metadata (1 min TTL)
-
- `InMemoryProtectedResourceMetadataCache`: Resource server metadata (1 min TTL)
-
- `InMemoryDpopNonceCache`: DPoP nonces (10 min TTL)
-
- `FlutterDidCache`: DID documents (1 min TTL)
-
- `FlutterHandleCache`: Handle → DID mappings (1 min TTL)
-
-
### `flutter_runtime.dart`
-
-
Implements cryptographic operations:
-
-
- **FlutterRuntime**: Platform-specific crypto implementation
-
- `createKey`: EC key generation (ES256/ES384/ES512/ES256K)
-
- `digest`: SHA-256/384/512 hashing
-
- `getRandomValues`: Cryptographically secure random bytes
-
- `requestLock`: Local (in-memory) locking for token refresh
-
-
Uses:
-
- `crypto` package for SHA hashing
-
- `Random.secure()` for randomness
-
- `utils/lock.dart` for concurrency control
-
-
### `flutter_key.dart`
-
-
Implements EC key management:
-
-
- **FlutterKey**: Elliptic Curve key for JWT signing
-
- Supports ES256, ES384, ES512, ES256K
-
- Uses `pointycastle` for ECDSA operations
-
- Implements `Key` interface from runtime layer
-
- Serializable (for session storage)
-
-
Features:
-
- Secure key generation with `FortunaRandom`
-
- JWT signing (compact format)
-
- JWK representation (public and private)
-
- Key reconstruction from JSON
-
-
### `flutter_oauth_client.dart`
-
-
High-level Flutter API:
-
-
- **FlutterOAuthClient**: Easy-to-use OAuth client
-
- Pre-configured storage and caching
-
- Automatic FlutterWebAuth2 integration
-
- Simplified sign-in flow
-
- Session management helpers
-
-
Key method:
-
```dart
-
// One-liner sign in!
-
final session = await client.signIn('alice.bsky.social');
-
```
-
-
This handles:
-
1. Authorization URL generation
-
2. Browser launch (FlutterWebAuth2)
-
3. Callback handling
-
4. Token exchange
-
5. Session storage
-
-
## Security Features
-
-
### 1. Secure Storage
-
-
Tokens are **never** stored in plain text:
-
-
- **iOS**: Stored in Keychain with device encryption
-
- **Android**: EncryptedSharedPreferences with AES-256
-
-
### 2. DPoP (Demonstrating Proof of Possession)
-
-
Tokens are cryptographically bound to EC keys:
-
-
- Prevents token theft (stolen tokens are useless without the key)
-
- Keys stored alongside tokens in secure storage
-
- Every API request includes a signed DPoP proof
-
-
### 3. PKCE (Proof Key for Code Exchange)
-
-
Protects authorization codes from interception:
-
-
- Random code verifier generated for each flow
-
- Challenge sent to server (SHA-256 hash of verifier)
-
- Verifier required to exchange code for tokens
-
-
### 4. Concurrency Control
-
-
Prevents race conditions in token refresh:
-
-
- Local lock ensures only one refresh at a time
-
- Reduces chances of using refresh token twice
-
- Handles concurrent requests gracefully
-
-
### 5. Automatic Cleanup
-
-
Sessions are automatically deleted on errors:
-
-
- Token refresh failures
-
- Invalid token errors
-
- Auth method unsatisfiable errors
-
- Revocation (local and remote)
-
-
## Usage
-
-
### Basic Usage
-
-
```dart
-
import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart';
-
-
// Initialize
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
);
-
-
// Sign in
-
final session = await client.signIn('alice.bsky.social');
-
-
// Use session
-
print('Signed in as: ${session.sub}');
-
-
// Restore later
-
final restored = await client.restore(session.sub);
-
-
// Sign out
-
await client.revoke(session.sub);
-
```
-
-
### Custom Configuration
-
-
```dart
-
final client = FlutterOAuthClient(
-
clientMetadata: ClientMetadata(
-
clientId: 'https://example.com/client-metadata.json',
-
redirectUris: ['myapp://oauth/callback'],
-
),
-
-
// Custom secure storage
-
secureStorage: FlutterSecureStorage(
-
aOptions: AndroidOptions(
-
encryptedSharedPreferences: true,
-
),
-
),
-
-
// Development mode
-
allowHttp: true,
-
-
// Custom PLC directory
-
plcDirectoryUrl: 'https://plc.example.com',
-
);
-
```
-
-
## Testing
-
-
The platform layer is designed to be testable:
-
-
1. **Mock Storage**: Provide test implementation of `SessionStore`
-
2. **Mock Runtime**: Provide test implementation of `RuntimeImplementation`
-
3. **Mock Keys**: Use fixed test keys instead of random generation
-
-
Example:
-
-
```dart
-
// Test storage that uses in-memory map
-
class TestSessionStore implements SessionStore {
-
final Map<String, Session> _store = {};
-
-
@override
-
Future<Session?> get(String key, {CancellationToken? signal}) async {
-
return _store[key];
-
}
-
-
@override
-
Future<void> set(String key, Session value) async {
-
_store[key] = value;
-
}
-
-
// ... etc
-
}
-
-
// Use in tests
-
final client = OAuthClient(
-
OAuthClientOptions(
-
// ... other options
-
sessionStore: TestSessionStore(),
-
),
-
);
-
```
-
-
## Platform Setup
-
-
### iOS
-
-
Add URL scheme to `Info.plist`:
-
-
```xml
-
<key>CFBundleURLTypes</key>
-
<array>
-
<dict>
-
<key>CFBundleURLSchemes</key>
-
<array>
-
<string>myapp</string>
-
</array>
-
</dict>
-
</array>
-
```
-
-
### Android
-
-
Add intent filter to `AndroidManifest.xml`:
-
-
```xml
-
<intent-filter>
-
<action android:name="android.intent.action.VIEW" />
-
<category android:name="android.intent.category.DEFAULT" />
-
<category android:name="android.intent.category.BROWSABLE" />
-
<data android:scheme="myapp" />
-
</intent-filter>
-
```
-
-
## Dependencies
-
-
- `flutter_secure_storage: ^9.2.2` - Secure token storage
-
- `flutter_web_auth_2: ^4.1.0` - Browser-based OAuth flow
-
- `pointycastle: ^3.9.1` - Elliptic Curve cryptography
-
- `crypto: ^3.0.3` - SHA hashing
-
-
## Known Limitations
-
-
### 1. Key Serialization
-
-
Currently, DPoP keys are regenerated on each app restart. This works but has drawbacks:
-
-
- Tokens bound to old keys become invalid (require refresh)
-
- Slight performance impact on session restoration
-
-
**Fix**: Implement proper `Key` serialization in `flutter_key.dart`:
-
- Add `toJson()` method that includes private key components
-
- Add `fromJson()` factory that reconstructs the key
-
- Store serialized keys in session storage
-
-
### 2. Local Lock Only
-
-
The lock implementation is in-memory and doesn't work across:
-
- Multiple isolates
-
- Multiple processes
-
- Multiple app instances
-
-
For most Flutter apps, this is fine. For advanced use cases, implement a platform-specific lock.
-
-
### 3. Cache TTLs
-
-
Cache TTLs are fixed (1 minute for most caches). Consider making these configurable if your app has different caching requirements.
-
-
## Future Improvements
-
-
1. **Key Persistence**: Implement proper key serialization (see above)
-
2. **Platform Locks**: Add iOS/Android native lock implementations
-
3. **Configurable TTLs**: Allow cache TTL customization
-
4. **Background Refresh**: Support token refresh in background
-
5. **Biometric Auth**: Optional biometric unlock for sessions
-
6. **Migration Helpers**: Tools for migrating from other OAuth libraries
-
-
## See Also
-
-
- [Example usage](../../example/flutter_oauth_example.dart)
-
- [Main library docs](../../atproto_oauth_flutter.dart)
-
- [Core OAuth client](../client/oauth_client.dart)
···
-435
packages/atproto_oauth_flutter/lib/src/platform/flutter_key.dart
···
-
import 'dart:convert';
-
import 'dart:math';
-
import 'dart:typed_data';
-
-
import 'package:pointycastle/export.dart' as pointycastle;
-
-
import '../runtime/runtime_implementation.dart';
-
-
/// Flutter implementation of Key using pointycastle for cryptographic operations.
-
///
-
/// Supports EC keys with the following algorithms:
-
/// - ES256 (P-256/secp256r1)
-
/// - ES384 (P-384/secp384r1)
-
/// - ES512 (P-521/secp521r1) - Note: P-521, not P-512
-
/// - ES256K (secp256k1)
-
///
-
/// This class handles:
-
/// - Key generation with secure randomness
-
/// - JWT signing (ES256/ES384/ES512/ES256K)
-
/// - JWK representation (public and private components)
-
/// - Serialization/deserialization for session storage
-
class FlutterKey implements Key {
-
/// The EC private key (contains both private and public components)
-
final pointycastle.ECPrivateKey privateKey;
-
-
/// The EC public key
-
final pointycastle.ECPublicKey publicKey;
-
-
/// The algorithm this key supports
-
final String algorithm;
-
-
/// Optional key ID
-
final String? _kid;
-
-
/// Creates a FlutterKey from EC key components.
-
FlutterKey({
-
required this.privateKey,
-
required this.publicKey,
-
required this.algorithm,
-
String? kid,
-
}) : _kid = kid;
-
-
@override
-
List<String> get algorithms => [algorithm];
-
-
@override
-
String? get kid => _kid;
-
-
@override
-
String get usage => 'sign';
-
-
@override
-
Map<String, dynamic>? get bareJwk {
-
// Return public key components only (no private key 'd')
-
final jwk = _ecPublicKeyToJwk(publicKey, algorithm);
-
if (_kid != null) {
-
jwk['kid'] = _kid;
-
}
-
return jwk;
-
}
-
-
/// Full JWK including private key components.
-
///
-
/// WARNING: This contains sensitive key material. Never log or expose.
-
/// Only use for secure storage.
-
Map<String, dynamic> get privateJwk {
-
final jwk = _ecPrivateKeyToJwk(privateKey, publicKey, algorithm);
-
if (_kid != null) {
-
jwk['kid'] = _kid;
-
}
-
return jwk;
-
}
-
-
@override
-
Future<String> createJwt(
-
Map<String, dynamic> header,
-
Map<String, dynamic> payload,
-
) async {
-
// Build JWT header
-
final jwtHeader = <String, dynamic>{
-
'typ': 'JWT',
-
'alg': algorithm,
-
...header,
-
};
-
if (_kid != null) {
-
jwtHeader['kid'] = _kid;
-
}
-
-
// Encode header and payload
-
final headerB64 = _base64UrlEncode(utf8.encode(json.encode(jwtHeader)));
-
final payloadB64 = _base64UrlEncode(utf8.encode(json.encode(payload)));
-
-
// Create signing input
-
final signingInput = '$headerB64.$payloadB64';
-
final signingBytes = utf8.encode(signingInput);
-
-
// Sign with appropriate algorithm
-
final signature = _signEcdsa(signingBytes, privateKey, algorithm);
-
-
// Encode signature
-
final signatureB64 = _base64UrlEncode(signature);
-
-
// Return compact JWT
-
return '$signingInput.$signatureB64';
-
}
-
-
/// Generates a new FlutterKey for the given algorithms.
-
///
-
/// Returns a key supporting the first compatible algorithm from the list.
-
///
-
/// Throws [UnsupportedError] if no compatible algorithm is found.
-
static Future<FlutterKey> generate(List<String> algs) async {
-
// Try algorithms in order
-
for (final alg in algs) {
-
switch (alg) {
-
case 'ES256':
-
return _generateECKey('ES256', 'P-256');
-
case 'ES384':
-
return _generateECKey('ES384', 'P-384');
-
case 'ES512':
-
return _generateECKey('ES512', 'P-521'); // Note: P-521, not P-512
-
case 'ES256K':
-
return _generateECKey('ES256K', 'secp256k1');
-
}
-
}
-
-
throw UnsupportedError(
-
'No supported algorithm found in: ${algs.join(", ")}',
-
);
-
}
-
-
/// Reconstructs a FlutterKey from serialized JWK data.
-
///
-
/// This is used when restoring sessions from storage.
-
factory FlutterKey.fromJwk(Map<String, dynamic> jwk) {
-
final kty = jwk['kty'] as String?;
-
if (kty != 'EC') {
-
throw FormatException('Unsupported key type: $kty');
-
}
-
-
final crv = jwk['crv'] as String?;
-
final alg = jwk['alg'] as String?;
-
final kid = jwk['kid'] as String?;
-
-
if (crv == null || alg == null) {
-
throw FormatException('Missing required JWK fields');
-
}
-
-
// Parse key components
-
final x = _base64UrlDecode(jwk['x'] as String);
-
final y = _base64UrlDecode(jwk['y'] as String);
-
final d = jwk['d'] != null ? _base64UrlDecode(jwk['d'] as String) : null;
-
-
if (d == null) {
-
throw FormatException('Private key component (d) is required');
-
}
-
-
// Get curve
-
final curve = _getCurveForName(crv);
-
-
// Reconstruct public key
-
final publicKey = pointycastle.ECPublicKey(
-
curve.curve.createPoint(_bytesToBigInt(x), _bytesToBigInt(y)),
-
curve,
-
);
-
-
// Reconstruct private key
-
final privateKey = pointycastle.ECPrivateKey(_bytesToBigInt(d), curve);
-
-
return FlutterKey(
-
privateKey: privateKey,
-
publicKey: publicKey,
-
algorithm: alg,
-
kid: kid,
-
);
-
}
-
-
/// Serializes this key to JSON (for session storage).
-
///
-
/// WARNING: Contains private key material. Store securely.
-
Map<String, dynamic> toJson() => privateJwk;
-
-
// ============================================================================
-
// Private helper methods
-
// ============================================================================
-
-
/// Generates an EC key pair for the given algorithm and curve.
-
static Future<FlutterKey> _generateECKey(
-
String algorithm,
-
String curveName,
-
) async {
-
final curve = _getCurveForName(curveName);
-
-
// Create secure random generator
-
final secureRandom = pointycastle.FortunaRandom();
-
final random = Random.secure();
-
final seeds = List<int>.generate(32, (_) => random.nextInt(256));
-
secureRandom.seed(pointycastle.KeyParameter(Uint8List.fromList(seeds)));
-
-
// Generate key pair
-
final keyGen = pointycastle.ECKeyGenerator();
-
keyGen.init(
-
pointycastle.ParametersWithRandom(
-
pointycastle.ECKeyGeneratorParameters(curve),
-
secureRandom,
-
),
-
);
-
-
final keyPair = keyGen.generateKeyPair();
-
final privateKey = keyPair.privateKey as pointycastle.ECPrivateKey;
-
final publicKey = keyPair.publicKey as pointycastle.ECPublicKey;
-
-
return FlutterKey(
-
privateKey: privateKey,
-
publicKey: publicKey,
-
algorithm: algorithm,
-
);
-
}
-
-
/// Gets the EC domain parameters for a given curve name.
-
static pointycastle.ECDomainParameters _getCurveForName(String name) {
-
// Use pointycastle's standard curve implementations
-
switch (name) {
-
case 'P-256':
-
case 'prime256v1':
-
case 'secp256r1':
-
return pointycastle.ECCurve_secp256r1();
-
case 'P-384':
-
case 'secp384r1':
-
return pointycastle.ECCurve_secp384r1();
-
case 'P-521':
-
case 'secp521r1':
-
return pointycastle.ECCurve_secp521r1();
-
case 'secp256k1':
-
return pointycastle.ECCurve_secp256k1();
-
default:
-
throw UnsupportedError('Unsupported curve: $name');
-
}
-
}
-
-
/// Gets the curve name for JWK representation.
-
static String _getCurveName(String algorithm) {
-
switch (algorithm) {
-
case 'ES256':
-
return 'P-256';
-
case 'ES384':
-
return 'P-384';
-
case 'ES512':
-
return 'P-521';
-
case 'ES256K':
-
return 'secp256k1';
-
default:
-
throw UnsupportedError('Unsupported algorithm: $algorithm');
-
}
-
}
-
-
/// Gets the hash algorithm for signing.
-
static String _getHashAlgorithm(String algorithm) {
-
switch (algorithm) {
-
case 'ES256':
-
case 'ES256K':
-
return 'SHA-256';
-
case 'ES384':
-
return 'SHA-384';
-
case 'ES512':
-
return 'SHA-512';
-
default:
-
throw UnsupportedError('Unsupported algorithm: $algorithm');
-
}
-
}
-
-
/// Signs data using ECDSA with deterministic signatures (RFC 6979).
-
///
-
/// This uses deterministic ECDSA which doesn't require a source of randomness,
-
/// making it more secure and avoiding SecureRandom initialization issues.
-
static Uint8List _signEcdsa(
-
List<int> data,
-
pointycastle.ECPrivateKey privateKey,
-
String algorithm,
-
) {
-
// Get the appropriate hash algorithm for this signing algorithm
-
final hashAlg = _getHashAlgorithm(algorithm);
-
-
// Build deterministic ECDSA signer name (e.g., "SHA-256/DET-ECDSA")
-
final signerName = '$hashAlg/DET-ECDSA';
-
-
// Use deterministic ECDSA signer (RFC 6979) - no randomness required!
-
final signer = pointycastle.Signer(signerName);
-
signer.init(
-
true, // signing mode
-
pointycastle.PrivateKeyParameter<pointycastle.ECPrivateKey>(privateKey),
-
);
-
-
// Sign the data (signer will hash it internally)
-
final signature =
-
signer.generateSignature(Uint8List.fromList(data))
-
as pointycastle.ECSignature;
-
-
// Encode as IEEE P1363 format (r || s)
-
final r = _bigIntToBytes(signature.r, _getSignatureLength(algorithm));
-
final s = _bigIntToBytes(signature.s, _getSignatureLength(algorithm));
-
-
return Uint8List.fromList([...r, ...s]);
-
}
-
-
/// Creates a pointycastle Digest for the given hash algorithm.
-
static pointycastle.Digest _createDigest(String algorithm) {
-
switch (algorithm) {
-
case 'SHA-256':
-
return pointycastle.SHA256Digest();
-
case 'SHA-384':
-
return pointycastle.SHA384Digest();
-
case 'SHA-512':
-
return pointycastle.SHA512Digest();
-
default:
-
throw UnsupportedError('Unsupported hash: $algorithm');
-
}
-
}
-
-
/// Gets the signature length in bytes for the algorithm.
-
static int _getSignatureLength(String algorithm) {
-
switch (algorithm) {
-
case 'ES256':
-
case 'ES256K':
-
return 32;
-
case 'ES384':
-
return 48;
-
case 'ES512':
-
return 66; // P-521 uses 66 bytes per component
-
default:
-
throw UnsupportedError('Unsupported algorithm: $algorithm');
-
}
-
}
-
-
/// Converts an EC public key to JWK format.
-
static Map<String, dynamic> _ecPublicKeyToJwk(
-
pointycastle.ECPublicKey publicKey,
-
String algorithm,
-
) {
-
final q = publicKey.Q!;
-
final curve = _getCurveName(algorithm);
-
-
return {
-
'kty': 'EC',
-
'crv': curve,
-
'x': _base64UrlEncode(_bigIntToBytes(q.x!.toBigInteger()!)),
-
'y': _base64UrlEncode(_bigIntToBytes(q.y!.toBigInteger()!)),
-
'alg': algorithm,
-
'use': 'sig',
-
'key_ops': ['sign'],
-
};
-
}
-
-
/// Converts an EC private key to JWK format (includes private component).
-
static Map<String, dynamic> _ecPrivateKeyToJwk(
-
pointycastle.ECPrivateKey privateKey,
-
pointycastle.ECPublicKey publicKey,
-
String algorithm,
-
) {
-
final jwk = _ecPublicKeyToJwk(publicKey, algorithm);
-
jwk['d'] = _base64UrlEncode(_bigIntToBytes(privateKey.d!));
-
return jwk;
-
}
-
-
/// Converts a BigInt to bytes with optional padding.
-
static Uint8List _bigIntToBytes(BigInt number, [int? length]) {
-
var bytes = _encodeBigInt(number);
-
-
if (length != null) {
-
if (bytes.length > length) {
-
// Remove leading zeros
-
bytes = bytes.sublist(bytes.length - length);
-
} else if (bytes.length < length) {
-
// Add leading zeros
-
final padded = Uint8List(length);
-
padded.setRange(length - bytes.length, length, bytes);
-
bytes = padded;
-
}
-
}
-
-
return bytes;
-
}
-
-
/// Encodes a BigInt as bytes (unsigned, big-endian).
-
static Uint8List _encodeBigInt(BigInt number) {
-
// Handle zero
-
if (number == BigInt.zero) {
-
return Uint8List.fromList([0]);
-
}
-
-
// Handle negative (should not happen for EC keys)
-
if (number.isNegative) {
-
throw ArgumentError('Cannot encode negative BigInt');
-
}
-
-
// Convert to bytes
-
final bytes = <int>[];
-
var n = number;
-
while (n > BigInt.zero) {
-
bytes.insert(0, (n & BigInt.from(0xff)).toInt());
-
n = n >> 8;
-
}
-
-
return Uint8List.fromList(bytes);
-
}
-
-
/// Converts bytes to BigInt (unsigned, big-endian).
-
static BigInt _bytesToBigInt(List<int> bytes) {
-
var result = BigInt.zero;
-
for (var byte in bytes) {
-
result = (result << 8) | BigInt.from(byte);
-
}
-
return result;
-
}
-
-
/// Base64url encodes bytes (no padding).
-
static String _base64UrlEncode(List<int> bytes) {
-
return base64Url.encode(bytes).replaceAll('=', '');
-
}
-
-
/// Base64url decodes a string.
-
static Uint8List _base64UrlDecode(String str) {
-
// Add padding if needed
-
var s = str;
-
switch (s.length % 4) {
-
case 2:
-
s += '==';
-
break;
-
case 3:
-
s += '=';
-
break;
-
}
-
return base64Url.decode(s);
-
}
-
}
···
-302
packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_client.dart
···
-
import 'package:dio/dio.dart';
-
import 'package:flutter/foundation.dart';
-
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
-
-
import '../client/oauth_client.dart';
-
import '../session/oauth_session.dart';
-
import 'flutter_runtime.dart';
-
import 'flutter_stores.dart';
-
-
/// Flutter-specific OAuth client with sensible defaults.
-
///
-
/// This is a high-level wrapper around [OAuthClient] that provides:
-
/// - Automatic storage configuration (flutter_secure_storage)
-
/// - Platform-specific crypto (pointycastle + crypto package)
-
/// - In-memory caching with TTL
-
/// - Convenient sign-in flow (authorize + FlutterWebAuth2 + callback)
-
/// - Session management (restore, revoke)
-
///
-
/// Example usage:
-
/// ```dart
-
/// // Initialize client
-
/// final client = FlutterOAuthClient(
-
/// clientMetadata: ClientMetadata(
-
/// clientId: 'https://example.com/client-metadata.json',
-
/// redirectUris: ['myapp://oauth/callback'],
-
/// scope: 'atproto transition:generic',
-
/// ),
-
/// );
-
///
-
/// // Sign in with handle
-
/// try {
-
/// final session = await client.signIn('alice.bsky.social');
-
/// print('Signed in as: ${session.sub}');
-
///
-
/// // Use the session for authenticated requests
-
/// final agent = session.pdsClient;
-
/// // ... make API calls
-
/// } catch (e) {
-
/// print('Sign in failed: $e');
-
/// }
-
///
-
/// // Later: restore session
-
/// try {
-
/// final session = await client.restore('did:plc:abc123');
-
/// print('Session restored');
-
/// } catch (e) {
-
/// print('Session restoration failed: $e');
-
/// }
-
///
-
/// // Sign out
-
/// await client.revoke('did:plc:abc123');
-
/// ```
-
class FlutterOAuthClient extends OAuthClient {
-
/// Creates a FlutterOAuthClient with Flutter-specific defaults.
-
///
-
/// Parameters:
-
/// - [clientMetadata]: Client configuration (required)
-
/// - [responseMode]: OAuth response mode (default: query)
-
/// - [allowHttp]: Allow HTTP for testing (default: false)
-
/// - [secureStorage]: Custom secure storage instance (optional)
-
/// - [dio]: Custom HTTP client (optional)
-
/// - [plcDirectoryUrl]: Custom PLC directory URL (optional)
-
/// - [handleResolverUrl]: Custom handle resolver URL (optional)
-
///
-
/// Throws [FormatException] if client metadata is invalid.
-
FlutterOAuthClient({
-
required ClientMetadata clientMetadata,
-
OAuthResponseMode responseMode = OAuthResponseMode.query,
-
bool allowHttp = false,
-
FlutterSecureStorage? secureStorage,
-
Dio? dio,
-
String? plcDirectoryUrl,
-
String? handleResolverUrl,
-
}) : super(
-
OAuthClientOptions(
-
// Config
-
responseMode: responseMode,
-
clientMetadata: clientMetadata.toJson(),
-
keyset: null, // Mobile apps are public clients
-
allowHttp: allowHttp,
-
-
// Storage (Flutter-specific)
-
stateStore: FlutterStateStore(),
-
sessionStore: FlutterSessionStore(secureStorage),
-
-
// Caches (in-memory with TTL)
-
authorizationServerMetadataCache:
-
InMemoryAuthorizationServerMetadataCache(),
-
protectedResourceMetadataCache:
-
InMemoryProtectedResourceMetadataCache(),
-
dpopNonceCache: InMemoryDpopNonceCache(),
-
didCache: FlutterDidCache(),
-
handleCache: FlutterHandleCache(),
-
-
// Platform implementation
-
runtimeImplementation: const FlutterRuntime(),
-
-
// HTTP client
-
dio: dio,
-
-
// Optional overrides
-
plcDirectoryUrl: plcDirectoryUrl,
-
handleResolverUrl: handleResolverUrl,
-
),
-
);
-
-
/// Sign in with an atProto handle, DID, or URL.
-
///
-
/// This is a convenience method that:
-
/// 1. Initiates authorization flow ([authorize])
-
/// 2. Opens browser with FlutterWebAuth2
-
/// 3. Handles OAuth callback
-
/// 4. Returns authenticated session
-
///
-
/// The [input] can be:
-
/// - An atProto handle: "alice.bsky.social"
-
/// - A DID: "did:plc:..."
-
/// - A PDS URL: "https://pds.example.com"
-
/// - An authorization server URL: "https://auth.example.com"
-
///
-
/// The [options] can specify:
-
/// - redirectUri: Override default redirect URI
-
/// - state: Application state to preserve
-
/// - scope: Override default scope
-
/// - Other OIDC parameters (prompt, display, etc.)
-
///
-
/// Returns an [OAuthSession] with authenticated access.
-
///
-
/// Throws:
-
/// - [FormatException] if parameters are invalid
-
/// - [OAuthResolverError] if identity resolution fails
-
/// - [OAuthCallbackError] if authentication fails
-
/// - [Exception] if user cancels (flutter_web_auth_2 throws PlatformException)
-
///
-
/// 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'),
-
/// );
-
/// ```
-
Future<OAuthSession> signIn(
-
String input, {
-
AuthorizeOptions? options,
-
CancelToken? cancelToken,
-
}) async {
-
// CRITICAL: Use HTTPS redirect URI for OAuth (prevents browser retry)
-
// but listen for CUSTOM SCHEME in FlutterWebAuth2 (only custom schemes can be intercepted)
-
// The HTTPS page will redirect to custom scheme, triggering the callback
-
final redirectUri =
-
options?.redirectUri ?? clientMetadata.redirectUris.first;
-
-
if (!clientMetadata.redirectUris.contains(redirectUri)) {
-
throw FormatException('Invalid redirect_uri: $redirectUri');
-
}
-
-
// Find the custom scheme redirect URI from the list
-
// FlutterWebAuth2 can ONLY intercept custom schemes, not HTTPS
-
final customSchemeUri = clientMetadata.redirectUris.firstWhere(
-
(uri) => !uri.startsWith('http://') && !uri.startsWith('https://'),
-
orElse:
-
() => redirectUri, // Fallback to primary if no custom scheme found
-
);
-
-
final callbackUrlScheme = _extractScheme(customSchemeUri);
-
-
// Step 1: Start OAuth authorization flow
-
final authUrl = await authorize(
-
input,
-
options:
-
options != null
-
? AuthorizeOptions(
-
redirectUri: redirectUri,
-
state: options.state,
-
scope: options.scope,
-
nonce: options.nonce,
-
dpopJkt: options.dpopJkt,
-
maxAge: options.maxAge,
-
claims: options.claims,
-
uiLocales: options.uiLocales,
-
idTokenHint: options.idTokenHint,
-
display: options.display ?? 'touch', // Mobile-friendly default
-
prompt: options.prompt,
-
authorizationDetails: options.authorizationDetails,
-
)
-
: AuthorizeOptions(
-
redirectUri: redirectUri,
-
display: 'touch', // Mobile-friendly default
-
),
-
cancelToken: cancelToken,
-
);
-
-
// Step 2: Open browser for user authentication
-
if (kDebugMode) {
-
print('🔐 Opening browser for OAuth...');
-
print(' Auth URL: $authUrl');
-
print(' OAuth redirect URI (PDS will redirect here): $redirectUri');
-
print(
-
' FlutterWebAuth2 callback scheme (listening for): $callbackUrlScheme',
-
);
-
}
-
-
String? callbackUrl;
-
try {
-
if (kDebugMode) {
-
print('📱 Calling FlutterWebAuth2.authenticate()...');
-
}
-
-
callbackUrl = await FlutterWebAuth2.authenticate(
-
url: authUrl.toString(),
-
callbackUrlScheme: callbackUrlScheme,
-
options: const FlutterWebAuth2Options(
-
// Use ephemeral session to force browser to close immediately
-
// This prevents browser retry that can invalidate the authorization code
-
preferEphemeral: true,
-
timeout: 300, // 5 minutes timeout
-
),
-
);
-
-
if (kDebugMode) {
-
print('✅ FlutterWebAuth2 returned successfully!');
-
print(' Callback URL: $callbackUrl');
-
print(
-
' ⏱️ Callback received at: ${DateTime.now().toIso8601String()}',
-
);
-
}
-
} catch (e, stackTrace) {
-
if (kDebugMode) {
-
print('❌ FlutterWebAuth2.authenticate() threw an error:');
-
print(' Error type: ${e.runtimeType}');
-
print(' Error message: $e');
-
print(' Stack trace: $stackTrace');
-
}
-
rethrow;
-
}
-
-
// Step 3: Parse callback URL parameters
-
final uri = Uri.parse(callbackUrl);
-
final params =
-
responseMode == OAuthResponseMode.fragment
-
? _parseFragment(uri.fragment)
-
: Map<String, String>.from(uri.queryParameters);
-
-
if (kDebugMode) {
-
print('🔄 Parsing callback parameters...');
-
print(' Response mode: $responseMode');
-
print(' Callback params: $params');
-
}
-
-
// Step 4: Complete OAuth flow
-
if (kDebugMode) {
-
print('📞 Calling callback() to exchange code for tokens...');
-
print(' Redirect URI: $redirectUri');
-
}
-
-
final result = await callback(
-
params,
-
options: CallbackOptions(redirectUri: redirectUri),
-
cancelToken: cancelToken,
-
);
-
-
if (kDebugMode) {
-
print('✅ Token exchange successful!');
-
print(' Session DID: ${result.session.sub}');
-
}
-
-
return result.session;
-
}
-
-
/// Extracts the URL scheme from a redirect URI.
-
///
-
/// Examples:
-
/// - "myapp://oauth/callback" → "myapp"
-
/// - "https://example.com/callback" → "https"
-
String _extractScheme(String redirectUri) {
-
final uri = Uri.parse(redirectUri);
-
return uri.scheme;
-
}
-
-
/// Parses URL fragment into a parameter map.
-
///
-
/// The fragment may start with '#' which we strip.
-
Map<String, String> _parseFragment(String fragment) {
-
// Remove leading '#' if present
-
final clean = fragment.startsWith('#') ? fragment.substring(1) : fragment;
-
if (clean.isEmpty) return {};
-
-
final params = <String, String>{};
-
for (final pair in clean.split('&')) {
-
final parts = pair.split('=');
-
if (parts.length == 2) {
-
params[Uri.decodeComponent(parts[0])] = Uri.decodeComponent(parts[1]);
-
}
-
}
-
return params;
-
}
-
}
···
-141
packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_router_helper.dart
···
-
/// Helper for configuring Flutter routers to work with OAuth callbacks.
-
///
-
/// When using declarative routing packages (go_router, auto_route, etc.),
-
/// OAuth callback deep links may be intercepted before flutter_web_auth_2
-
/// can handle them. This helper provides utilities to configure your router
-
/// to ignore OAuth callback URIs.
-
///
-
/// ## go_router Example
-
///
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
/// customSchemes: ['com.example.myapp'],
-
/// ),
-
/// );
-
/// ```
-
///
-
/// ## Manual Configuration
-
///
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: (context, state) {
-
/// if (FlutterOAuthRouterHelper.isOAuthCallback(
-
/// state.uri,
-
/// customSchemes: ['com.example.myapp'],
-
/// )) {
-
/// return null; // Let flutter_web_auth_2 handle it
-
/// }
-
/// return null; // Normal routing
-
/// },
-
/// );
-
/// ```
-
library;
-
-
import 'dart:async';
-
import 'package:flutter/foundation.dart';
-
import 'package:flutter/widgets.dart';
-
-
/// Helper class for configuring routers to work with OAuth callbacks.
-
class FlutterOAuthRouterHelper {
-
/// Checks if a URI is an OAuth callback that should be ignored by the router.
-
///
-
/// Returns `true` if the URI uses a custom scheme from [customSchemes],
-
/// indicating it's an OAuth callback deep link that flutter_web_auth_2
-
/// should handle.
-
///
-
/// Example:
-
/// ```dart
-
/// if (FlutterOAuthRouterHelper.isOAuthCallback(
-
/// uri,
-
/// customSchemes: ['com.example.myapp'],
-
/// )) {
-
/// // This is an OAuth callback - don't route it
-
/// return null;
-
/// }
-
/// ```
-
static bool isOAuthCallback(Uri uri, {required List<String> customSchemes}) {
-
return customSchemes.contains(uri.scheme);
-
}
-
-
/// Creates a redirect function for go_router that ignores OAuth callbacks.
-
///
-
/// This is a convenience method that returns a redirect function you can
-
/// pass directly to GoRouter's `redirect` parameter.
-
///
-
/// Parameters:
-
/// - [customSchemes]: List of custom URL schemes used for OAuth callbacks
-
/// (e.g., `['com.example.myapp']`)
-
/// - [fallbackRedirect]: Optional custom redirect logic for non-OAuth URIs
-
///
-
/// Example:
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
/// customSchemes: ['com.example.myapp'],
-
/// ),
-
/// );
-
/// ```
-
///
-
/// With custom redirect logic:
-
/// ```dart
-
/// final router = GoRouter(
-
/// routes: [...],
-
/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect(
-
/// customSchemes: ['com.example.myapp'],
-
/// fallbackRedirect: (context, state) {
-
/// // Your custom auth redirect logic
-
/// if (!isAuthenticated) return '/login';
-
/// return null;
-
/// },
-
/// ),
-
/// );
-
/// ```
-
static FutureOr<String?> Function(BuildContext, dynamic)
-
createGoRouterRedirect({
-
required List<String> customSchemes,
-
FutureOr<String?> Function(BuildContext, dynamic)? fallbackRedirect,
-
}) {
-
return (BuildContext context, dynamic state) {
-
// Extract URI from the state object (works with any router's state object that has a 'uri' property)
-
final uri = (state as dynamic).uri as Uri;
-
-
// Check if this is an OAuth callback
-
if (isOAuthCallback(uri, customSchemes: customSchemes)) {
-
// Let flutter_web_auth_2 handle OAuth callbacks
-
if (kDebugMode) {
-
print('🔀 RouterHelper: Detected OAuth callback - allowing through');
-
print(' URI: $uri');
-
}
-
return null;
-
}
-
-
// Apply custom redirect logic if provided
-
if (fallbackRedirect != null) {
-
return fallbackRedirect(context, state);
-
}
-
-
// No redirect needed
-
return null;
-
};
-
}
-
-
/// Extracts the scheme from a redirect URI.
-
///
-
/// This is useful for getting the custom scheme from your OAuth configuration.
-
///
-
/// Example:
-
/// ```dart
-
/// final scheme = FlutterOAuthRouterHelper.extractScheme(
-
/// 'com.example.myapp:/oauth/callback'
-
/// );
-
/// // Returns: 'com.example.myapp'
-
/// ```
-
static String extractScheme(String redirectUri) {
-
final uri = Uri.parse(redirectUri);
-
return uri.scheme;
-
}
-
}
···
-91
packages/atproto_oauth_flutter/lib/src/platform/flutter_runtime.dart
···
-
import 'dart:math';
-
import 'dart:typed_data';
-
-
import 'package:crypto/crypto.dart' as crypto;
-
-
import '../runtime/runtime_implementation.dart';
-
import '../utils/lock.dart';
-
import 'flutter_key.dart';
-
-
/// Flutter implementation of RuntimeImplementation.
-
///
-
/// Provides cryptographic operations for OAuth flows using:
-
/// - pointycastle for EC key generation (via FlutterKey)
-
/// - crypto package for SHA hashing
-
/// - Random.secure() for cryptographically secure random values
-
/// - requestLocalLock for concurrency control
-
///
-
/// This implementation supports:
-
/// - ES256, ES384, ES512, ES256K (Elliptic Curve algorithms)
-
/// - SHA-256, SHA-384, SHA-512 (Hash algorithms)
-
/// - Secure random number generation
-
/// - Local (in-memory) locking for token refresh
-
///
-
/// Example:
-
/// ```dart
-
/// final runtime = FlutterRuntime();
-
///
-
/// // Generate a key
-
/// final key = await runtime.createKey(['ES256', 'ES384']);
-
///
-
/// // Hash some data
-
/// final hash = await runtime.digest(
-
/// Uint8List.fromList([1, 2, 3]),
-
/// DigestAlgorithm.sha256(),
-
/// );
-
///
-
/// // Generate random bytes
-
/// final random = await runtime.getRandomValues(32);
-
/// ```
-
class FlutterRuntime implements RuntimeImplementation {
-
/// Creates a FlutterRuntime instance.
-
const FlutterRuntime();
-
-
@override
-
RuntimeKeyFactory get createKey {
-
return (List<String> algs) async {
-
return FlutterKey.generate(algs);
-
};
-
}
-
-
@override
-
RuntimeDigest get digest {
-
return (Uint8List bytes, DigestAlgorithm algorithm) async {
-
switch (algorithm.name) {
-
case 'sha256':
-
case 'SHA-256':
-
return Uint8List.fromList(crypto.sha256.convert(bytes).bytes);
-
-
case 'sha384':
-
case 'SHA-384':
-
return Uint8List.fromList(crypto.sha384.convert(bytes).bytes);
-
-
case 'sha512':
-
case 'SHA-512':
-
return Uint8List.fromList(crypto.sha512.convert(bytes).bytes);
-
-
default:
-
throw UnsupportedError(
-
'Unsupported digest algorithm: ${algorithm.name}',
-
);
-
}
-
};
-
}
-
-
@override
-
RuntimeRandomValues get getRandomValues {
-
return (int length) async {
-
final random = Random.secure();
-
return Uint8List.fromList(
-
List.generate(length, (_) => random.nextInt(256)),
-
);
-
};
-
}
-
-
@override
-
RuntimeLock get requestLock {
-
// Use the local lock implementation from utils/lock.dart
-
// This prevents concurrent token refresh within a single isolate
-
return requestLocalLock;
-
}
-
}
···
-341
packages/atproto_oauth_flutter/lib/src/platform/flutter_stores.dart
···
-
import 'dart:convert';
-
-
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
-
-
import '../identity/did_document.dart';
-
import '../identity/did_resolver.dart';
-
import '../identity/handle_resolver.dart';
-
import '../oauth/authorization_server_metadata_resolver.dart';
-
import '../oauth/oauth_server_agent.dart';
-
import '../oauth/protected_resource_metadata_resolver.dart';
-
import '../session/oauth_session.dart';
-
import '../session/session_getter.dart';
-
import '../session/state_store.dart';
-
import '../util.dart';
-
-
// ============================================================================
-
// Session and State Storage (uses flutter_secure_storage)
-
// ============================================================================
-
-
/// Flutter implementation of SessionStore using flutter_secure_storage.
-
///
-
/// This stores OAuth sessions (tokens and keys) in the device's secure storage:
-
/// - iOS: Keychain
-
/// - Android: EncryptedSharedPreferences
-
///
-
/// Sessions are persisted across app restarts and are encrypted at rest.
-
///
-
/// Example:
-
/// ```dart
-
/// final store = FlutterSessionStore();
-
/// await store.set('did:plc:abc123', session);
-
/// final restored = await store.get('did:plc:abc123');
-
/// ```
-
class FlutterSessionStore implements SessionStore {
-
final FlutterSecureStorage _storage;
-
static const _prefix = 'atproto_session_';
-
-
FlutterSessionStore([FlutterSecureStorage? storage])
-
: _storage =
-
storage ??
-
const FlutterSecureStorage(
-
aOptions: AndroidOptions(encryptedSharedPreferences: true),
-
);
-
-
@override
-
Future<Session?> get(String key, {CancellationToken? signal}) async {
-
try {
-
final json = await _storage.read(key: _prefix + key);
-
if (json == null) return null;
-
-
final data = jsonDecode(json) as Map<String, dynamic>;
-
return Session.fromJson(data);
-
} catch (e) {
-
return null;
-
}
-
}
-
-
@override
-
Future<void> set(String key, Session value) async {
-
final json = jsonEncode(value.toJson());
-
await _storage.write(key: _prefix + key, value: json);
-
}
-
-
@override
-
Future<void> del(String key) async {
-
await _storage.delete(key: _prefix + key);
-
}
-
-
@override
-
Future<void> clear() async {
-
// Delete all session keys
-
final all = await _storage.readAll();
-
for (final key in all.keys) {
-
if (key.startsWith(_prefix)) {
-
await _storage.delete(key: key);
-
}
-
}
-
}
-
}
-
-
/// Flutter implementation of StateStore for ephemeral OAuth state.
-
///
-
/// This stores temporary state data during the OAuth authorization flow.
-
/// State data includes PKCE verifiers, nonces, and application state.
-
///
-
/// Uses in-memory storage since state is short-lived (only needed during the
-
/// authorization flow, which typically completes within minutes).
-
///
-
/// Example:
-
/// ```dart
-
/// final store = FlutterStateStore();
-
/// await store.set('state123', InternalStateData(...));
-
/// final state = await store.get('state123');
-
/// await store.del('state123'); // Clean up after use
-
/// ```
-
class FlutterStateStore implements StateStore {
-
final Map<String, InternalStateData> _store = {};
-
-
@override
-
Future<InternalStateData?> get(String key) async {
-
return _store[key];
-
}
-
-
@override
-
Future<void> set(String key, InternalStateData data) async {
-
_store[key] = data;
-
}
-
-
@override
-
Future<void> del(String key) async {
-
_store.remove(key);
-
}
-
-
@override
-
Future<void> clear() async {
-
_store.clear();
-
}
-
}
-
-
// ============================================================================
-
// In-Memory Caches with TTL
-
// ============================================================================
-
-
/// Base class for in-memory caches with time-to-live (TTL).
-
///
-
/// This provides a generic caching mechanism with automatic expiration.
-
/// Cached items are stored with a timestamp and are considered stale
-
/// after the TTL period.
-
class _InMemoryCache<V> {
-
final Map<String, _CacheEntry<V>> _cache = {};
-
final Duration _ttl;
-
-
_InMemoryCache(this._ttl);
-
-
Future<V?> get(String key) async {
-
final entry = _cache[key];
-
if (entry == null) return null;
-
-
// Check if expired
-
if (DateTime.now().isAfter(entry.expiresAt)) {
-
_cache.remove(key);
-
return null;
-
}
-
-
return entry.value;
-
}
-
-
Future<void> set(String key, V value) async {
-
_cache[key] = _CacheEntry(
-
value: value,
-
expiresAt: DateTime.now().add(_ttl),
-
);
-
}
-
-
Future<void> del(String key) async {
-
_cache.remove(key);
-
}
-
-
Future<void> clear() async {
-
_cache.clear();
-
}
-
-
/// Removes expired entries from the cache.
-
void purge() {
-
final now = DateTime.now();
-
_cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt));
-
}
-
}
-
-
/// Cache entry with expiration time.
-
class _CacheEntry<V> {
-
final V value;
-
final DateTime expiresAt;
-
-
_CacheEntry({required this.value, required this.expiresAt});
-
}
-
-
/// In-memory cache for OAuth Authorization Server metadata.
-
///
-
/// Caches metadata fetched from /.well-known/oauth-authorization-server
-
/// to avoid redundant network requests.
-
///
-
/// Default TTL: 1 minute (metadata rarely changes)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = InMemoryAuthorizationServerMetadataCache();
-
/// await cache.set('https://auth.example.com', metadata);
-
/// final cached = await cache.get('https://auth.example.com');
-
/// ```
-
class InMemoryAuthorizationServerMetadataCache
-
implements AuthorizationServerMetadataCache {
-
final _InMemoryCache<Map<String, dynamic>> _cache;
-
-
InMemoryAuthorizationServerMetadataCache({
-
Duration ttl = const Duration(minutes: 1),
-
}) : _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<Map<String, dynamic>?> get(String key, {CancellationToken? signal}) =>
-
_cache.get(key);
-
-
@override
-
Future<void> set(String key, Map<String, dynamic> value) =>
-
_cache.set(key, value);
-
-
@override
-
Future<void> del(String key) => _cache.del(key);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for OAuth Protected Resource metadata.
-
///
-
/// Caches metadata fetched from /.well-known/oauth-protected-resource
-
/// to avoid redundant network requests.
-
///
-
/// Default TTL: 1 minute (metadata rarely changes)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = InMemoryProtectedResourceMetadataCache();
-
/// await cache.set('https://pds.example.com', metadata);
-
/// ```
-
class InMemoryProtectedResourceMetadataCache
-
implements ProtectedResourceMetadataCache {
-
final _InMemoryCache<Map<String, dynamic>> _cache;
-
-
InMemoryProtectedResourceMetadataCache({
-
Duration ttl = const Duration(minutes: 1),
-
}) : _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<Map<String, dynamic>?> get(String key, {CancellationToken? signal}) =>
-
_cache.get(key);
-
-
@override
-
Future<void> set(String key, Map<String, dynamic> value) =>
-
_cache.set(key, value);
-
-
@override
-
Future<void> del(String key) => _cache.del(key);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for DPoP nonces.
-
///
-
/// DPoP nonces are server-provided values used for replay protection.
-
/// They're cached per authorization/resource server origin.
-
///
-
/// Default TTL: 10 minutes (nonces typically have short lifetimes)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = InMemoryDpopNonceCache();
-
/// await cache.set('https://auth.example.com', 'nonce123');
-
/// final nonce = await cache.get('https://auth.example.com');
-
/// ```
-
class InMemoryDpopNonceCache implements DpopNonceCache {
-
final _InMemoryCache<String> _cache;
-
-
InMemoryDpopNonceCache({Duration ttl = const Duration(minutes: 10)})
-
: _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<String?> get(String key, {CancellationToken? signal}) =>
-
_cache.get(key);
-
-
@override
-
Future<void> set(String key, String value) => _cache.set(key, value);
-
-
@override
-
Future<void> del(String key) => _cache.del(key);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for DID documents.
-
///
-
/// Caches resolved DID documents (from DidDocument class) to avoid redundant
-
/// resolution requests.
-
///
-
/// Default TTL: 1 minute (DID documents can change but not frequently)
-
///
-
/// Note: DidDocument is a complex class, but it has toJson/fromJson methods.
-
/// We store the JSON representation and reconstruct on retrieval.
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = FlutterDidCache();
-
/// await cache.set('did:plc:abc123', didDocument);
-
/// final doc = await cache.get('did:plc:abc123');
-
/// ```
-
class FlutterDidCache implements DidCache {
-
final _InMemoryCache<DidDocument> _cache;
-
-
FlutterDidCache({Duration ttl = const Duration(minutes: 1)})
-
: _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<DidDocument?> get(String key) => _cache.get(key);
-
-
@override
-
Future<void> set(String key, DidDocument value) => _cache.set(key, value);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
-
-
/// In-memory cache for handle → DID resolutions.
-
///
-
/// Caches the resolution of atProto handles (e.g., "alice.bsky.social") to DIDs.
-
/// The cache stores simple string mappings (handle → DID).
-
///
-
/// Default TTL: 1 minute (handles can be reassigned but not frequently)
-
///
-
/// Example:
-
/// ```dart
-
/// final cache = FlutterHandleCache();
-
/// await cache.set('alice.bsky.social', 'did:plc:abc123');
-
/// final did = await cache.get('alice.bsky.social');
-
/// ```
-
class FlutterHandleCache implements HandleCache {
-
final _InMemoryCache<String> _cache;
-
-
FlutterHandleCache({Duration ttl = const Duration(minutes: 1)})
-
: _cache = _InMemoryCache(ttl);
-
-
@override
-
Future<String?> get(String key) => _cache.get(key);
-
-
@override
-
Future<void> set(String key, String value) => _cache.set(key, value);
-
-
@override
-
Future<void> clear() => _cache.clear();
-
}
···
-280
packages/atproto_oauth_flutter/lib/src/runtime/runtime.dart
···
-
import 'dart:convert';
-
import 'dart:typed_data';
-
-
import '../utils/lock.dart';
-
import 'runtime_implementation.dart';
-
-
/// Main runtime class that wraps a RuntimeImplementation and provides
-
/// high-level cryptographic operations for OAuth.
-
///
-
/// This class handles:
-
/// - Key generation with algorithm preference sorting
-
/// - SHA-256 hashing with base64url encoding
-
/// - Nonce generation
-
/// - PKCE (Proof Key for Code Exchange) generation
-
/// - JWK thumbprint calculation
-
///
-
/// All operations use the underlying RuntimeImplementation for
-
/// platform-specific cryptographic primitives.
-
class Runtime {
-
final RuntimeImplementation _implementation;
-
-
/// Whether the implementation provides a custom lock mechanism.
-
final bool hasImplementationLock;
-
-
/// The lock function to use (either custom or local fallback).
-
final RuntimeLock usingLock;
-
-
Runtime(this._implementation)
-
: hasImplementationLock = _implementation.requestLock != null,
-
usingLock = _implementation.requestLock ?? requestLocalLock;
-
-
/// Generates a cryptographic key that supports the given algorithms.
-
///
-
/// The algorithms are sorted by preference before being passed to the
-
/// key factory. This ensures consistent key selection across platforms.
-
///
-
/// Algorithm preference order (most to least preferred):
-
/// 1. ES256K (secp256k1)
-
/// 2. ES256, ES384, ES512 (elliptic curve, shorter keys first)
-
/// 3. PS256, PS384, PS512 (RSA-PSS, shorter keys first)
-
/// 4. RS256, RS384, RS512 (RSA-PKCS1, shorter keys first)
-
/// 5. Other algorithms (maintain original order)
-
///
-
/// Example:
-
/// ```dart
-
/// final key = await runtime.generateKey(['ES256', 'RS256', 'ES384']);
-
/// // Returns key supporting ES256 (preferred over RS256 and ES384)
-
/// ```
-
Future<Key> generateKey(List<String> algs) async {
-
final algsSorted = List<String>.from(algs)..sort(_compareAlgos);
-
return _implementation.createKey(algsSorted);
-
}
-
-
/// Computes the SHA-256 hash of the input text and returns it as base64url.
-
///
-
/// This is used extensively in OAuth for:
-
/// - PKCE code challenge (S256 method)
-
/// - JWK thumbprint calculation
-
/// - DPoP access token hash (ath claim)
-
///
-
/// Example:
-
/// ```dart
-
/// final hash = await runtime.sha256('hello world');
-
/// // Returns base64url-encoded SHA-256 hash
-
/// ```
-
Future<String> sha256(String text) async {
-
final bytes = utf8.encode(text);
-
final digest = await _implementation.digest(
-
Uint8List.fromList(bytes),
-
const DigestAlgorithm.sha256(),
-
);
-
return _base64UrlEncode(digest);
-
}
-
-
/// Generates a cryptographically secure random nonce.
-
///
-
/// The nonce is base64url-encoded and has the specified byte length
-
/// (default 16 bytes = 128 bits of entropy).
-
///
-
/// Used for:
-
/// - OAuth state parameter
-
/// - OIDC nonce parameter
-
/// - DPoP jti (JWT ID) claim
-
///
-
/// Example:
-
/// ```dart
-
/// final nonce = await runtime.generateNonce(); // 16 bytes
-
/// final longNonce = await runtime.generateNonce(32); // 32 bytes
-
/// ```
-
Future<String> generateNonce([int length = 16]) async {
-
final bytes = await _implementation.getRandomValues(length);
-
return _base64UrlEncode(bytes);
-
}
-
-
/// Generates PKCE (Proof Key for Code Exchange) parameters.
-
///
-
/// PKCE is a security extension for OAuth that prevents authorization code
-
/// interception attacks. It's required for public clients (mobile/desktop apps).
-
///
-
/// Returns a map with:
-
/// - `verifier`: Random code verifier (base64url-encoded)
-
/// - `challenge`: SHA-256 hash of verifier (base64url-encoded)
-
/// - `method`: 'S256' (indicating SHA-256 hashing method)
-
///
-
/// The verifier should be stored securely and sent during token exchange.
-
/// The challenge is sent during authorization.
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc7636
-
///
-
/// Example:
-
/// ```dart
-
/// final pkce = await runtime.generatePKCE();
-
/// // Use pkce['challenge'] in authorization request
-
/// // Store pkce['verifier'] for token exchange
-
/// ```
-
Future<Map<String, String>> generatePKCE([int? byteLength]) async {
-
final verifier = await _generateVerifier(byteLength);
-
final challenge = await sha256(verifier);
-
return {'verifier': verifier, 'challenge': challenge, 'method': 'S256'};
-
}
-
-
/// Calculates the JWK thumbprint (jkt) for a given JSON Web Key.
-
///
-
/// The thumbprint is a hash of the key's essential components, used to
-
/// uniquely identify a key. For DPoP, this binds tokens to specific keys.
-
///
-
/// The calculation follows RFC 7638:
-
/// 1. Extract required components based on key type (kty)
-
/// 2. Create canonical JSON representation
-
/// 3. Compute SHA-256 hash
-
/// 4. Base64url-encode the result
-
///
-
/// Required components by key type:
-
/// - EC: crv, kty, x, y
-
/// - OKP: crv, kty, x
-
/// - RSA: e, kty, n
-
/// - oct: k, kty
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc7638
-
///
-
/// Example:
-
/// ```dart
-
/// final thumbprint = await runtime.calculateJwkThumbprint(jwk);
-
/// // Returns base64url-encoded SHA-256 hash of key components
-
/// ```
-
Future<String> calculateJwkThumbprint(Map<String, dynamic> jwk) async {
-
final components = _extractJktComponents(jwk);
-
final data = jsonEncode(components);
-
return sha256(data);
-
}
-
-
/// Generates a PKCE code verifier.
-
///
-
/// The verifier is a cryptographically random string that:
-
/// - Has length between 43-128 characters (32-96 bytes before encoding)
-
/// - Is base64url-encoded
-
/// - SHOULD be 32 bytes (43 chars) per RFC 7636 recommendations
-
///
-
/// See: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
-
Future<String> _generateVerifier([int? byteLength]) async {
-
final length = byteLength ?? 32;
-
-
if (length < 32 || length > 96) {
-
throw ArgumentError(
-
'Invalid code_verifier length: must be between 32 and 96 bytes',
-
);
-
}
-
-
final bytes = await _implementation.getRandomValues(length);
-
return _base64UrlEncode(bytes);
-
}
-
-
/// Base64url encodes a byte array without padding.
-
///
-
/// Base64url encoding is standard base64 with URL-safe characters:
-
/// - '+' becomes '-'
-
/// - '/' becomes '_'
-
/// - Padding ('=') is removed
-
///
-
/// This is the encoding used throughout OAuth and JWT specifications.
-
String _base64UrlEncode(Uint8List bytes) {
-
return base64Url.encode(bytes).replaceAll('=', '');
-
}
-
}
-
-
/// Extracts the required components from a JWK for thumbprint calculation.
-
///
-
/// This follows RFC 7638 which specifies exactly which fields to include
-
/// in the thumbprint hash for each key type.
-
///
-
/// The components are returned in a Map that will be serialized to JSON
-
/// in lexicographic order (Dart's jsonEncode naturally does this).
-
///
-
/// Throws ArgumentError if:
-
/// - Required fields are missing
-
/// - Key type (kty) is unsupported
-
Map<String, String> _extractJktComponents(Map<String, dynamic> jwk) {
-
String getRequired(String field) {
-
final value = jwk[field];
-
if (value is! String || value.isEmpty) {
-
throw ArgumentError('"$field" parameter missing or invalid');
-
}
-
return value;
-
}
-
-
final kty = getRequired('kty');
-
-
switch (kty) {
-
case 'EC':
-
// Elliptic Curve keys (ES256, ES384, ES512, ES256K)
-
return {
-
'crv': getRequired('crv'),
-
'kty': kty,
-
'x': getRequired('x'),
-
'y': getRequired('y'),
-
};
-
-
case 'OKP':
-
// Octet Key Pair (EdDSA)
-
return {'crv': getRequired('crv'), 'kty': kty, 'x': getRequired('x')};
-
-
case 'RSA':
-
// RSA keys (RS256, RS384, RS512, PS256, PS384, PS512)
-
return {'e': getRequired('e'), 'kty': kty, 'n': getRequired('n')};
-
-
case 'oct':
-
// Symmetric keys (HS256, HS384, HS512)
-
return {'k': getRequired('k'), 'kty': kty};
-
-
default:
-
throw ArgumentError(
-
'"kty" (Key Type) parameter missing or unsupported: $kty',
-
);
-
}
-
}
-
-
/// Compares two algorithm strings for preference ordering.
-
///
-
/// Algorithm preference order:
-
/// 1. ES256K (secp256k1) - always most preferred
-
/// 2. ES* (Elliptic Curve) - prefer shorter keys
-
/// - ES256 > ES384 > ES512
-
/// 3. PS* (RSA-PSS) - prefer shorter keys
-
/// - PS256 > PS384 > PS512
-
/// 4. RS* (RSA-PKCS1) - prefer shorter keys
-
/// - RS256 > RS384 > RS512
-
/// 5. Other algorithms - maintain original order
-
///
-
/// Returns:
-
/// - Negative if `a` is preferred over `b`
-
/// - Positive if `b` is preferred over `a`
-
/// - Zero if no preference (maintain order)
-
int _compareAlgos(String a, String b) {
-
// ES256K is always most preferred
-
if (a == 'ES256K') return -1;
-
if (b == 'ES256K') return 1;
-
-
// Check algorithm families in preference order: ES > PS > RS
-
for (final prefix in ['ES', 'PS', 'RS']) {
-
if (a.startsWith(prefix)) {
-
if (b.startsWith(prefix)) {
-
// Both have same prefix, prefer shorter key length
-
// Extract the number (e.g., "256" from "ES256")
-
final aLen = int.tryParse(a.substring(2, 5)) ?? 0;
-
final bLen = int.tryParse(b.substring(2, 5)) ?? 0;
-
-
// Prefer shorter keys (256 < 384 < 512)
-
return aLen - bLen;
-
}
-
// 'a' has the prefix, 'b' doesn't - prefer 'a'
-
return -1;
-
} else if (b.startsWith(prefix)) {
-
// 'b' has the prefix, 'a' doesn't - prefer 'b'
-
return 1;
-
}
-
}
-
-
// No known preference, maintain original order
-
return 0;
-
}
···
-167
packages/atproto_oauth_flutter/lib/src/runtime/runtime_implementation.dart
···
-
import 'dart:async';
-
import 'dart:typed_data';
-
-
/// Represents a cryptographic key that can sign and verify JWTs.
-
///
-
/// This is a placeholder for the Key class from @atproto/jwk.
-
/// In the full implementation, this should be imported from the jwk package.
-
///
-
/// The Key class contains:
-
/// - JWK representation (public and private)
-
/// - Supported algorithms
-
/// - createJwt() method for signing
-
/// - verifyJwt() method for verification
-
///
-
/// ## Key Serialization (IMPLEMENTED)
-
///
-
/// DPoP keys are fully serialized and persisted in session storage via:
-
///
-
/// 1. FlutterKey.toJson() / FlutterKey.privateJwk:
-
/// - Serializes the full JWK including private key components
-
/// - Used when storing sessions to secure storage
-
///
-
/// 2. FlutterKey.fromJwk(Map<String, dynamic> jwk):
-
/// - Reconstructs a Key from serialized JWK
-
/// - Validates JWK structure and throws on corruption
-
/// - Used when restoring sessions from storage
-
///
-
/// This ensures DPoP keys persist across app restarts, maintaining
-
/// token binding consistency and avoiding unnecessary token refreshes.
-
abstract class Key {
-
/// Create a signed JWT with the given header and payload.
-
Future<String> createJwt(
-
Map<String, dynamic> header,
-
Map<String, dynamic> payload,
-
);
-
-
/// The list of algorithms this key supports.
-
List<String> get algorithms;
-
-
/// The bare JWK (public key components only, for DPoP proofs).
-
/// Returns null for symmetric keys.
-
Map<String, dynamic>? get bareJwk;
-
-
/// The key ID (kid) from the JWK.
-
/// Returns null if the key doesn't have a kid.
-
String? get kid;
-
-
/// The usage of this key ('sign' or 'enc').
-
String get usage;
-
-
// TODO: Uncomment these when implementing serialization:
-
// Map<String, dynamic> toJson();
-
// static Key fromJson(Map<String, dynamic> json);
-
}
-
-
/// Factory function that creates a cryptographic key for the given algorithms.
-
///
-
/// The key should support at least one of the provided algorithms.
-
/// Algorithms are typically in order of preference.
-
///
-
/// Common algorithms:
-
/// - ES256, ES384, ES512 (Elliptic Curve)
-
/// - ES256K (secp256k1)
-
/// - RS256, RS384, RS512 (RSA)
-
/// - PS256, PS384, PS512 (RSA-PSS)
-
typedef RuntimeKeyFactory = FutureOr<Key> Function(List<String> algs);
-
-
/// Generates cryptographically secure random bytes.
-
///
-
/// Returns a Uint8List of the specified length filled with random bytes.
-
/// Must use a cryptographically secure random number generator.
-
typedef RuntimeRandomValues = FutureOr<Uint8List> Function(int length);
-
-
/// Digest algorithm specification.
-
class DigestAlgorithm {
-
/// The hash algorithm name: 'sha256', 'sha384', or 'sha512'.
-
final String name;
-
-
const DigestAlgorithm({required this.name});
-
-
const DigestAlgorithm.sha256() : name = 'sha256';
-
const DigestAlgorithm.sha384() : name = 'sha384';
-
const DigestAlgorithm.sha512() : name = 'sha512';
-
}
-
-
/// Computes a cryptographic hash (digest) of the input data.
-
///
-
/// The algorithm specifies which hash function to use (SHA-256, SHA-384, SHA-512).
-
/// Returns the hash as a Uint8List.
-
typedef RuntimeDigest =
-
FutureOr<Uint8List> Function(Uint8List data, DigestAlgorithm alg);
-
-
/// Acquires a lock for the given name and executes the function while holding the lock.
-
///
-
/// This ensures that only one execution of the function can run at a time for a given lock name.
-
/// This is critical for preventing race conditions during token refresh operations.
-
///
-
/// Example:
-
/// ```dart
-
/// final result = await requestLock('token-refresh', () async {
-
/// // Critical section - only one execution at a time
-
/// return await refreshToken();
-
/// });
-
/// ```
-
typedef RuntimeLock =
-
Future<T> Function<T>(String name, FutureOr<T> Function() fn);
-
-
/// Platform-specific runtime implementation for cryptographic operations.
-
///
-
/// This interface defines the core cryptographic primitives needed for OAuth:
-
/// - Key generation (createKey)
-
/// - Random number generation (getRandomValues)
-
/// - Cryptographic hashing (digest)
-
/// - Optional locking mechanism (requestLock)
-
///
-
/// Implementations must use secure cryptographic libraries:
-
/// - For Dart: pointycastle (ECDSA), crypto (SHA hashing)
-
/// - Random values must come from dart:math.Random.secure()
-
///
-
/// Security considerations:
-
/// - Keys must be generated using cryptographically secure randomness
-
/// - Private keys must never be logged or exposed
-
/// - Hash functions must be collision-resistant (SHA-256 minimum)
-
/// - Lock implementation should prevent race conditions in token refresh
-
abstract class RuntimeImplementation {
-
/// Creates a cryptographic key that supports at least one of the given algorithms.
-
///
-
/// The algorithms list is typically sorted by preference, with the most preferred first.
-
///
-
/// For OAuth DPoP, common algorithm preferences are:
-
/// - ES256K (secp256k1) - preferred for atproto
-
/// - ES256, ES384, ES512 (NIST curves)
-
/// - PS256, PS384, PS512 (RSA-PSS)
-
/// - RS256, RS384, RS512 (RSA-PKCS1)
-
///
-
/// Throws if no suitable key can be generated for any of the algorithms.
-
RuntimeKeyFactory get createKey;
-
-
/// Generates cryptographically secure random bytes.
-
///
-
/// MUST use a cryptographically secure random number generator.
-
/// In Dart, use Random.secure() from dart:math.
-
///
-
/// Never use a regular Random() - this is a security vulnerability.
-
RuntimeRandomValues get getRandomValues;
-
-
/// Computes a cryptographic hash of the input data.
-
///
-
/// Supported algorithms: SHA-256, SHA-384, SHA-512
-
///
-
/// Implementation should use the crypto package's sha256, sha384, sha512.
-
RuntimeDigest get digest;
-
-
/// Optional platform-specific lock implementation.
-
///
-
/// If provided, this will be used to prevent concurrent token refresh operations.
-
/// If not provided, a local (in-memory) lock implementation will be used as fallback.
-
///
-
/// The lock should be:
-
/// - Re-entrant safe (same isolate can acquire multiple times)
-
/// - Fair (FIFO order)
-
/// - Automatically released on error
-
///
-
/// For Flutter apps, the default local lock is usually sufficient.
-
/// For multi-process scenarios, you may need a platform-specific implementation.
-
RuntimeLock? get requestLock;
-
}
···
-395
packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart
···
-
import 'dart:async';
-
import 'package:dio/dio.dart';
-
import 'package:http/http.dart' as http;
-
-
import '../dpop/fetch_dpop.dart';
-
import '../errors/token_invalid_error.dart';
-
import '../errors/token_revoked_error.dart';
-
import '../oauth/oauth_server_agent.dart';
-
-
/// Type alias for AtprotoDid (user's DID)
-
typedef AtprotoDid = String;
-
-
/// Type alias for AtprotoOAuthScope
-
typedef AtprotoOAuthScope = String;
-
-
/// Placeholder for OAuthAuthorizationServerMetadata
-
/// Will be properly typed in later chunks
-
typedef OAuthAuthorizationServerMetadata = Map<String, dynamic>;
-
-
/// Information about the current token.
-
class TokenInfo {
-
/// When the token expires (null if no expiration)
-
final DateTime? expiresAt;
-
-
/// Whether the token is expired (null if no expiration)
-
final bool? expired;
-
-
/// The scope of access granted
-
final AtprotoOAuthScope scope;
-
-
/// The issuer URL
-
final String iss;
-
-
/// The audience (resource server)
-
final String aud;
-
-
/// The subject (user's DID)
-
final AtprotoDid sub;
-
-
TokenInfo({
-
this.expiresAt,
-
this.expired,
-
required this.scope,
-
required this.iss,
-
required this.aud,
-
required this.sub,
-
});
-
}
-
-
/// Abstract interface for session management.
-
///
-
/// This will be implemented by SessionGetter in session_getter.dart.
-
/// We define it here to avoid circular dependencies.
-
abstract class SessionGetterInterface {
-
Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale});
-
-
Future<void> delStored(AtprotoDid sub, [Object? cause]);
-
}
-
-
/// Represents an active OAuth session.
-
///
-
/// A session is created after successful authentication and provides methods
-
/// for making authenticated requests and managing the session lifecycle.
-
class Session {
-
/// The DPoP key used for this session (serialized as Map for storage)
-
final Map<String, dynamic> dpopKey;
-
-
/// The client authentication method (serialized as Map or String for storage).
-
/// Can be:
-
/// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT
-
/// - A Map containing {method: 'none'} for no authentication
-
/// - A String 'legacy' for backwards compatibility
-
/// - null (defaults to 'legacy' when loading)
-
final dynamic authMethod;
-
-
/// The token set containing access and refresh tokens
-
final TokenSet tokenSet;
-
-
const Session({
-
required this.dpopKey,
-
this.authMethod,
-
required this.tokenSet,
-
});
-
-
/// Creates a Session from JSON.
-
factory Session.fromJson(Map<String, dynamic> json) {
-
return Session(
-
dpopKey: json['dpopKey'] as Map<String, dynamic>,
-
authMethod: json['authMethod'], // Can be Map or String
-
tokenSet: TokenSet.fromJson(json['tokenSet'] as Map<String, dynamic>),
-
);
-
}
-
-
/// Converts this Session to JSON.
-
Map<String, dynamic> toJson() {
-
final json = <String, dynamic>{
-
'dpopKey': dpopKey,
-
'tokenSet': tokenSet.toJson(),
-
};
-
-
if (authMethod != null) json['authMethod'] = authMethod;
-
-
return json;
-
}
-
}
-
-
/// Represents an active OAuth session with methods for authenticated requests.
-
///
-
/// This class wraps an OAuth session and provides:
-
/// - Automatic token refresh on expiry
-
/// - DPoP-protected requests
-
/// - Session lifecycle management (sign out)
-
///
-
/// Example:
-
/// ```dart
-
/// final session = OAuthSession(
-
/// server: oauthServer,
-
/// sub: 'did:plc:abc123',
-
/// sessionGetter: sessionGetter,
-
/// );
-
///
-
/// // Make an authenticated request
-
/// final response = await session.fetchHandler('/api/posts');
-
///
-
/// // Get token information
-
/// final info = await session.getTokenInfo();
-
/// print('Token expires at: ${info.expiresAt}');
-
///
-
/// // Sign out
-
/// await session.signOut();
-
/// ```
-
class OAuthSession {
-
/// The OAuth server agent
-
final OAuthServerAgent server;
-
-
/// The subject (user's DID)
-
final AtprotoDid sub;
-
-
/// The session getter for retrieving and refreshing tokens
-
final SessionGetterInterface sessionGetter;
-
-
/// Dio instance with DPoP interceptor for authenticated requests
-
final Dio _dio;
-
-
/// Creates a new OAuth session.
-
///
-
/// Parameters:
-
/// - [server]: The OAuth server agent
-
/// - [sub]: The subject (user's DID)
-
/// - [sessionGetter]: The session getter for token management
-
OAuthSession({
-
required this.server,
-
required this.sub,
-
required this.sessionGetter,
-
}) : _dio = Dio() {
-
// Add DPoP interceptor for authenticated requests to resource servers
-
_dio.interceptors.add(
-
createDpopInterceptor(
-
DpopFetchWrapperOptions(
-
key: server.dpopKey,
-
nonces: server.dpopNonces,
-
sha256: server.runtime.sha256,
-
isAuthServer: false, // Resource server requests (PDS)
-
),
-
),
-
);
-
}
-
-
/// Alias for [sub]
-
AtprotoDid get did => sub;
-
-
/// The server metadata
-
OAuthAuthorizationServerMetadata get serverMetadata => server.serverMetadata;
-
-
/// Gets the current token set.
-
///
-
/// Parameters:
-
/// - [refresh]: When `true`, forces a token refresh even if not expired.
-
/// When `false`, uses cached tokens even if expired.
-
/// When `'auto'`, refreshes only if expired (default).
-
Future<TokenSet> _getTokenSet(dynamic refresh) async {
-
final session = await sessionGetter.get(
-
sub,
-
noCache: refresh == true,
-
allowStale: refresh == false,
-
);
-
-
return session.tokenSet;
-
}
-
-
/// Gets information about the current token.
-
///
-
/// Parameters:
-
/// - [refresh]: When `true`, forces a token refresh even if not expired.
-
/// When `false`, uses cached tokens even if expired.
-
/// When `'auto'`, refreshes only if expired (default).
-
Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']) async {
-
final tokenSet = await _getTokenSet(refresh);
-
final expiresAtStr = tokenSet.expiresAt;
-
final expiresAt =
-
expiresAtStr != null ? DateTime.parse(expiresAtStr) : null;
-
-
return TokenInfo(
-
expiresAt: expiresAt,
-
expired:
-
expiresAt != null
-
? expiresAt.isBefore(
-
DateTime.now().subtract(Duration(seconds: 5)),
-
)
-
: null,
-
scope: tokenSet.scope,
-
iss: tokenSet.iss,
-
aud: tokenSet.aud,
-
sub: tokenSet.sub,
-
);
-
}
-
-
/// Signs out the user.
-
///
-
/// This revokes the access token and deletes the session from storage.
-
/// Even if revocation fails, the session is removed locally.
-
Future<void> signOut() async {
-
try {
-
final tokenSet = await _getTokenSet(false);
-
await server.revoke(tokenSet.accessToken);
-
} finally {
-
await sessionGetter.delStored(sub, TokenRevokedError(sub));
-
}
-
}
-
-
/// Makes an authenticated HTTP request to the given pathname.
-
///
-
/// This method:
-
/// 1. Automatically refreshes tokens if they're expired
-
/// 2. Adds DPoP and Authorization headers
-
/// 3. Retries once with a fresh token if the initial request fails with 401
-
///
-
/// Parameters:
-
/// - [pathname]: The pathname to request (relative to the audience URL)
-
/// - [method]: HTTP method (default: 'GET')
-
/// - [headers]: Additional headers to include
-
/// - [body]: Request body
-
///
-
/// Returns the HTTP response.
-
///
-
/// Example:
-
/// ```dart
-
/// final response = await session.fetchHandler(
-
/// '/xrpc/com.atproto.repo.createRecord',
-
/// method: 'POST',
-
/// headers: {'Content-Type': 'application/json'},
-
/// body: jsonEncode({'repo': did, 'collection': 'app.bsky.feed.post', ...}),
-
/// );
-
/// ```
-
Future<http.Response> fetchHandler(
-
String pathname, {
-
String method = 'GET',
-
Map<String, String>? headers,
-
dynamic body,
-
}) async {
-
// Try to refresh the token if it's known to be expired
-
final tokenSet = await _getTokenSet('auto');
-
-
final initialUrl = Uri.parse(tokenSet.aud).resolve(pathname);
-
final initialAuth = '${tokenSet.tokenType} ${tokenSet.accessToken}';
-
-
final initialHeaders = <String, String>{
-
...?headers,
-
'Authorization': initialAuth,
-
};
-
-
// Make request with DPoP - the interceptor will automatically add DPoP header
-
final initialResponse = await _makeDpopRequest(
-
initialUrl,
-
method: method,
-
headers: initialHeaders,
-
body: body,
-
);
-
-
// If the token is not expired, we don't need to refresh it
-
if (!_isInvalidTokenResponse(initialResponse)) {
-
return initialResponse;
-
}
-
-
// Token is invalid, try to refresh
-
TokenSet tokenSetFresh;
-
try {
-
// Force a refresh
-
tokenSetFresh = await _getTokenSet(true);
-
} catch (err) {
-
// If refresh fails, return the original response
-
return initialResponse;
-
}
-
-
// Retry with fresh token
-
final finalAuth = '${tokenSetFresh.tokenType} ${tokenSetFresh.accessToken}';
-
final finalUrl = Uri.parse(tokenSetFresh.aud).resolve(pathname);
-
-
final finalHeaders = <String, String>{
-
...?headers,
-
'Authorization': finalAuth,
-
};
-
-
final finalResponse = await _makeDpopRequest(
-
finalUrl,
-
method: method,
-
headers: finalHeaders,
-
body: body,
-
);
-
-
// The token was successfully refreshed, but is still not accepted by the
-
// resource server. This might be due to the resource server not accepting
-
// credentials from the authorization server (e.g. because some migration
-
// occurred). Any ways, there is no point in keeping the session.
-
if (_isInvalidTokenResponse(finalResponse)) {
-
await sessionGetter.delStored(sub, TokenInvalidError(sub));
-
}
-
-
return finalResponse;
-
}
-
-
/// Makes an HTTP request with DPoP authentication.
-
///
-
/// Uses Dio with DPoP interceptor which automatically adds:
-
/// - DPoP header with proof JWT
-
/// - Access token hash (ath) binding
-
///
-
/// Throws [DioException] for network errors, timeouts, and cancellations.
-
Future<http.Response> _makeDpopRequest(
-
Uri url, {
-
required String method,
-
Map<String, String>? headers,
-
dynamic body,
-
}) async {
-
try {
-
// Make request with Dio - interceptor will add DPoP header
-
final response = await _dio.requestUri(
-
url,
-
options: Options(
-
method: method,
-
headers: headers,
-
responseType: ResponseType.bytes, // Get raw bytes for compatibility
-
validateStatus: (status) => true, // Don't throw on any status code
-
),
-
data: body,
-
);
-
-
// Convert Dio Response to http.Response for compatibility
-
return http.Response.bytes(
-
response.data as List<int>,
-
response.statusCode!,
-
headers: response.headers.map.map(
-
(key, value) => MapEntry(key, value.join(', ')),
-
),
-
reasonPhrase: response.statusMessage,
-
);
-
} on DioException catch (e) {
-
// If we have a response (4xx/5xx), convert it to http.Response
-
if (e.response != null) {
-
final errorResponse = e.response!;
-
return http.Response.bytes(
-
errorResponse.data is List<int>
-
? errorResponse.data as List<int>
-
: (errorResponse.data?.toString() ?? '').codeUnits,
-
errorResponse.statusCode!,
-
headers: errorResponse.headers.map.map(
-
(key, value) => MapEntry(key, value.join(', ')),
-
),
-
reasonPhrase: errorResponse.statusMessage,
-
);
-
}
-
// Network errors, timeouts, cancellations - rethrow
-
rethrow;
-
}
-
}
-
-
/// Checks if a response indicates an invalid token.
-
///
-
/// See:
-
/// - https://datatracker.ietf.org/doc/html/rfc6750#section-3
-
/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
-
bool _isInvalidTokenResponse(http.Response response) {
-
if (response.statusCode != 401) return false;
-
-
final wwwAuth = response.headers['www-authenticate'];
-
return wwwAuth != null &&
-
(wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) &&
-
wwwAuth.contains('error="invalid_token"');
-
}
-
-
/// Disposes of resources used by this session.
-
void dispose() {
-
_dio.close();
-
}
-
}
···
-42
packages/atproto_oauth_flutter/lib/src/session/session.dart
···
-
/// Session management layer for atproto OAuth.
-
///
-
/// This module provides session storage, retrieval, and lifecycle management
-
/// for OAuth sessions. It includes:
-
///
-
/// - [StateStore] - Stores ephemeral OAuth state during authorization
-
/// - [SessionStore] - Stores persistent session data
-
/// - [Session] - Represents an authenticated session with tokens
-
/// - [TokenSet] - Contains OAuth tokens and metadata
-
/// - [OAuthSession] - High-level API for authenticated requests
-
/// - [SessionGetter] - Manages session caching and token refresh
-
///
-
/// Example:
-
/// ```dart
-
/// // Create a session store implementation
-
/// final sessionStore = MySessionStore();
-
///
-
/// // Create a session getter
-
/// final sessionGetter = SessionGetter(
-
/// sessionStore: sessionStore,
-
/// serverFactory: serverFactory,
-
/// runtime: runtime,
-
/// );
-
///
-
/// // Get a session (automatically refreshes if needed)
-
/// final session = await sessionGetter.getSession('did:plc:abc123');
-
///
-
/// // Create an OAuthSession for making requests
-
/// final oauthSession = OAuthSession(
-
/// server: server,
-
/// sub: 'did:plc:abc123',
-
/// sessionGetter: sessionGetter,
-
/// );
-
///
-
/// // Make authenticated requests
-
/// final response = await oauthSession.fetchHandler('/api/posts');
-
/// ```
-
library;
-
-
export 'state_store.dart';
-
export 'oauth_session.dart';
-
export 'session_getter.dart';
···
-644
packages/atproto_oauth_flutter/lib/src/session/session_getter.dart
···
-
import 'dart:async';
-
import 'dart:convert';
-
import 'dart:math' as math;
-
-
import '../errors/auth_method_unsatisfiable_error.dart';
-
import '../errors/token_invalid_error.dart';
-
import '../errors/token_refresh_error.dart';
-
import '../errors/token_revoked_error.dart';
-
import '../oauth/client_auth.dart' show ClientAuthMethod;
-
import '../oauth/oauth_server_agent.dart';
-
import '../oauth/oauth_server_factory.dart';
-
import '../platform/flutter_key.dart';
-
import '../runtime/runtime.dart';
-
import '../util.dart';
-
import 'oauth_session.dart';
-
-
/// Options for getting a cached value.
-
class GetCachedOptions {
-
/// Cancellation token for aborting the operation
-
final CancellationToken? signal;
-
-
/// Do not use the cache to get the value. Always get a new value.
-
final bool? noCache;
-
-
/// Allow returning stale values from the cache.
-
final bool? allowStale;
-
-
const GetCachedOptions({this.signal, this.noCache, this.allowStale});
-
}
-
-
/// Abstract storage interface for values.
-
///
-
/// This is a generic key-value store interface.
-
abstract class SimpleStore<K, V> {
-
/// Gets a value from the store.
-
///
-
/// Returns `null` if the key doesn't exist.
-
Future<V?> get(K key, {CancellationToken? signal});
-
-
/// Sets a value in the store.
-
Future<void> set(K key, V value);
-
-
/// Deletes a value from the store.
-
Future<void> del(K key);
-
-
/// Optionally clears all values from the store.
-
Future<void> clear() async {}
-
}
-
-
/// Type alias for session storage
-
typedef SessionStore = SimpleStore<String, Session>;
-
-
/// Details of a session update event.
-
class SessionUpdatedEvent {
-
/// The subject (user's DID)
-
final String sub;
-
-
/// The DPoP key
-
final Map<String, dynamic> dpopKey;
-
-
/// The authentication method
-
final String? authMethod;
-
-
/// The token set
-
final TokenSet tokenSet;
-
-
const SessionUpdatedEvent({
-
required this.sub,
-
required this.dpopKey,
-
this.authMethod,
-
required this.tokenSet,
-
});
-
}
-
-
/// Details of a session deletion event.
-
class SessionDeletedEvent {
-
/// The subject (user's DID)
-
final String sub;
-
-
/// The cause of deletion
-
final Object cause;
-
-
const SessionDeletedEvent({required this.sub, required this.cause});
-
}
-
-
/// Manages session retrieval, caching, and refreshing.
-
///
-
/// The SessionGetter wraps a session store and provides:
-
/// - Automatic token refresh when tokens are stale/expired
-
/// - Caching to avoid redundant refresh operations
-
/// - Events for session updates and deletions
-
/// - Concurrency control to prevent multiple simultaneous refreshes
-
///
-
/// This is a critical component that ensures at most one token refresh
-
/// is happening at a time for a given user, even across multiple tabs
-
/// or app instances.
-
///
-
/// Example:
-
/// ```dart
-
/// final sessionGetter = SessionGetter(
-
/// sessionStore: mySessionStore,
-
/// serverFactory: myServerFactory,
-
/// runtime: myRuntime,
-
/// );
-
///
-
/// // Listen for session updates
-
/// sessionGetter.onUpdated.listen((event) {
-
/// print('Session updated for ${event.sub}');
-
/// });
-
///
-
/// // Listen for session deletions
-
/// sessionGetter.onDeleted.listen((event) {
-
/// print('Session deleted for ${event.sub}: ${event.cause}');
-
/// });
-
///
-
/// // Get a session (automatically refreshes if expired)
-
/// final session = await sessionGetter.getSession('did:plc:abc123');
-
///
-
/// // Force refresh
-
/// final freshSession = await sessionGetter.getSession('did:plc:abc123', true);
-
/// ```
-
class SessionGetter extends CachedGetter<AtprotoDid, Session> {
-
final OAuthServerFactory _serverFactory;
-
final Runtime _runtime;
-
-
final _eventTarget = CustomEventTarget<Map<String, dynamic>>();
-
final _updatedController = StreamController<SessionUpdatedEvent>.broadcast();
-
final _deletedController = StreamController<SessionDeletedEvent>.broadcast();
-
-
/// Stream of session update events.
-
Stream<SessionUpdatedEvent> get onUpdated => _updatedController.stream;
-
-
/// Stream of session deletion events.
-
Stream<SessionDeletedEvent> get onDeleted => _deletedController.stream;
-
-
SessionGetter({
-
required super.sessionStore,
-
required OAuthServerFactory serverFactory,
-
required Runtime runtime,
-
}) : _serverFactory = serverFactory,
-
_runtime = runtime,
-
super(
-
getter: null, // Will be set in _createGetter
-
options: CachedGetterOptions(
-
isStale: (sub, session) {
-
final tokenSet = session.tokenSet;
-
if (tokenSet.expiresAt == null) return false;
-
-
final expiresAt = DateTime.parse(tokenSet.expiresAt!);
-
final now = DateTime.now();
-
-
// Add some lee way to ensure the token is not expired when it
-
// reaches the server (10 seconds)
-
// Add some randomness to reduce the chances of multiple
-
// instances trying to refresh the token at the same time (0-30 seconds)
-
final buffer = Duration(
-
milliseconds:
-
10000 + (math.Random().nextDouble() * 30000).toInt(),
-
);
-
-
return expiresAt.isBefore(now.add(buffer));
-
},
-
onStoreError: (err, sub, session) async {
-
if (err is! AuthMethodUnsatisfiableError) {
-
// If the error was an AuthMethodUnsatisfiableError, there is no
-
// point in trying to call `fromIssuer`.
-
try {
-
// Parse authMethod
-
final authMethodValue = session.authMethod;
-
final authMethod =
-
authMethodValue is Map<String, dynamic>
-
? ClientAuthMethod.fromJson(authMethodValue)
-
: (authMethodValue as String?) ?? 'legacy';
-
-
// Restore DPoP key from session for revocation
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding
-
final dpopKey = FlutterKey.fromJwk(
-
session.dpopKey as Map<String, dynamic>,
-
);
-
-
// If the token data cannot be stored, let's revoke it
-
final server = await serverFactory.fromIssuer(
-
session.tokenSet.iss,
-
authMethod,
-
dpopKey,
-
);
-
await server.revoke(
-
session.tokenSet.refreshToken ??
-
session.tokenSet.accessToken,
-
);
-
} catch (_) {
-
// Let the original error propagate
-
}
-
}
-
-
throw err;
-
},
-
deleteOnError: (err) async {
-
return err is TokenRefreshError ||
-
err is TokenRevokedError ||
-
err is TokenInvalidError ||
-
err is AuthMethodUnsatisfiableError;
-
},
-
),
-
) {
-
// Set the getter function after construction
-
_getter = _createGetter();
-
}
-
-
/// Creates the getter function for refreshing sessions.
-
Future<Session> Function(AtprotoDid, GetCachedOptions, Session?)
-
_createGetter() {
-
return (sub, options, storedSession) async {
-
// There needs to be a previous session to be able to refresh. If
-
// storedSession is null, it means that the store does not contain
-
// a session for the given sub.
-
if (storedSession == null) {
-
// Because the session is not in the store, delStored() method
-
// will not be called by the CachedGetter class (because there is
-
// nothing to delete). This would typically happen if there is no
-
// synchronization mechanism between instances of this class. Let's
-
// make sure an event is dispatched here if this occurs.
-
const msg = 'The session was deleted by another process';
-
final cause = TokenRefreshError(sub, msg);
-
_dispatchDeletedEvent(sub, cause);
-
throw cause;
-
}
-
-
// From this point forward, throwing a TokenRefreshError will result in
-
// delStored() being called, resulting in an event being dispatched,
-
// even if the session was removed from the store through a concurrent
-
// access (which, normally, should not happen if a proper runtime lock
-
// was provided).
-
-
// authMethod can be a Map (serialized ClientAuthMethod) or String ('legacy')
-
final authMethodValue = storedSession.authMethod;
-
final authMethod =
-
authMethodValue is Map<String, dynamic>
-
? ClientAuthMethod.fromJson(authMethodValue)
-
: (authMethodValue as String?) ?? 'legacy';
-
final tokenSet = storedSession.tokenSet;
-
-
if (sub != tokenSet.sub) {
-
// Fool-proofing (e.g. against invalid session storage)
-
throw TokenRefreshError(sub, 'Stored session sub mismatch');
-
}
-
-
if (tokenSet.refreshToken == null) {
-
throw TokenRefreshError(sub, 'No refresh token available');
-
}
-
-
// Since refresh tokens can only be used once, we might run into
-
// concurrency issues if multiple instances (e.g. browser tabs) are
-
// trying to refresh the same token simultaneously. The chances of this
-
// happening when multiple instances are started simultaneously is
-
// reduced by randomizing the expiry time (see isStale above). The
-
// best solution is to use a mutex/lock to ensure that only one instance
-
// is refreshing the token at a time (runtime.usingLock) but that is not
-
// always possible. If no lock implementation is provided, we will use
-
// the store to check if a concurrent refresh occurred.
-
-
// Restore dpopKey from stored private JWK with error handling
-
// CRITICAL FIX: Use the stored key instead of generating a new one
-
// This ensures DPoP proofs match the token binding during refresh
-
final FlutterKey dpopKey;
-
try {
-
dpopKey = FlutterKey.fromJwk(
-
storedSession.dpopKey as Map<String, dynamic>,
-
);
-
} catch (e) {
-
// If key is corrupted, the session is unusable - force re-authentication
-
throw TokenRefreshError(
-
sub,
-
'Corrupted DPoP key in stored session: $e. Re-authentication required.',
-
);
-
}
-
-
final server = await _serverFactory.fromIssuer(
-
tokenSet.iss,
-
authMethod,
-
dpopKey,
-
);
-
-
// Because refresh tokens can only be used once, we must not use the
-
// "signal" to abort the refresh, or throw any abort error beyond this
-
// point. Any thrown error beyond this point will prevent the
-
// SessionGetter from obtaining, and storing, the new token set,
-
// effectively rendering the currently saved session unusable.
-
options.signal?.throwIfCancelled();
-
-
try {
-
final newTokenSet = await server.refresh(tokenSet);
-
-
if (sub != newTokenSet.sub) {
-
// The server returned another sub. Was the tokenSet manipulated?
-
throw TokenRefreshError(sub, 'Token set sub mismatch');
-
}
-
-
// CRITICAL FIX: Preserve the stored DPoP key (full private JWK)
-
// This ensures the same key is used across token refreshes
-
return Session(
-
dpopKey: storedSession.dpopKey,
-
tokenSet: newTokenSet,
-
authMethod: server.authMethod.toJson(),
-
);
-
} catch (cause) {
-
// If the refresh token is invalid, let's try to recover from
-
// concurrency issues, or make sure the session is deleted by throwing
-
// a TokenRefreshError.
-
if (cause is OAuthResponseError &&
-
cause.status == 400 &&
-
cause.error == 'invalid_grant') {
-
// In case there is no lock implementation in the runtime, we will
-
// wait for a short time to give the other concurrent instances a
-
// chance to finish their refreshing of the token. If a concurrent
-
// refresh did occur, we will pretend that this one succeeded.
-
if (!_runtime.hasImplementationLock) {
-
await Future.delayed(Duration(seconds: 1));
-
-
final stored = await getStored(sub);
-
if (stored == null) {
-
// A concurrent refresh occurred and caused the session to be
-
// deleted (for a reason we can't know at this point).
-
-
// Using a distinct error message mainly for debugging
-
// purposes. Also, throwing a TokenRefreshError to trigger
-
// deletion through the deleteOnError callback.
-
const msg = 'The session was deleted by another process';
-
throw TokenRefreshError(sub, msg, cause: cause);
-
} else if (stored.tokenSet.accessToken != tokenSet.accessToken ||
-
stored.tokenSet.refreshToken != tokenSet.refreshToken) {
-
// A concurrent refresh occurred. Pretend this one succeeded.
-
return stored;
-
} else {
-
// There were no concurrent refresh. The token is (likely)
-
// simply no longer valid.
-
}
-
}
-
-
// Make sure the session gets deleted from the store
-
final msg = cause.errorDescription ?? 'The session was revoked';
-
throw TokenRefreshError(sub, msg, cause: cause);
-
}
-
-
// Re-throw the original exception if it wasn't an invalid_grant error
-
if (cause is Exception) {
-
throw cause;
-
} else {
-
throw Exception('Token refresh failed: $cause');
-
}
-
}
-
};
-
}
-
-
@override
-
Future<void> setStored(String key, Session value) async {
-
// Prevent tampering with the stored value
-
if (key != value.tokenSet.sub) {
-
throw TypeError();
-
}
-
-
await super.setStored(key, value);
-
-
// Serialize authMethod to String for the event
-
// authMethod can be Map<String, dynamic>, String, or null
-
String? authMethodString;
-
if (value.authMethod is Map) {
-
authMethodString = jsonEncode(value.authMethod);
-
} else if (value.authMethod is String) {
-
authMethodString = value.authMethod as String;
-
} else {
-
authMethodString = null;
-
}
-
-
_dispatchUpdatedEvent(key, value.dpopKey, authMethodString, value.tokenSet);
-
}
-
-
@override
-
Future<void> delStored(AtprotoDid key, [Object? cause]) async {
-
await super.delStored(key, cause);
-
_dispatchDeletedEvent(key, cause ?? Exception('Session deleted'));
-
}
-
-
/// Gets a session, optionally refreshing it.
-
///
-
/// Parameters:
-
/// - [sub]: The subject (user's DID)
-
/// - [refresh]: When `true`, forces a token refresh even if not expired.
-
/// When `false`, uses cached tokens even if expired.
-
/// When `'auto'`, refreshes only if expired (default).
-
Future<Session> getSession(AtprotoDid sub, [dynamic refresh = 'auto']) {
-
return get(
-
sub,
-
GetCachedOptions(noCache: refresh == true, allowStale: refresh == false),
-
);
-
}
-
-
@override
-
Future<Session> get(AtprotoDid key, [GetCachedOptions? options]) async {
-
final session = await _runtime.usingLock(
-
'@atproto-oauth-client-$key',
-
() async {
-
// Make sure, even if there is no signal in the options, that the
-
// request will be cancelled after at most 30 seconds.
-
final timeoutToken = CancellationToken();
-
final timeoutTimer = Timer(Duration(seconds: 30), () => timeoutToken.cancel());
-
-
final combinedSignal =
-
options?.signal != null
-
? combineSignals([options!.signal, timeoutToken])
-
: CombinedCancellationToken([timeoutToken]);
-
-
try {
-
return await super.get(
-
key,
-
GetCachedOptions(
-
signal: CancellationToken(), // Use combined signal
-
noCache: options?.noCache,
-
allowStale: options?.allowStale,
-
),
-
);
-
} finally {
-
timeoutTimer.cancel(); // Cancel timer before disposing token
-
combinedSignal.dispose();
-
timeoutToken.dispose();
-
}
-
},
-
);
-
-
if (key != session.tokenSet.sub) {
-
// Fool-proofing (e.g. against invalid session storage)
-
throw Exception('Token set does not match the expected sub');
-
}
-
-
return session;
-
}
-
-
void _dispatchUpdatedEvent(
-
String sub,
-
Map<String, dynamic> dpopKey,
-
String? authMethod,
-
TokenSet tokenSet,
-
) {
-
final event = SessionUpdatedEvent(
-
sub: sub,
-
dpopKey: dpopKey,
-
authMethod: authMethod,
-
tokenSet: tokenSet,
-
);
-
-
_updatedController.add(event);
-
_eventTarget.dispatchCustomEvent('updated', event);
-
}
-
-
void _dispatchDeletedEvent(String sub, Object cause) {
-
final event = SessionDeletedEvent(sub: sub, cause: cause);
-
-
_deletedController.add(event);
-
_eventTarget.dispatchCustomEvent('deleted', event);
-
}
-
-
/// Disposes of resources used by this session getter.
-
void dispose() {
-
_updatedController.close();
-
_deletedController.close();
-
_eventTarget.dispose();
-
}
-
}
-
-
/// Placeholder for OAuthResponseError
-
/// Will be implemented in later chunks
-
class OAuthResponseError implements Exception {
-
final int status;
-
final String? error;
-
final String? errorDescription;
-
-
OAuthResponseError({required this.status, this.error, this.errorDescription});
-
}
-
-
/// Options for the CachedGetter.
-
class CachedGetterOptions<K, V> {
-
/// Function to determine if a cached value is stale
-
final bool Function(K key, V value)? isStale;
-
-
/// Function called when storing a value fails
-
final Future<void> Function(Object err, K key, V value)? onStoreError;
-
-
/// Function to determine if a value should be deleted on error
-
final Future<bool> Function(Object err)? deleteOnError;
-
-
const CachedGetterOptions({
-
this.isStale,
-
this.onStoreError,
-
this.deleteOnError,
-
});
-
}
-
-
/// A pending item in the cache.
-
class _PendingItem<V> {
-
final Future<({V value, bool isFresh})> future;
-
-
_PendingItem(this.future);
-
}
-
-
/// Wrapper utility that uses a store to speed up the retrieval of values.
-
///
-
/// The CachedGetter ensures that at most one fresh call is ever being made
-
/// for a given key. It also contains logic for reading from the cache which,
-
/// if the cache is based on localStorage/indexedDB, will sync across multiple
-
/// tabs (for a given key).
-
///
-
/// This is an abstract base class. Subclasses should provide the getter
-
/// function and any additional logic.
-
class CachedGetter<K, V> {
-
final SimpleStore<K, V> _store;
-
final CachedGetterOptions<K, V> _options;
-
final Map<K, _PendingItem<V>> _pending = {};
-
-
late Future<V> Function(K, GetCachedOptions, V?) _getter;
-
-
CachedGetter({
-
required SimpleStore<K, V> sessionStore,
-
required Future<V> Function(K, GetCachedOptions, V?)? getter,
-
required CachedGetterOptions<K, V> options,
-
}) : _store = sessionStore,
-
_options = options {
-
if (getter != null) {
-
_getter = getter;
-
}
-
}
-
-
Future<V> get(K key, [GetCachedOptions? options]) async {
-
options ??= GetCachedOptions();
-
final signal = options.signal;
-
final noCache = options.noCache ?? false;
-
final allowStale = options.allowStale ?? false;
-
-
signal?.throwIfCancelled();
-
-
final isStale = _options.isStale;
-
final deleteOnError = _options.deleteOnError;
-
-
// Determine if a stored value can be used
-
bool allowStored(V value) {
-
if (noCache) return false; // Never allow stored values
-
if (allowStale || isStale == null) return true; // Always allow
-
return !isStale(key, value); // Check if stale
-
}
-
-
// As long as concurrent requests are made for the same key, only one
-
// request will be made to the getStored & getter functions at a time.
-
_PendingItem<V>? previousExecutionFlow;
-
while ((previousExecutionFlow = _pending[key]) != null) {
-
try {
-
final result = await previousExecutionFlow!.future;
-
final isFresh = result.isFresh;
-
final value = result.value;
-
-
// Use the concurrent request's result if it is fresh
-
if (isFresh) return value;
-
// Use the concurrent request's result if not fresh (loaded from the
-
// store), and matches the conditions for using a stored value.
-
if (allowStored(value)) return value;
-
} catch (_) {
-
// Ignore errors from previous execution flows (they will have been
-
// propagated by that flow).
-
}
-
-
// Break the loop if the signal was cancelled
-
signal?.throwIfCancelled();
-
}
-
-
final currentExecutionFlow = _PendingItem<V>(
-
Future(() async {
-
final storedValue = await getStored(key, signal: signal);
-
-
if (storedValue != null && allowStored(storedValue)) {
-
// Use the stored value as return value for the current execution
-
// flow. Notify other concurrent execution flows that we got a value,
-
// but that it came from the store (isFresh = false).
-
return (value: storedValue, isFresh: false);
-
}
-
-
return Future(() async {
-
return await _getter(key, options!, storedValue);
-
})
-
.catchError((err) async {
-
if (storedValue != null) {
-
try {
-
if (deleteOnError != null && await deleteOnError(err)) {
-
await delStored(key, err);
-
}
-
} catch (error) {
-
throw Exception('Error while deleting stored value: $error');
-
}
-
}
-
throw err;
-
})
-
.then((value) async {
-
// The value should be stored even if the signal was cancelled.
-
await setStored(key, value);
-
return (value: value, isFresh: true);
-
});
-
}).whenComplete(() {
-
_pending.remove(key);
-
}),
-
);
-
-
if (_pending.containsKey(key)) {
-
// This should never happen. There must not be any 'await'
-
// statement between this and the loop iteration check.
-
throw Exception('Concurrent request for the same key');
-
}
-
-
_pending[key] = currentExecutionFlow;
-
-
final result = await currentExecutionFlow.future;
-
return result.value;
-
}
-
-
Future<V?> getStored(K key, {CancellationToken? signal}) async {
-
try {
-
return await _store.get(key, signal: signal);
-
} catch (err) {
-
return null;
-
}
-
}
-
-
Future<void> setStored(K key, V value) async {
-
try {
-
await _store.set(key, value);
-
} catch (err) {
-
final onStoreError = _options.onStoreError;
-
if (onStoreError != null) {
-
await onStoreError(err, key, value);
-
}
-
}
-
}
-
-
Future<void> delStored(K key, [Object? cause]) async {
-
await _store.del(key);
-
}
-
}
···
-112
packages/atproto_oauth_flutter/lib/src/session/state_store.dart
···
-
/// Internal state data stored during OAuth authorization flow.
-
///
-
/// This contains ephemeral data needed to complete the OAuth flow,
-
/// such as PKCE code verifiers, state parameters, and nonces.
-
class InternalStateData {
-
/// The OAuth issuer URL
-
final String iss;
-
-
/// The DPoP key used for this authorization
-
final Map<String, dynamic> dpopKey;
-
-
/// Client authentication method (serialized as Map or String)
-
///
-
/// Can be:
-
/// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT
-
/// - A Map containing {method: 'none'} for no authentication
-
/// - A String 'legacy' for backwards compatibility
-
/// - null (defaults to 'legacy' when loading)
-
final dynamic authMethod;
-
-
/// PKCE code verifier for authorization code flow
-
final String? verifier;
-
-
/// The redirect URI used during authorization
-
/// MUST match exactly during token exchange
-
final String? redirectUri;
-
-
/// Application state to preserve across the OAuth flow
-
final String? appState;
-
-
const InternalStateData({
-
required this.iss,
-
required this.dpopKey,
-
this.authMethod,
-
this.verifier,
-
this.redirectUri,
-
this.appState,
-
});
-
-
/// Creates an instance from a JSON map.
-
factory InternalStateData.fromJson(Map<String, dynamic> json) {
-
return InternalStateData(
-
iss: json['iss'] as String,
-
dpopKey: json['dpopKey'] as Map<String, dynamic>,
-
authMethod: json['authMethod'], // Can be Map or String
-
verifier: json['verifier'] as String?,
-
redirectUri: json['redirectUri'] as String?,
-
appState: json['appState'] as String?,
-
);
-
}
-
-
/// Converts this instance to a JSON map.
-
Map<String, dynamic> toJson() {
-
final json = <String, dynamic>{'iss': iss, 'dpopKey': dpopKey};
-
-
if (authMethod != null) json['authMethod'] = authMethod;
-
if (verifier != null) json['verifier'] = verifier;
-
if (redirectUri != null) json['redirectUri'] = redirectUri;
-
if (appState != null) json['appState'] = appState;
-
-
return json;
-
}
-
}
-
-
/// Abstract storage interface for OAuth state data.
-
///
-
/// Implementations should store state data temporarily during the OAuth flow.
-
/// This data is typically short-lived and can be cleared after successful
-
/// authorization or timeout.
-
///
-
/// Example implementation using in-memory storage:
-
/// ```dart
-
/// class MemoryStateStore implements StateStore {
-
/// final Map<String, InternalStateData> _store = {};
-
///
-
/// @override
-
/// Future<InternalStateData?> get(String key) async => _store[key];
-
///
-
/// @override
-
/// Future<void> set(String key, InternalStateData data) async {
-
/// _store[key] = data;
-
/// }
-
///
-
/// @override
-
/// Future<void> del(String key) async {
-
/// _store.remove(key);
-
/// }
-
/// }
-
/// ```
-
abstract class StateStore {
-
/// Retrieves state data for the given key.
-
///
-
/// Returns `null` if no data exists for the key.
-
Future<InternalStateData?> get(String key);
-
-
/// Stores state data for the given key.
-
///
-
/// Overwrites any existing data for the key.
-
Future<void> set(String key, InternalStateData data);
-
-
/// Deletes state data for the given key.
-
///
-
/// Does nothing if no data exists for the key.
-
Future<void> del(String key);
-
-
/// Optionally clears all state data.
-
///
-
/// Implementations may choose not to implement this method.
-
Future<void> clear() async {
-
// Default implementation does nothing
-
}
-
}
···
-352
packages/atproto_oauth_flutter/lib/src/types.dart
···
-
// Note: These types are not prefixed with `OAuth` because they are not specific
-
// to OAuth. They are specific to this package. OAuth specific types will be in
-
// a separate oauth-types module or imported from an external package.
-
-
// TODO: These types currently reference schemas from @atproto/oauth-types which
-
// need to be ported to Dart. For now, we're using Map<String, dynamic> as placeholders.
-
// These will be replaced with proper typed classes once oauth-types is ported.
-
-
/// Options for initiating an authorization request.
-
///
-
/// Omits client_id, response_mode, response_type, login_hint,
-
/// code_challenge, and code_challenge_method from OAuthAuthorizationRequestParameters
-
/// as these are managed internally.
-
class AuthorizeOptions {
-
/// Optional URI to redirect to after authorization
-
final String? redirectUri;
-
-
/// Optional state parameter for CSRF protection
-
final String? state;
-
-
/// Optional scope parameter defining requested permissions
-
final String? scope;
-
-
/// Optional nonce parameter for replay protection
-
final String? nonce;
-
-
/// Optional DPoP JKT (JSON Web Key Thumbprint)
-
final String? dpopJkt;
-
-
/// Optional max age in seconds for authentication
-
final int? maxAge;
-
-
/// Optional claims parameter
-
final Map<String, dynamic>? claims;
-
-
/// Optional UI locales
-
final String? uiLocales;
-
-
/// Optional ID token hint
-
final String? idTokenHint;
-
-
/// Optional display mode
-
final String? display;
-
-
/// Optional prompt value
-
final String? prompt;
-
-
/// Optional authorization details
-
final Map<String, dynamic>? authorizationDetails;
-
-
const AuthorizeOptions({
-
this.redirectUri,
-
this.state,
-
this.scope,
-
this.nonce,
-
this.dpopJkt,
-
this.maxAge,
-
this.claims,
-
this.uiLocales,
-
this.idTokenHint,
-
this.display,
-
this.prompt,
-
this.authorizationDetails,
-
});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{};
-
if (redirectUri != null) map['redirect_uri'] = redirectUri;
-
if (state != null) map['state'] = state;
-
if (scope != null) map['scope'] = scope;
-
if (nonce != null) map['nonce'] = nonce;
-
if (dpopJkt != null) map['dpop_jkt'] = dpopJkt;
-
if (maxAge != null) map['max_age'] = maxAge;
-
if (claims != null) map['claims'] = claims;
-
if (uiLocales != null) map['ui_locales'] = uiLocales;
-
if (idTokenHint != null) map['id_token_hint'] = idTokenHint;
-
if (display != null) map['display'] = display;
-
if (prompt != null) map['prompt'] = prompt;
-
if (authorizationDetails != null) {
-
map['authorization_details'] = authorizationDetails;
-
}
-
return map;
-
}
-
}
-
-
/// Options for handling OAuth callback.
-
class CallbackOptions {
-
/// Optional redirect URI that was used in the authorization request
-
final String? redirectUri;
-
-
const CallbackOptions({this.redirectUri});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{};
-
if (redirectUri != null) map['redirect_uri'] = redirectUri;
-
return map;
-
}
-
}
-
-
/// Client metadata for OAuth configuration.
-
///
-
/// TODO: This extends the base oauthClientMetadataSchema with specific
-
/// client_id validation. Once oauth-types is ported, this will properly
-
/// validate client_id as either discoverable or loopback type.
-
class ClientMetadata {
-
/// Client identifier (either discoverable HTTPS URI or loopback URI)
-
final String? clientId;
-
-
/// Array of redirect URIs
-
final List<String> redirectUris;
-
-
/// Response types supported by the client
-
final List<String> responseTypes;
-
-
/// Grant types supported by the client
-
final List<String> grantTypes;
-
-
/// Optional scope
-
final String? scope;
-
-
/// Token endpoint authentication method
-
final String tokenEndpointAuthMethod;
-
-
/// Optional token endpoint authentication signing algorithm
-
final String? tokenEndpointAuthSigningAlg;
-
-
/// Optional userinfo signed response algorithm
-
final String? userinfoSignedResponseAlg;
-
-
/// Optional userinfo encrypted response algorithm
-
final String? userinfoEncryptedResponseAlg;
-
-
/// Optional JWKS URI
-
final String? jwksUri;
-
-
/// Optional JWKS
-
final Map<String, dynamic>? jwks;
-
-
/// Application type (web or native)
-
final String applicationType;
-
-
/// Subject type (public or pairwise)
-
final String subjectType;
-
-
/// Optional request object signing algorithm
-
final String? requestObjectSigningAlg;
-
-
/// Optional ID token signed response algorithm
-
final String? idTokenSignedResponseAlg;
-
-
/// Authorization signed response algorithm
-
final String authorizationSignedResponseAlg;
-
-
/// Optional authorization encrypted response encoding
-
final String? authorizationEncryptedResponseEnc;
-
-
/// Optional authorization encrypted response algorithm
-
final String? authorizationEncryptedResponseAlg;
-
-
/// Optional client name
-
final String? clientName;
-
-
/// Optional client URI
-
final String? clientUri;
-
-
/// Optional policy URI
-
final String? policyUri;
-
-
/// Optional terms of service URI
-
final String? tosUri;
-
-
/// Optional logo URI
-
final String? logoUri;
-
-
/// Optional default max age
-
final int? defaultMaxAge;
-
-
/// Optional require auth time
-
final bool? requireAuthTime;
-
-
/// Optional contact emails
-
final List<String>? contacts;
-
-
/// Optional TLS client certificate bound access tokens
-
final bool? tlsClientCertificateBoundAccessTokens;
-
-
/// Optional DPoP bound access tokens
-
final bool? dpopBoundAccessTokens;
-
-
/// Optional authorization details types
-
final List<String>? authorizationDetailsTypes;
-
-
const ClientMetadata({
-
this.clientId,
-
required this.redirectUris,
-
this.responseTypes = const ['code'],
-
this.grantTypes = const ['authorization_code'],
-
this.scope,
-
this.tokenEndpointAuthMethod = 'client_secret_basic',
-
this.tokenEndpointAuthSigningAlg,
-
this.userinfoSignedResponseAlg,
-
this.userinfoEncryptedResponseAlg,
-
this.jwksUri,
-
this.jwks,
-
this.applicationType = 'web',
-
this.subjectType = 'public',
-
this.requestObjectSigningAlg,
-
this.idTokenSignedResponseAlg,
-
this.authorizationSignedResponseAlg = 'RS256',
-
this.authorizationEncryptedResponseEnc,
-
this.authorizationEncryptedResponseAlg,
-
this.clientName,
-
this.clientUri,
-
this.policyUri,
-
this.tosUri,
-
this.logoUri,
-
this.defaultMaxAge,
-
this.requireAuthTime,
-
this.contacts,
-
this.tlsClientCertificateBoundAccessTokens,
-
this.dpopBoundAccessTokens,
-
this.authorizationDetailsTypes,
-
});
-
-
Map<String, dynamic> toJson() {
-
final map = <String, dynamic>{
-
'redirect_uris': redirectUris,
-
'response_types': responseTypes,
-
'grant_types': grantTypes,
-
'token_endpoint_auth_method': tokenEndpointAuthMethod,
-
'application_type': applicationType,
-
'subject_type': subjectType,
-
'authorization_signed_response_alg': authorizationSignedResponseAlg,
-
};
-
-
if (clientId != null) map['client_id'] = clientId;
-
if (scope != null) map['scope'] = scope;
-
if (tokenEndpointAuthSigningAlg != null) {
-
map['token_endpoint_auth_signing_alg'] = tokenEndpointAuthSigningAlg;
-
}
-
if (userinfoSignedResponseAlg != null) {
-
map['userinfo_signed_response_alg'] = userinfoSignedResponseAlg;
-
}
-
if (userinfoEncryptedResponseAlg != null) {
-
map['userinfo_encrypted_response_alg'] = userinfoEncryptedResponseAlg;
-
}
-
if (jwksUri != null) map['jwks_uri'] = jwksUri;
-
if (jwks != null) map['jwks'] = jwks;
-
if (requestObjectSigningAlg != null) {
-
map['request_object_signing_alg'] = requestObjectSigningAlg;
-
}
-
if (idTokenSignedResponseAlg != null) {
-
map['id_token_signed_response_alg'] = idTokenSignedResponseAlg;
-
}
-
if (authorizationEncryptedResponseEnc != null) {
-
map['authorization_encrypted_response_enc'] =
-
authorizationEncryptedResponseEnc;
-
}
-
if (authorizationEncryptedResponseAlg != null) {
-
map['authorization_encrypted_response_alg'] =
-
authorizationEncryptedResponseAlg;
-
}
-
if (clientName != null) map['client_name'] = clientName;
-
if (clientUri != null) map['client_uri'] = clientUri;
-
if (policyUri != null) map['policy_uri'] = policyUri;
-
if (tosUri != null) map['tos_uri'] = tosUri;
-
if (logoUri != null) map['logo_uri'] = logoUri;
-
if (defaultMaxAge != null) map['default_max_age'] = defaultMaxAge;
-
if (requireAuthTime != null) map['require_auth_time'] = requireAuthTime;
-
if (contacts != null) map['contacts'] = contacts;
-
if (tlsClientCertificateBoundAccessTokens != null) {
-
map['tls_client_certificate_bound_access_tokens'] =
-
tlsClientCertificateBoundAccessTokens;
-
}
-
if (dpopBoundAccessTokens != null) {
-
map['dpop_bound_access_tokens'] = dpopBoundAccessTokens;
-
}
-
if (authorizationDetailsTypes != null) {
-
map['authorization_details_types'] = authorizationDetailsTypes;
-
}
-
-
return map;
-
}
-
-
factory ClientMetadata.fromJson(Map<String, dynamic> json) {
-
return ClientMetadata(
-
clientId: json['client_id'] as String?,
-
redirectUris:
-
json['redirect_uris'] != null
-
? (json['redirect_uris'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: [],
-
responseTypes:
-
json['response_types'] != null
-
? (json['response_types'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: const ['code'],
-
grantTypes:
-
json['grant_types'] != null
-
? (json['grant_types'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: const ['authorization_code'],
-
scope: json['scope'] as String?,
-
tokenEndpointAuthMethod:
-
json['token_endpoint_auth_method'] as String? ??
-
'client_secret_basic',
-
tokenEndpointAuthSigningAlg:
-
json['token_endpoint_auth_signing_alg'] as String?,
-
userinfoSignedResponseAlg:
-
json['userinfo_signed_response_alg'] as String?,
-
userinfoEncryptedResponseAlg:
-
json['userinfo_encrypted_response_alg'] as String?,
-
jwksUri: json['jwks_uri'] as String?,
-
jwks: json['jwks'] as Map<String, dynamic>?,
-
applicationType: json['application_type'] as String? ?? 'web',
-
subjectType: json['subject_type'] as String? ?? 'public',
-
requestObjectSigningAlg: json['request_object_signing_alg'] as String?,
-
idTokenSignedResponseAlg: json['id_token_signed_response_alg'] as String?,
-
authorizationSignedResponseAlg:
-
json['authorization_signed_response_alg'] as String? ?? 'RS256',
-
authorizationEncryptedResponseEnc:
-
json['authorization_encrypted_response_enc'] as String?,
-
authorizationEncryptedResponseAlg:
-
json['authorization_encrypted_response_alg'] as String?,
-
clientName: json['client_name'] as String?,
-
clientUri: json['client_uri'] as String?,
-
policyUri: json['policy_uri'] as String?,
-
tosUri: json['tos_uri'] as String?,
-
logoUri: json['logo_uri'] as String?,
-
defaultMaxAge: json['default_max_age'] as int?,
-
requireAuthTime: json['require_auth_time'] as bool?,
-
contacts:
-
json['contacts'] != null
-
? (json['contacts'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: null,
-
tlsClientCertificateBoundAccessTokens:
-
json['tls_client_certificate_bound_access_tokens'] as bool?,
-
dpopBoundAccessTokens: json['dpop_bound_access_tokens'] as bool?,
-
authorizationDetailsTypes:
-
json['authorization_details_types'] != null
-
? (json['authorization_details_types'] as List<dynamic>)
-
.map((e) => e as String)
-
.toList()
-
: null,
-
);
-
}
-
}
···
-195
packages/atproto_oauth_flutter/lib/src/util.dart
···
-
import 'dart:async';
-
-
/// Returns the input if it's a String, otherwise returns null.
-
String? ifString<V>(V v) => v is String ? v : null;
-
-
/// Extracts the MIME type from Content-Type header.
-
///
-
/// Example: "application/json; charset=utf-8" -> "application/json"
-
String? contentMime(Map<String, String> headers) {
-
final contentType = headers['content-type'];
-
if (contentType == null) return null;
-
return contentType.split(';')[0].trim();
-
}
-
-
/// Event detail map for custom event handling.
-
///
-
/// This is a simplified version of TypeScript's CustomEvent pattern,
-
/// adapted for Dart using StreamController and typed events.
-
///
-
/// Example:
-
/// ```dart
-
/// final target = CustomEventTarget();
-
/// final subscription = target.addEventListener('myEvent', (String detail) {
-
/// print('Received: $detail');
-
/// });
-
///
-
/// // Later, to remove the listener:
-
/// subscription.cancel();
-
/// ```
-
class CustomEventTarget<EventDetailMap> {
-
final Map<String, StreamController<dynamic>> _controllers = {};
-
-
/// Add an event listener for a specific event type.
-
///
-
/// Returns a [StreamSubscription] that can be cancelled to remove the listener.
-
///
-
/// Throws [TypeError] if an event type is already registered with a different type parameter.
-
///
-
/// Example:
-
/// ```dart
-
/// final subscription = target.addEventListener('event', (detail) => print(detail));
-
/// subscription.cancel(); // Remove this specific listener
-
/// ```
-
StreamSubscription<T> addEventListener<T>(
-
String type,
-
void Function(T detail) callback,
-
) {
-
final existingController = _controllers[type];
-
-
// Check if a controller already exists with a different type
-
if (existingController != null &&
-
existingController is! StreamController<T>) {
-
throw TypeError();
-
}
-
-
final controller =
-
_controllers.putIfAbsent(type, () => StreamController<T>.broadcast())
-
as StreamController<T>;
-
-
return controller.stream.listen(callback);
-
}
-
-
/// Dispatch a custom event with detail data.
-
///
-
/// Returns true if the event was dispatched successfully.
-
bool dispatchCustomEvent<T>(String type, T detail) {
-
final controller = _controllers[type];
-
if (controller == null) return false;
-
-
(controller as StreamController<T>).add(detail);
-
return true;
-
}
-
-
/// Dispose of all stream controllers.
-
///
-
/// Call this when the event target is no longer needed to prevent memory leaks.
-
void dispose() {
-
for (final controller in _controllers.values) {
-
controller.close();
-
}
-
_controllers.clear();
-
}
-
}
-
-
/// Combines multiple cancellation tokens into a single cancellable operation.
-
///
-
/// This is a Dart adaptation of the TypeScript combineSignals function.
-
/// Since Dart doesn't have AbortSignal/AbortController, we use CancellationToken
-
/// pattern with StreamController.
-
///
-
/// The returned controller will be cancelled if any of the input tokens are cancelled.
-
class CombinedCancellationToken {
-
final StreamController<void> _controller = StreamController<void>.broadcast();
-
final List<StreamSubscription<void>> _subscriptions = [];
-
bool _isCancelled = false;
-
Object? _reason;
-
-
CombinedCancellationToken(List<CancellationToken?> tokens) {
-
for (final token in tokens) {
-
if (token != null) {
-
if (token.isCancelled) {
-
cancel(Exception('Operation was cancelled: ${token.reason}'));
-
return;
-
}
-
-
final subscription = token.stream.listen((_) {
-
cancel(Exception('Operation was cancelled: ${token.reason}'));
-
});
-
_subscriptions.add(subscription);
-
}
-
}
-
}
-
-
/// Whether this operation has been cancelled.
-
bool get isCancelled => _isCancelled;
-
-
/// The reason for cancellation, if any.
-
Object? get reason => _reason;
-
-
/// Stream that emits when the operation is cancelled.
-
Stream<void> get stream => _controller.stream;
-
-
/// Cancel the operation with an optional reason.
-
void cancel([Object? reason]) {
-
if (_isCancelled) return;
-
-
_isCancelled = true;
-
_reason = reason ?? Exception('Operation was cancelled');
-
-
_controller.add(null);
-
dispose();
-
}
-
-
/// Clean up resources.
-
void dispose() {
-
for (final subscription in _subscriptions) {
-
subscription.cancel();
-
}
-
_subscriptions.clear();
-
_controller.close();
-
}
-
}
-
-
/// Represents a cancellable operation.
-
///
-
/// This is a Dart equivalent of AbortSignal in JavaScript.
-
class CancellationToken {
-
final StreamController<void> _controller = StreamController<void>.broadcast();
-
bool _isCancelled = false;
-
Object? _reason;
-
-
CancellationToken();
-
-
/// Whether this operation has been cancelled.
-
bool get isCancelled => _isCancelled;
-
-
/// The reason for cancellation, if any.
-
Object? get reason => _reason;
-
-
/// Stream that emits when the operation is cancelled.
-
Stream<void> get stream => _controller.stream;
-
-
/// Cancel the operation with an optional reason.
-
void cancel([Object? reason]) {
-
if (_isCancelled) return;
-
-
_isCancelled = true;
-
_reason = reason ?? Exception('Operation was cancelled');
-
-
// Only add to stream if not already closed
-
if (!_controller.isClosed) {
-
_controller.add(null);
-
}
-
}
-
-
/// Throw an exception if the operation has been cancelled.
-
void throwIfCancelled() {
-
if (_isCancelled) {
-
throw _reason ?? Exception('Operation was cancelled');
-
}
-
}
-
-
/// Dispose of the stream controller.
-
void dispose() {
-
_controller.close();
-
}
-
}
-
-
/// Combines multiple cancellation tokens into a single token.
-
///
-
/// If any of the input tokens are cancelled, the returned token will also be cancelled.
-
/// The returned token should be disposed when no longer needed.
-
CombinedCancellationToken combineSignals(List<CancellationToken?> signals) {
-
return CombinedCancellationToken(signals);
-
}
···
-100
packages/atproto_oauth_flutter/lib/src/utils/lock.dart
···
-
import 'dart:async';
-
-
import '../runtime/runtime_implementation.dart';
-
-
/// A map storing active locks by name.
-
///
-
/// Each lock is represented as a Future that completes when the lock is released.
-
/// This allows queuing of operations waiting for the same lock.
-
final Map<Object, Future<void>> _locks = {};
-
-
/// Acquires a lock for the given name.
-
///
-
/// Returns a function that releases the lock when called.
-
/// The lock is automatically added to the queue of pending operations.
-
///
-
/// This implements a fair (FIFO) mutex pattern where operations are executed
-
/// in the order they acquire the lock.
-
Future<void Function()> _acquireLocalLock(Object name) {
-
final completer = Completer<void Function()>();
-
-
// Get the previous lock in the queue (or a resolved promise if none)
-
final prev = _locks[name] ?? Future.value();
-
-
// Create a completer for the release function
-
final releaseCompleter = Completer<void>();
-
-
// Chain onto the previous lock
-
final next = prev.then((_) {
-
// This runs when we've acquired the lock
-
return releaseCompleter.future;
-
});
-
-
// Store our lock as the new tail of the queue
-
_locks[name] = next;
-
-
// Resolve the acquire promise with the release function
-
prev.then((_) {
-
void release() {
-
// Only delete the lock if it's still the current one
-
// (it might have been replaced by a newer lock)
-
if (_locks[name] == next) {
-
_locks.remove(name);
-
}
-
-
// Complete the release, allowing the next operation to proceed
-
if (!releaseCompleter.isCompleted) {
-
releaseCompleter.complete();
-
}
-
}
-
-
completer.complete(release);
-
});
-
-
return completer.future;
-
}
-
-
/// Executes a function while holding a named lock.
-
///
-
/// This is a local (in-memory) lock implementation that prevents concurrent
-
/// execution of the same operation within a single isolate/process.
-
///
-
/// The lock is automatically released when the function completes or throws an error.
-
///
-
/// Example:
-
/// ```dart
-
/// final result = await requestLocalLock('my-operation', () async {
-
/// // Only one execution at a time for 'my-operation'
-
/// return await performCriticalOperation();
-
/// });
-
/// ```
-
///
-
/// Use cases:
-
/// - Token refresh (prevent multiple simultaneous refresh requests)
-
/// - Database transactions
-
/// - File operations
-
/// - Any operation that must not run concurrently with itself
-
///
-
/// Note: This is an in-memory lock. It does not work across:
-
/// - Multiple isolates
-
/// - Multiple processes
-
/// - Multiple app instances
-
///
-
/// For cross-process locking, implement a platform-specific RuntimeLock.
-
Future<T> requestLocalLock<T>(String name, FutureOr<T> Function() fn) async {
-
// Acquire the lock and get the release function
-
final release = await _acquireLocalLock(name);
-
-
try {
-
// Execute the function while holding the lock
-
return await fn();
-
} finally {
-
// Always release the lock, even if the function throws
-
release();
-
}
-
}
-
-
/// Convenience getter that returns the requestLocalLock function as a RuntimeLock.
-
///
-
/// This can be used as the default implementation for RuntimeImplementation.requestLock.
-
RuntimeLock get requestLocalLockImpl => requestLocalLock;
···
-530
packages/atproto_oauth_flutter/pubspec.lock
···
-
# Generated by pub
-
# See https://dart.dev/tools/pub/glossary#lockfile
-
packages:
-
async:
-
dependency: transitive
-
description:
-
name: async
-
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.12.0"
-
boolean_selector:
-
dependency: transitive
-
description:
-
name: boolean_selector
-
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.2"
-
characters:
-
dependency: transitive
-
description:
-
name: characters
-
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.4.0"
-
clock:
-
dependency: transitive
-
description:
-
name: clock
-
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.2"
-
collection:
-
dependency: "direct main"
-
description:
-
name: collection
-
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.19.1"
-
convert:
-
dependency: "direct main"
-
description:
-
name: convert
-
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.2"
-
crypto:
-
dependency: "direct main"
-
description:
-
name: crypto
-
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.0.6"
-
desktop_webview_window:
-
dependency: transitive
-
description:
-
name: desktop_webview_window
-
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.2.3"
-
dio:
-
dependency: "direct main"
-
description:
-
name: dio
-
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.9.0"
-
dio_web_adapter:
-
dependency: transitive
-
description:
-
name: dio_web_adapter
-
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.1"
-
fake_async:
-
dependency: transitive
-
description:
-
name: fake_async
-
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.3.3"
-
ffi:
-
dependency: transitive
-
description:
-
name: ffi
-
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.4"
-
flutter:
-
dependency: "direct main"
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
flutter_lints:
-
dependency: "direct dev"
-
description:
-
name: flutter_lints
-
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.0.0"
-
flutter_secure_storage:
-
dependency: "direct main"
-
description:
-
name: flutter_secure_storage
-
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
-
url: "https://pub.dev"
-
source: hosted
-
version: "9.2.4"
-
flutter_secure_storage_linux:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_linux
-
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.2.3"
-
flutter_secure_storage_macos:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_macos
-
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.3"
-
flutter_secure_storage_platform_interface:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_platform_interface
-
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.2"
-
flutter_secure_storage_web:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_web
-
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.2.1"
-
flutter_secure_storage_windows:
-
dependency: transitive
-
description:
-
name: flutter_secure_storage_windows
-
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.2"
-
flutter_test:
-
dependency: "direct dev"
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
flutter_web_auth_2:
-
dependency: "direct main"
-
description:
-
name: flutter_web_auth_2
-
sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696"
-
url: "https://pub.dev"
-
source: hosted
-
version: "4.1.0"
-
flutter_web_auth_2_platform_interface:
-
dependency: transitive
-
description:
-
name: flutter_web_auth_2_platform_interface
-
sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d
-
url: "https://pub.dev"
-
source: hosted
-
version: "4.1.0"
-
flutter_web_plugins:
-
dependency: transitive
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
http:
-
dependency: "direct main"
-
description:
-
name: http
-
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.5.0"
-
http_parser:
-
dependency: transitive
-
description:
-
name: http_parser
-
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
-
url: "https://pub.dev"
-
source: hosted
-
version: "4.1.2"
-
js:
-
dependency: transitive
-
description:
-
name: js
-
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.6.7"
-
leak_tracker:
-
dependency: transitive
-
description:
-
name: leak_tracker
-
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
-
url: "https://pub.dev"
-
source: hosted
-
version: "11.0.2"
-
leak_tracker_flutter_testing:
-
dependency: transitive
-
description:
-
name: leak_tracker_flutter_testing
-
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.0.10"
-
leak_tracker_testing:
-
dependency: transitive
-
description:
-
name: leak_tracker_testing
-
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.0.2"
-
lints:
-
dependency: transitive
-
description:
-
name: lints
-
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.1.1"
-
matcher:
-
dependency: transitive
-
description:
-
name: matcher
-
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.12.17"
-
material_color_utilities:
-
dependency: transitive
-
description:
-
name: material_color_utilities
-
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.11.1"
-
meta:
-
dependency: transitive
-
description:
-
name: meta
-
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.16.0"
-
mime:
-
dependency: transitive
-
description:
-
name: mime
-
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.0.0"
-
path:
-
dependency: transitive
-
description:
-
name: path
-
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.9.1"
-
path_provider:
-
dependency: transitive
-
description:
-
name: path_provider
-
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.5"
-
path_provider_android:
-
dependency: transitive
-
description:
-
name: path_provider_android
-
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.2.19"
-
path_provider_foundation:
-
dependency: transitive
-
description:
-
name: path_provider_foundation
-
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.4.2"
-
path_provider_linux:
-
dependency: transitive
-
description:
-
name: path_provider_linux
-
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.2.1"
-
path_provider_platform_interface:
-
dependency: transitive
-
description:
-
name: path_provider_platform_interface
-
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.2"
-
path_provider_windows:
-
dependency: transitive
-
description:
-
name: path_provider_windows
-
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.3.0"
-
platform:
-
dependency: transitive
-
description:
-
name: platform
-
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.6"
-
plugin_platform_interface:
-
dependency: transitive
-
description:
-
name: plugin_platform_interface
-
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.8"
-
pointycastle:
-
dependency: "direct main"
-
description:
-
name: pointycastle
-
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.9.1"
-
sky_engine:
-
dependency: transitive
-
description: flutter
-
source: sdk
-
version: "0.0.0"
-
source_span:
-
dependency: transitive
-
description:
-
name: source_span
-
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.10.1"
-
stack_trace:
-
dependency: transitive
-
description:
-
name: stack_trace
-
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.12.1"
-
stream_channel:
-
dependency: transitive
-
description:
-
name: stream_channel
-
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.1.4"
-
string_scanner:
-
dependency: transitive
-
description:
-
name: string_scanner
-
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.4.1"
-
term_glyph:
-
dependency: transitive
-
description:
-
name: term_glyph
-
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.2.2"
-
test_api:
-
dependency: transitive
-
description:
-
name: test_api
-
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.7.6"
-
typed_data:
-
dependency: transitive
-
description:
-
name: typed_data
-
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.4.0"
-
url_launcher:
-
dependency: transitive
-
description:
-
name: url_launcher
-
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
-
url: "https://pub.dev"
-
source: hosted
-
version: "6.3.2"
-
url_launcher_android:
-
dependency: transitive
-
description:
-
name: url_launcher_android
-
sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e"
-
url: "https://pub.dev"
-
source: hosted
-
version: "6.3.20"
-
url_launcher_ios:
-
dependency: transitive
-
description:
-
name: url_launcher_ios
-
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
-
url: "https://pub.dev"
-
source: hosted
-
version: "6.3.4"
-
url_launcher_linux:
-
dependency: transitive
-
description:
-
name: url_launcher_linux
-
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.2.1"
-
url_launcher_macos:
-
dependency: transitive
-
description:
-
name: url_launcher_macos
-
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.2.3"
-
url_launcher_platform_interface:
-
dependency: transitive
-
description:
-
name: url_launcher_platform_interface
-
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.3.2"
-
url_launcher_web:
-
dependency: transitive
-
description:
-
name: url_launcher_web
-
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.4.1"
-
url_launcher_windows:
-
dependency: transitive
-
description:
-
name: url_launcher_windows
-
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
-
url: "https://pub.dev"
-
source: hosted
-
version: "3.1.4"
-
vector_math:
-
dependency: transitive
-
description:
-
name: vector_math
-
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
-
url: "https://pub.dev"
-
source: hosted
-
version: "2.2.0"
-
vm_service:
-
dependency: transitive
-
description:
-
name: vm_service
-
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
-
url: "https://pub.dev"
-
source: hosted
-
version: "14.3.1"
-
web:
-
dependency: transitive
-
description:
-
name: web
-
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.1"
-
win32:
-
dependency: transitive
-
description:
-
name: win32
-
sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba"
-
url: "https://pub.dev"
-
source: hosted
-
version: "5.13.0"
-
window_to_front:
-
dependency: transitive
-
description:
-
name: window_to_front
-
sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee"
-
url: "https://pub.dev"
-
source: hosted
-
version: "0.0.3"
-
xdg_directories:
-
dependency: transitive
-
description:
-
name: xdg_directories
-
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
-
url: "https://pub.dev"
-
source: hosted
-
version: "1.1.0"
-
sdks:
-
dart: ">=3.8.0-0 <4.0.0"
-
flutter: ">=3.29.0"
···
-24
packages/atproto_oauth_flutter/pubspec.yaml
···
-
name: atproto_oauth_flutter
-
description: Official AT Protocol OAuth client for Flutter - 1:1 port of @atproto/oauth-client
-
version: 0.1.0
-
publish_to: none
-
-
environment:
-
sdk: ^3.7.2
-
-
dependencies:
-
flutter:
-
sdk: flutter
-
dio: ^5.9.0
-
flutter_secure_storage: ^9.2.2
-
flutter_web_auth_2: ^4.1.0
-
crypto: ^3.0.3
-
pointycastle: ^3.9.1
-
convert: ^3.1.1
-
collection: ^1.18.0
-
http: ^1.2.0
-
-
dev_dependencies:
-
flutter_test:
-
sdk: flutter
-
flutter_lints: ^5.0.0
···
-245
packages/atproto_oauth_flutter/test/identity_resolver_test.dart
···
-
/// Unit tests for the identity resolution layer.
-
///
-
/// Note: These are basic validation tests. Real integration tests would
-
/// require network calls to live services.
-
-
import 'package:flutter_test/flutter_test.dart';
-
import 'package:atproto_oauth_flutter/src/identity/identity.dart';
-
-
void main() {
-
group('DID Validation', () {
-
test('isDidPlc validates did:plc correctly', () {
-
// did:plc must be exactly 32 chars total (8 prefix + 24 base32 [a-z2-7])
-
expect(isDidPlc('did:plc:z72i7hdynmk6r22z27h6abc2'), isTrue);
-
expect(isDidPlc('did:plc:2222222222222222222222ab'), isTrue);
-
expect(isDidPlc('did:plc:abcdefgabcdefgabcdefgabc'), isTrue);
-
-
// Wrong length
-
expect(isDidPlc('did:plc:short'), isFalse);
-
expect(isDidPlc('did:plc:toolonggggggggggggggggggggg'), isFalse);
-
-
// Wrong prefix
-
expect(isDidPlc('did:web:example.com'), isFalse);
-
-
// Invalid characters (not base32)
-
expect(isDidPlc('did:plc:0000000000000000000000'), isFalse); // has 0
-
expect(isDidPlc('did:plc:1111111111111111111111'), isFalse); // has 1
-
});
-
-
test('isDidWeb validates did:web correctly', () {
-
expect(isDidWeb('did:web:example.com'), isTrue);
-
expect(isDidWeb('did:web:example.com:user:alice'), isTrue);
-
expect(isDidWeb('did:web:localhost%3A3000'), isTrue);
-
-
// Wrong prefix
-
expect(isDidWeb('did:plc:abc123xyz789abc123xyz789'), isFalse);
-
-
// Can't start with colon after prefix
-
expect(isDidWeb('did:web::example.com'), isFalse);
-
});
-
-
test('isDid validates general DIDs', () {
-
expect(isDid('did:plc:abc123xyz789abc123xyz789'), isTrue);
-
expect(isDid('did:web:example.com'), isTrue);
-
expect(
-
isDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'),
-
isTrue,
-
);
-
-
// Invalid
-
expect(isDid('not-a-did'), isFalse);
-
expect(isDid('did:'), isFalse);
-
expect(isDid('did:method'), isFalse);
-
expect(isDid(''), isFalse);
-
});
-
-
test('extractDidMethod extracts method name', () {
-
expect(extractDidMethod('did:plc:abc123'), equals('plc'));
-
expect(extractDidMethod('did:web:example.com'), equals('web'));
-
expect(extractDidMethod('did:key:z6Mk...'), equals('key'));
-
});
-
-
test('didWebToUrl converts did:web to URL', () {
-
final url1 = didWebToUrl('did:web:example.com');
-
expect(url1.toString(), equals('https://example.com'));
-
-
final url2 = didWebToUrl('did:web:example.com:user:alice');
-
expect(url2.toString(), equals('https://example.com/user/alice'));
-
-
final url3 = didWebToUrl('did:web:localhost%3A3000');
-
expect(url3.toString(), equals('http://localhost:3000'));
-
});
-
-
test('urlToDidWeb converts URL to did:web', () {
-
final did1 = urlToDidWeb(Uri.parse('https://example.com'));
-
expect(did1, equals('did:web:example.com'));
-
-
final did2 = urlToDidWeb(Uri.parse('https://example.com/user/alice'));
-
expect(did2, equals('did:web:example.com:user:alice'));
-
});
-
});
-
-
group('Handle Validation', () {
-
test('isValidHandle validates handles', () {
-
expect(isValidHandle('alice.example.com'), isTrue);
-
expect(isValidHandle('user.bsky.social'), isTrue);
-
expect(isValidHandle('sub.domain.example.com'), isTrue);
-
expect(isValidHandle('a.b'), isTrue);
-
-
// Invalid
-
expect(isValidHandle(''), isFalse);
-
expect(isValidHandle('no-tld'), isFalse);
-
expect(isValidHandle('.starts-with-dot.com'), isFalse);
-
expect(isValidHandle('ends-with-dot.com.'), isFalse);
-
expect(isValidHandle('has..double-dot.com'), isFalse);
-
expect(isValidHandle('has spaces.com'), isFalse);
-
-
// Too long (254+ chars)
-
final longHandle = '${'a' * 250}.com';
-
expect(isValidHandle(longHandle), isFalse);
-
});
-
-
test('normalizeHandle converts to lowercase', () {
-
expect(normalizeHandle('Alice.Example.Com'), equals('alice.example.com'));
-
expect(normalizeHandle('USER.BSKY.SOCIAL'), equals('user.bsky.social'));
-
});
-
-
test('asNormalizedHandle validates and normalizes', () {
-
expect(
-
asNormalizedHandle('Alice.Example.Com'),
-
equals('alice.example.com'),
-
);
-
expect(asNormalizedHandle('invalid'), isNull);
-
expect(asNormalizedHandle(''), isNull);
-
});
-
});
-
-
group('DID Document', () {
-
test('DidDocument parses from JSON', () {
-
final json = {
-
'id': 'did:plc:abc123xyz789abc123xyz789',
-
'alsoKnownAs': ['at://alice.bsky.social'],
-
'service': [
-
{
-
'id': '#atproto_pds',
-
'type': 'AtprotoPersonalDataServer',
-
'serviceEndpoint': 'https://pds.example.com',
-
},
-
],
-
};
-
-
final doc = DidDocument.fromJson(json);
-
-
expect(doc.id, equals('did:plc:abc123xyz789abc123xyz789'));
-
expect(doc.alsoKnownAs, contains('at://alice.bsky.social'));
-
expect(doc.service?.length, equals(1));
-
expect(doc.service?[0].type, equals('AtprotoPersonalDataServer'));
-
});
-
-
test('DidDocument extracts PDS URL', () {
-
final doc = DidDocument(
-
id: 'did:plc:test',
-
service: [
-
DidService(
-
id: '#atproto_pds',
-
type: 'AtprotoPersonalDataServer',
-
serviceEndpoint: 'https://pds.example.com',
-
),
-
],
-
);
-
-
expect(doc.extractPdsUrl(), equals('https://pds.example.com'));
-
});
-
-
test('DidDocument extracts handle', () {
-
final doc = DidDocument(
-
id: 'did:plc:test',
-
alsoKnownAs: ['at://alice.bsky.social', 'https://example.com'],
-
);
-
-
expect(doc.extractAtprotoHandle(), equals('alice.bsky.social'));
-
expect(doc.extractNormalizedHandle(), equals('alice.bsky.social'));
-
});
-
-
test('DidDocument returns null for missing PDS', () {
-
final doc = DidDocument(id: 'did:plc:test');
-
expect(doc.extractPdsUrl(), isNull);
-
});
-
-
test('DidDocument returns null for missing handle', () {
-
final doc = DidDocument(id: 'did:plc:test');
-
expect(doc.extractAtprotoHandle(), isNull);
-
expect(doc.extractNormalizedHandle(), isNull);
-
});
-
});
-
-
group('Cache', () {
-
test('InMemoryDidCache stores and retrieves', () async {
-
final cache = InMemoryDidCache(ttl: Duration(seconds: 1));
-
final doc = DidDocument(id: 'did:plc:test');
-
-
await cache.set('did:plc:test', doc);
-
final retrieved = await cache.get('did:plc:test');
-
-
expect(retrieved?.id, equals('did:plc:test'));
-
});
-
-
test('InMemoryDidCache expires entries', () async {
-
final cache = InMemoryDidCache(ttl: Duration(milliseconds: 100));
-
final doc = DidDocument(id: 'did:plc:test');
-
-
await cache.set('did:plc:test', doc);
-
-
// Should exist immediately
-
expect(await cache.get('did:plc:test'), isNotNull);
-
-
// Wait for expiration
-
await Future.delayed(Duration(milliseconds: 150));
-
-
// Should be expired
-
expect(await cache.get('did:plc:test'), isNull);
-
});
-
-
test('InMemoryHandleCache stores and retrieves', () async {
-
final cache = InMemoryHandleCache(ttl: Duration(seconds: 1));
-
-
await cache.set('alice.bsky.social', 'did:plc:test');
-
final retrieved = await cache.get('alice.bsky.social');
-
-
expect(retrieved, equals('did:plc:test'));
-
});
-
-
test('Cache clears all entries', () async {
-
final cache = InMemoryDidCache();
-
final doc = DidDocument(id: 'did:plc:test');
-
-
await cache.set('did:plc:test', doc);
-
expect(await cache.get('did:plc:test'), isNotNull);
-
-
await cache.clear();
-
expect(await cache.get('did:plc:test'), isNull);
-
});
-
});
-
-
group('Error Types', () {
-
test('IdentityResolverError has message', () {
-
final error = IdentityResolverError('Test error');
-
expect(error.message, equals('Test error'));
-
expect(error.toString(), contains('Test error'));
-
});
-
-
test('InvalidDidError includes DID', () {
-
final error = InvalidDidError('not:valid', 'Invalid format');
-
expect(error.did, equals('not:valid'));
-
expect(error.toString(), contains('not:valid'));
-
expect(error.toString(), contains('Invalid format'));
-
});
-
-
test('InvalidHandleError includes handle', () {
-
final error = InvalidHandleError('invalid', 'Invalid format');
-
expect(error.handle, equals('invalid'));
-
expect(error.toString(), contains('invalid'));
-
expect(error.toString(), contains('Invalid format'));
-
});
-
});
-
}
···