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) {