Skip to content

Commit 5a924c3

Browse files
committed
Support for OAuth2 Demonstrating Proof of Possession
1 parent 199ce85 commit 5a924c3

File tree

17 files changed

+3060
-26
lines changed

17 files changed

+3060
-26
lines changed

extensions/devservices/keycloak/src/main/java/io/quarkus/devservices/keycloak/KeycloakDevServicesProcessor.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ public class KeycloakDevServicesProcessor {
112112
private static final String KEYCLOAK_QUARKUS_ADMIN_PROP = "KC_BOOTSTRAP_ADMIN_USERNAME";
113113
private static final String KEYCLOAK_QUARKUS_ADMIN_PASSWORD_PROP = "KC_BOOTSTRAP_ADMIN_PASSWORD";
114114
private static final String KEYCLOAK_QUARKUS_START_CMD = "start --http-enabled=true --hostname-strict=false "
115-
+ "--spi-user-profile-declarative-user-profile-config-file=/opt/keycloak/upconfig.json";
115+
+ "--features=preview --spi-user-profile-declarative-user-profile-config-file=/opt/keycloak/upconfig.json";
116116

117117
private static final String JAVA_OPTS = "JAVA_OPTS";
118118
private static final String OIDC_USERS = "oidc.users";

extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java

+4
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,10 @@ public static String base64UrlDecode(String encodedContent) {
782782
return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8);
783783
}
784784

