diff --git a/src/main/java/org/solid/testharness/http/Client.java b/src/main/java/org/solid/testharness/http/Client.java index 1d81ffd2..643e526f 100644 --- a/src/main/java/org/solid/testharness/http/Client.java +++ b/src/main/java/org/solid/testharness/http/Client.java @@ -49,6 +49,8 @@ import java.net.http.HttpResponse.BodyHandlers; import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -186,14 +188,16 @@ public String requestAccessToken() { if (accessToken == null || isAccessTokenExpired()) { logger.debug("Request access token for {} using grant type {}", user, tokenRequestData.get(HttpConstants.GRANT_TYPE)); + // Token-endpoint proof: no access token is presented here, so it must NOT carry `ath` + // (pass null explicitly — a stale token in the field during a refresh must not leak in). final var requestBuilder = signRequest( HttpUtils.newRequestBuilder(tokenEndpoint) .header(HttpConstants.HEADER_AUTHORIZATION, authHeader) .header(HttpConstants.HEADER_CONTENT_TYPE, HttpConstants.MEDIA_TYPE_APPLICATION_FORM_URLENCODED) .header(HttpConstants.HEADER_ACCEPT, HttpConstants.MEDIA_TYPE_APPLICATION_JSON) - .POST(HttpUtils.ofFormData(tokenRequestData)) - ); + .POST(HttpUtils.ofFormData(tokenRequestData)), + null); final HttpResponse response; try { final var request = requestBuilder.build(); @@ -373,10 +377,18 @@ private CompletableFuture> tryResend(final HttpClient client * @return The builder with the DPoP header added */ public HttpRequest.Builder signRequest(@NotNull final HttpRequest.Builder builder) { + // A resource request presents the access token, so RFC 9449 requires the proof to be bound to + // it via `ath`. + return signRequest(builder, accessToken); + } + + private HttpRequest.Builder signRequest(@NotNull final HttpRequest.Builder builder, + final String boundAccessToken) { requireNonNull(builder, "builder is required"); if (!dpopSupported) return builder; final var provisionalRequest = builder.copy().build(); - final var dpopToken = generateDpopToken(provisionalRequest.method(), provisionalRequest.uri().toString()); + final var dpopToken = generateDpopToken(provisionalRequest.method(), provisionalRequest.uri().toString(), + boundAccessToken); return builder.header(HttpConstants.HEADER_DPOP, dpopToken); } @@ -395,7 +407,7 @@ public Map getAuthHeaders(@NotNull final String method, @NotNull if (accessToken == null) return headers; if (dpopSupported) { headers.put(HttpConstants.HEADER_AUTHORIZATION, HttpConstants.PREFIX_DPOP + accessToken); - final var dpopToken = generateDpopToken(method, uri.toString()); + final var dpopToken = generateDpopToken(method, uri.toString(), accessToken); headers.put(HttpConstants.HEADER_DPOP, dpopToken); } else { headers.put(HttpConstants.HEADER_AUTHORIZATION, HttpConstants.PREFIX_BEARER + accessToken); @@ -425,16 +437,32 @@ private boolean isAccessTokenExpired() { return NumericDate.now().isOnOrAfter(expirationTime); } - private String generateDpopToken(final String htm, final String htu) { + private String generateDpopToken(final String htm, final String htu, final String accessToken) { requireNonNull(clientKey, "This instance does not have DPoP support added"); final var claims = new JwtClaims(); claims.setJwtId(randomUUID().toString()); claims.setStringClaim("htm", htm); claims.setStringClaim("htu", htu); + // When the proof accompanies an access token to a protected resource, RFC 9449 requires the + // `ath` claim (base64url SHA-256 of the access token) so the proof is bound to that specific + // token. Omitted for the token request itself, where there is no access token yet. + if (accessToken != null) { + claims.setStringClaim(HttpConstants.DPOP_ATH, accessTokenHash(accessToken)); + } claims.setIssuedAtToNow(); return JwsUtils.generateDpopToken(clientKey, claims); } + private static String accessTokenHash(final String accessToken) { + try { + final var digest = MessageDigest.getInstance("SHA-256") + .digest(accessToken.getBytes(StandardCharsets.US_ASCII)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (final NoSuchAlgorithmException e) { + throw new TestHarnessInitializationException("SHA-256 is required for the DPoP ath claim", e); + } + } + @Override public String toString() { return String.format("Client: user=%s, dPoP=%s, session=%s, local=%s", diff --git a/src/main/java/org/solid/testharness/http/HttpConstants.java b/src/main/java/org/solid/testharness/http/HttpConstants.java index 30f2e5dc..fe65a94c 100644 --- a/src/main/java/org/solid/testharness/http/HttpConstants.java +++ b/src/main/java/org/solid/testharness/http/HttpConstants.java @@ -41,6 +41,9 @@ public final class HttpConstants { public static final String PREFIX_DPOP = "DPoP "; public static final String PREFIX_BEARER = "Bearer "; + + // RFC 9449 DPoP proof claim: base64url SHA-256 of the access token it accompanies. + public static final String DPOP_ATH = "ath"; public static final String PREFIX_BASIC = "Basic "; public static final String MEDIA_TYPE_APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded"; diff --git a/src/test/java/org/solid/testharness/http/ClientTest.java b/src/test/java/org/solid/testharness/http/ClientTest.java index 0d23d66f..81673149 100644 --- a/src/test/java/org/solid/testharness/http/ClientTest.java +++ b/src/test/java/org/solid/testharness/http/ClientTest.java @@ -24,6 +24,7 @@ package org.solid.testharness.http; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusTest; @@ -40,6 +41,9 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; import java.util.List; import java.util.Map; import java.util.UUID; @@ -493,6 +497,35 @@ void getAuthHeadersDpop() { assertTrue(headers.containsKey(HttpConstants.USER_AGENT)); } + @Test + void getAuthHeadersDpopProofBindsAth() throws Exception { + // RFC 9449 §4.2/§7.1: a DPoP proof presented with an access token to a protected resource MUST + // carry `ath` = base64url(SHA-256(access token)) so the proof is bound to that token. + final Client client = mockClient(true); + final Map headers = client.getAuthHeaders("GET", TEST_URL); + final var claims = dpopProofClaims(headers.get(HttpConstants.HEADER_DPOP)); + final var expected = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(accessToken.getBytes(StandardCharsets.US_ASCII))); + assertEquals(expected, claims.get("ath")); + } + + @Test + void signRequestDpopProofBindsAth() throws Exception { + final Client client = mockClient(true); + final HttpRequest request = client.signRequest(HttpRequest.newBuilder(TEST_URL)).build(); + final var claims = dpopProofClaims(request.headers().firstValue(HttpConstants.HEADER_DPOP).orElseThrow()); + final var expected = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(accessToken.getBytes(StandardCharsets.US_ASCII))); + assertEquals(expected, claims.get("ath")); + } + + /** Decode the (unverified) payload of a DPoP proof JWS into its claims map. */ + private Map dpopProofClaims(final String proof) throws JsonProcessingException { + final String payload = new String( + Base64.getUrlDecoder().decode(proof.split("\\.")[1]), StandardCharsets.UTF_8); + return objectMapper.readValue(payload, new TypeReference<>() { }); + } + @Test void getAuthHeadersNullMethod() { final Client client = new Client.Builder().build();