From ff20a808b71a0851a961c3f311e3cad01b057271 Mon Sep 17 00:00:00 2001 From: Nicolas Ayral Seydoux Date: Mon, 9 Nov 2026 11:56:08 +0100 Subject: [PATCH] JCL-483: Add ath claim to DPoP proof This updates the DPoP implementation of this library to make it compliant with the published version of RFC 9449. The previous implementation was based on a draft version of the RFC which has been published since then. Previous signatures of com.inrupt.client.auth.DPoP::generateProof and com.inrupt.client.auth.Session::generateProof are deprecated. Users of the high-level clients are not impacted. --- .../accessgrant/AccessGrantSession.java | 5 + .../java/com/inrupt/client/auth/DPoP.java | 25 ++- .../java/com/inrupt/client/auth/Session.java | 15 ++ .../com/inrupt/client/core/DefaultClient.java | 2 +- .../client/core/DefaultDpopService.java | 24 ++- .../inrupt/client/core/DPoPManagerTest.java | 60 ++++++++ .../client/core/DefaultClientDpopTest.java | 143 ++++++++++++++++++ .../inrupt/client/openid/OpenIdSession.java | 6 + 8 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 core/src/test/java/com/inrupt/client/core/DefaultClientDpopTest.java diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantSession.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantSession.java index 925be04e3ec..4d8714b3d48 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantSession.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantSession.java @@ -145,6 +145,11 @@ public Optional generateProof(final String jkt, final Request request) { return session.generateProof(jkt, request); } + @Override + public Optional generateProof(final String jkt, final Request request, final String accessToken) { + return session.generateProof(jkt, request, accessToken); + } + @Override public CompletionStage> authenticate(final Authenticator authenticator, final Request request, final Set algorithms) { diff --git a/api/src/main/java/com/inrupt/client/auth/DPoP.java b/api/src/main/java/com/inrupt/client/auth/DPoP.java index e2c5900fada..ef7c2e2fdb8 100644 --- a/api/src/main/java/com/inrupt/client/auth/DPoP.java +++ b/api/src/main/java/com/inrupt/client/auth/DPoP.java @@ -32,7 +32,8 @@ /** * An abstraction for working with OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP). * - * @see DPoP draft specification + * @see RFC 9449: OAuth 2.0 Demonstrating Proof of Possession + * (DPoP) */ public interface DPoP { @@ -43,9 +44,31 @@ public interface DPoP { * @param uri the HTTP URI * @param method the HTTP method * @return the DPoP Proof, serialized as a Base64-encoded string, suitable for use with HTTP headers + * @deprecated The Access Token should be included in the proof. */ String generateProof(String algorithm, URI uri, String method); + /** + * Generate a DPoP proof for a given URI and method pair, bound to an access token. + * + *

When presenting a DPoP-bound access token to a protected resource, the proof must include + * an {@code ath} claim containing the hash of that access token + * (RFC 9449, section 4.2). + * + * @implSpec The default implementation ignores the access token and delegates to + * {@link #generateProof(String, URI, String)}. Implementations should override this method + * in order to include the {@code ath} claim. + * @param algorithm the algorithm to use + * @param uri the HTTP URI + * @param method the HTTP method + * @param accessToken the access token sent alongside the proof, may be {@code null} + * @return the DPoP Proof, serialized as a Base64-encoded string, suitable for use with HTTP headers + */ + default String generateProof(final String algorithm, final URI uri, final String method, + final String accessToken) { + return generateProof(algorithm, uri, method); + } + /** * Return a collection of the supported algorithm names. * diff --git a/api/src/main/java/com/inrupt/client/auth/Session.java b/api/src/main/java/com/inrupt/client/auth/Session.java index f59df8d872a..a0de3f3fca3 100644 --- a/api/src/main/java/com/inrupt/client/auth/Session.java +++ b/api/src/main/java/com/inrupt/client/auth/Session.java @@ -81,9 +81,24 @@ public interface Session { * @param jkt the JSON Key thumbprint for the proof key * @param request the request * @return a proof token + * @deprecated The Access Token should be included in the proof. */ Optional generateProof(String jkt, Request request); + /** + * Generate a proof for a request, bound to the access token sent with that request. + * + * @implSpec The default implementation ignores the access token and delegates to + * {@link #generateProof(String, Request)}. + * @param jkt the JSON Key thumbprint for the proof key + * @param request the request + * @param accessToken the access token sent with the request, may be {@code null} + * @return a proof token + */ + default Optional generateProof(final String jkt, final Request request, final String accessToken) { + return generateProof(jkt, request); + } + /** * Select the thumbprint from a set of candidate algorithms. * diff --git a/core/src/main/java/com/inrupt/client/core/DefaultClient.java b/core/src/main/java/com/inrupt/client/core/DefaultClient.java index 852442aaff9..eebee049e62 100644 --- a/core/src/main/java/com/inrupt/client/core/DefaultClient.java +++ b/core/src/main/java/com/inrupt/client/core/DefaultClient.java @@ -119,7 +119,7 @@ Request upgradeRequest(final Request request, final Credential token) { // Use setHeader to overwrite any possible existing authorization header builder.setHeader(AUTHORIZATION, String.join(" ", token.getScheme(), token.getToken())); if (DPOP.equalsIgnoreCase(token.getScheme())) { - token.getProofThumbprint().flatMap(jkt -> clientSession.generateProof(jkt, request)) + token.getProofThumbprint().flatMap(jkt -> clientSession.generateProof(jkt, request, token.getToken())) .ifPresent(proof -> builder.setHeader(DPOP, proof)); } diff --git a/core/src/main/java/com/inrupt/client/core/DefaultDpopService.java b/core/src/main/java/com/inrupt/client/core/DefaultDpopService.java index b091737bb9a..2010599c171 100644 --- a/core/src/main/java/com/inrupt/client/core/DefaultDpopService.java +++ b/core/src/main/java/com/inrupt/client/core/DefaultDpopService.java @@ -20,6 +20,7 @@ */ package com.inrupt.client.core; +import static java.nio.charset.StandardCharsets.US_ASCII; import static org.jose4j.jwx.HeaderParameterNames.TYPE; import static org.jose4j.lang.HashUtil.SHA_256; @@ -37,11 +38,13 @@ import java.util.TreeMap; import java.util.UUID; +import org.jose4j.base64url.Base64Url; import org.jose4j.jwk.PublicJsonWebKey; import org.jose4j.jws.JsonWebSignature; import org.jose4j.jwt.JwtClaims; import org.jose4j.keys.EcKeyUtil; import org.jose4j.keys.EllipticCurves; +import org.jose4j.lang.HashUtil; import org.jose4j.lang.JoseException; /** @@ -98,6 +101,12 @@ public Optional lookupThumbprint(final String algorithm) { @Override public String generateProof(final String algorithm, final URI uri, final String method) { + return generateProof(algorithm, uri, method, null); + } + + @Override + public String generateProof(final String algorithm, final URI uri, final String method, + final String accessToken) { final KeyPair keypair = keypairs.get(Objects.requireNonNull(algorithm)); if (keypair == null) { throw new AuthenticationException("Unsupported DPoP algorithm: " + algorithm); @@ -117,8 +126,11 @@ public String generateProof(final String algorithm, final URI uri, final String final JwtClaims claims = new JwtClaims(); claims.setJwtId(UUID.randomUUID().toString()); claims.setStringClaim("htm", htm); - claims.setStringClaim("htu", htu.toString()); + claims.setStringClaim("htu", stripQueryAndFragment(htu)); claims.setIssuedAtToNow(); + if (accessToken != null) { + claims.setStringClaim("ath", accessTokenHash(accessToken)); + } jws.setPayload(claims.toJson()); return jws.getCompactSerialization(); @@ -133,6 +145,16 @@ public Set algorithms() { } } + static String stripQueryAndFragment(final URI uri) { + // The htu claim is the HTTP target URI, without query and fragment parts (RFC 9449, section 4.2). + return uri.getScheme() + "://" + uri.getRawAuthority() + uri.getRawPath(); + } + + static String accessTokenHash(final String accessToken) { + // The ath claim is the encoded hash of the access token (RFC 9449, section 4.2). + return Base64Url.encode(HashUtil.getMessageDigest(SHA_256).digest(accessToken.getBytes(US_ASCII))); + } + static KeyPair defaultKeyPair(final ECParameterSpec spec) { try { final EcKeyUtil keyUtil = new EcKeyUtil(); diff --git a/core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java b/core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java index 463a666cced..22d5ad510eb 100644 --- a/core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java +++ b/core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java @@ -30,10 +30,13 @@ import org.jose4j.jwt.JwtClaims; import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; import org.jose4j.jwt.consumer.JwtConsumerBuilder; import org.jose4j.jwt.consumer.Validator; import org.jose4j.keys.resolvers.EmbeddedJwkVerificationKeyResolver; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class DPoPManagerTest { @@ -55,6 +58,54 @@ void testGenerateProof() { assertDoesNotThrow(() -> verifyDpop(proof, uri, method)); } + @Test + void testGenerateProofWithoutAccessToken() throws Exception { + final DPoP dpop = DPoP.of(); + final URI uri = URI.create("https://storage.example/resource"); + final JwtClaims claims = parseClaims(dpop.generateProof("ES256", uri, "GET")); + assertFalse(claims.hasClaim("ath")); + } + + @Test + void testGenerateProofWithAccessToken() throws Exception { + final DPoP dpop = DPoP.of(); + final String method = "GET"; + final URI uri = URI.create("https://storage.example/resource"); + // Example from RFC 9449, section 7.1 + final String accessToken = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + final String proof = dpop.generateProof("ES256", uri, method, accessToken); + + assertDoesNotThrow(() -> verifyDpop(proof, uri, method)); + assertEquals("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo", + parseClaims(proof).getStringClaimValue("ath")); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://storage.example/resource?query=value", + "https://storage.example/resource#fragment", + "https://storage.example/resource?query=value#fragment" + }) + void testHtuWithoutQueryAndFragment(final String target) throws Exception { + final DPoP dpop = DPoP.of(); + final String proof = dpop.generateProof("ES256", URI.create(target), "GET", "token"); + assertEquals("https://storage.example/resource", parseClaims(proof).getStringClaimValue("htu")); + } + + @ParameterizedTest + @ValueSource(strings = { + "https://storage.example/a%2Fb", + "https://storage.example/caf%C3%A9", + "https://storage.example/a%20b", + "https://user@storage.example:8443/resource", + "https://storage.example" + }) + void testHtuKeepsRawComponents(final String target) throws Exception { + final DPoP dpop = DPoP.of(); + final String proof = dpop.generateProof("ES256", URI.create(target + "?q=1#f"), "GET", "token"); + assertEquals(target, parseClaims(proof).getStringClaimValue("htu")); + } + @Test void testInvalidAlgorithm() { final DPoP dpop = DPoP.of(); @@ -91,6 +142,15 @@ static void verifyDpop(final String proof, final URI uri, final String method) { } } + static JwtClaims parseClaims(final String proof) throws InvalidJwtException { + final JwtConsumer consumer = new JwtConsumerBuilder() + .setVerificationKeyResolver(new EmbeddedJwkVerificationKeyResolver()) + .setJwsAlgorithmConstraints(PERMIT, algorithms) + .setExpectedType(true, "dpop+jwt") + .build(); + return consumer.processToClaims(proof); + } + static Validator htuValidator(final String url) { return ctx -> { final JwtClaims claims = ctx.getJwtClaims(); diff --git a/core/src/test/java/com/inrupt/client/core/DefaultClientDpopTest.java b/core/src/test/java/com/inrupt/client/core/DefaultClientDpopTest.java new file mode 100644 index 00000000000..e6b66e7a349 --- /dev/null +++ b/core/src/test/java/com/inrupt/client/core/DefaultClientDpopTest.java @@ -0,0 +1,143 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.core; + +import static org.jose4j.jwa.AlgorithmConstraints.ConstraintType.PERMIT; +import static org.junit.jupiter.api.Assertions.*; + +import com.inrupt.client.Request; +import com.inrupt.client.auth.Authenticator; +import com.inrupt.client.auth.Credential; +import com.inrupt.client.auth.DPoP; +import com.inrupt.client.auth.Session; +import com.inrupt.client.spi.ServiceProvider; + +import java.net.URI; +import java.time.Instant; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.keys.resolvers.EmbeddedJwkVerificationKeyResolver; +import org.junit.jupiter.api.Test; + +class DefaultClientDpopTest { + + // Example from RFC 9449, section 7.1 + static final String ACCESS_TOKEN = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"; + static final String ATH = "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"; + + @Test + void testUpgradeRequestWithDpopBoundToken() throws Exception { + final DPoP dpop = DPoP.of(); + final String jkt = dpop.lookupThumbprint("ES256").orElseThrow(); + final DefaultClient client = new DefaultClient(ServiceProvider.getHttpService(), new DpopSession(dpop)); + final Credential credential = new Credential("DPoP", URI.create("https://issuer.example"), + ACCESS_TOKEN, Instant.now().plusSeconds(300), null, jkt); + final Request request = Request.newBuilder(URI.create("https://storage.example/resource?q=1#frag")) + .header("DPoP", "stale-proof") + .GET().build(); + + final Request upgraded = client.upgradeRequest(request, credential); + + assertEquals(Collections.singletonList("DPoP " + ACCESS_TOKEN), + upgraded.headers().allValues("Authorization")); + assertEquals(1, upgraded.headers().allValues("DPoP").size()); + final String proof = upgraded.headers().firstValue("DPoP").orElseThrow(); + final JwtClaims claims = new JwtConsumerBuilder() + .setVerificationKeyResolver(new EmbeddedJwkVerificationKeyResolver()) + .setJwsAlgorithmConstraints(PERMIT, "ES256") + .setExpectedType(true, "dpop+jwt") + .setRequireJwtId() + .setRequireIssuedAt() + .build().processToClaims(proof); + assertEquals("GET", claims.getStringClaimValue("htm")); + assertEquals("https://storage.example/resource", claims.getStringClaimValue("htu")); + assertEquals(ATH, claims.getStringClaimValue("ath")); + } + + static class DpopSession implements Session { + private final DPoP dpop; + private final String id = UUID.randomUUID().toString(); + + DpopSession(final DPoP dpop) { + this.dpop = dpop; + } + + @Override + public String getId() { + return id; + } + + @Override + public Optional getPrincipal() { + return Optional.empty(); + } + + @Override + public Set supportedSchemes() { + return Collections.singleton("DPoP"); + } + + @Override + public Optional getCredential(final URI name, final URI uri) { + return Optional.empty(); + } + + @Override + public Optional fromCache(final Request request) { + return Optional.empty(); + } + + @Override + public Optional generateProof(final String jkt, final Request request) { + return generateProof(jkt, request, null); + } + + @Override + public Optional generateProof(final String jkt, final Request request, final String accessToken) { + return dpop.lookupAlgorithm(jkt) + .map(alg -> dpop.generateProof(alg, request.uri(), request.method(), accessToken)); + } + + @Override + public Optional selectThumbprint(final Collection algorithms) { + return Optional.empty(); + } + + @Override + public void reset() { + // no-op + } + + @Override + public CompletionStage> authenticate(final Authenticator authenticator, + final Request request, final Set algorithms) { + return CompletableFuture.completedFuture(Optional.empty()); + } + } +} diff --git a/openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java b/openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java index 15609da0397..1d13ead299e 100644 --- a/openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java +++ b/openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java @@ -223,6 +223,12 @@ public Optional generateProof(final String jkt, final Request request) { return dpop.lookupAlgorithm(jkt).map(alg -> dpop.generateProof(alg, request.uri(), request.method())); } + @Override + public Optional generateProof(final String jkt, final Request request, final String accessToken) { + return dpop.lookupAlgorithm(jkt) + .map(alg -> dpop.generateProof(alg, request.uri(), request.method(), accessToken)); + } + @Override public Optional fromCache(final Request request) { if (request != null) {