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
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ public Optional<String> generateProof(final String jkt, final Request request) {
return session.generateProof(jkt, request);
}

@Override
public Optional<String> generateProof(final String jkt, final Request request, final String accessToken) {
return session.generateProof(jkt, request, accessToken);
}

@Override
public CompletionStage<Optional<Credential>> authenticate(final Authenticator authenticator,
final Request request, final Set<String> algorithms) {
Expand Down
25 changes: 24 additions & 1 deletion api/src/main/java/com/inrupt/client/auth/DPoP.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
/**
* An abstraction for working with OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP).
*
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop">DPoP draft specification</a>
* @see <a href="https://www.rfc-editor.org/rfc/rfc9449">RFC 9449: OAuth 2.0 Demonstrating Proof of Possession
* (DPoP)</a>
*/
public interface DPoP {

Expand All @@ -43,9 +44,31 @@
* @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);

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View workflow job for this annotation

GitHub Actions / Documentation Check (17)

deprecated item is not annotated with @deprecated

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View workflow job for this annotation

GitHub Actions / Performance Tests (17)

deprecated item is not annotated with @deprecated

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View workflow job for this annotation

GitHub Actions / Java environment (21)

deprecated item is not annotated with @deprecated

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add the missing @Deprecated annotation.

See more on https://sonarcloud.io/project/issues?id=solid-client-java&issues=AaHACXruE_4QFyqAMElK&open=AaHACXruE_4QFyqAMElK&pullRequest=2800

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=solid-client-java&issues=AaHACXruE_4QFyqAMElJ&open=AaHACXruE_4QFyqAMElJ&pullRequest=2800

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View workflow job for this annotation

GitHub Actions / Sonar Scan

deprecated item is not annotated with @deprecated

Check warning on line 49 in api/src/main/java/com/inrupt/client/auth/DPoP.java

View workflow job for this annotation

GitHub Actions / Java environment (17)

deprecated item is not annotated with @deprecated

/**
* Generate a DPoP proof for a given URI and method pair, bound to an access token.
*
* <p>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
* (<a href="https://www.rfc-editor.org/rfc/rfc9449#section-4.2">RFC 9449, section 4.2</a>).
*
* @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.
*
Expand Down
15 changes: 15 additions & 0 deletions api/src/main/java/com/inrupt/client/auth/Session.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,24 @@
* @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<String> generateProof(String jkt, Request request);

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View workflow job for this annotation

GitHub Actions / Documentation Check (17)

deprecated item is not annotated with @deprecated

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View workflow job for this annotation

GitHub Actions / Performance Tests (17)

deprecated item is not annotated with @deprecated

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View workflow job for this annotation

GitHub Actions / Java environment (21)

deprecated item is not annotated with @deprecated

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=solid-client-java&issues=AaHACXsdE_4QFyqAMElL&open=AaHACXsdE_4QFyqAMElL&pullRequest=2800

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add the missing @Deprecated annotation.

See more on https://sonarcloud.io/project/issues?id=solid-client-java&issues=AaHACXsdE_4QFyqAMElM&open=AaHACXsdE_4QFyqAMElM&pullRequest=2800

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View workflow job for this annotation

GitHub Actions / Sonar Scan

deprecated item is not annotated with @deprecated

Check warning on line 86 in api/src/main/java/com/inrupt/client/auth/Session.java

View workflow job for this annotation

GitHub Actions / Java environment (17)

deprecated item is not annotated with @deprecated

/**
* 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<String> generateProof(final String jkt, final Request request, final String accessToken) {
return generateProof(jkt, request);
}

/**
* Select the thumbprint from a set of candidate algorithms.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

/**
Expand Down Expand Up @@ -97,7 +100,13 @@
}

@Override
public String generateProof(final String algorithm, final URI uri, final String method) {

Check warning on line 103 in core/src/main/java/com/inrupt/client/core/DefaultDpopService.java

View workflow job for this annotation

GitHub Actions / Performance Tests (17)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 103 in core/src/main/java/com/inrupt/client/core/DefaultDpopService.java

View workflow job for this annotation

GitHub Actions / Java environment (21)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 103 in core/src/main/java/com/inrupt/client/core/DefaultDpopService.java

View workflow job for this annotation

GitHub Actions / Sonar Scan

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 103 in core/src/main/java/com/inrupt/client/core/DefaultDpopService.java

View workflow job for this annotation

GitHub Actions / Java environment (17)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated
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);
Expand All @@ -117,8 +126,11 @@
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();
Expand All @@ -133,6 +145,16 @@
}
}

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();
Expand Down
60 changes: 60 additions & 0 deletions core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -50,11 +53,59 @@
final DPoP dpop = DPoP.of();
final String method = "GET";
final URI uri = URI.create("https://storage.example/resource");
final String proof = dpop.generateProof("ES256", uri, method);

Check warning on line 56 in core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java

View workflow job for this annotation

GitHub Actions / Performance Tests (17)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 56 in core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java

View workflow job for this annotation

GitHub Actions / Java environment (21)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 56 in core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java

View workflow job for this annotation

GitHub Actions / Sonar Scan

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 56 in core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java

View workflow job for this annotation

GitHub Actions / Java environment (17)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

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();
Expand Down Expand Up @@ -91,6 +142,15 @@
}
}

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();
Expand Down
143 changes: 143 additions & 0 deletions core/src/test/java/com/inrupt/client/core/DefaultClientDpopTest.java
Original file line number Diff line number Diff line change
@@ -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<URI> getPrincipal() {
return Optional.empty();
}

@Override
public Set<String> supportedSchemes() {
return Collections.singleton("DPoP");
}

@Override
public Optional<Credential> getCredential(final URI name, final URI uri) {
return Optional.empty();
}

@Override
public Optional<Credential> fromCache(final Request request) {
return Optional.empty();
}

@Override
public Optional<String> generateProof(final String jkt, final Request request) {
return generateProof(jkt, request, null);
}

@Override
public Optional<String> 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<String> selectThumbprint(final Collection<String> algorithms) {
return Optional.empty();
}

@Override
public void reset() {
// no-op
}

@Override
public CompletionStage<Optional<Credential>> authenticate(final Authenticator authenticator,
final Request request, final Set<String> algorithms) {
return CompletableFuture.completedFuture(Optional.empty());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,16 @@
}

@Override
public Optional<String> generateProof(final String jkt, final Request request) {

Check warning on line 222 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Performance Tests (17)

generateProof(java.lang.String,com.inrupt.client.Request) in com.inrupt.client.auth.Session has been deprecated

Check warning on line 222 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Java environment (21)

generateProof(java.lang.String,com.inrupt.client.Request) in com.inrupt.client.auth.Session has been deprecated

Check warning on line 222 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Sonar Scan

generateProof(java.lang.String,com.inrupt.client.Request) in com.inrupt.client.auth.Session has been deprecated

Check warning on line 222 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Java environment (17)

generateProof(java.lang.String,com.inrupt.client.Request) in com.inrupt.client.auth.Session has been deprecated
return dpop.lookupAlgorithm(jkt).map(alg -> dpop.generateProof(alg, request.uri(), request.method()));

Check warning on line 223 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Performance Tests (17)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 223 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Java environment (21)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 223 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Sonar Scan

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated

Check warning on line 223 in openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java

View workflow job for this annotation

GitHub Actions / Java environment (17)

generateProof(java.lang.String,java.net.URI,java.lang.String) in com.inrupt.client.auth.DPoP has been deprecated
}

@Override
public Optional<String> 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<Credential> fromCache(final Request request) {
if (request != null) {
Expand Down
Loading