Skip to content

Commit

Permalink
Support for OAuth2 Demonstrating Proof of Possession
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Jan 25, 2025
1 parent beee3db commit 0ca32c5
Show file tree
Hide file tree
Showing 14 changed files with 2,833 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public class KeycloakDevServicesProcessor {
private static final String KEYCLOAK_QUARKUS_ADMIN_PROP = "KC_BOOTSTRAP_ADMIN_USERNAME";
private static final String KEYCLOAK_QUARKUS_ADMIN_PASSWORD_PROP = "KC_BOOTSTRAP_ADMIN_PASSWORD";
private static final String KEYCLOAK_QUARKUS_START_CMD = "start --http-enabled=true --hostname-strict=false "
+ "--spi-user-profile-declarative-user-profile-config-file=/opt/keycloak/upconfig.json";
+ "--features=preview --spi-user-profile-declarative-user-profile-config-file=/opt/keycloak/upconfig.json";

private static final String JAVA_OPTS = "JAVA_OPTS";
private static final String OIDC_USERS = "oidc.users";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,10 @@ public static String base64UrlDecode(String encodedContent) {
return new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8);
}

public static String base64UrlEncode(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

public static JsonObject decodeAsJsonObject(String encodedContent) {
try {
return new JsonObject(base64UrlDecode(encodedContent));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public final class OidcConstants {
public static final String CLIENT_SECRET = "client_secret";

public static final String BEARER_SCHEME = "Bearer";
public static final String DPOP_SCHEME = "DPoP";
public static final String BASIC_SCHEME = "Basic";

public static final String AUTHORIZATION_CODE = "authorization_code";
Expand Down Expand Up @@ -89,4 +90,8 @@ public final class OidcConstants {

public static final String CONFIRMATION_CLAIM = "cnf";
public static final String X509_SHA256_THUMBPRINT = "x5t#S256";
public static final String DPOP_JWK_SHA256_THUMBPRINT = "jkt";
public static final String DPOP_JWK_HEADER = "jwk";
public static final String DPOP_ACCESS_TOKEN_THUMBPRINT = "ath";
public static final String DPOP_PROOF = "DPOP";
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
if (token != null) {
try {
setCertificateThumbprint(context, oidcTenantConfig);
setDPopProof(context, oidcTenantConfig);
} catch (AuthenticationFailedException ex) {
return Uni.createFrom().failure(ex);
}
Expand All @@ -54,6 +55,18 @@ private static void setCertificateThumbprint(RoutingContext context, OidcTenantC
}
}

private static void setDPopProof(RoutingContext context, OidcTenantConfig oidcTenantConfig) {
if (OidcConstants.DPOP_SCHEME.equals(oidcTenantConfig.token().authorizationScheme())) {
String proof = context.request().getHeader(OidcConstants.DPOP_SCHEME);
if (proof == null) {
LOG.warn("DPOP proof header must be present to verify the DPOP access token binding");
throw new AuthenticationFailedException();
}
// Initial proof check.
context.put(OidcConstants.DPOP_PROOF, proof);
}
}

private static Certificate getCertificate(RoutingContext context) {
try {
return context.request().sslSession().getPeerCertificates()[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static io.quarkus.oidc.runtime.OidcUtils.validateAndCreateIdentity;
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;

import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -201,33 +202,98 @@ private Uni<TokenVerificationResult> verifyPrimaryTokenUni(Map<String, Object> r
final boolean idToken = isIdToken(request);
Uni<TokenVerificationResult> result = verifyTokenUni(requestData, resolvedContext, request.getToken(), idToken,
false, userInfo);
if (!idToken && resolvedContext.oidcConfig().token().binding().certificate()) {
return result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {
if (!idToken) {
if (resolvedContext.oidcConfig().token().binding().certificate()) {
result = result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {

@Override
public TokenVerificationResult apply(TokenVerificationResult t) {
String tokenCertificateThumbprint = getTokenCertThumbprint(requestData, t);
if (tokenCertificateThumbprint == null) {
LOG.warn(
"Access token does not contain a confirmation 'cnf' claim with the certificate thumbprint");
throw new AuthenticationFailedException();
}
String clientCertificateThumbprint = (String) requestData.get(OidcConstants.X509_SHA256_THUMBPRINT);
if (clientCertificateThumbprint == null) {
LOG.warn("Client certificate thumbprint is not available");
throw new AuthenticationFailedException();
@Override
public TokenVerificationResult apply(TokenVerificationResult t) {
String tokenCertificateThumbprint = getTokenCertThumbprint(requestData, t);
if (tokenCertificateThumbprint == null) {
LOG.warn(
"Access token does not contain a confirmation 'cnf' claim with the certificate thumbprint");
throw new AuthenticationFailedException();
}
String clientCertificateThumbprint = (String) requestData.get(OidcConstants.X509_SHA256_THUMBPRINT);
if (clientCertificateThumbprint == null) {
LOG.warn("Client certificate thumbprint is not available");
throw new AuthenticationFailedException();
}
if (!clientCertificateThumbprint.equals(tokenCertificateThumbprint)) {
LOG.warn("Client certificate thumbprint does not match the token certificate thumbprint");
throw new AuthenticationFailedException();
}
return t;
}
if (!clientCertificateThumbprint.equals(tokenCertificateThumbprint)) {
LOG.warn("Client certificate thumbprint does not match the token certificate thumbprint");
throw new AuthenticationFailedException();

});
}

if (requestData.containsKey(OidcConstants.DPOP_PROOF)) {
result = result.onItem().transform(new Function<TokenVerificationResult, TokenVerificationResult>() {

@Override
public TokenVerificationResult apply(TokenVerificationResult t) {

String dpopJwkThumbprint = getDpopJwkThumbprint(requestData, t);
if (dpopJwkThumbprint == null) {
LOG.warn(
"DPoP access token does not contain a confirmation 'cnf' claim with the JWK certificate thumbprint");
throw new AuthenticationFailedException();
}

String proof = (String) requestData.get(OidcConstants.DPOP_PROOF);

JsonObject proofHeaders = OidcUtils.decodeJwtHeaders(proof);
JsonObject jwkProof = proofHeaders.getJsonObject(OidcConstants.DPOP_JWK_HEADER);
if (jwkProof == null) {
LOG.warn("DPoP proof jwk header is missing");
throw new AuthenticationFailedException();
}

String jwkProofThumbprint = null;
try {
jwkProofThumbprint = OidcCommonUtils.base64UrlEncode(
OidcUtils.getSha256Digest(jwkProof.toString()));
} catch (NoSuchAlgorithmException ex) {
// SHA256 is always supported
}

if (!dpopJwkThumbprint.equals(jwkProofThumbprint)) {
LOG.warn("DPoP access token JWK tnumbprint does not match the DPoP proof JWK thumbprint");
throw new AuthenticationFailedException();
}

// Get jwk header and compare with the thumbprint
JsonObject proofClaims = OidcCommonUtils.decodeJwtContent(proof);
// Calculate the access token thumprint and compare with the `ath` claim
String accessTokenProof = proofClaims.getString(OidcConstants.DPOP_ACCESS_TOKEN_THUMBPRINT);
if (accessTokenProof == null) {
LOG.warn("DPoP proof access token hash is missing");
throw new AuthenticationFailedException();
}

String accessTokenHash = null;
try {
accessTokenHash = OidcCommonUtils.base64UrlEncode(
OidcUtils.getSha256Digest(request.getToken().getToken()));
} catch (NoSuchAlgorithmException ex) {
// SHA256 is always supported
}

if (!accessTokenProof.equals(accessTokenHash)) {
LOG.warn("DPoP access token hash does not match the DPoP proof access token hash");
throw new AuthenticationFailedException();
}

return t;
}
return t;
}

});
} else {
return result;
});
}
}

return result;
}
}

Expand All @@ -243,6 +309,19 @@ private static String getTokenCertThumbprint(Map<String, Object> requestData, To
return thumbprint;
}

private static String getDpopJwkThumbprint(Map<String, Object> requestData, TokenVerificationResult t) {
JsonObject json = t.localVerificationResult != null ? t.localVerificationResult
: new JsonObject(t.introspectionResult.getIntrospectionString());
JsonObject cnf = json.getJsonObject(OidcConstants.CONFIRMATION_CLAIM);
String thumbprint = cnf == null ? null : cnf.getString(OidcConstants.DPOP_JWK_SHA256_THUMBPRINT);
if (thumbprint != null) {
requestData.put(
(t.introspectionResult == null ? OidcUtils.DPOP_JWT_THUMBPRINT : OidcUtils.DPOP_INTROSPECTION_THUMBPRINT),
true);
}
return thumbprint;
}

private Uni<SecurityIdentity> getUserInfoAndCreateIdentity(Uni<TokenVerificationResult> tokenUni,
Map<String, Object> requestData,
TokenAuthenticationRequest request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import static io.quarkus.oidc.common.runtime.OidcConstants.TOKEN_SCOPE;
import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getRoutingContextAttribute;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
Expand Down Expand Up @@ -97,6 +99,8 @@ public final class OidcUtils {
public static final String STATE_COOKIE_NAME = "q_auth";
public static final String JWT_THUMBPRINT = "jwt_thumbprint";
public static final String INTROSPECTION_THUMBPRINT = "introspection_thumbprint";
public static final String DPOP_JWT_THUMBPRINT = "dpop_jwt_thumbprint";
public static final String DPOP_INTROSPECTION_THUMBPRINT = "dpop_introspection_thumbprint";
private static final String APPLICATION_JWT = "application/jwt";

// Browsers enforce that the total Set-Cookie expression such as
Expand Down Expand Up @@ -562,6 +566,14 @@ static OidcTenantConfig resolveProviderConfig(OidcTenantConfig oidcTenantConfig)

}

public static byte[] getSha256Digest(String value) throws NoSuchAlgorithmException {
return getSha256Digest(value, StandardCharsets.UTF_8);
}

public static byte[] getSha256Digest(String value, Charset charset) throws NoSuchAlgorithmException {
return getSha256Digest(value.getBytes(charset));
}

public static byte[] getSha256Digest(byte[] value) throws NoSuchAlgorithmException {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
sha256.update(value);
Expand Down
139 changes: 139 additions & 0 deletions integration-tests/oidc-dpop/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>quarkus-integration-tests-parent</artifactId>
<groupId>io.quarkus</groupId>
<version>999-SNAPSHOT</version>
<relativePath>../</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>quarkus-integration-test-oidc-dpop</artifactId>
<name>Quarkus - Integration Tests - OpenID Connect DPoP</name>
<description>Module that contains OpenID Connect DPoP tests</description>

<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

<profiles>
<profile>
<id>test-keycloak</id>
<activation>
<property>
<name>test-containers</name>
</property>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<skip>false</skip>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<skip>false</skip>
<systemPropertyVariables>
<keycloak.url>${keycloak.url}</keycloak.url>
</systemPropertyVariables>
</configuration>
</plugin>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>

</project>
Loading

0 comments on commit 0ca32c5

Please sign in to comment.