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 28, 2025
1 parent beee3db commit 782827d
Show file tree
Hide file tree
Showing 15 changed files with 2,954 additions and 24 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,9 @@ 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_HTTP_METHOD = "htm";
public static final String DPOP_HTTP_REQUEST_URI = "htu";
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.oidc.common.runtime.OidcConstants;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.smallrye.mutiny.Uni;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;

public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMechanism {
Expand All @@ -33,6 +35,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 +57,52 @@ 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:
JsonObject proofJwtHeaders = OidcUtils.decodeJwtHeaders(proof);
JsonObject proofJwtClaims = OidcCommonUtils.decodeJwtContent(proof);

// Check HTTP method and request URI
String proofHttpMethod = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_METHOD);
if (proofHttpMethod == null) {
LOG.warn("DPOP proof HTTP method claim is missing");
throw new AuthenticationFailedException();
}

if (!context.request().method().name().equals(proofHttpMethod)) {
LOG.warn("DPOP proof HTTP method claim does not match the request HTTP method");
throw new AuthenticationFailedException();
}

// Check HTTP request URI
String proofHttpRequestUri = proofJwtClaims.getString(OidcConstants.DPOP_HTTP_REQUEST_URI);
if (proofHttpRequestUri == null) {
LOG.warn("DPOP proof HTTP request uri claim is missing");
throw new AuthenticationFailedException();
}

String httpRequestUri = context.request().absoluteURI();
int queryIndex = httpRequestUri.indexOf("?");
if (queryIndex > 0) {
httpRequestUri = httpRequestUri.substring(0, queryIndex);
}
if (!httpRequestUri.equals(proofHttpRequestUri)) {
LOG.warn("DPOP proof HTTP request uri claim does not match the request HTTP uri");
throw new AuthenticationFailedException();
}

context.put(OidcUtils.DPOP_PROOF, proof);
context.put(OidcUtils.DPOP_PROOF_JWT_HEADERS, proofJwtHeaders);
context.put(OidcUtils.DPOP_PROOF_JWT_CLAIMS, proofJwtClaims);
}
}

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 All @@ -14,6 +15,8 @@

import org.eclipse.microprofile.jwt.Claims;
import org.jboss.logging.Logger;
import org.jose4j.jwk.PublicJsonWebKey;
import org.jose4j.lang.JoseException;
import org.jose4j.lang.UnresolvableKeyException;

import io.quarkus.oidc.AccessTokenCredential;
Expand Down Expand Up @@ -201,33 +204,100 @@ 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(OidcUtils.DPOP_PROOF_JWT_HEADERS)) {
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();
}

JsonObject proofHeaders = (JsonObject) requestData.get(OidcUtils.DPOP_PROOF_JWT_HEADERS);

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 {
byte[] jwkProofDigest = PublicJsonWebKey.Factory.newPublicJwk(jwkProof.getMap())
.calculateThumbprint("SHA-256");
jwkProofThumbprint = OidcCommonUtils.base64UrlEncode(jwkProofDigest);
} catch (JoseException ex) {
LOG.warn("JWK thumbprint can not be calculated");
throw new AuthenticationFailedException(ex);
}

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

JsonObject proofClaims = (JsonObject) requestData.get(OidcUtils.DPOP_PROOF_JWT_CLAIMS);

// 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 +313,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 @@ -340,7 +340,8 @@ private UniOnItem<HttpResponse<Buffer>> getHttpResponse(OidcRequestContextProper
}
}

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

OidcEndpoint.Type endpoint = introspect ? OidcEndpoint.Type.INTROSPECTION : OidcEndpoint.Type.TOKEN;
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,12 @@ 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";
public static final String DPOP_PROOF = "dpop_proof";
public static final String DPOP_PROOF_JWT_HEADERS = "dpop_proof_jwt_headers";
public static final String DPOP_PROOF_JWT_CLAIMS = "dpop_proof_jwt_claims";

private static final String APPLICATION_JWT = "application/jwt";

// Browsers enforce that the total Set-Cookie expression such as
Expand Down Expand Up @@ -562,6 +570,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
Loading

0 comments on commit 782827d

Please sign in to comment.