Skip to content
Open
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
38 changes: 33 additions & 5 deletions src/main/java/org/solid/testharness/http/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> response;
try {
final var request = requestBuilder.build();
Expand Down Expand Up @@ -373,10 +377,18 @@ private <T> CompletableFuture<HttpResponse<T>> 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);
}

Comment on lines +381 to +384
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);
}

Expand All @@ -395,7 +407,7 @@ public Map<String, String> 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);
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/org/solid/testharness/http/HttpConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
33 changes: 33 additions & 0 deletions src/test/java/org/solid/testharness/http/ClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, String> 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"));
}
Comment on lines +505 to +510

@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<String, Object> 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();
Expand Down
Loading