Skip to content

Commit 971eb35

Browse files
authored
JCL-483: Add ath claim to DPoP proof (#2800)
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.
1 parent 1fa6494 commit 971eb35

8 files changed

Lines changed: 277 additions & 3 deletions

File tree

‎access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantSession.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,11 @@ public Optional<String> generateProof(final String jkt, final Request request) {
145145
return session.generateProof(jkt, request);
146146
}
147147

148+
@Override
149+
public Optional<String> generateProof(final String jkt, final Request request, final String accessToken) {
150+
return session.generateProof(jkt, request, accessToken);
151+
}
152+
148153
@Override
149154
public CompletionStage<Optional<Credential>> authenticate(final Authenticator authenticator,
150155
final Request request, final Set<String> algorithms) {

‎api/src/main/java/com/inrupt/client/auth/DPoP.java‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
/**
3333
* An abstraction for working with OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP).
3434
*
35-
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop">DPoP draft specification</a>
35+
* @see <a href="https://www.rfc-editor.org/rfc/rfc9449">RFC 9449: OAuth 2.0 Demonstrating Proof of Possession
36+
* (DPoP)</a>
3637
*/
3738
public interface DPoP {
3839

@@ -43,9 +44,31 @@ public interface DPoP {
4344
* @param uri the HTTP URI
4445
* @param method the HTTP method
4546
* @return the DPoP Proof, serialized as a Base64-encoded string, suitable for use with HTTP headers
47+
* @deprecated The Access Token should be included in the proof.
4648
*/
4749
String generateProof(String algorithm, URI uri, String method);
4850

51+
/**
52+
* Generate a DPoP proof for a given URI and method pair, bound to an access token.
53+
*
54+
* <p>When presenting a DPoP-bound access token to a protected resource, the proof must include
55+
* an {@code ath} claim containing the hash of that access token
56+
* (<a href="https://www.rfc-editor.org/rfc/rfc9449#section-4.2">RFC 9449, section 4.2</a>).
57+
*
58+
* @implSpec The default implementation ignores the access token and delegates to
59+
* {@link #generateProof(String, URI, String)}. Implementations should override this method
60+
* in order to include the {@code ath} claim.
61+
* @param algorithm the algorithm to use
62+
* @param uri the HTTP URI
63+
* @param method the HTTP method
64+
* @param accessToken the access token sent alongside the proof, may be {@code null}
65+
* @return the DPoP Proof, serialized as a Base64-encoded string, suitable for use with HTTP headers
66+
*/
67+
default String generateProof(final String algorithm, final URI uri, final String method,
68+
final String accessToken) {
69+
return generateProof(algorithm, uri, method);
70+
}
71+
4972
/**
5073
* Return a collection of the supported algorithm names.
5174
*

‎api/src/main/java/com/inrupt/client/auth/Session.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,24 @@ public interface Session {
8181
* @param jkt the JSON Key thumbprint for the proof key
8282
* @param request the request
8383
* @return a proof token
84+
* @deprecated The Access Token should be included in the proof.
8485
*/
8586
Optional<String> generateProof(String jkt, Request request);
8687

88+
/**
89+
* Generate a proof for a request, bound to the access token sent with that request.
90+
*
91+
* @implSpec The default implementation ignores the access token and delegates to
92+
* {@link #generateProof(String, Request)}.
93+
* @param jkt the JSON Key thumbprint for the proof key
94+
* @param request the request
95+
* @param accessToken the access token sent with the request, may be {@code null}
96+
* @return a proof token
97+
*/
98+
default Optional<String> generateProof(final String jkt, final Request request, final String accessToken) {
99+
return generateProof(jkt, request);
100+
}
101+
87102
/**
88103
* Select the thumbprint from a set of candidate algorithms.
89104
*

‎core/src/main/java/com/inrupt/client/core/DefaultClient.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ Request upgradeRequest(final Request request, final Credential token) {
119119
// Use setHeader to overwrite any possible existing authorization header
120120
builder.setHeader(AUTHORIZATION, String.join(" ", token.getScheme(), token.getToken()));
121121
if (DPOP.equalsIgnoreCase(token.getScheme())) {
122-
token.getProofThumbprint().flatMap(jkt -> clientSession.generateProof(jkt, request))
122+
token.getProofThumbprint().flatMap(jkt -> clientSession.generateProof(jkt, request, token.getToken()))
123123
.ifPresent(proof -> builder.setHeader(DPOP, proof));
124124
}
125125

‎core/src/main/java/com/inrupt/client/core/DefaultDpopService.java‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
*/
2121
package com.inrupt.client.core;
2222

23+
import static java.nio.charset.StandardCharsets.US_ASCII;
2324
import static org.jose4j.jwx.HeaderParameterNames.TYPE;
2425
import static org.jose4j.lang.HashUtil.SHA_256;
2526

@@ -37,11 +38,13 @@
3738
import java.util.TreeMap;
3839
import java.util.UUID;
3940

41+
import org.jose4j.base64url.Base64Url;
4042
import org.jose4j.jwk.PublicJsonWebKey;
4143
import org.jose4j.jws.JsonWebSignature;
4244
import org.jose4j.jwt.JwtClaims;
4345
import org.jose4j.keys.EcKeyUtil;
4446
import org.jose4j.keys.EllipticCurves;
47+
import org.jose4j.lang.HashUtil;
4548
import org.jose4j.lang.JoseException;
4649

4750
/**
@@ -98,6 +101,12 @@ public Optional<String> lookupThumbprint(final String algorithm) {
98101

99102
@Override
100103
public String generateProof(final String algorithm, final URI uri, final String method) {
104+
return generateProof(algorithm, uri, method, null);
105+
}
106+
107+
@Override
108+
public String generateProof(final String algorithm, final URI uri, final String method,
109+
final String accessToken) {
101110
final KeyPair keypair = keypairs.get(Objects.requireNonNull(algorithm));
102111
if (keypair == null) {
103112
throw new AuthenticationException("Unsupported DPoP algorithm: " + algorithm);
@@ -117,8 +126,11 @@ public String generateProof(final String algorithm, final URI uri, final String
117126
final JwtClaims claims = new JwtClaims();
118127
claims.setJwtId(UUID.randomUUID().toString());
119128
claims.setStringClaim("htm", htm);
120-
claims.setStringClaim("htu", htu.toString());
129+
claims.setStringClaim("htu", stripQueryAndFragment(htu));
121130
claims.setIssuedAtToNow();
131+
if (accessToken != null) {
132+
claims.setStringClaim("ath", accessTokenHash(accessToken));
133+
}
122134
jws.setPayload(claims.toJson());
123135

124136
return jws.getCompactSerialization();
@@ -133,6 +145,16 @@ public Set<String> algorithms() {
133145
}
134146
}
135147

148+
static String stripQueryAndFragment(final URI uri) {
149+
// The htu claim is the HTTP target URI, without query and fragment parts (RFC 9449, section 4.2).
150+
return uri.getScheme() + "://" + uri.getRawAuthority() + uri.getRawPath();
151+
}
152+
153+
static String accessTokenHash(final String accessToken) {
154+
// The ath claim is the encoded hash of the access token (RFC 9449, section 4.2).
155+
return Base64Url.encode(HashUtil.getMessageDigest(SHA_256).digest(accessToken.getBytes(US_ASCII)));
156+
}
157+
136158
static KeyPair defaultKeyPair(final ECParameterSpec spec) {
137159
try {
138160
final EcKeyUtil keyUtil = new EcKeyUtil();

‎core/src/test/java/com/inrupt/client/core/DPoPManagerTest.java‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,13 @@
3030

3131
import org.jose4j.jwt.JwtClaims;
3232
import org.jose4j.jwt.consumer.InvalidJwtException;
33+
import org.jose4j.jwt.consumer.JwtConsumer;
3334
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
3435
import org.jose4j.jwt.consumer.Validator;
3536
import org.jose4j.keys.resolvers.EmbeddedJwkVerificationKeyResolver;
3637
import org.junit.jupiter.api.Test;
38+
import org.junit.jupiter.params.ParameterizedTest;
39+
import org.junit.jupiter.params.provider.ValueSource;
3740

3841
class DPoPManagerTest {
3942

@@ -55,6 +58,54 @@ void testGenerateProof() {
5558
assertDoesNotThrow(() -> verifyDpop(proof, uri, method));
5659
}
5760

61+
@Test
62+
void testGenerateProofWithoutAccessToken() throws Exception {
63+
final DPoP dpop = DPoP.of();
64+
final URI uri = URI.create("https://storage.example/resource");
65+
final JwtClaims claims = parseClaims(dpop.generateProof("ES256", uri, "GET"));
66+
assertFalse(claims.hasClaim("ath"));
67+
}
68+
69+
@Test
70+
void testGenerateProofWithAccessToken() throws Exception {
71+
final DPoP dpop = DPoP.of();
72+
final String method = "GET";
73+
final URI uri = URI.create("https://storage.example/resource");
74+
// Example from RFC 9449, section 7.1
75+
final String accessToken = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU";
76+
final String proof = dpop.generateProof("ES256", uri, method, accessToken);
77+
78+
assertDoesNotThrow(() -> verifyDpop(proof, uri, method));
79+
assertEquals("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
80+
parseClaims(proof).getStringClaimValue("ath"));
81+
}
82+
83+
@ParameterizedTest
84+
@ValueSource(strings = {
85+
"https://storage.example/resource?query=value",
86+
"https://storage.example/resource#fragment",
87+
"https://storage.example/resource?query=value#fragment"
88+
})
89+
void testHtuWithoutQueryAndFragment(final String target) throws Exception {
90+
final DPoP dpop = DPoP.of();
91+
final String proof = dpop.generateProof("ES256", URI.create(target), "GET", "token");
92+
assertEquals("https://storage.example/resource", parseClaims(proof).getStringClaimValue("htu"));
93+
}
94+
95+
@ParameterizedTest
96+
@ValueSource(strings = {
97+
"https://storage.example/a%2Fb",
98+
"https://storage.example/caf%C3%A9",
99+
"https://storage.example/a%20b",
100+
"https://user@storage.example:8443/resource",
101+
"https://storage.example"
102+
})
103+
void testHtuKeepsRawComponents(final String target) throws Exception {
104+
final DPoP dpop = DPoP.of();
105+
final String proof = dpop.generateProof("ES256", URI.create(target + "?q=1#f"), "GET", "token");
106+
assertEquals(target, parseClaims(proof).getStringClaimValue("htu"));
107+
}
108+
58109
@Test
59110
void testInvalidAlgorithm() {
60111
final DPoP dpop = DPoP.of();
@@ -91,6 +142,15 @@ static void verifyDpop(final String proof, final URI uri, final String method) {
91142
}
92143
}
93144

145+
static JwtClaims parseClaims(final String proof) throws InvalidJwtException {
146+
final JwtConsumer consumer = new JwtConsumerBuilder()
147+
.setVerificationKeyResolver(new EmbeddedJwkVerificationKeyResolver())
148+
.setJwsAlgorithmConstraints(PERMIT, algorithms)
149+
.setExpectedType(true, "dpop+jwt")
150+
.build();
151+
return consumer.processToClaims(proof);
152+
}
153+
94154
static Validator htuValidator(final String url) {
95155
return ctx -> {
96156
final JwtClaims claims = ctx.getJwtClaims();
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright Inrupt Inc.
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy
5+
* of this software and associated documentation files (the "Software"), to deal in
6+
* the Software without restriction, including without limitation the rights to use,
7+
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
8+
* Software, and to permit persons to whom the Software is furnished to do so,
9+
* subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in
12+
* all copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
15+
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
16+
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
17+
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
18+
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
19+
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
package com.inrupt.client.core;
22+
23+
import static org.jose4j.jwa.AlgorithmConstraints.ConstraintType.PERMIT;
24+
import static org.junit.jupiter.api.Assertions.*;
25+
26+
import com.inrupt.client.Request;
27+
import com.inrupt.client.auth.Authenticator;
28+
import com.inrupt.client.auth.Credential;
29+
import com.inrupt.client.auth.DPoP;
30+
import com.inrupt.client.auth.Session;
31+
import com.inrupt.client.spi.ServiceProvider;
32+
33+
import java.net.URI;
34+
import java.time.Instant;
35+
import java.util.Collection;
36+
import java.util.Collections;
37+
import java.util.Optional;
38+
import java.util.Set;
39+
import java.util.UUID;
40+
import java.util.concurrent.CompletableFuture;
41+
import java.util.concurrent.CompletionStage;
42+
43+
import org.jose4j.jwt.JwtClaims;
44+
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
45+
import org.jose4j.keys.resolvers.EmbeddedJwkVerificationKeyResolver;
46+
import org.junit.jupiter.api.Test;
47+
48+
class DefaultClientDpopTest {
49+
50+
// Example from RFC 9449, section 7.1
51+
static final String ACCESS_TOKEN = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU";
52+
static final String ATH = "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo";
53+
54+
@Test
55+
void testUpgradeRequestWithDpopBoundToken() throws Exception {
56+
final DPoP dpop = DPoP.of();
57+
final String jkt = dpop.lookupThumbprint("ES256").orElseThrow();
58+
final DefaultClient client = new DefaultClient(ServiceProvider.getHttpService(), new DpopSession(dpop));
59+
final Credential credential = new Credential("DPoP", URI.create("https://issuer.example"),
60+
ACCESS_TOKEN, Instant.now().plusSeconds(300), null, jkt);
61+
final Request request = Request.newBuilder(URI.create("https://storage.example/resource?q=1#frag"))
62+
.header("DPoP", "stale-proof")
63+
.GET().build();
64+
65+
final Request upgraded = client.upgradeRequest(request, credential);
66+
67+
assertEquals(Collections.singletonList("DPoP " + ACCESS_TOKEN),
68+
upgraded.headers().allValues("Authorization"));
69+
assertEquals(1, upgraded.headers().allValues("DPoP").size());
70+
final String proof = upgraded.headers().firstValue("DPoP").orElseThrow();
71+
final JwtClaims claims = new JwtConsumerBuilder()
72+
.setVerificationKeyResolver(new EmbeddedJwkVerificationKeyResolver())
73+
.setJwsAlgorithmConstraints(PERMIT, "ES256")
74+
.setExpectedType(true, "dpop+jwt")
75+
.setRequireJwtId()
76+
.setRequireIssuedAt()
77+
.build().processToClaims(proof);
78+
assertEquals("GET", claims.getStringClaimValue("htm"));
79+
assertEquals("https://storage.example/resource", claims.getStringClaimValue("htu"));
80+
assertEquals(ATH, claims.getStringClaimValue("ath"));
81+
}
82+
83+
static class DpopSession implements Session {
84+
private final DPoP dpop;
85+
private final String id = UUID.randomUUID().toString();
86+
87+
DpopSession(final DPoP dpop) {
88+
this.dpop = dpop;
89+
}
90+
91+
@Override
92+
public String getId() {
93+
return id;
94+
}
95+
96+
@Override
97+
public Optional<URI> getPrincipal() {
98+
return Optional.empty();
99+
}
100+
101+
@Override
102+
public Set<String> supportedSchemes() {
103+
return Collections.singleton("DPoP");
104+
}
105+
106+
@Override
107+
public Optional<Credential> getCredential(final URI name, final URI uri) {
108+
return Optional.empty();
109+
}
110+
111+
@Override
112+
public Optional<Credential> fromCache(final Request request) {
113+
return Optional.empty();
114+
}
115+
116+
@Override
117+
public Optional<String> generateProof(final String jkt, final Request request) {
118+
return generateProof(jkt, request, null);
119+
}
120+
121+
@Override
122+
public Optional<String> generateProof(final String jkt, final Request request, final String accessToken) {
123+
return dpop.lookupAlgorithm(jkt)
124+
.map(alg -> dpop.generateProof(alg, request.uri(), request.method(), accessToken));
125+
}
126+
127+
@Override
128+
public Optional<String> selectThumbprint(final Collection<String> algorithms) {
129+
return Optional.empty();
130+
}
131+
132+
@Override
133+
public void reset() {
134+
// no-op
135+
}
136+
137+
@Override
138+
public CompletionStage<Optional<Credential>> authenticate(final Authenticator authenticator,
139+
final Request request, final Set<String> algorithms) {
140+
return CompletableFuture.completedFuture(Optional.empty());
141+
}
142+
}
143+
}

‎openid/src/main/java/com/inrupt/client/openid/OpenIdSession.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ public Optional<String> generateProof(final String jkt, final Request request) {
223223
return dpop.lookupAlgorithm(jkt).map(alg -> dpop.generateProof(alg, request.uri(), request.method()));
224224
}
225225

226+
@Override
227+
public Optional<String> generateProof(final String jkt, final Request request, final String accessToken) {
228+
return dpop.lookupAlgorithm(jkt)
229+
.map(alg -> dpop.generateProof(alg, request.uri(), request.method(), accessToken));
230+
}
231+
226232
@Override
227233
public Optional<Credential> fromCache(final Request request) {
228234
if (request != null) {

0 commit comments

Comments
 (0)