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.socialas 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.
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.resolveHandleendpoint - 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 endpointextractNormalizedHandle(): Gets the validated handle
Bi-directional Resolution#
For security, we enforce bi-directional resolution:
- Handle → DID resolution must succeed
- DID document must contain the original handle
- 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#
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#
// 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#
// 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#
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#
- No DNS Resolution: Dart doesn't have built-in DNS TXT lookups. We use XRPC only.
- Simplified Caching: In-memory only (TypeScript has more cache backends).
- Dio instead of Fetch: Using Dio HTTP client instead of global fetch.
- 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:
// 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#
- Bi-directional Validation: Always enforced to prevent spoofing
- HTTPS Only: All HTTP requests use HTTPS (except localhost for testing)
- No Redirects: HTTP redirects are rejected to prevent attacks
- Input Validation: All handles and DIDs are validated before use
- 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