Skip to content

Commit 509aa4e

Browse files
committed
Support for OAuth2 Demonstrating Proof of Possession
1 parent 199ce85 commit 509aa4e

File tree

17 files changed

+3052
-26
lines changed

17 files changed

+3052
-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

+54
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@
1414
import io.netty.handler.codec.http.HttpResponseStatus;
1515
import io.quarkus.oidc.AccessTokenCredential;
1616
import io.quarkus.oidc.OidcTenantConfig;
17+
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
1718
import io.quarkus.oidc.common.runtime.OidcConstants;
1819
import io.quarkus.security.AuthenticationFailedException;
1920
import io.quarkus.security.identity.IdentityProviderManager;
2021
import io.quarkus.security.identity.SecurityIdentity;
2122
import io.quarkus.vertx.http.runtime.security.ChallengeData;
2223
import io.smallrye.mutiny.Uni;
24+
import io.vertx.core.json.JsonObject;
2325
import io.vertx.ext.web.RoutingContext;
2426

2527
public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMechanism {
@@ -33,6 +35,7 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
3335
if (token != null) {
3436
try {
3537
setCertificateThumbprint(context, oidcTenantConfig);
38+
setDPopProof(context, oidcTenantConfig);
3639
} catch (AuthenticationFailedException ex) {
3740
return Uni.createFrom().failure(ex);
3841
}
@@ -54,6 +57,57 @@ private static void setCertificateThumbprint(RoutingContext context, OidcTenantC
5457
}
5558
}
5659

60+
private static void setDPopProof(RoutingContext context, OidcTenantConfig oidcTenantConfig) {
61+
if (OidcConstants.DPOP_SCHEME.equals(oidcTenantConfig.token().authorizationScheme())) {
62+
String proof = context.request().getHeader(OidcConstants.DPOP_SCHEME);
63+
if (proof == null) {
64+
LOG.warn("DPOP proof header must be present to verify the DPOP access token binding");
65+
throw new AuthenticationFailedException();
66+
}
67+
// Initial proof check:
68+
JsonObject proofJwtHeaders = OidcUtils.decodeJwtHeaders(proof);
69+
JsonObject proofJwtClaims = OidcCommonUtils.decodeJwtContent(proof);
70+
71+
if (!OidcConstants.DPOP_TOKEN_TYPE.equals(proofJwtHeaders.getString(OidcConstants.TOKEN_TYPE_HEADER))) {
72+
LOG.warn("Invalid DPOP proof token type ('typ') header");
73+
throw new AuthenticationFailedException();
74+
}
75+
76+
// Check HTTP method and request URI
77+
String proofHttpMethod = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_METHOD);
78+
if (proofHttpMethod == null) {
79+
LOG.warn("DPOP proof HTTP method claim is missing");
80+
throw new AuthenticationFailedException();
81+
}
82+
83+
if (!context.request().method().name().equals(proofHttpMethod)) {
84+
LOG.warn("DPOP proof HTTP method claim does not match the request HTTP method");
85+
throw new AuthenticationFailedException();
86+
}
87+
88+
// Check HTTP request URI
89+
String proofHttpRequestUri = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_REQUEST_URI);
90+
if (proofHttpRequestUri == null) {
91+
LOG.warn("DPOP proof HTTP request uri claim is missing");
92+
throw new AuthenticationFailedException();
93+
}
94+
95+
String httpRequestUri = context.request().absoluteURI();
96+
int queryIndex = httpRequestUri.indexOf("?");
97+
if (queryIndex > 0) {
98+
httpRequestUri = httpRequestUri.substring(0, queryIndex);
99+
}
100+
if (!httpRequestUri.equals(proofHttpRequestUri)) {
101+
LOG.warn("DPOP proof HTTP request uri claim does not match the request HTTP uri");
102+
throw new AuthenticationFailedException();
103+
}
104+
105+
context.put(OidcUtils.DPOP_PROOF, proof);
106+
context.put(OidcUtils.DPOP_PROOF_JWT_HEADERS, proofJwtHeaders);
107+
context.put(OidcUtils.DPOP_PROOF_JWT_CLAIMS, proofJwtClaims);
108+
}
109+
}
110+
57111
private static Certificate getCertificate(RoutingContext context) {
58112
try {
59113
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;

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

+16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import static io.quarkus.oidc.common.runtime.OidcConstants.TOKEN_SCOPE;
66
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;
77

8+
import java.nio.charset.Charset;
9+
import java.nio.charset.StandardCharsets;
810
import java.security.Key;
911
import java.security.MessageDigest;
1012
import java.security.NoSuchAlgorithmException;
@@ -97,6 +99,12 @@ public final class OidcUtils {
9799
public static final String STATE_COOKIE_NAME = "q_auth";
98100
public static final String JWT_THUMBPRINT = "jwt_thumbprint";
99101
public static final String INTROSPECTION_THUMBPRINT = "introspection_thumbprint";
102+
public static final String DPOP_JWT_THUMBPRINT = "dpop_jwt_thumbprint";
103+
public static final String DPOP_INTROSPECTION_THUMBPRINT = "dpop_introspection_thumbprint";
104+
public static final String DPOP_PROOF = "dpop_proof";
105+
public static final String DPOP_PROOF_JWT_HEADERS = "dpop_proof_jwt_headers";
106+
public static final String DPOP_PROOF_JWT_CLAIMS = "dpop_proof_jwt_claims";
107+
100108
private static final String APPLICATION_JWT = "application/jwt";
101109

102110
// Browsers enforce that the total Set-Cookie expression such as
@@ -562,6 +570,14 @@ static OidcTenantConfig resolveProviderConfig(OidcTenantConfig oidcTenantConfig)
562570

563571
}
564572

573+
public static byte[] getSha256Digest(String value) throws NoSuchAlgorithmException {
574+
return getSha256Digest(value, StandardCharsets.UTF_8);
575+
}
576+
577+
public static byte[] getSha256Digest(String value, Charset charset) throws NoSuchAlgorithmException {
578+
return getSha256Digest(value.getBytes(charset));
579+
}
580+
565581
public static byte[] getSha256Digest(byte[] value) throws NoSuchAlgorithmException {
566582
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
567583
sha256.update(value);

0 commit comments

Comments
 (0)