Skip to content

Commit

Permalink
Support for OIDC MTLS binding
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Dec 19, 2024
1 parent 084fa99 commit 365dc84
Show file tree
Hide file tree
Showing 15 changed files with 2,983 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,81 @@ If you set `quarkus.oidc.client-id`, but your endpoint does not require remote a
Quarkus `web-app` applications always require the `quarkus.oidc.client-id` property.
====

== Mutual TLS token binding

https://datatracker.ietf.org/doc/html/rfc8705[RFC8705] describes a mechanism for binding access tokens to Mutual TLS (mTLS) client authentication certificates.
It requires that a client certificate's SHA256 thumbprint matches a JWT token or token introspection confirmation `x5t#S256` certificate thumbprint.

For example, see https://datatracker.ietf.org/doc/html/rfc8705#section-3.1[JWT Certificate Thumbprint Confirmation Method] and https://datatracker.ietf.org/doc/html/rfc8705#section-3.2[Confirmation Method for Token Introspection] sections of https://datatracker.ietf.org/doc/html/rfc8705[RFC8705].

MTLS token binding supports a `holder of key` concept, and can be used to confirm that the current access token was issued to the current authenticated client who presents this token.

When you use both mTLS and OIDC bearer authentication mechanisms, you can enforce that the access tokens must be certificate bound with a single property, after configuring your Quarkus endpoint and Quarkus OIDC to require the use of mTLS.

For example:

[source,properties]
----
quarkus.oidc.auth-server-url=${your_oidc_provider_url}
quarkus.oidc.token.certificate-bound=true <1>
quarkus.oidc.tls.tls-configuration-name=oidc-client-tls <2>
quarkus.tls.oidc-client-tls.key-store.p12.path=target/certificates/oidc-client-keystore.p12 <2>
quarkus.tls.oidc-client-tls.key-store.p12.password=password
quarkus.tls.oidc-client-tls.trust-store.p12.path=target/certificates/oidc-client-truststore.p12
quarkus.tls.oidc-client-tls.trust-store.p12.password=password
quarkus.http.tls-configuration-name=oidc-server-mtls <3>
quarkus.tls.oidc-server-mtls.key-store.p12.path=target/certificates/oidc-keystore.p12
quarkus.tls.oidc-server-mtls.key-store.p12.password=password
quarkus.tls.oidc-server-mtls.trust-store.p12.path=target/certificates/oidc-server-truststore.p12
quarkus.tls.oidc-server-mtls.trust-store.p12.password=password
----
<1> Require that bearer access tokens must be bound to the client certificates.
<2> TLS registry configuration for Quarkus OIDC be able to communicate with the OIDC provider over MTLS
<3> TLS registry configuration requiring external clients to authenticate to the Quarkus endpoint over MTLS

The above configuration is sufficient to require that OIDC bearer tokens are bound to the client certificates.

Next, if you need to access both mTLS and OIDC bearer security identities, consider enabling xref:security-authentication-mechanisms#combining-authentication-mechanisms[Inclusive authentication] with `quarkus.http.auth.inclusive=true`.

Now you can access both MTLS and OIDC security identities as follows:

[source,java]
----
package io.quarkus.it.oidc;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.security.Authenticated;
import io.quarkus.security.credential.CertificateCredential;
import io.quarkus.security.identity.SecurityIdentity;
@Path("/service")
@Authenticated
public class OidcMtlsEndpoint {
@Inject
SecurityIdentity mtlsIdentity; <1>
@Inject
JsonWebToken oidcAccessToken; <2>
@GET
public String getIdentities() {
var cred = identity.getCredential(CertificateCredential.class).getCertificate();
return "Identities: " + cred.getSubjectX500Principal().getName().split(",")[0]
+ ", " + accessToken.getName();
}
}
----
<1> `SecurityIdentity` always represents the primary mTLS authentication when mTLS is used and an inclusive authentication is enabled.
<2> OIDC security identity is also available because enabling an inclusive authentication requires all registered mechanisms to produce the security identity.


== Authentication after an HTTP request has completed

