1import '../errors/oauth_resolver_error.dart'; 2import '../identity/did_document.dart'; 3import '../identity/identity_resolver.dart'; 4import 'authorization_server_metadata_resolver.dart'; 5import 'protected_resource_metadata_resolver.dart'; 6 7/// Complete result of OAuth resolution from an identity. 8class ResolvedOAuthIdentityFromIdentity { 9 /// The resolved identity information 10 final IdentityInfo identityInfo; 11 12 /// The authorization server metadata 13 final Map<String, dynamic> metadata; 14 15 /// The PDS URL 16 final Uri pds; 17 18 const ResolvedOAuthIdentityFromIdentity({ 19 required this.identityInfo, 20 required this.metadata, 21 required this.pds, 22 }); 23} 24 25/// Result of OAuth resolution from a service URL. 26class ResolvedOAuthIdentityFromService { 27 /// The authorization server metadata 28 final Map<String, dynamic> metadata; 29 30 /// Optional identity info (only present if resolved from handle/DID) 31 final IdentityInfo? identityInfo; 32 33 const ResolvedOAuthIdentityFromService({ 34 required this.metadata, 35 this.identityInfo, 36 }); 37} 38 39/// Options for OAuth resolution. 40typedef ResolveOAuthOptions = GetCachedOptions; 41 42/// Main OAuth resolver that combines identity and metadata resolution. 43/// 44/// This class orchestrates the complete OAuth discovery flow: 45/// 46/// 1. **From handle/DID** (resolveFromIdentity): 47/// - Resolve handle → DID (if needed) 48/// - Fetch DID document 49/// - Extract PDS URL from DID document 50/// - Fetch protected resource metadata from PDS 51/// - Extract authorization server(s) from resource metadata 52/// - Fetch authorization server metadata 53/// - Verify PDS is protected by the authorization server 54/// 55/// 2. **From URL** (resolveFromService): 56/// - Try as PDS URL (fetch protected resource metadata) 57/// - Extract authorization server from metadata 58/// - Fallback: try as authorization server directly 59/// 60/// This is the critical piece that enables decentralization - users can 61/// host their data on any PDS, and we discover the OAuth server dynamically. 62class OAuthResolver { 63 final IdentityResolver identityResolver; 64 final OAuthProtectedResourceMetadataResolver 65 protectedResourceMetadataResolver; 66 final OAuthAuthorizationServerMetadataResolver 67 authorizationServerMetadataResolver; 68 69 OAuthResolver({ 70 required this.identityResolver, 71 required this.protectedResourceMetadataResolver, 72 required this.authorizationServerMetadataResolver, 73 }); 74 75 /// Resolves OAuth metadata from an input (handle, DID, or URL). 76 /// 77 /// The [input] can be: 78 /// - An atProto handle (e.g., "alice.bsky.social") 79 /// - A DID (e.g., "did:plc:...") 80 /// - A PDS URL (e.g., "https://pds.example.com") 81 /// - An authorization server URL (e.g., "https://auth.example.com") 82 /// 83 /// Returns metadata for the authorization server. The identityInfo 84 /// is only present if input was a handle or DID. 85 Future<ResolvedOAuthIdentityFromService> resolve( 86 String input, [ 87 ResolveOAuthOptions? options, 88 ]) async { 89 // Detect if input is a URL (starts with http:// or https://) 90 if (RegExp(r'^https?://').hasMatch(input)) { 91 return resolveFromService(input, options); 92 } else { 93 final result = await resolveFromIdentity(input, options); 94 return ResolvedOAuthIdentityFromService( 95 metadata: result.metadata, 96 identityInfo: result.identityInfo, 97 ); 98 } 99 } 100 101 /// Resolves OAuth metadata from a service URL (PDS or authorization server). 102 /// 103 /// This method: 104 /// 1. First tries to resolve as a PDS (protected resource) 105 /// 2. If that fails, tries to resolve as an authorization server directly 106 /// 107 /// This allows both "login with PDS URL" and "login with auth server URL" 108 /// flows, useful when users forget their handle or for compatibility. 109 Future<ResolvedOAuthIdentityFromService> resolveFromService( 110 String input, [ 111 ResolveOAuthOptions? options, 112 ]) async { 113 try { 114 // Assume first that input is a PDS URL (as required by ATPROTO) 115 final metadata = await getResourceServerMetadata(input, options); 116 return ResolvedOAuthIdentityFromService(metadata: metadata); 117 } catch (err) { 118 // Check if request was cancelled - note: Dio's CancelToken doesn't have throwIfCanceled() 119 // We rely on Dio throwing CancelError automatically 120 121 if (err is OAuthResolverError) { 122 try { 123 // Fallback to trying to fetch as an issuer (Entryway/Authorization Server) 124 final issuerUri = Uri.tryParse(input); 125 if (issuerUri != null && issuerUri.hasScheme) { 126 final metadata = await getAuthorizationServerMetadata( 127 input, 128 options, 129 ); 130 return ResolvedOAuthIdentityFromService(metadata: metadata); 131 } 132 } catch (_) { 133 // Fallback failed, throw original error 134 } 135 } 136 137 rethrow; 138 } 139 } 140 141 /// Resolves OAuth metadata from a handle or DID. 142 /// 143 /// This is the primary OAuth discovery flow: 144 /// 1. Resolve handle → DID → DID document (via IdentityResolver) 145 /// 2. Extract PDS URL from DID document 146 /// 3. Get protected resource metadata from PDS 147 /// 4. Extract authorization server(s) 148 /// 5. Get authorization server metadata 149 /// 6. Verify PDS is protected by the auth server 150 Future<ResolvedOAuthIdentityFromIdentity> resolveFromIdentity( 151 String input, [ 152 ResolveOAuthOptions? options, 153 ]) async { 154 final identityInfo = await resolveIdentity( 155 input, 156 options != null 157 ? ResolveIdentityOptions( 158 noCache: options.noCache, 159 cancelToken: options.cancelToken, 160 ) 161 : null, 162 ); 163 164 final pds = _extractPdsUrl(identityInfo.didDoc); 165 166 final metadata = await getResourceServerMetadata(pds, options); 167 168 return ResolvedOAuthIdentityFromIdentity( 169 identityInfo: identityInfo, 170 metadata: metadata, 171 pds: pds, 172 ); 173 } 174 175 /// Resolves an identity (handle or DID) to IdentityInfo. 176 /// 177 /// Wraps the IdentityResolver with proper error handling. 178 Future<IdentityInfo> resolveIdentity( 179 String input, [ 180 ResolveIdentityOptions? options, 181 ]) async { 182 try { 183 return await identityResolver.resolve(input, options); 184 } catch (cause) { 185 throw OAuthResolverError.from( 186 cause, 187 'Failed to resolve identity: $input', 188 ); 189 } 190 } 191 192 /// Gets authorization server metadata for an issuer. 193 /// 194 /// Wraps the AuthorizationServerMetadataResolver with proper error handling. 195 Future<Map<String, dynamic>> getAuthorizationServerMetadata( 196 String issuer, [ 197 GetCachedOptions? options, 198 ]) async { 199 try { 200 return await authorizationServerMetadataResolver.get(issuer, options); 201 } catch (cause) { 202 throw OAuthResolverError.from( 203 cause, 204 'Failed to resolve OAuth server metadata for issuer: $issuer', 205 ); 206 } 207 } 208 209 /// Gets authorization server metadata for a protected resource (PDS). 210 /// 211 /// This method: 212 /// 1. Fetches protected resource metadata 213 /// 2. Validates exactly one authorization server is listed (ATPROTO requirement) 214 /// 3. Fetches authorization server metadata 215 /// 4. Verifies the PDS is in the auth server's protected_resources list 216 Future<Map<String, dynamic>> getResourceServerMetadata( 217 dynamic pdsUrl, [ 218 GetCachedOptions? options, 219 ]) async { 220 try { 221 final rsMetadata = await protectedResourceMetadataResolver.get( 222 pdsUrl, 223 options, 224 ); 225 226 // ATPROTO requires exactly one authorization server 227 final authServers = rsMetadata['authorization_servers']; 228 if (authServers is! List || authServers.length != 1) { 229 throw OAuthResolverError( 230 authServers == null || (authServers as List).isEmpty 231 ? 'No authorization servers found for PDS: $pdsUrl' 232 : 'Unable to determine authorization server for PDS: $pdsUrl', 233 ); 234 } 235 236 final issuer = authServers[0] as String; 237 238 final asMetadata = await getAuthorizationServerMetadata(issuer, options); 239 240 // Verify PDS is protected by this authorization server 241 // https://www.rfc-editor.org/rfc/rfc9728.html#section-4 242 final protectedResources = asMetadata['protected_resources']; 243 if (protectedResources != null) { 244 final resource = rsMetadata['resource'] as String; 245 if (!(protectedResources as List).contains(resource)) { 246 throw OAuthResolverError( 247 'PDS "$pdsUrl" not protected by issuer "$issuer"', 248 ); 249 } 250 } 251 252 return asMetadata; 253 } catch (cause) { 254 throw OAuthResolverError.from( 255 cause, 256 'Failed to resolve OAuth server metadata for resource: $pdsUrl', 257 ); 258 } 259 } 260 261 /// Extracts the PDS URL from a DID document. 262 /// 263 /// Throws OAuthResolverError if no PDS URL is found. 264 Uri _extractPdsUrl(DidDocument document) { 265 // Find the atproto_pds service 266 final service = document.service?.firstWhere( 267 (s) => _isAtprotoPersonalDataServerService(s, document), 268 orElse: 269 () => 270 throw OAuthResolverError( 271 'Identity "${document.id}" does not have a PDS URL', 272 ), 273 ); 274 275 if (service == null) { 276 throw OAuthResolverError( 277 'Identity "${document.id}" does not have a PDS URL', 278 ); 279 } 280 281 try { 282 return Uri.parse(service.serviceEndpoint as String); 283 } catch (cause) { 284 throw OAuthResolverError( 285 'Invalid PDS URL in DID document: ${service.serviceEndpoint}', 286 cause: cause, 287 ); 288 } 289 } 290 291 /// Checks if a service is an AtprotoPersonalDataServer. 292 bool _isAtprotoPersonalDataServerService( 293 DidService service, 294 DidDocument document, 295 ) { 296 if (service.serviceEndpoint is! String) return false; 297 if (service.type != 'AtprotoPersonalDataServer') return false; 298 299 // Check service ID 300 final id = service.id; 301 if (id.startsWith('#')) { 302 return id == '#atproto_pds'; 303 } else { 304 return id == '${document.id}#atproto_pds'; 305 } 306 } 307}