Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ request. Unlike the Python library, the caller's header map is never mutated.
(plus `@query` when a query string is present). Body signing is opt-in via
`additionalComponents(List.of("content-digest"))`.

> **HTTP client defaults:** every HTTP client the library constructs itself (JWKS/metadata
> fetching, token exchange) is pinned to **HTTP/1.1** — the JDK's default h2c upgrade breaks
> h11-based servers (uvicorn/FastAPI person servers reject requests or drop bodies).
> Clients you inject yourself are used as-is.

## Signature verification

```java
Expand Down Expand Up @@ -145,6 +150,9 @@ RequestVerifier.Result result = verifier.verifyRequest(
if (result.valid()) {
System.out.println("Agent: " + result.agentId() + ", Scopes: " + result.scopes());
}
// When a request carries both a Content-Digest header and a body, verifyRequest recomputes
// the RFC 9530 digest from the body and rejects mismatches ("content-digest mismatch") —
// stricter than the Python reference, which only verifies the header value.

// Resource-side challenge building (401 responses)
ChallengeBuilder challenges = new ChallengeBuilder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ public static String extractResourceToken(Map<String, String> headers) {
* @param resourceToken the resource token from the 401 challenge
* @param keyPair the agent's signing key pair
* @param agentJwt the agent token ({@code aa-agent+jwt}) for the Signature-Key header
* @param httpClient HTTP client; {@code null} uses a default with a 30s timeout
* @param httpClient HTTP client; {@code null} uses a default with a 30s timeout pinned to
* HTTP/1.1 (the JDK's h2c upgrade breaks h11-based person servers such as
* uvicorn/FastAPI). Caller-injected clients are used as-is.
* @param onInteraction callback invoked with (interactionUrl, code) when the PS requires
* human interaction
* @param onClarification callback invoked with (pendingUrl, question); returns the answer
Expand All @@ -76,6 +78,7 @@ public record Exchange(
}
httpClient = httpClient == null
? HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(30))
.build()
: httpClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ public final class CachingJwksFetcher {
private final Clock clock;
private final ConcurrentHashMap<String, Long> lastFetchTimes = new ConcurrentHashMap<>();

/** Creates a fetcher with the JDK HTTP client, 1-hour cache TTL, 60-second rate limit. */
/**
* Creates a fetcher with the JDK HTTP client (pinned to HTTP/1.1 for h11-server
* compatibility — see {@link DefaultHttpClient}), 1-hour cache TTL, 60-second rate limit.
*/
public CachingJwksFetcher() {
this(new DefaultHttpClient(), new JwksCache(), 60, Clock.systemUTC());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@
import java.time.Duration;
import java.util.Map;

/** JDK {@link HttpClient}-based JSON fetcher with a 10-second timeout. */
/**
* JDK {@link HttpClient}-based JSON fetcher with a 10-second timeout.
*
* <p>The default client is pinned to HTTP/1.1: the JDK's default HTTP/2 h2c upgrade breaks
* h11-based servers (uvicorn/FastAPI reject the request or silently drop the body), which AAuth
* person servers commonly run on. Inject a client via {@link #DefaultHttpClient(HttpClient)} to
* override.
*/
public final class DefaultHttpClient implements JsonHttpClient {

private static final ObjectMapper MAPPER = new ObjectMapper();
Expand All @@ -21,11 +28,17 @@ public final class DefaultHttpClient implements JsonHttpClient {

public DefaultHttpClient() {
this(HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build());
}

/** The underlying client (exposed for configuration assertions). */
HttpClient httpClient() {
return httpClient;
}

public DefaultHttpClient(HttpClient httpClient) {
this.httpClient = httpClient;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ public static Doc personServer(String personServer, String tokenEndpoint, String
/**
* Fetches a metadata document over HTTPS (HTTP allowed only for localhost development).
*
* <p>When {@code httpClient} is {@code null}, the default client is pinned to HTTP/1.1 for
* h11-server compatibility — see {@link DefaultHttpClient}.
*
* @throws IllegalArgumentException if the URL is non-HTTPS and not localhost
* @throws MetadataException if the fetch fails
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.github.marcofanti.aauth.signing.HttpSignatureException;
import io.github.marcofanti.aauth.signing.JwksFetcher;
import io.github.marcofanti.aauth.signing.Jwts;
import io.github.marcofanti.aauth.signing.SignatureBase;
import io.github.marcofanti.aauth.signing.SignatureKeyHeader;
import io.github.marcofanti.aauth.signing.SignatureVerifier;
import io.github.marcofanti.aauth.signing.VerifyRequest;
Expand Down Expand Up @@ -42,6 +43,10 @@ static Result failure(String error) {
/**
* Verifies an incoming request's HTTP signature and extracts identity/authorization context.
*
* <p>When the request carries both a {@code Content-Digest} header and a body, the digest is
* recomputed from the body per RFC 9530 and the request is rejected on mismatch — unlike the
* Python reference, which only signs/verifies the header value.
*
* @param method HTTP method
* @param targetUri target URI as received
* @param headers request headers (must include the three signature headers)
Expand Down Expand Up @@ -89,6 +94,17 @@ public Result verifyRequest(
return Result.failure(e.getMessage());
}

// RFC 9530 body-digest enforcement: the signature base covers the Content-Digest
// *header*, not the body itself, so a tampered body with an intact header would pass
// signature verification. Recompute whenever both header and body are present.
// Intentional divergence from the Python reference, which trusts the header.
String contentDigest = header(headers, "Content-Digest");
if (contentDigest != null && body != null && body.length > 0) {
if (!contentDigest.strip().equals(SignatureBase.contentDigest(body))) {
return Result.failure("content-digest mismatch");
}
}

String agentId = null;
Map<String, Object> act = null;
String userSub = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.github.marcofanti.aauth.keys;

import static org.assertj.core.api.Assertions.assertThat;

import io.github.marcofanti.aauth.agent.TokenExchange;
import io.github.marcofanti.aauth.signing.keys.KeyPairs;
import java.net.http.HttpClient;
import org.junit.jupiter.api.Test;

/**
* Library-constructed default HTTP clients must be pinned to HTTP/1.1: the JDK's h2c upgrade
* breaks h11-based servers (uvicorn/FastAPI person servers reject requests or drop bodies).
*/
class Http11DefaultTest {

@Test
void defaultJsonHttpClientIsHttp11() {
assertThat(new DefaultHttpClient().httpClient().version()).isEqualTo(HttpClient.Version.HTTP_1_1);
}

@Test
void tokenExchangeDefaultClientIsHttp11() {
TokenExchange.Exchange exchange = TokenExchange.Exchange.builder(
"a.b.c", KeyPairs.generateEd25519(), "agent.jwt")
.build();

assertThat(exchange.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_1_1);
}

@Test
void callerInjectedClientIsUntouched() {
HttpClient http2Client =
HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build();

TokenExchange.Exchange exchange = TokenExchange.Exchange.builder(
"a.b.c", KeyPairs.generateEd25519(), "agent.jwt")
.httpClient(http2Client)
.build();

assertThat(exchange.httpClient()).isSameAs(http2Client);
assertThat(exchange.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_2);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,52 @@ void scopeSplittingCollapsesWhitespace() {
assertThat(result.scopes()).containsExactly("data.read", "data.write");
}

@Test
void bodyWithMatchingContentDigestPasses() {
byte[] body = "{\"amount\": 100}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
// The low-level signer covers content-digest via additionalComponents.
Map<String, String> headers = withSignatureHeaders(io.github.marcofanti.aauth.signing.RequestSigner.sign(
io.github.marcofanti.aauth.signing.SignRequest.builder("POST", TARGET)
.keyPair(agentKeys)
.scheme(new io.github.marcofanti.aauth.signing.SignatureScheme.Hwk())
.body(body)
.additionalComponents(List.of("content-digest"))
.build()));

RequestVerifier.Result result = verifier.verifyRequest("POST", TARGET, headers, body, false, false);

assertThat(result.valid()).isTrue();
}

@Test
void tamperedBodyWithIntactContentDigestIsRejected() {
byte[] body = "{\"amount\": 100}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
Map<String, String> headers = withSignatureHeaders(io.github.marcofanti.aauth.signing.RequestSigner.sign(
io.github.marcofanti.aauth.signing.SignRequest.builder("POST", TARGET)
.keyPair(agentKeys)
.scheme(new io.github.marcofanti.aauth.signing.SignatureScheme.Hwk())
.body(body)
.additionalComponents(List.of("content-digest"))
.build()));
byte[] tamperedBody = "{\"amount\": 999999}".getBytes(java.nio.charset.StandardCharsets.UTF_8);

RequestVerifier.Result result = verifier.verifyRequest("POST", TARGET, headers, tamperedBody, false, false);

assertThat(result.valid()).isFalse();
assertThat(result.error()).isEqualTo("content-digest mismatch");
}

@Test
void bodyWithoutContentDigestHeaderIsUnaffected() {
AgentRequestSigner signer = AgentRequestSigner.builder(agentKeys).build();
byte[] body = "{\"note\": \"no digest coverage\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
Map<String, String> headers = withSignatureHeaders(signer.signRequest("POST", TARGET, Map.of(), body, "hwk"));

RequestVerifier.Result result = verifier.verifyRequest("POST", TARGET, headers, body, false, false);

assertThat(result.valid()).isTrue();
}

@Test
void missingSignatureHeadersFailCleanly() {
RequestVerifier.Result result = verifier.verifyRequest("GET", TARGET, Map.of(), null, false, false);
Expand Down
13 changes: 13 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ reference with no findings.
(sources/javadoc jars, GPG signing, central-publishing-maven-plugin). Manual steps
(Portal signup, GPG key) documented in RELEASING.md. Normal builds unaffected.

- **HTTP/1.1 pinned for library-constructed clients (2026-08-02)**: downstream use against
uvicorn/h11-based servers (the AAuth Person Server) showed the JDK HttpClient's default
h2c upgrade makes h11 reject requests (400) or silently drop POST bodies. Both
library-constructed defaults (`DefaultHttpClient`, `TokenExchange.Exchange`) now set
`HttpClient.Version.HTTP_1_1`; caller-injected clients are untouched.

## Test fixtures

Per user request (2026-07-30), test fixtures and examples use the local UMA lab hostnames
Expand All @@ -80,6 +86,13 @@ localhost-only HTTP carve-out itself.

## Deviations from the Python library

- **Content-Digest is enforced in the resource role (2026-08-02)**: both this library's
low-level `SignatureVerifier` and the Python reference only sign/verify the
`Content-Digest` *header*, so a tampered body with an intact header passes the HTTP
signature. `RequestVerifier.verifyRequest` now recomputes the RFC 9530 digest from the
body whenever both header and body are present and fails with
`content-digest mismatch` on divergence. The low-level `SignatureVerifier` is unchanged
for wire-format parity.
- **Signature header base64 flavor preserved**: the Python library base64url-encodes the
`Signature` header value (RFC 9421 §4.2 specifies sf-binary, i.e. standard base64).
We mirror the Python behavior for interop; both parsers only accept the urlsafe alphabet.
Expand Down