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 30, 2025
1 parent 199ce85 commit 5a924c3
Show file tree
Hide file tree
Showing 17 changed files with 3,060 additions and 26 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 @@ -39,13 +39,16 @@ public final class OidcConstants {
public static final String PASSWORD_GRANT_USERNAME = "username";
public static final String PASSWORD_GRANT_PASSWORD = "password";

public static final String TOKEN_TYPE_HEADER = "typ";
public static final String TOKEN_ALGORITHM_HEADER = "alg";
public static final String TOKEN_SCOPE = "scope";
public static final String GRANT_TYPE = "grant_type";

public static final String CLIENT_ID = "client_id";
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 +92,10 @@ 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_TOKEN_TYPE = "dpop+jwt";
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 @@ -4,6 +4,7 @@

import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.function.Function;

import javax.net.ssl.SSLPeerUnverifiedException;
Expand All @@ -14,12 +15,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 +36,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 +58,64 @@ private static void setCertificateThumbprint(RoutingContext context, OidcTenantC
}
}

private static void setDPopProof(RoutingContext context, OidcTenantConfig oidcTenantConfig) {
if (OidcConstants.DPOP_SCHEME.equals(oidcTenantConfig.token().authorizationScheme())) {

List<String> proofs = context.request().headers().getAll(OidcConstants.DPOP_SCHEME);
if (proofs == null || proofs.isEmpty()) {
LOG.warn("DPOP proof header must be present to verify the DPOP access token binding");
throw new AuthenticationFailedException();
}
if (proofs.size() != 1) {
LOG.warn("Only a single DPOP proof header is accepted");
throw new AuthenticationFailedException();
}
String proof = proofs.get(0);

// Initial proof check:
JsonObject proofJwtHeaders = OidcUtils.decodeJwtHeaders(proof);
JsonObject proofJwtClaims = OidcCommonUtils.decodeJwtContent(proof);

if (!OidcConstants.DPOP_TOKEN_TYPE.equals(proofJwtHeaders.getString(OidcConstants.TOKEN_TYPE_HEADER))) {
LOG.warn("Invalid DPOP proof token type ('typ') header");
throw new AuthenticationFailedException();
}

// 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,9 @@

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

import io.quarkus.oidc.AccessTokenCredential;
Expand Down Expand Up @@ -201,33 +205,120 @@ 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 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();
}

PublicJsonWebKey publicJsonWebKey = null;
try {
publicJsonWebKey = PublicJsonWebKey.Factory.newPublicJwk(jwkProof.getMap());
} catch (JoseException ex) {
LOG.warn("DPoP proof jwk header does not represent a valid JWK key");
throw new AuthenticationFailedException(ex);
}

if (publicJsonWebKey.getPrivateKey() != null) {
LOG.warn("DPoP proof JWK key is a private key but it must be a public key");
throw new AuthenticationFailedException();
}

byte[] jwkProofDigest = publicJsonWebKey.calculateThumbprint("SHA-256");
String jwkProofThumbprint = OidcCommonUtils.base64UrlEncode(jwkProofDigest);

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

try {
JsonWebSignature jws = new JsonWebSignature();
jws.setAlgorithmConstraints(OidcProvider.ASYMMETRIC_ALGORITHM_CONSTRAINTS);
jws.setCompactSerialization((String) requestData.get(OidcUtils.DPOP_PROOF));
jws.setKey(publicJsonWebKey.getPublicKey());
if (!jws.verifySignature()) {
LOG.warn("DPoP proof token signature is invalid");
throw new AuthenticationFailedException();
}
} catch (JoseException ex) {
LOG.warn("DPoP proof token signature can not be verified");
throw new AuthenticationFailedException(ex);
}

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 +334,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 @@ -62,10 +62,10 @@ public class OidcProvider implements Closeable {
SignatureAlgorithm.PS384.getAlgorithm(),
SignatureAlgorithm.PS512.getAlgorithm(),
SignatureAlgorithm.EDDSA.getAlgorithm() };
private static final AlgorithmConstraints ASYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
AlgorithmConstraints.ConstraintType.PERMIT, ASYMMETRIC_SUPPORTED_ALGORITHMS);
private static final AlgorithmConstraints SYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
AlgorithmConstraints.ConstraintType.PERMIT, SignatureAlgorithm.HS256.getAlgorithm());
static final AlgorithmConstraints ASYMMETRIC_ALGORITHM_CONSTRAINTS = new AlgorithmConstraints(
AlgorithmConstraints.ConstraintType.PERMIT, ASYMMETRIC_SUPPORTED_ALGORITHMS);
static final String ANY_ISSUER = "any";

private final List<Validator> customValidators;
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
Loading

0 comments on commit 5a924c3

Please sign in to comment.