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}