Sometimes, `SecurityIdentity` for a given token must be created when there is no active HTTP request context.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ private static Map<String, String> prepareConfiguration(
BuildProducer<KeycloakDevServicesConfigBuildItem> keycloakBuildItemBuildProducer, String internalURL,
String hostURL, List<RealmRepresentation> realmReps, List<String> errors,
KeycloakDevServicesConfigurator devServicesConfigurator, String internalBaseUrl) {
final String realmName = realmReps != null && !realmReps.isEmpty() ? realmReps.iterator().next().getRealm()
final String realmName = !realmReps.isEmpty() ? realmReps.iterator().next().getRealm()
: getDefaultRealmName();
final String authServerInternalUrl = realmsURL(internalURL, realmName);

Expand All @@ -320,29 +320,32 @@ private static Map<String, String> prepareConfiguration(

List<String> realmNames = new LinkedList<>();

// this needs to be only if we actually start the dev-service as it adds a shutdown hook
// whose TCCL is the Augmentation CL, which if not removed, causes a massive memory leaks
if (vertxInstance == null) {
vertxInstance = Vertx.vertx();
}
if (createDefaultRealm || !realmReps.isEmpty()) {

WebClient client = createWebClient(vertxInstance);
try {
String adminToken = getAdminToken(client, clientAuthServerBaseUrl);
if (createDefaultRealm) {
createDefaultRealm(client, adminToken, clientAuthServerBaseUrl, users, oidcClientId, oidcClientSecret, errors,
devServicesConfigurator);
realmNames.add(realmName);
} else {
if (realmReps != null) {
// this needs to be only if we actually start the dev-service as it adds a shutdown hook
// whose TCCL is the Augmentation CL, which if not removed, causes a massive memory leaks
if (vertxInstance == null) {
vertxInstance = Vertx.vertx();
}

WebClient client = createWebClient(vertxInstance);
try {
String adminToken = getAdminToken(client, clientAuthServerBaseUrl);
if (createDefaultRealm) {
createDefaultRealm(client, adminToken, clientAuthServerBaseUrl, users, oidcClientId, oidcClientSecret,
errors,
devServicesConfigurator);
realmNames.add(realmName);
} else if (realmReps != null) {
for (RealmRepresentation realmRep : realmReps) {
createRealm(client, adminToken, clientAuthServerBaseUrl, realmRep, errors);
realmNames.add(realmRep.getRealm());
}
}

} finally {
client.close();
}
} finally {
client.close();
}

Map<String, String> configProperties = new HashMap<>();
Expand Down Expand Up @@ -427,7 +430,7 @@ private static RunningDevService startContainer(
// TODO: this probably needs to be addressed
String sharedContainerUrl = getSharedContainerUrl(containerAddress);
Map<String, String> configs = prepareConfiguration(keycloakBuildItemBuildProducer, sharedContainerUrl,
sharedContainerUrl, null, errors, devServicesConfigurator, sharedContainerUrl);
sharedContainerUrl, List.of(), errors, devServicesConfigurator, sharedContainerUrl);
return new RunningDevService(KEYCLOAK_CONTAINER_NAME, containerAddress.getId(), null, configs);
})
.orElseGet(defaultKeycloakContainerSupplier);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,7 @@ public final class OidcConstants {
public static final String CLIENT_METADATA_POST_LOGOUT_URIS = "post_logout_redirect_uris";
public static final String CLIENT_METADATA_SECRET_EXPIRES_AT = "client_secret_expires_at";
public static final String CLIENT_METADATA_ID_ISSUED_AT = "client_id_issued_at";

public static final String CONFIRMATION_CLAIM = "cnf";
public static final String X509_SHA256_THUMBPRINT = "x5t#S256";
}
Original file line number Diff line number Diff line change
Expand Up @@ -2340,6 +2340,15 @@ public static Token fromAudience(String... audience) {
*/
public Optional<Boolean> verifyAccessTokenWithUserInfo = Optional.empty();

/**
* If a bearer access token must be bound to the client mTLS certificate.
* It requires that JWT tokens must contain a confirmation `cnf` claim with a SHA256 certificate thumbprint
* matching the client mTLS certificate's SHA256 certificate thumbprint.
* <p>
* For opaque tokens, SHA256 certificate thumbprint must be returned in their introspection response.
*/
boolean certificateBound = false;

public Optional<Boolean> isVerifyAccessTokenWithUserInfo() {
return verifyAccessTokenWithUserInfo;
}
Expand Down Expand Up @@ -2436,6 +2445,10 @@ public void setAllowOpaqueTokenIntrospection(boolean allowOpaqueTokenIntrospecti
this.allowOpaqueTokenIntrospection = allowOpaqueTokenIntrospection;
}

public boolean certificateBound() {
return certificateBound;
}

public Optional<Duration> getAge() {
return age;
}
Expand Down Expand Up @@ -2530,6 +2543,7 @@ private void addConfigMappingValues(io.quarkus.oidc.runtime.OidcTenantConfig.Tok
allowOpaqueTokenIntrospection = mapping.allowOpaqueTokenIntrospection();
customizerName = mapping.customizerName();
verifyAccessTokenWithUserInfo = mapping.verifyAccessTokenWithUserInfo();
certificateBound = mapping.certificateBound();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@

import static io.quarkus.oidc.runtime.OidcUtils.extractBearerToken;

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

import javax.net.ssl.SSLPeerUnverifiedException;

import org.jboss.logging.Logger;

import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.OidcTenantConfig;
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;
Expand All @@ -25,12 +31,38 @@ public Uni<SecurityIdentity> authenticate(RoutingContext context,
String token = extractBearerToken(context, oidcTenantConfig);
// if a bearer token is provided try to authenticate
if (token != null) {
try {
setCertificateThumbprint(context, oidcTenantConfig);
} catch (AuthenticationFailedException ex) {
return Uni.createFrom().failure(ex);
}
return authenticate(identityProviderManager, context, new AccessTokenCredential(token));
}
LOG.debug("Bearer access token is not available");
return Uni.createFrom().nullItem();
}

private static void setCertificateThumbprint(RoutingContext context, OidcTenantConfig oidcTenantConfig) {
if (oidcTenantConfig.token().certificateBound()) {
Certificate cert = getCertificate(context);
if (!(cert instanceof X509Certificate)) {
LOG.warn("Access token must be bound to X509 client certiifcate");
throw new AuthenticationFailedException();
}
context.put(OidcConstants.X509_SHA256_THUMBPRINT,
TrustStoreUtils.calculateThumprint((X509Certificate) cert));
}
}

private static Certificate getCertificate(RoutingContext context) {
try {
return context.request().sslSession().getPeerCertificates()[0];
} catch (SSLPeerUnverifiedException e) {
LOG.warn("Access token must be certificate bound but no client certificate is available");
throw new AuthenticationFailedException();
}
}

public Uni<ChallengeData> getChallenge(RoutingContext context) {
Uni<TenantConfigContext> tenantContext = resolver.resolveContext(context);
return tenantContext.onItem().transformToUni(new Function<TenantConfigContext, Uni<? extends ChallengeData>>() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,49 @@ private Uni<TokenVerificationResult> verifyPrimaryTokenUni(Map<String, Object> r
return verifySelfSignedTokenUni(resolvedContext, request.getToken().getToken());
}
} else {
return verifyTokenUni(requestData, resolvedContext, request.getToken(),
isIdToken(request), userInfo);
final boolean idToken = isIdToken(request);
Uni<TokenVerificationResult> result = verifyTokenUni(requestData, resolvedContext, request.getToken(), idToken,
userInfo);
if (!idToken && resolvedContext.oidcConfig().token().certificateBound()) {
return 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();
}
if (!clientCertificateThumbprint.equals(tokenCertificateThumbprint)) {
LOG.warn("Client certificate thumbprint does not match the token certificate thumbprint");
throw new AuthenticationFailedException();
}
return t;
}

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

private static String getTokenCertThumbprint(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.X509_SHA256_THUMBPRINT);
if (thumbprint != null) {
requestData.put((t.introspectionResult == null ? OidcUtils.JWT_THUMBPRINT : OidcUtils.INTROSPECTION_THUMBPRINT),
true);
}
return thumbprint;
}

private Uni<SecurityIdentity> getUserInfoAndCreateIdentity(Uni<TokenVerificationResult> tokenUni,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,16 @@ interface Token {
@ConfigDocDefault("false")
Optional<Boolean> verifyAccessTokenWithUserInfo();

/**
* If a bearer access token must be bound to the client mTLS certificate.
* It requires that JWT tokens must contain a confirmation `cnf` claim with a SHA256 certificate thumbprint
* matching the client mTLS certificate's SHA256 certificate thumbprint.
* <p>
* For opaque tokens, SHA256 certificate thumbprint must be returned in their introspection response.
*/
@WithDefault("false")
boolean certificateBound();

}

enum ApplicationType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ public final class OidcUtils {
public static final String SESSION_AT_COOKIE_NAME = SESSION_COOKIE_NAME + ACCESS_TOKEN_COOKIE_SUFFIX;
public static final String SESSION_RT_COOKIE_NAME = SESSION_COOKIE_NAME + REFRESH_TOKEN_COOKIE_SUFFIX;
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";

// Browsers enforce that the total Set-Cookie expression such as
// `q_session_tenant-a=<value>,Path=/somepath,Expires=...` does not exceed 4096
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ private record TokenImpl(Optional<String> issuer, Optional<List<String>> audienc
String authorizationScheme, Optional<OidcTenantConfig.SignatureAlgorithm> signatureAlgorithm,
Optional<String> decryptionKeyLocation, boolean allowJwtIntrospection, boolean requireJwtIntrospectionOnly,
boolean allowOpaqueTokenIntrospection, Optional<String> customizerName,
Optional<Boolean> verifyAccessTokenWithUserInfo) implements OidcTenantConfig.Token {
Optional<Boolean> verifyAccessTokenWithUserInfo, boolean certificateBound) implements OidcTenantConfig.Token {
}

private final OidcTenantConfigBuilder builder;
Expand All @@ -50,6 +50,7 @@ private record TokenImpl(Optional<String> issuer, Optional<List<String>> audienc
private boolean allowOpaqueTokenIntrospection;
private Optional<String> customizerName;
private Optional<Boolean> verifyAccessTokenWithUserInfo;
private boolean certificateBound;

public TokenConfigBuilder() {
this(new OidcTenantConfigBuilder());
Expand Down Expand Up @@ -83,6 +84,7 @@ public TokenConfigBuilder(OidcTenantConfigBuilder builder) {
this.allowOpaqueTokenIntrospection = token.allowOpaqueTokenIntrospection();
this.customizerName = token.customizerName();
this.verifyAccessTokenWithUserInfo = token.verifyAccessTokenWithUserInfo();
this.certificateBound = token.certificateBound();
}

/**
Expand Down Expand Up @@ -371,6 +373,24 @@ public TokenConfigBuilder verifyAccessTokenWithUserInfo(boolean verifyAccessToke
return this;
}

/**
* Sets {@link OidcTenantConfig.Token#certificateBound()} to true.
*
* @return this builder
*/
public TokenConfigBuilder certificateBound() {
return certificateBound(true);
}

/**
* @param certificateBound {@link OidcTenantConfig.Token#certificateBound()}
* @return this builder
*/
public TokenConfigBuilder certificateBound(boolean certificateBound) {
this.certificateBound = certificateBound;
return this;
}

/**
* @return built {@link OidcTenantConfig.Token}
*/
Expand All @@ -381,7 +401,7 @@ public OidcTenantConfig.Token build() {
lifespanGrace, age, issuedAtRequired, principalClaim, refreshExpired, refreshTokenTimeSkew,
forcedJwkRefreshInterval, header, authorizationScheme, signatureAlgorithm, decryptionKeyLocation,
allowJwtIntrospection, requireJwtIntrospectionOnly, allowOpaqueTokenIntrospection, customizerName,
verifyAccessTokenWithUserInfo);
verifyAccessTokenWithUserInfo, certificateBound);
}

}
Loading

0 comments on commit 365dc84

Please sign in to comment.