Main coves client
1import 'dart:async';
2import 'package:dio/dio.dart';
3import 'package:http/http.dart' as http;
4
5import '../dpop/fetch_dpop.dart';
6import '../errors/token_invalid_error.dart';
7import '../errors/token_revoked_error.dart';
8import '../oauth/oauth_server_agent.dart';
9
10/// Type alias for AtprotoDid (user's DID)
11typedef AtprotoDid = String;
12
13/// Type alias for AtprotoOAuthScope
14typedef AtprotoOAuthScope = String;
15
16/// Placeholder for OAuthAuthorizationServerMetadata
17/// Will be properly typed in later chunks
18typedef OAuthAuthorizationServerMetadata = Map<String, dynamic>;
19
20/// Information about the current token.
21class TokenInfo {
22 /// When the token expires (null if no expiration)
23 final DateTime? expiresAt;
24
25 /// Whether the token is expired (null if no expiration)
26 final bool? expired;
27
28 /// The scope of access granted
29 final AtprotoOAuthScope scope;
30
31 /// The issuer URL
32 final String iss;
33
34 /// The audience (resource server)
35 final String aud;
36
37 /// The subject (user's DID)
38 final AtprotoDid sub;
39
40 TokenInfo({
41 this.expiresAt,
42 this.expired,
43 required this.scope,
44 required this.iss,
45 required this.aud,
46 required this.sub,
47 });
48}
49
50/// Abstract interface for session management.
51///
52/// This will be implemented by SessionGetter in session_getter.dart.
53/// We define it here to avoid circular dependencies.
54abstract class SessionGetterInterface {
55 Future<Session> get(AtprotoDid sub, {bool? noCache, bool? allowStale});
56
57 Future<void> delStored(AtprotoDid sub, [Object? cause]);
58}
59
60/// Represents an active OAuth session.
61///
62/// A session is created after successful authentication and provides methods
63/// for making authenticated requests and managing the session lifecycle.
64class Session {
65 /// The DPoP key used for this session (serialized as Map for storage)
66 final Map<String, dynamic> dpopKey;
67
68 /// The client authentication method (serialized as Map or String for storage).
69 /// Can be:
70 /// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT
71 /// - A Map containing {method: 'none'} for no authentication
72 /// - A String 'legacy' for backwards compatibility
73 /// - null (defaults to 'legacy' when loading)
74 final dynamic authMethod;
75
76 /// The token set containing access and refresh tokens
77 final TokenSet tokenSet;
78
79 const Session({
80 required this.dpopKey,
81 this.authMethod,
82 required this.tokenSet,
83 });
84
85 /// Creates a Session from JSON.
86 factory Session.fromJson(Map<String, dynamic> json) {
87 return Session(
88 dpopKey: json['dpopKey'] as Map<String, dynamic>,
89 authMethod: json['authMethod'], // Can be Map or String
90 tokenSet: TokenSet.fromJson(json['tokenSet'] as Map<String, dynamic>),
91 );
92 }
93
94 /// Converts this Session to JSON.
95 Map<String, dynamic> toJson() {
96 final json = <String, dynamic>{
97 'dpopKey': dpopKey,
98 'tokenSet': tokenSet.toJson(),
99 };
100
101 if (authMethod != null) json['authMethod'] = authMethod;
102
103 return json;
104 }
105}
106
107/// Represents an active OAuth session with methods for authenticated requests.
108///
109/// This class wraps an OAuth session and provides:
110/// - Automatic token refresh on expiry
111/// - DPoP-protected requests
112/// - Session lifecycle management (sign out)
113///
114/// Example:
115/// ```dart
116/// final session = OAuthSession(
117/// server: oauthServer,
118/// sub: 'did:plc:abc123',
119/// sessionGetter: sessionGetter,
120/// );
121///
122/// // Make an authenticated request
123/// final response = await session.fetchHandler('/api/posts');
124///
125/// // Get token information
126/// final info = await session.getTokenInfo();
127/// print('Token expires at: ${info.expiresAt}');
128///
129/// // Sign out
130/// await session.signOut();
131/// ```
132class OAuthSession {
133 /// The OAuth server agent
134 final OAuthServerAgent server;
135
136 /// The subject (user's DID)
137 final AtprotoDid sub;
138
139 /// The session getter for retrieving and refreshing tokens
140 final SessionGetterInterface sessionGetter;
141
142 /// Dio instance with DPoP interceptor for authenticated requests
143 final Dio _dio;
144
145 /// Creates a new OAuth session.
146 ///
147 /// Parameters:
148 /// - [server]: The OAuth server agent
149 /// - [sub]: The subject (user's DID)
150 /// - [sessionGetter]: The session getter for token management
151 OAuthSession({
152 required this.server,
153 required this.sub,
154 required this.sessionGetter,
155 }) : _dio = Dio() {
156 // Add DPoP interceptor for authenticated requests to resource servers
157 _dio.interceptors.add(
158 createDpopInterceptor(
159 DpopFetchWrapperOptions(
160 key: server.dpopKey,
161 nonces: server.dpopNonces,
162 sha256: server.runtime.sha256,
163 isAuthServer: false, // Resource server requests (PDS)
164 ),
165 ),
166 );
167 }
168
169 /// Alias for [sub]
170 AtprotoDid get did => sub;
171
172 /// The server metadata
173 OAuthAuthorizationServerMetadata get serverMetadata => server.serverMetadata;
174
175 /// Gets the current token set.
176 ///
177 /// Parameters:
178 /// - [refresh]: When `true`, forces a token refresh even if not expired.
179 /// When `false`, uses cached tokens even if expired.
180 /// When `'auto'`, refreshes only if expired (default).
181 Future<TokenSet> _getTokenSet(dynamic refresh) async {
182 final session = await sessionGetter.get(
183 sub,
184 noCache: refresh == true,
185 allowStale: refresh == false,
186 );
187
188 return session.tokenSet;
189 }
190
191 /// Gets information about the current token.
192 ///
193 /// Parameters:
194 /// - [refresh]: When `true`, forces a token refresh even if not expired.
195 /// When `false`, uses cached tokens even if expired.
196 /// When `'auto'`, refreshes only if expired (default).
197 Future<TokenInfo> getTokenInfo([dynamic refresh = 'auto']) async {
198 final tokenSet = await _getTokenSet(refresh);
199 final expiresAtStr = tokenSet.expiresAt;
200 final expiresAt =
201 expiresAtStr != null ? DateTime.parse(expiresAtStr) : null;
202
203 return TokenInfo(
204 expiresAt: expiresAt,
205 expired:
206 expiresAt != null
207 ? expiresAt.isBefore(
208 DateTime.now().subtract(Duration(seconds: 5)),
209 )
210 : null,
211 scope: tokenSet.scope,
212 iss: tokenSet.iss,
213 aud: tokenSet.aud,
214 sub: tokenSet.sub,
215 );
216 }
217
218 /// Signs out the user.
219 ///
220 /// This revokes the access token and deletes the session from storage.
221 /// Even if revocation fails, the session is removed locally.
222 Future<void> signOut() async {
223 try {
224 final tokenSet = await _getTokenSet(false);
225 await server.revoke(tokenSet.accessToken);
226 } finally {
227 await sessionGetter.delStored(sub, TokenRevokedError(sub));
228 }
229 }
230
231 /// Makes an authenticated HTTP request to the given pathname.
232 ///
233 /// This method:
234 /// 1. Automatically refreshes tokens if they're expired
235 /// 2. Adds DPoP and Authorization headers
236 /// 3. Retries once with a fresh token if the initial request fails with 401
237 ///
238 /// Parameters:
239 /// - [pathname]: The pathname to request (relative to the audience URL)
240 /// - [method]: HTTP method (default: 'GET')
241 /// - [headers]: Additional headers to include
242 /// - [body]: Request body
243 ///
244 /// Returns the HTTP response.
245 ///
246 /// Example:
247 /// ```dart
248 /// final response = await session.fetchHandler(
249 /// '/xrpc/com.atproto.repo.createRecord',
250 /// method: 'POST',
251 /// headers: {'Content-Type': 'application/json'},
252 /// body: jsonEncode({'repo': did, 'collection': 'app.bsky.feed.post', ...}),
253 /// );
254 /// ```
255 Future<http.Response> fetchHandler(
256 String pathname, {
257 String method = 'GET',
258 Map<String, String>? headers,
259 dynamic body,
260 }) async {
261 // Try to refresh the token if it's known to be expired
262 final tokenSet = await _getTokenSet('auto');
263
264 final initialUrl = Uri.parse(tokenSet.aud).resolve(pathname);
265 final initialAuth = '${tokenSet.tokenType} ${tokenSet.accessToken}';
266
267 final initialHeaders = <String, String>{
268 ...?headers,
269 'Authorization': initialAuth,
270 };
271
272 // Make request with DPoP - the interceptor will automatically add DPoP header
273 final initialResponse = await _makeDpopRequest(
274 initialUrl,
275 method: method,
276 headers: initialHeaders,
277 body: body,
278 );
279
280 // If the token is not expired, we don't need to refresh it
281 if (!_isInvalidTokenResponse(initialResponse)) {
282 return initialResponse;
283 }
284
285 // Token is invalid, try to refresh
286 TokenSet tokenSetFresh;
287 try {
288 // Force a refresh
289 tokenSetFresh = await _getTokenSet(true);
290 } catch (err) {
291 // If refresh fails, return the original response
292 return initialResponse;
293 }
294
295 // Retry with fresh token
296 final finalAuth = '${tokenSetFresh.tokenType} ${tokenSetFresh.accessToken}';
297 final finalUrl = Uri.parse(tokenSetFresh.aud).resolve(pathname);
298
299 final finalHeaders = <String, String>{
300 ...?headers,
301 'Authorization': finalAuth,
302 };
303
304 final finalResponse = await _makeDpopRequest(
305 finalUrl,
306 method: method,
307 headers: finalHeaders,
308 body: body,
309 );
310
311 // The token was successfully refreshed, but is still not accepted by the
312 // resource server. This might be due to the resource server not accepting
313 // credentials from the authorization server (e.g. because some migration
314 // occurred). Any ways, there is no point in keeping the session.
315 if (_isInvalidTokenResponse(finalResponse)) {
316 await sessionGetter.delStored(sub, TokenInvalidError(sub));
317 }
318
319 return finalResponse;
320 }
321
322 /// Makes an HTTP request with DPoP authentication.
323 ///
324 /// Uses Dio with DPoP interceptor which automatically adds:
325 /// - DPoP header with proof JWT
326 /// - Access token hash (ath) binding
327 ///
328 /// Throws [DioException] for network errors, timeouts, and cancellations.
329 Future<http.Response> _makeDpopRequest(
330 Uri url, {
331 required String method,
332 Map<String, String>? headers,
333 dynamic body,
334 }) async {
335 try {
336 // Make request with Dio - interceptor will add DPoP header
337 final response = await _dio.requestUri(
338 url,
339 options: Options(
340 method: method,
341 headers: headers,
342 responseType: ResponseType.bytes, // Get raw bytes for compatibility
343 validateStatus: (status) => true, // Don't throw on any status code
344 ),
345 data: body,
346 );
347
348 // Convert Dio Response to http.Response for compatibility
349 return http.Response.bytes(
350 response.data as List<int>,
351 response.statusCode!,
352 headers: response.headers.map.map(
353 (key, value) => MapEntry(key, value.join(', ')),
354 ),
355 reasonPhrase: response.statusMessage,
356 );
357 } on DioException catch (e) {
358 // If we have a response (4xx/5xx), convert it to http.Response
359 if (e.response != null) {
360 final errorResponse = e.response!;
361 return http.Response.bytes(
362 errorResponse.data is List<int>
363 ? errorResponse.data as List<int>
364 : (errorResponse.data?.toString() ?? '').codeUnits,
365 errorResponse.statusCode!,
366 headers: errorResponse.headers.map.map(
367 (key, value) => MapEntry(key, value.join(', ')),
368 ),
369 reasonPhrase: errorResponse.statusMessage,
370 );
371 }
372 // Network errors, timeouts, cancellations - rethrow
373 rethrow;
374 }
375 }
376
377 /// Checks if a response indicates an invalid token.
378 ///
379 /// See:
380 /// - https://datatracker.ietf.org/doc/html/rfc6750#section-3
381 /// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
382 bool _isInvalidTokenResponse(http.Response response) {
383 if (response.statusCode != 401) return false;
384
385 final wwwAuth = response.headers['www-authenticate'];
386 return wwwAuth != null &&
387 (wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) &&
388 wwwAuth.contains('error="invalid_token"');
389 }
390
391 /// Disposes of resources used by this session.
392 void dispose() {
393 _dio.close();
394 }
395}