Main coves client
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}