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}