785+
public static String base64UrlEncode(byte[] bytes) {
786+
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
787+
}
788+
785789
public static JsonObject decodeAsJsonObject(String encodedContent) {
786790
try {
787791
return new JsonObject(base64UrlDecode(encodedContent));

extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java

+9
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,16 @@ public final class OidcConstants {
3939
public static final String PASSWORD_GRANT_USERNAME = "username";
4040
public static final String PASSWORD_GRANT_PASSWORD = "password";
4141

42+
public static final String TOKEN_TYPE_HEADER = "typ";
43+
public static final String TOKEN_ALGORITHM_HEADER = "alg";
4244
public static final String TOKEN_SCOPE = "scope";
4345
public static final String GRANT_TYPE = "grant_type";
4446

4547
public static final String CLIENT_ID = "client_id";
4648
public static final String CLIENT_SECRET = "client_secret";
4749

4850
public static final String BEARER_SCHEME = "Bearer";
51+
public static final String DPOP_SCHEME = "DPoP";
4952
public static final String BASIC_SCHEME = "Basic";
5053

5154
public static final String AUTHORIZATION_CODE = "authorization_code";
@@ -89,4 +92,10 @@ public final class OidcConstants {
8992

9093
public static final String CONFIRMATION_CLAIM = "cnf";
9194
public static final String X509_SHA256_THUMBPRINT = "x5t#S256";
95+
public static final String DPOP_TOKEN_TYPE = "dpop+jwt";
96+
public static final String DPOP_JWK_SHA256_THUMBPRINT = "jkt";
97+
public static final String DPOP_JWK_HEADER = "jwk";
98+
public static final String DPOP_ACCESS_TOKEN_THUMBPRINT = "ath";
99+
public static final String DPOP_HTTP_METHOD = "htm";
100+
public static final String DPOP_HTTP_REQUEST_URI = "htu";
92101
}

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java

+62
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import java.security.cert.Certificate;
66
import java.security.cert.X509Certificate;
7+
import java.util.List;
78
import java.util.function.Function;
89

910
import javax.net.ssl.SSLPeerUnverifiedException;
@@ -14,12 +15,14 @@
1415
import io.netty.handler.codec.http.HttpResponseStatus;
1516
import io.quarkus.oidc.AccessTokenCredential;
1617
import io.quarkus.oidc.OidcTenantConfig;
18+
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
1719
import io.quarkus.oidc.common.runtime.OidcConstants;
1820
import io.quarkus.security.AuthenticationFailedException;
1921
import io.quarkus.security.identity.IdentityProviderManager;
2022
import io.quarkus.security.identity.SecurityIdentity;
2123
import io.quarkus.vertx.http.runtime.security.ChallengeData;
2224
import io.smallrye.mutiny.Uni;
25+
import io.vertx.core.json.JsonObject;
2326
import io.vertx.ext.web.RoutingContext;
2427

2528
public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMechanism {
@@ -33,6 +36,7 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
3336
if (token != null) {
3437
try {
3538
setCertificateThumbprint(context, oidcTenantConfig);
39+
setDPopProof(context, oidcTenantConfig);
3640
} catch (AuthenticationFailedException ex) {
3741
return Uni.createFrom().failure(ex);
3842
}
@@ -54,6 +58,64 @@ private static void setCertificateThumbprint(RoutingContext context, OidcTenantC
5458
}
5559
}
5660

61+
private static void setDPopProof(RoutingContext context, OidcTenantConfig oidcTenantConfig) {
62+
if (OidcConstants.DPOP_SCHEME.equals(oidcTenantConfig.token().authorizationScheme())) {
63+
64+
List<String> proofs = context.request().headers().getAll(OidcConstants.DPOP_SCHEME);
65+
if (proofs == null || proofs.isEmpty()) {
66+
LOG.warn("DPOP proof header must be present to verify the DPOP access token binding");
67+
throw new AuthenticationFailedException();
68+
}
69+
if (proofs.size() != 1) {
70+
LOG.warn("Only a single DPOP proof header is accepted");
71+
throw new AuthenticationFailedException();
72+
}
73+
String proof = proofs.get(0);
74+
75+
// Initial proof check:
76+
JsonObject proofJwtHeaders = OidcUtils.decodeJwtHeaders(proof);
77+
JsonObject proofJwtClaims = OidcCommonUtils.decodeJwtContent(proof);
78+
79+
if (!OidcConstants.DPOP_TOKEN_TYPE.equals(proofJwtHeaders.getString(OidcConstants.TOKEN_TYPE_HEADER))) {
80+
LOG.warn("Invalid DPOP proof token type ('typ') header");
81+
throw new AuthenticationFailedException();
82+
}
83+
84+
// Check HTTP method and request URI
85+
String proofHttpMethod = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_METHOD);
86+
if (proofHttpMethod == null) {
87+
LOG.warn("DPOP proof HTTP method claim is missing");
88+
throw new AuthenticationFailedException();
89+
}
90+
91+
if (!context.request().method().name().equals(proofHttpMethod)) {
92+
LOG.warn("DPOP proof HTTP method claim does not match the request HTTP method");
93+
throw new AuthenticationFailedException();
94+
}
95+
96+
// Check HTTP request URI
97+
String proofHttpRequestUri = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_REQUEST_URI);
98+
if (proofHttpRequestUri == null) {
99+
LOG.warn("DPOP proof HTTP request uri claim is missing");
100+
throw new AuthenticationFailedException();
101+
}
102+
103+
String httpRequestUri = context.request().absoluteURI();
104+
int queryIndex = httpRequestUri.indexOf("?");
105+
if (queryIndex > 0) {
106+
httpRequestUri = httpRequestUri.substring(0, queryIndex);
107+
}
108+
if (!httpRequestUri.equals(proofHttpRequestUri)) {
109+
LOG.warn("DPOP proof HTTP request uri claim does not match the request HTTP uri");
110+
throw new AuthenticationFailedException();
111+
}
112+
113+
context.put(OidcUtils.DPOP_PROOF, proof);
114+
context.put(OidcUtils.DPOP_PROOF_JWT_HEADERS, proofJwtHeaders);
115+
context.put(OidcUtils.DPOP_PROOF_JWT_CLAIMS, proofJwtClaims);
116+
}
117+
}
118+
57119
private static Certificate getCertificate(RoutingContext context) {
58120
try {
59121
return context.request().sslSession().getPeerCertificates()[0];

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java

+126-22
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static io.quarkus.oidc.runtime.OidcUtils.validateAndCreateIdentity;
44
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;
55

6+
import java.security.NoSuchAlgorithmException;
67
import java.security.Principal;
78
import java.util.Map;
89
import java.util.Set;
@@ -14,6 +15,9 @@
1415

1516
import org.eclipse.microprofile.jwt.Claims;
1617
import org.jboss.logging.Logger;
18+
import org.jose4j.jwk.PublicJsonWebKey;
19+
import org.jose4j.jws.JsonWebSignature;
20+
import org.jose4j.lang.JoseException;
1721
import org.jose4j.lang.UnresolvableKeyException;
1822

1923
import io.quarkus.oidc.AccessTokenCredential;
@@ -201,33 +205,120 @@ private Uni<TokenVerificationResult> verifyPrimaryTokenUni(Map<String, Object> r
201205
final boolean idToken = isIdToken(request);
202206
Uni<TokenVerificationResult> result = verifyTokenUni(requestData, resolvedContext, request.getToken(), idToken,
203207
false, userInfo);
204-
if (!idToken && resolvedContext.oidcConfig().token().binding().certificate()) {
205-
return result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {
208+
if (!idToken) {
209+
if (resolvedContext.oidcConfig().token().binding().certificate()) {
210+
result = result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {
206211

207-
@Override
208-
public TokenVerificationResult apply(TokenVerificationResult t) {
209-
String tokenCertificateThumbprint = getTokenCertThumbprint(requestData, t);
210-
if (tokenCertificateThumbprint == null) {
211-
LOG.warn(
212-
"Access token does not contain a confirmation 'cnf' claim with the certificate thumbprint");
213-
throw new AuthenticationFailedException();
214-
}
215-
String clientCertificateThumbprint = (String) requestData.get(OidcConstants.X509_SHA256_THUMBPRINT);
216-
if (clientCertificateThumbprint == null) {
217-
LOG.warn("Client certificate thumbprint is not available");
218-
throw new AuthenticationFailedException();
212+
@Override
213+
public TokenVerificationResult apply(TokenVerificationResult t) {
214+
String tokenCertificateThumbprint = getTokenCertThumbprint(requestData, t);
215+
if (tokenCertificateThumbprint == null) {
216+
LOG.warn(
217+
"Access token does not contain a confirmation 'cnf' claim with the certificate thumbprint");
218+
throw new AuthenticationFailedException();
219+
}
220+
String clientCertificateThumbprint = (String) requestData.get(OidcConstants.X509_SHA256_THUMBPRINT);
221+
if (clientCertificateThumbprint == null) {
222+
LOG.warn("Client certificate thumbprint is not available");
223+
throw new AuthenticationFailedException();
224+
}
225+
if (!clientCertificateThumbprint.equals(tokenCertificateThumbprint)) {
226+
LOG.warn("Client certificate thumbprint does not match the token certificate thumbprint");
227+
throw new AuthenticationFailedException();
228+
}
229+
return t;
219230
}
220-
if (!clientCertificateThumbprint.equals(tokenCertificateThumbprint)) {
221-
LOG.warn("Client certificate thumbprint does not match the token certificate thumbprint");
222-
throw new AuthenticationFailedException();
231+
232+
});
233+
}
234+
235+
if (requestData.containsKey(OidcUtils.DPOP_PROOF_JWT_HEADERS)) {
236+
result = result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {
237+
238+
@Override
239+
public TokenVerificationResult apply(TokenVerificationResult t) {
240+
241+
String dpopJwkThumbprint = getDpopJwkThumbprint(requestData, t);
242+
if (dpopJwkThumbprint == null) {
243+
LOG.warn(
244+
"DPoP access token does not contain a confirmation 'cnf' claim with the JWK thumbprint");
245+
throw new AuthenticationFailedException();
246+
}
247+
248+
JsonObject proofHeaders = (JsonObject) requestData.get(OidcUtils.DPOP_PROOF_JWT_HEADERS);
249+
250+
JsonObject jwkProof = proofHeaders.getJsonObject(OidcConstants.DPOP_JWK_HEADER);
251+
if (jwkProof == null) {
252+
LOG.warn("DPoP proof jwk header is missing");
253+
throw new AuthenticationFailedException();
254+
}
255+
256+
PublicJsonWebKey publicJsonWebKey = null;
257+
try {
258+
publicJsonWebKey = PublicJsonWebKey.Factory.newPublicJwk(jwkProof.getMap());
259+
} catch (JoseException ex) {
260+
LOG.warn("DPoP proof jwk header does not represent a valid JWK key");
261+
throw new AuthenticationFailedException(ex);
262+
}
263+
264+
if (publicJsonWebKey.getPrivateKey() != null) {
265+
LOG.warn("DPoP proof JWK key is a private key but it must be a public key");
266+
throw new AuthenticationFailedException();
267+
}
268+
269+
byte[] jwkProofDigest = publicJsonWebKey.calculateThumbprint("SHA-256");
270+
String jwkProofThumbprint = OidcCommonUtils.base64UrlEncode(jwkProofDigest);
271+
272+
if (!dpopJwkThumbprint.equals(jwkProofThumbprint)) {
273+
LOG.warn("DPoP access token JWK thumbprint does not match the DPoP proof JWK thumbprint");
274+
throw new AuthenticationFailedException();
275+
}
276+
277+
try {
278+
JsonWebSignature jws = new JsonWebSignature();
279+
jws.setAlgorithmConstraints(OidcProvider.ASYMMETRIC_ALGORITHM_CONSTRAINTS);
280+
jws.setCompactSerialization((String) requestData.get(OidcUtils.DPOP_PROOF));
281+
jws.setKey(publicJsonWebKey.getPublicKey());
282+
if (!jws.verifySignature()) {
283+
LOG.warn("DPoP proof token signature is invalid");
284+
throw new AuthenticationFailedException();
285+
}
286+
} catch (JoseException ex) {
287+
LOG.warn("DPoP proof token signature can not be verified");
288+
throw new AuthenticationFailedException(ex);
289+
}
290+
291+
JsonObject proofClaims = (JsonObject) requestData.get(OidcUtils.DPOP_PROOF_JWT_CLAIMS);
292+
293+
// Calculate the access token thumprint and compare with the `ath` claim
294+
295+
String accessTokenProof = proofClaims.getString(OidcConstants.DPOP_ACCESS_TOKEN_THUMBPRINT);
296+
if (accessTokenProof == null) {
297+
LOG.warn("DPoP proof access token hash is missing");
298+
throw new AuthenticationFailedException();
299+
}
300+
301+
String accessTokenHash = null;
302+
try {
303+
accessTokenHash = OidcCommonUtils.base64UrlEncode(
304+
OidcUtils.getSha256Digest(request.getToken().getToken()));
305+
} catch (NoSuchAlgorithmException ex) {
306+
// SHA256 is always supported
307+
}
308+
309+
if (!accessTokenProof.equals(accessTokenHash)) {
310+
LOG.warn("DPoP access token hash does not match the DPoP proof access token hash");
311+
throw new AuthenticationFailedException();
312+
}
313+
314+
return t;
223315
}
224-
return t;
225-
}
226316

227-
});
228-
} else {
229-
return result;
317+
});
318+
}
230319
}
320+
321+
return result;
231322
}
232323
}
233324

@@ -243,6 +334,19 @@ private static String getTokenCertThumbprint(Map<String, Object> requestData, To
243334
return thumbprint;
244335
}
245336

337+
private static String getDpopJwkThumbprint(Map<String, Object> requestData, TokenVerificationResult t) {
338+
JsonObject json = t.localVerificationResult != null ? t.localVerificationResult
339+
: new JsonObject(t.introspectionResult.getIntrospectionString());
340+
JsonObject cnf = json.getJsonObject(OidcConstants.CONFIRMATION_CLAIM);
341+
String thumbprint = cnf == null ? null : cnf.getString(OidcConstants.DPOP_JWK_SHA256_THUMBPRINT);
342+
if (thumbprint != null) {
343+
requestData.put(
344+
(t.introspectionResult == null ? OidcUtils.DPOP_JWT_THUMBPRINT : OidcUtils.DPOP_INTROSPECTION_THUMBPRINT),
345+
true);
346+
}
347+
return thumbprint;
348+
}
349+
246350
private Uni<SecurityIdentity> getUserInfoAndCreateIdentity(Uni<TokenVerificationResult> tokenUni,
247351
Map<String, Object> requestData,
248352
TokenAuthenticationRequest request,

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ public class OidcProvider implements Closeable {
6262
SignatureAlgorithm.PS384.getAlgorithm(),
6363
SignatureAlgorithm.PS512.getAlgorithm(),
6464
SignatureAlgorithm.EDDSA.getAlgorithm() };
65-
private static final AlgorithmConstraints ASYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
66-
AlgorithmConstraints.ConstraintType.PERMIT, ASYMMETRIC_SUPPORTED_ALGORITHMS);
6765
private static final AlgorithmConstraints SYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
6866
AlgorithmConstraints.ConstraintType.PERMIT, SignatureAlgorithm.HS256.getAlgorithm());
67+
static final AlgorithmConstraints ASYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
68+
AlgorithmConstraints.ConstraintType.PERMIT, ASYMMETRIC_SUPPORTED_ALGORITHMS);
6969
static final String ANY_ISSUER = "any";
7070

7171
private final List<Validator> customValidators;

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClientImpl.java

+2-1
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ private UniOnItem<HttpResponse<Buffer>> getHttpResponse(OidcRequestContextProper
340340
}
341341
}
342342

343-
LOG.debugf("Get token on: %s params: %s headers: %s", metadata.getTokenUri(), formBody, request.headers());
343+
LOG.debugf("%s token: %s params: %s headers: %s", (introspect ? "Introspect" : "Get"), metadata.getTokenUri(), formBody,
344+
request.headers());
344345
// Retry up to three times with a one-second delay between the retries if the connection is closed.
345346

346347
OidcEndpoint.Type endpoint = introspect ? OidcEndpoint.Type.INTROSPECTION : OidcEndpoint.Type.TOKEN;

0 commit comments

Comments
 (0)