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