From 65ea38daa8003848e22e150ad2084f4e51e104e3 Mon Sep 17 00:00:00 2001 From: Bretton Date: Sun, 2 Nov 2025 14:06:54 -0800 Subject: [PATCH] feat: integrate DPoP authentication into OAuthSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables authenticated writes to user PDS by adding DPoP (Demonstrating Proof-of-Possession) support to OAuthSession.fetchHandler(). Changes: - Replace http.Client with Dio + DPoP interceptor - Automatically add DPoP headers with JWT proofs and token binding (ath) - Handle nonce management and automatic retry on nonce errors - Add proper DioException handling for network/timeout errors - Remove deprecated _makeRequest method and _httpClient field This unblocks voting functionality by matching the Expo oauth-client-expo DPoP implementation, allowing the Flutter app to write records to the PDS. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../lib/src/session/oauth_session.dart | 97 +++++++++++++------ packages/atproto_oauth_flutter/pubspec.lock | 26 ++--- 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart b/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart index 77506ae..cbcc0a3 100644 --- a/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart +++ b/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'package:dio/dio.dart'; import 'package:http/http.dart' as http; +import '../dpop/fetch_dpop.dart'; import '../errors/token_invalid_error.dart'; import '../errors/token_revoked_error.dart'; import '../oauth/oauth_server_agent.dart'; @@ -137,8 +139,8 @@ class OAuthSession { /// The session getter for retrieving and refreshing tokens final SessionGetterInterface sessionGetter; - /// HTTP client for making requests - final http.Client _httpClient; + /// Dio instance with DPoP interceptor for authenticated requests + final Dio _dio; /// Creates a new OAuth session. /// @@ -146,13 +148,23 @@ class OAuthSession { /// - [server]: The OAuth server agent /// - [sub]: The subject (user's DID) /// - [sessionGetter]: The session getter for token management - /// - [httpClient]: Optional HTTP client (defaults to http.Client()) OAuthSession({ required this.server, required this.sub, required this.sessionGetter, - http.Client? httpClient, - }) : _httpClient = httpClient ?? http.Client(); + }) : _dio = Dio() { + // Add DPoP interceptor for authenticated requests to resource servers + _dio.interceptors.add( + createDpopInterceptor( + DpopFetchWrapperOptions( + key: server.dpopKey, + nonces: server.dpopNonces, + sha256: server.runtime.sha256, + isAuthServer: false, // Resource server requests (PDS) + ), + ), + ); + } /// Alias for [sub] AtprotoDid get did => sub; @@ -257,10 +269,8 @@ class OAuthSession { 'Authorization': initialAuth, }; - // TODO: In later chunks, add DPoP header generation - // For now, just make the request with Authorization header - - final initialResponse = await _makeRequest( + // Make request with DPoP - the interceptor will automatically add DPoP header + final initialResponse = await _makeDpopRequest( initialUrl, method: method, headers: initialHeaders, @@ -291,7 +301,7 @@ class OAuthSession { 'Authorization': finalAuth, }; - final finalResponse = await _makeRequest( + final finalResponse = await _makeDpopRequest( finalUrl, method: method, headers: finalHeaders, @@ -309,31 +319,60 @@ class OAuthSession { return finalResponse; } - /// Makes an HTTP request. - Future _makeRequest( + /// Makes an HTTP request with DPoP authentication. + /// + /// Uses Dio with DPoP interceptor which automatically adds: + /// - DPoP header with proof JWT + /// - Access token hash (ath) binding + /// + /// Throws [DioException] for network errors, timeouts, and cancellations. + Future _makeDpopRequest( Uri url, { required String method, Map? headers, dynamic body, }) async { - final request = http.Request(method, url); - - if (headers != null) { - request.headers.addAll(headers); - } - - if (body != null) { - if (body is String) { - request.body = body; - } else if (body is List) { - request.bodyBytes = body; - } else { - throw ArgumentError('Body must be String or List'); + try { + // Make request with Dio - interceptor will add DPoP header + final response = await _dio.requestUri( + url, + options: Options( + method: method, + headers: headers, + responseType: ResponseType.bytes, // Get raw bytes for compatibility + validateStatus: (status) => + true, // Don't throw on any status code + ), + data: body, + ); + + // Convert Dio Response to http.Response for compatibility + return http.Response.bytes( + response.data as List, + response.statusCode!, + headers: response.headers.map.map( + (key, value) => MapEntry(key, value.join(', ')), + ), + reasonPhrase: response.statusMessage, + ); + } on DioException catch (e) { + // If we have a response (4xx/5xx), convert it to http.Response + if (e.response != null) { + final errorResponse = e.response!; + return http.Response.bytes( + errorResponse.data is List + ? errorResponse.data as List + : (errorResponse.data?.toString() ?? '').codeUnits, + errorResponse.statusCode!, + headers: errorResponse.headers.map.map( + (key, value) => MapEntry(key, value.join(', ')), + ), + reasonPhrase: errorResponse.statusMessage, + ); } + // Network errors, timeouts, cancellations - rethrow + rethrow; } - - final streamedResponse = await _httpClient.send(request); - return await http.Response.fromStream(streamedResponse); } /// Checks if a response indicates an invalid token. @@ -352,6 +391,6 @@ class OAuthSession { /// Disposes of resources used by this session. void dispose() { - _httpClient.close(); + _dio.close(); } } diff --git a/packages/atproto_oauth_flutter/pubspec.lock b/packages/atproto_oauth_flutter/pubspec.lock index 6e9f78d..ac5da0d 100644 --- a/packages/atproto_oauth_flutter/pubspec.lock +++ b/packages/atproto_oauth_flutter/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -212,26 +212,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -401,10 +401,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" typed_data: dependency: transitive description: @@ -481,10 +481,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -526,5 +526,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.7.2 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.29.0" -- 2.51.2