1import 'package:dio/dio.dart'; 2import 'package:flutter/foundation.dart' hide Key; 3 4import '../dpop/fetch_dpop.dart'; 5import '../errors/oauth_response_error.dart'; 6import '../errors/token_refresh_error.dart'; 7import '../runtime/runtime.dart'; 8import '../runtime/runtime_implementation.dart'; 9import '../types.dart'; 10import 'authorization_server_metadata_resolver.dart' show GetCachedOptions; 11import 'client_auth.dart'; 12import 'oauth_resolver.dart'; 13 14/// Represents a token set returned from OAuth token endpoint. 15class TokenSet { 16 /// Issuer (authorization server URL) 17 final String iss; 18 19 /// Subject (DID of the user) 20 final String sub; 21 22 /// Audience (PDS URL) 23 final String aud; 24 25 /// Scope (space-separated list of scopes) 26 final String scope; 27 28 /// Refresh token (optional) 29 final String? refreshToken; 30 31 /// Access token 32 final String accessToken; 33 34 /// Token type (must be "DPoP" for ATPROTO) 35 final String tokenType; 36 37 /// Expiration time (ISO date string) 38 final String? expiresAt; 39 40 const TokenSet({ 41 required this.iss, 42 required this.sub, 43 required this.aud, 44 required this.scope, 45 this.refreshToken, 46 required this.accessToken, 47 required this.tokenType, 48 this.expiresAt, 49 }); 50 51 Map<String, dynamic> toJson() { 52 return { 53 'iss': iss, 54 'sub': sub, 55 'aud': aud, 56 'scope': scope, 57 if (refreshToken != null) 'refresh_token': refreshToken, 58 'access_token': accessToken, 59 'token_type': tokenType, 60 if (expiresAt != null) 'expires_at': expiresAt, 61 }; 62 } 63 64 factory TokenSet.fromJson(Map<String, dynamic> json) { 65 return TokenSet( 66 iss: json['iss'] as String, 67 sub: json['sub'] as String, 68 aud: json['aud'] as String, 69 scope: json['scope'] as String, 70 refreshToken: json['refresh_token'] as String?, 71 accessToken: json['access_token'] as String, 72 tokenType: json['token_type'] as String, 73 expiresAt: json['expires_at'] as String?, 74 ); 75 } 76} 77 78/// DPoP nonce cache type. 79typedef DpopNonceCache = SimpleStore<String, String>; 80 81/// Agent for interacting with an OAuth authorization server. 82/// 83/// This class handles: 84/// - Token exchange (authorization code → tokens) 85/// - Token refresh (refresh token → new tokens) 86/// - Token revocation 87/// - DPoP proof generation and nonce management 88/// - Client authentication 89/// 90/// All token requests include DPoP proofs to bind tokens to keys. 91class OAuthServerAgent { 92 final ClientAuthMethod authMethod; 93 final Key dpopKey; 94 final Map<String, dynamic> serverMetadata; 95 final ClientMetadata clientMetadata; 96 final DpopNonceCache dpopNonces; 97 final OAuthResolver oauthResolver; 98 final Runtime runtime; 99 final Keyset? keyset; 100 final Dio _dio; 101 final ClientCredentialsFactory _clientCredentialsFactory; 102 103 /// Creates an OAuth server agent. 104 /// 105 /// Throws [AuthMethodUnsatisfiableError] if the auth method cannot be satisfied. 106 OAuthServerAgent({ 107 required this.authMethod, 108 required this.dpopKey, 109 required this.serverMetadata, 110 required this.clientMetadata, 111 required this.dpopNonces, 112 required this.oauthResolver, 113 required this.runtime, 114 this.keyset, 115 Dio? dio, 116 }) : // CRITICAL: Always create a NEW Dio instance to avoid duplicate interceptors 117 // If we reuse a shared Dio instance, each OAuthServerAgent will add its 118 // interceptors to the same instance, causing duplicate requests! 119 _dio = Dio(dio?.options ?? BaseOptions()), 120 _clientCredentialsFactory = createClientCredentialsFactory( 121 authMethod, 122 serverMetadata, 123 clientMetadata, 124 runtime, 125 keyset, 126 ) { 127 // Add debug logging interceptor (runs before DPoP interceptor) 128 if (kDebugMode) { 129 _dio.interceptors.add( 130 InterceptorsWrapper( 131 onRequest: (options, handler) { 132 if (options.uri.path.contains('/token')) { 133 print( 134 '📤 [BEFORE DPoP] Request headers: ${options.headers.keys.toList()}', 135 ); 136 } 137 handler.next(options); 138 }, 139 ), 140 ); 141 } 142 143 // Add DPoP interceptor 144 _dio.interceptors.add( 145 createDpopInterceptor( 146 DpopFetchWrapperOptions( 147 key: dpopKey, 148 nonces: dpopNonces, 149 sha256: runtime.sha256, 150 isAuthServer: true, 151 ), 152 ), 153 ); 154 155 // Add final logging interceptor (runs after DPoP interceptor) 156 if (kDebugMode) { 157 _dio.interceptors.add( 158 InterceptorsWrapper( 159 onRequest: (options, handler) { 160 if (options.uri.path.contains('/token')) { 161 print( 162 '📤 [AFTER DPoP] Request headers: ${options.headers.keys.toList()}', 163 ); 164 if (options.headers.containsKey('dpop')) { 165 print( 166 ' DPoP header present: ${options.headers['dpop']?.toString().substring(0, 50)}...', 167 ); 168 } else if (options.headers.containsKey('DPoP')) { 169 print( 170 ' DPoP header present: ${options.headers['DPoP']?.toString().substring(0, 50)}...', 171 ); 172 } else { 173 print(' ⚠️ DPoP header MISSING!'); 174 } 175 } 176 handler.next(options); 177 }, 178 onError: (error, handler) { 179 if (error.requestOptions.uri.path.contains('/token')) { 180 print('📥 Token request error: ${error.message}'); 181 } 182 handler.next(error); 183 }, 184 ), 185 ); 186 } 187 } 188 189 /// The issuer (authorization server URL). 190 String get issuer => serverMetadata['issuer'] as String; 191 192 /// Revokes a token. 193 /// 194 /// Errors are silently ignored as revocation is best-effort. 195 Future<void> revoke(String token) async { 196 try { 197 await _request('revocation', {'token': token}); 198 } catch (_) { 199 // Don't care if revocation fails 200 } 201 } 202 203 /// Pre-fetches a DPoP nonce from the token endpoint. 204 /// 205 /// This is critical for authorization code exchange because: 206 /// 1. First token request without nonce → PDS consumes code + returns use_dpop_nonce error 207 /// 2. Retry with nonce → "Invalid code" because already consumed 208 /// 209 /// Solution: Get a nonce BEFORE attempting code exchange. 210 /// 211 /// We make a lightweight invalid request that will fail but return a nonce. 212 /// The server responds with a nonce in the DPoP-Nonce header, which the 213 /// interceptor automatically caches for subsequent requests. 214 Future<void> _prefetchDpopNonce() async { 215 final tokenEndpoint = serverMetadata['token_endpoint'] as String?; 216 if (tokenEndpoint == null) return; 217 218 final origin = Uri.parse(tokenEndpoint); 219 final originKey = 220 '${origin.scheme}://${origin.host}${origin.hasPort ? ':${origin.port}' : ''}'; 221 222 // Clear any stale nonce from previous sessions 223 try { 224 await dpopNonces.del(originKey); 225 if (kDebugMode) { 226 print('🧹 Cleared stale DPoP nonce from cache'); 227 } 228 } catch (_) { 229 // Ignore deletion errors 230 } 231 232 if (kDebugMode) { 233 print('⏱️ Pre-fetch starting at: ${DateTime.now().toIso8601String()}'); 234 } 235 236 try { 237 // Make a minimal invalid request to trigger nonce response 238 // Use an invalid grant_type that will fail fast without side effects 239 await _dio.post<Map<String, dynamic>>( 240 tokenEndpoint, 241 data: 'grant_type=invalid_prefetch', 242 options: Options( 243 headers: {'Content-Type': 'application/x-www-form-urlencoded'}, 244 validateStatus: (status) => true, // Accept any status 245 ), 246 ); 247 } catch (_) { 248 // Ignore all errors - we just want the nonce from the response headers 249 // The DPoP interceptor will have cached it in onError or onResponse 250 } 251 252 if (kDebugMode) { 253 print('⏱️ Pre-fetch completed at: ${DateTime.now().toIso8601String()}'); 254 final cachedNonce = await dpopNonces.get(originKey); 255 print('🎫 DPoP nonce pre-fetch result:'); 256 print( 257 ' Cached nonce: ${cachedNonce != null ? "${cachedNonce.substring(0, 20)}..." : "❌ not found"}', 258 ); 259 } 260 } 261 262 /// Exchanges an authorization code for tokens. 263 /// 264 /// This is called after the user completes authorization and you receive 265 /// the authorization code in the callback. 266 /// 267 /// [code] is the authorization code from the callback. 268 /// [codeVerifier] is the PKCE code verifier (if PKCE was used). 269 /// [redirectUri] is the redirect URI used in the authorization request. 270 /// 271 /// Returns a [TokenSet] with access token, optional refresh token, and metadata. 272 /// 273 /// IMPORTANT: This method verifies the issuer before returning tokens. 274 /// If verification fails, the access token is automatically revoked. 275 Future<TokenSet> exchangeCode( 276 String code, { 277 String? codeVerifier, 278 String? redirectUri, 279 }) async { 280 // CRITICAL: DO NOT pre-fetch! Exchange immediately! 281 // The pre-fetch adds ~678ms delay, during which the browser re-navigates 282 // and invalidates the authorization code. We need to exchange within ~270ms. 283 // If we get a nonce error, we'll handle it via the interceptor (though PDS 284 // doesn't seem to require nonces for initial token exchange). 285 286 final now = DateTime.now(); 287 288 final tokenResponse = await _request('token', { 289 'grant_type': 'authorization_code', 290 'redirect_uri': redirectUri ?? clientMetadata.redirectUris.first, 291 'code': code, 292 if (codeVerifier != null) 'code_verifier': codeVerifier, 293 }); 294 295 try { 296 // CRITICAL: Verify issuer before trusting the sub 297 // The tokenResponse MUST always be valid before the "sub" can be trusted 298 // See: https://atproto.com/specs/oauth 299 final aud = await _verifyIssuer(tokenResponse['sub'] as String); 300 301 return TokenSet( 302 aud: aud, 303 sub: tokenResponse['sub'] as String, 304 iss: issuer, 305 scope: tokenResponse['scope'] as String, 306 refreshToken: tokenResponse['refresh_token'] as String?, 307 accessToken: tokenResponse['access_token'] as String, 308 tokenType: tokenResponse['token_type'] as String, 309 expiresAt: 310 tokenResponse['expires_in'] != null 311 ? now 312 .add(Duration(seconds: tokenResponse['expires_in'] as int)) 313 .toIso8601String() 314 : null, 315 ); 316 } catch (err) { 317 // If verification fails, revoke the access token 318 await revoke(tokenResponse['access_token'] as String); 319 rethrow; 320 } 321 } 322 323 /// Refreshes a token set using the refresh token. 324 /// 325 /// [tokenSet] is the current token set with a refresh_token. 326 /// 327 /// Returns a new [TokenSet] with fresh tokens. 328 /// 329 /// Throws [TokenRefreshError] if refresh fails or no refresh token is available. 330 /// 331 /// IMPORTANT: This method verifies the issuer before returning tokens. 332 Future<TokenSet> refresh(TokenSet tokenSet) async { 333 if (tokenSet.refreshToken == null) { 334 throw TokenRefreshError(tokenSet.sub, 'No refresh token available'); 335 } 336 337 // CRITICAL: Verify issuer BEFORE refresh to avoid unnecessary requests 338 // and ensure the sub is still valid for this issuer 339 final aud = await _verifyIssuer(tokenSet.sub); 340 341 final now = DateTime.now(); 342 343 final tokenResponse = await _request('token', { 344 'grant_type': 'refresh_token', 345 'refresh_token': tokenSet.refreshToken, 346 }); 347 348 return TokenSet( 349 aud: aud, 350 sub: tokenSet.sub, 351 iss: issuer, 352 scope: tokenResponse['scope'] as String, 353 refreshToken: tokenResponse['refresh_token'] as String?, 354 accessToken: tokenResponse['access_token'] as String, 355 tokenType: tokenResponse['token_type'] as String, 356 expiresAt: 357 tokenResponse['expires_in'] != null 358 ? now 359 .add(Duration(seconds: tokenResponse['expires_in'] as int)) 360 .toIso8601String() 361 : null, 362 ); 363 } 364 365 /// Verifies that the sub (DID) is indeed issued by this authorization server. 366 /// 367 /// This is CRITICAL for security. We must verify that the DID's PDS 368 /// is protected by this authorization server before trusting tokens. 369 /// 370 /// Returns the user's PDS URL (the resource server). 371 /// 372 /// Throws if: 373 /// - DID resolution fails 374 /// - Issuer mismatch (user may have switched PDS or attack detected) 375 Future<String> _verifyIssuer(String sub) async { 376 final cancelToken = CancelToken(); 377 final resolved = await oauthResolver 378 .resolveFromIdentity( 379 sub, 380 GetCachedOptions( 381 noCache: true, 382 allowStale: false, 383 cancelToken: cancelToken, 384 ), 385 ) 386 .timeout( 387 const Duration(seconds: 10), 388 onTimeout: () { 389 cancelToken.cancel(); 390 throw TimeoutException('Issuer verification timed out'); 391 }, 392 ); 393 394 if (issuer != resolved.metadata['issuer']) { 395 // Best case: user switched PDS 396 // Worst case: attack attempt 397 // Either way: MUST NOT allow this token to be used 398 throw FormatException('Issuer mismatch'); 399 } 400 401 return resolved.pds.toString(); 402 } 403 404 /// Makes a request to an OAuth endpoint (public API). 405 /// 406 /// This is a generic method for making OAuth endpoint requests with proper typing. 407 /// Currently supports: token, revocation, pushed_authorization_request. 408 /// 409 /// [endpoint] is the endpoint name. 410 /// [payload] is the request body parameters. 411 /// 412 /// Returns the parsed JSON response. 413 /// Throws [OAuthResponseError] if the server returns an error. 414 Future<Map<String, dynamic>> request( 415 String endpoint, 416 Map<String, dynamic> payload, 417 ) async { 418 return _request(endpoint, payload); 419 } 420 421 /// Makes a request to an OAuth endpoint (internal implementation). 422 /// 423 /// [endpoint] is the endpoint name (e.g., 'token', 'revocation', 'pushed_authorization_request'). 424 /// [payload] is the request body parameters. 425 /// 426 /// Returns the parsed JSON response. 427 /// Throws [OAuthResponseError] if the server returns an error. 428 Future<Map<String, dynamic>> _request( 429 String endpoint, 430 Map<String, dynamic> payload, 431 ) async { 432 final url = serverMetadata['${endpoint}_endpoint']; 433 if (url == null) { 434 throw StateError('No $endpoint endpoint available'); 435 } 436 437 final auth = await _clientCredentialsFactory(); 438 439 final fullPayload = {...payload, ...auth.payload.toJson()}; 440 final encodedData = _wwwFormUrlEncode(fullPayload); 441 442 if (kDebugMode && endpoint == 'token') { 443 print('🌐 Token exchange HTTP request:'); 444 print(' ⏱️ Request starting at: ${DateTime.now().toIso8601String()}'); 445 print(' URL: $url'); 446 print(' Payload keys: ${fullPayload.keys.toList()}'); 447 print(' grant_type: ${fullPayload['grant_type']}'); 448 print(' client_id: ${fullPayload['client_id']}'); 449 print(' redirect_uri: ${fullPayload['redirect_uri']}'); 450 print(' code: ${fullPayload['code']?.toString().substring(0, 20)}...'); 451 print( 452 ' code_verifier: ${fullPayload['code_verifier']?.toString().substring(0, 20)}...', 453 ); 454 print(' Headers: ${auth.headers?.keys.toList() ?? []}'); 455 } 456 457 try { 458 final response = await _dio.post<Map<String, dynamic>>( 459 url as String, 460 data: encodedData, 461 options: Options( 462 headers: { 463 if (auth.headers != null) ...auth.headers!, 464 'Content-Type': 'application/x-www-form-urlencoded', 465 }, 466 ), 467 ); 468 469 final data = response.data; 470 if (data == null) { 471 throw OAuthResponseError(response, {'error': 'empty_response'}); 472 } 473 474 if (kDebugMode && endpoint == 'token') { 475 print(' ✅ Token exchange successful!'); 476 } 477 478 return data; 479 } on DioException catch (e) { 480 final response = e.response; 481 if (response != null) { 482 if (kDebugMode && endpoint == 'token') { 483 print(' ❌ Token exchange failed:'); 484 print(' Status: ${response.statusCode}'); 485 print(' Response: ${response.data}'); 486 } 487 throw OAuthResponseError(response, response.data); 488 } 489 rethrow; 490 } 491 } 492 493 /// Encodes a map as application/x-www-form-urlencoded. 494 String _wwwFormUrlEncode(Map<String, dynamic> payload) { 495 final entries = payload.entries 496 .where((e) => e.value != null) 497 .map((e) => MapEntry(e.key, _stringifyValue(e.value))); 498 499 return Uri(queryParameters: Map.fromEntries(entries)).query; 500 } 501 502 /// Converts a value to string for form encoding. 503 String _stringifyValue(dynamic value) { 504 if (value is String) return value; 505 if (value is num) return value.toString(); 506 if (value is bool) return value.toString(); 507 // For complex types, use JSON encoding 508 return value.toString(); 509 } 510} 511 512/// Timeout exception. 513class TimeoutException implements Exception { 514 final String message; 515 TimeoutException(this.message); 516 517 @override 518 String toString() => 'TimeoutException: $message'; 519}