feat: integrate DPoP authentication into OAuthSession

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 <noreply@anthropic.com>

Changed files
+80 -41
packages
atproto_oauth_flutter
+67 -28
packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart
···
import 'dart:async';
import 'package:http/http.dart' as http;
import '../errors/token_invalid_error.dart';
import '../errors/token_revoked_error.dart';
import '../oauth/oauth_server_agent.dart';
···
/// The session getter for retrieving and refreshing tokens
final SessionGetterInterface sessionGetter;
-
/// HTTP client for making requests
-
final http.Client _httpClient;
/// Creates a new OAuth session.
///
···
/// - [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();
/// Alias for [sub]
AtprotoDid get did => sub;
···
'Authorization': initialAuth,
};
-
// TODO: In later chunks, add DPoP header generation
-
// For now, just make the request with Authorization header
-
-
final initialResponse = await _makeRequest(
initialUrl,
method: method,
headers: initialHeaders,
···
'Authorization': finalAuth,
};
-
final finalResponse = await _makeRequest(
finalUrl,
method: method,
headers: finalHeaders,
···
return finalResponse;
}
-
/// Makes an HTTP request.
-
Future<http.Response> _makeRequest(
Uri url, {
required String method,
Map<String, String>? 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<int>) {
-
request.bodyBytes = body;
-
} else {
-
throw ArgumentError('Body must be String or List<int>');
}
}
-
-
final streamedResponse = await _httpClient.send(request);
-
return await http.Response.fromStream(streamedResponse);
}
/// Checks if a response indicates an invalid token.
···
/// Disposes of resources used by this session.
void dispose() {
-
_httpClient.close();
}
}
···
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';
···
/// The session getter for retrieving and refreshing tokens
final SessionGetterInterface sessionGetter;
+
/// Dio instance with DPoP interceptor for authenticated requests
+
final Dio _dio;
/// Creates a new OAuth session.
///
···
/// - [server]: The OAuth server agent
/// - [sub]: The subject (user's DID)
/// - [sessionGetter]: The session getter for token management
OAuthSession({
required this.server,
required this.sub,
required this.sessionGetter,
+
}) : _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;
···
'Authorization': initialAuth,
};
+
// Make request with DPoP - the interceptor will automatically add DPoP header
+
final initialResponse = await _makeDpopRequest(
initialUrl,
method: method,
headers: initialHeaders,
···
'Authorization': finalAuth,
};
+
final finalResponse = await _makeDpopRequest(
finalUrl,
method: method,
headers: finalHeaders,
···
return finalResponse;
}
+
/// 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<http.Response> _makeDpopRequest(
Uri url, {
required String method,
Map<String, String>? headers,
dynamic body,
}) async {
+
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<int>,
+
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<int>
+
? errorResponse.data as List<int>
+
: (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;
}
}
/// Checks if a response indicates an invalid token.
···
/// Disposes of resources used by this session.
void dispose() {
+
_dio.close();
}
}
+13 -13
packages/atproto_oauth_flutter/pubspec.lock
···
dependency: transitive
description:
name: fake_async
-
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
-
version: "1.3.2"
ffi:
dependency: transitive
description:
···
dependency: transitive
description:
name: leak_tracker
-
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
-
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
-
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
-
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
-
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
-
version: "3.0.1"
lints:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
-
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
-
version: "0.7.4"
typed_data:
dependency: transitive
description:
···
dependency: transitive
description:
name: vector_math
-
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
-
version: "2.1.4"
vm_service:
dependency: transitive
description:
···
source: hosted
version: "1.1.0"
sdks:
-
dart: ">=3.7.2 <4.0.0"
flutter: ">=3.29.0"
···
dependency: transitive
description:
name: fake_async
+
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
+
version: "1.3.3"
ffi:
dependency: transitive
description:
···
dependency: transitive
description:
name: leak_tracker
+
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
+
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
+
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
+
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
+
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
+
version: "3.0.2"
lints:
dependency: transitive
description:
···
dependency: transitive
description:
name: test_api
+
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
+
version: "0.7.6"
typed_data:
dependency: transitive
description:
···
dependency: transitive
description:
name: vector_math
+
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
+
version: "2.2.0"
vm_service:
dependency: transitive
description:
···
source: hosted
version: "1.1.0"
sdks:
+
dart: ">=3.8.0-0 <4.0.0"
flutter: ">=3.29.0"