diff --git a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc index 4ff538208f223..73dc068a08d6a 100644 --- a/docs/src/main/asciidoc/security-authentication-mechanisms.adoc +++ b/docs/src/main/asciidoc/security-authentication-mechanisms.adoc @@ -584,6 +584,70 @@ ifndef::no-quarkus-elytron-security-oauth2[ ^|Yes] If different sources provide the user credentials, you can combine authentication mechanisms. For example, you can combine the built-in Basic and the Quarkus `quarkus-oidc` Bearer token authentication mechanisms. +The authentication process completes as soon as the first `SecurityIdentity` is produced by one of the authentication mechanisms. + +In some cases it can be required that all registered authentication mechanisms create their `SecurityIdentity`. +It can be required when the credentials such as tokens have to be passed over <>, +for example, when users are authenticating via `Virtual Private Network`, or when the current token has to be bound +to the client certificate for the token verification to succeed, guaranteeing that the token was issued exactly +to the same client which is currently passing this token to Quarkus alongside its client certificate. + +In Quarkus such authentication is called `inclusive` and you can enable it like this: + +[source,properties] +---- +quarkus.http.auth.inclusive=true +---- + +If the authentication is inclusive then `SecurityIdentity` created by the first authentication mechanism can be +injected into the application code. +For example, if both <> and basic authentication mechanism authentications are required, +the <> authentication mechanism will create `SecurityIdentity` first. + +Additional `SecurityIdentity` instances can be accessed as a `quarkus.security.identities` attribute on the first +`SecurityIdentity`, however, accessing these extra identities directly may not be necessary, for example, +when both <> and xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer authentication] mechanisms +have been combined and done their authentications, the authenticated bearer token can be injected as a token +credential alongside `SecurityIdentity` created by <>. This is exemplified below: + +[source,java] +---- +package org.acme.security; + +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.common.runtime.OidcConstants; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class InclusiveAuthExampleBean { + + @Inject + SecurityIdentity mtlsIdentity; <1> + + @Inject + AccessTokenCredential accessTokenCredential; + + private AccessTokenCredential getAccessTokenCredential() { + if (doItHardWay()) { + var securityIdentities = HttpSecurityUtils.getSecurityIdentities(mtlsIdentity); <2> + if (securityIdentities != null) { + SecurityIdentity bearerIdentity = securityIdentities.get(OidcConstants.BEARER_SCHEME); + if (bearerIdentity != null) { + return bearerIdentity.getCredential(AccessTokenCredential.class); + } + } + } + return accessTokenCredential; + } + +} +---- +<1> This is the `SecurityIdentity` created by applicable authentication mechanism with the highest priority. +<2> Other applicable authentication mechanisms performed authentication and are available on the `SecurityIdentity`. + [IMPORTANT] ==== You cannot combine the Quarkus `quarkus-oidc` Bearer token and `smallrye-jwt` authentication mechanisms because both mechanisms attempt to verify the token extracted from the HTTP Bearer token authentication scheme. diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java index c3f7f3b22f1b9..85a92a7b2c90f 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcConfigurationMetadataProducer.java @@ -18,9 +18,7 @@ public class OidcConfigurationMetadataProducer { @Produces @RequestScoped OidcConfigurationMetadata produce() { - OidcConfigurationMetadata configMetadata = null; - - configMetadata = (OidcConfigurationMetadata) identity.getAttribute(OidcUtils.CONFIG_METADATA_ATTRIBUTE); + OidcConfigurationMetadata configMetadata = OidcUtils.getAttribute(identity, OidcUtils.CONFIG_METADATA_ATTRIBUTE); if (configMetadata == null && tenantConfig.getDefaultTenant().oidcConfig.tenantEnabled) { configMetadata = tenantConfig.getDefaultTenant().provider.getMetadata(); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java index c9a66cf76f5d9..12ea233cf004b 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java @@ -61,7 +61,7 @@ private JsonWebToken getTokenCredential(Class type) { && ((OidcJwtCallerPrincipal) identity.getPrincipal()).getCredential().getClass() == type) { return (JsonWebToken) identity.getPrincipal(); } - TokenCredential credential = identity.getCredential(type); + TokenCredential credential = OidcUtils.getTokenCredential(identity, type); if (credential != null && credential.getToken() != null) { if (credential instanceof AccessTokenCredential && ((AccessTokenCredential) credential).isOpaque()) { throw new OIDCException("Opaque access token can not be converted to JsonWebToken"); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java index fff629c7bffb5..d738578f8846b 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTokenCredentialProducer.java @@ -30,7 +30,7 @@ public class OidcTokenCredentialProducer { @Produces @RequestScoped IdTokenCredential currentIdToken() { - IdTokenCredential cred = identity.getCredential(IdTokenCredential.class); + IdTokenCredential cred = OidcUtils.getTokenCredential(identity, IdTokenCredential.class); if (cred == null || cred.getToken() == null) { LOG.trace("IdToken is null"); cred = new IdTokenCredential(); @@ -43,7 +43,7 @@ IdTokenCredential currentIdToken() { @Alternative @Priority(1) AccessTokenCredential currentAccessToken() { - AccessTokenCredential cred = identity.getCredential(AccessTokenCredential.class); + AccessTokenCredential cred = OidcUtils.getTokenCredential(identity, AccessTokenCredential.class); if (cred == null || cred.getToken() == null) { LOG.trace("AccessToken is null"); cred = new AccessTokenCredential(); @@ -54,7 +54,7 @@ AccessTokenCredential currentAccessToken() { @Produces @RequestScoped RefreshToken currentRefreshToken() { - RefreshToken cred = identity.getCredential(RefreshToken.class); + RefreshToken cred = OidcUtils.getTokenCredential(identity, RefreshToken.class); if (cred == null) { LOG.trace("RefreshToken is null"); cred = new RefreshToken(); @@ -70,7 +70,7 @@ RefreshToken currentRefreshToken() { @Produces @RequestScoped UserInfo currentUserInfo() { - UserInfo userInfo = (UserInfo) identity.getAttribute(OidcUtils.USER_INFO_ATTRIBUTE); + UserInfo userInfo = OidcUtils.getAttribute(identity, OidcUtils.USER_INFO_ATTRIBUTE); if (userInfo == null) { LOG.trace("UserInfo is null"); userInfo = new UserInfo(); @@ -106,8 +106,8 @@ TokenIntrospection idTokenIntrospection() { @Produces @RequestScoped TokenIntrospection tokenIntrospection() { - TokenVerificationResult codeFlowAccessTokenResult = (TokenVerificationResult) identity - .getAttribute(OidcUtils.CODE_ACCESS_TOKEN_RESULT); + TokenVerificationResult codeFlowAccessTokenResult = OidcUtils.getAttribute(identity, + OidcUtils.CODE_ACCESS_TOKEN_RESULT); if (codeFlowAccessTokenResult == null) { return tokenIntrospectionFromIdentityAttribute(); } else { @@ -116,7 +116,7 @@ TokenIntrospection tokenIntrospection() { } TokenIntrospection tokenIntrospectionFromIdentityAttribute() { - TokenIntrospection introspection = (TokenIntrospection) identity.getAttribute(OidcUtils.INTROSPECTION_ATTRIBUTE); + TokenIntrospection introspection = OidcUtils.getAttribute(identity, OidcUtils.INTROSPECTION_ATTRIBUTE); if (introspection == null) { LOG.trace("TokenIntrospection is null"); introspection = new TokenIntrospection(); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 6773643e284e8..2f169837cface 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -53,9 +53,11 @@ import io.quarkus.security.StringPermission; import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.identity.AuthenticationRequestContext; +import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.TokenAuthenticationRequest; import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.quarkus.security.runtime.QuarkusSecurityIdentity.Builder; +import io.quarkus.vertx.http.runtime.security.HttpSecurityUtils; import io.smallrye.jwt.algorithm.ContentEncryptionAlgorithm; import io.smallrye.jwt.algorithm.KeyEncryptionAlgorithm; import io.smallrye.mutiny.Uni; @@ -814,4 +816,34 @@ public static SecretKey createSecretKeyFromDigest(byte[] secretBytes) { throw new OIDCException(ex); } } + + public static T getTokenCredential(SecurityIdentity identity, Class type) { + T credential = identity.getCredential(type); + if (credential == null) { + Map identities = HttpSecurityUtils.getSecurityIdentities(identity); + if (identities != null) { + for (String scheme : identities.keySet()) { + if (scheme.equalsIgnoreCase(OidcConstants.BEARER_SCHEME)) { + return identities.get(scheme).getCredential(type); + } + } + } + } + return credential; + } + + public static T getAttribute(SecurityIdentity identity, String name) { + T attribute = identity.getAttribute(name); + if (attribute == null) { + Map identities = HttpSecurityUtils.getSecurityIdentities(identity); + if (identities != null) { + for (String scheme : identities.keySet()) { + if (scheme.equalsIgnoreCase(OidcConstants.BEARER_SCHEME)) { + return identities.get(scheme).getAttribute(name); + } + } + } + } + return attribute; + } } diff --git a/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java new file mode 100644 index 0000000000000..83285ca98d6ef --- /dev/null +++ b/extensions/vertx-http/deployment/src/test/java/io/quarkus/vertx/http/security/InclusiveAuthValidationTest.java @@ -0,0 +1,95 @@ +package io.quarkus.vertx.http.security; + +import java.io.File; +import java.util.Set; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.runtime.StartupEvent; +import io.quarkus.security.identity.IdentityProviderManager; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.security.identity.request.AuthenticationRequest; +import io.quarkus.security.identity.request.UsernamePasswordAuthenticationRequest; +import io.quarkus.security.test.utils.TestIdentityController; +import io.quarkus.security.test.utils.TestIdentityProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.vertx.http.runtime.security.ChallengeData; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism; +import io.quarkus.vertx.http.runtime.security.HttpAuthenticator; +import io.quarkus.vertx.http.runtime.security.MtlsAuthenticationMechanism; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import io.smallrye.certs.junit5.Certificates; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@Certificates(baseDir = "target/certs", certificates = @Certificate(name = "mtls-test", password = "secret", formats = { + Format.JKS }, client = true)) +public class InclusiveAuthValidationTest { + + private static final String configuration = """ + quarkus.http.auth.inclusive=true + quarkus.http.ssl.certificate.key-store-file=server-keystore.jks + quarkus.http.ssl.certificate.key-store-password=secret + quarkus.http.ssl.certificate.trust-store-file=server-truststore.jks + quarkus.http.ssl.certificate.trust-store-password=secret + quarkus.http.ssl.client-auth=REQUEST + quarkus.http.auth.basic=true + quarkus.http.auth.proactive=true + """; + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(TopPriorityAuthMechanism.class, StartupObserver.class) + .addClasses(TestIdentityProvider.class, TestTrustedIdentityProvider.class, TestIdentityController.class) + .addAsResource(new StringAsset(configuration), "application.properties") + .addAsResource(new File("target/certs/mtls-test-keystore.jks"), "server-keystore.jks") + .addAsResource(new File("target/certs/mtls-test-server-truststore.jks"), "server-truststore.jks")) + .assertException(throwable -> { + var errMsg = throwable.getMessage(); + Assertions.assertTrue(errMsg.contains("Inclusive authentication is enabled")); + Assertions.assertTrue(errMsg.contains("TopPriorityAuthMechanism")); + }); + + @Test + public void test() { + Assertions.fail(); + } + + public static class StartupObserver { + void observe(@Observes StartupEvent startupEvent, HttpAuthenticator authenticator) { + // authenticator is only initialized when required + } + } + + @ApplicationScoped + public static class TopPriorityAuthMechanism implements HttpAuthenticationMechanism { + + @Override + public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + return Uni.createFrom().nullItem(); + } + + @Override + public Uni getChallenge(RoutingContext context) { + return Uni.createFrom().nullItem(); + } + + @Override + public Set> getCredentialTypes() { + return Set.of(UsernamePasswordAuthenticationRequest.class); + } + + @Override + public int getPriority() { + return MtlsAuthenticationMechanism.PRIORITY + 1; + } + } +} diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java index 79bccaec08a5c..08107d606038f 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/AuthConfig.java @@ -35,4 +35,27 @@ public class AuthConfig { */ @ConfigItem(defaultValue = "true") public boolean proactive; + + /** + * Require that all registered HTTP authentication mechanisms must complete the authentication. + *

+ * Typically, this property has to be true when the credentials are carried over mTLS, when both mTLS and another + * authentication, for example, OIDC bearer token authentication, must succeed. + * In such cases, `SecurityIdentity` created by the first mechanism, mTLS, can be injected, identities created + * by other mechanisms will be available on `SecurityIdentity`. + * The identities can be retrieved using utility method as in the example below: + * + *

+     * {@code
+     * io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getSecurityIdentities(securityIdentity)
+     * }
+     * 
+ *

+ * This property is false by default which means that the authentication process is complete as soon as the first + * `SecurityIdentity` is created. + *

+ * This property will be ignored if the path specific authentication is enabled. + */ + @ConfigItem(defaultValue = "false") + public boolean inclusive; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java index a4cfd9425edd2..362b60fbd1c8d 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpAuthenticator.java @@ -2,12 +2,16 @@ import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHENTICATION_FAILURE; import static io.quarkus.security.spi.runtime.SecurityEventHelper.AUTHENTICATION_SUCCESS; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.SECURITY_IDENTITIES_ATTRIBUTE; +import static io.quarkus.vertx.http.runtime.security.HttpSecurityUtils.getSecurityIdentities; import static io.quarkus.vertx.http.runtime.security.RolesMapping.ROLES_MAPPING_KEY; +import static io.quarkus.vertx.http.runtime.security.RoutingContextAwareSecurityIdentity.addRoutingCtxToIdentityIfMissing; import static java.lang.Boolean.TRUE; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -25,6 +29,7 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.quarkus.arc.Arc; +import io.quarkus.arc.ClientProxy; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.IdentityProviderManager; @@ -77,8 +82,9 @@ public class HttpAuthenticator { private final IdentityProviderManager identityProviderManager; private final HttpAuthenticationMechanism[] mechanisms; private final SecurityEventHelper securityEventHelper; + private final boolean inclusiveAuth; - public HttpAuthenticator(IdentityProviderManager identityProviderManager, + HttpAuthenticator(IdentityProviderManager identityProviderManager, Event authFailureEvent, Event authSuccessEvent, BeanManager beanManager, HttpBuildTimeConfig httpBuildTimeConfig, @@ -88,6 +94,7 @@ public HttpAuthenticator(IdentityProviderManager identityProviderManager, this.securityEventHelper = new SecurityEventHelper<>(authSuccessEvent, authFailureEvent, AUTHENTICATION_SUCCESS, AUTHENTICATION_FAILURE, beanManager, securityEventsEnabled); this.identityProviderManager = identityProviderManager; + this.inclusiveAuth = httpBuildTimeConfig.auth.inclusive; List mechanisms = new ArrayList<>(); for (HttpAuthenticationMechanism mechanism : httpAuthenticationMechanism) { if (mechanism.getCredentialTypes().isEmpty()) { @@ -141,6 +148,21 @@ public int compare(HttpAuthenticationMechanism mech1, HttpAuthenticationMechanis } }); this.mechanisms = mechanisms.toArray(new HttpAuthenticationMechanism[mechanisms.size()]); + + // if inclusive auth and mTLS are enabled, the mTLS must have the highest priority + if (inclusiveAuth && Arc.container().instance(MtlsAuthenticationMechanism.class).isAvailable()) { + var topMechanism = ClientProxy.unwrap(this.mechanisms[0]); + boolean isMutualTls = topMechanism instanceof MtlsAuthenticationMechanism; + if (!isMutualTls) { + throw new IllegalStateException( + """ + Inclusive authentication is enabled and '%s' does not have + the highest priority. Please lower priority of the '%s' authentication mechanism under '%s'. + """.formatted(MtlsAuthenticationMechanism.class.getName(), + topMechanism.getClass().getName(), + MtlsAuthenticationMechanism.PRIORITY)); + } + } } } @@ -225,6 +247,9 @@ private Uni createSecurityIdentity(RoutingContext routingConte @Override public Uni apply(SecurityIdentity identity) { if (identity != null) { + if (inclusiveAuth) { + return authenticateWithAllMechanisms(identity, i, routingContext); + } if (selectAuthMechanismWithAnnotation && !isAuthMechanismSelected(routingContext)) { return rememberAuthMechScheme(mechanisms[i], routingContext).replaceWith(identity); } @@ -308,6 +333,41 @@ public Uni apply(ChallengeData data) { return result; } + private Uni authenticateWithAllMechanisms(SecurityIdentity identity, int i, + RoutingContext routingContext) { + return getCredentialTransport(mechanisms[i], routingContext) + .onItem().transformToUni(new Function>() { + @Override + public Uni apply(HttpCredentialTransport httpCredentialTransport) { + if (httpCredentialTransport == null || httpCredentialTransport.getAuthenticationScheme() == null) { + log.error(""" + Illegal state - HttpAuthenticationMechanism '%s' authentication scheme is not available. + The authentication scheme is required when inclusive authentication is enabled. + """.formatted(ClientProxy.unwrap(mechanisms[i]).getClass().getName())); + return Uni.createFrom().failure(new AuthenticationFailedException()); + } + var authMechanism = httpCredentialTransport.getAuthenticationScheme(); + + // add current identity to the RoutingContext + var authMechToIdentity = getSecurityIdentities(routingContext); + boolean isFirstIdentity = authMechToIdentity == null; + if (isFirstIdentity) { + authMechToIdentity = new HashMap<>(); + routingContext.put(SECURITY_IDENTITIES_ATTRIBUTE, authMechToIdentity); + } + authMechToIdentity.putIfAbsent(authMechanism, identity); + + // authenticate with remaining mechanisms + if (isFirstIdentity) { + return createSecurityIdentity(routingContext, i + 1) + .replaceWith(addRoutingCtxToIdentityIfMissing(identity, routingContext)); + } else { + return createSecurityIdentity(routingContext, i + 1); + } + } + }); + } + private Uni findBestCandidateMechanism(RoutingContext routingContext, String pathSpecificMechanism, int i) { if (i == mechanisms.length) { diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java index 92479ac4e8725..c310d2efa0022 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityUtils.java @@ -7,17 +7,44 @@ import javax.naming.ldap.Rdn; import javax.security.auth.x500.X500Principal; +import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.AuthenticationRequest; import io.vertx.ext.web.RoutingContext; public final class HttpSecurityUtils { public final static String ROUTING_CONTEXT_ATTRIBUTE = "quarkus.http.routing.context"; + static final String SECURITY_IDENTITIES_ATTRIBUTE = "io.quarkus.security.identities"; static final String COMMON_NAME = "CN"; private HttpSecurityUtils() { } + /** + * Provides all the {@link SecurityIdentity} created by the inclusive authentication. + * + * @return null if {@link RoutingContext} is not available or {@link #getSecurityIdentities(RoutingContext)} + * @see #getSecurityIdentities(RoutingContext) + */ + public static Map getSecurityIdentities(SecurityIdentity identity) { + var routingContext = getRoutingContextAttribute(identity); + if (routingContext == null) { + return null; + } + return getSecurityIdentities(routingContext); + } + + /** + * When inclusive authentication is enabled, we allow all authentication mechanisms to produce identity. + * However, only the first identity (provided by applicable mechanism with the highest priority) is stored + * in the CDI container. Therefore, we put all the identities into the RoutingContext. + * + * @return null if no identities were found or map with authentication mechanism key and security identity value + */ + public static Map getSecurityIdentities(RoutingContext routingContext) { + return routingContext.get(SECURITY_IDENTITIES_ATTRIBUTE); + } + public static AuthenticationRequest setRoutingContextAttribute(AuthenticationRequest request, RoutingContext context) { request.setAttribute(ROUTING_CONTEXT_ATTRIBUTE, context); return request; @@ -27,6 +54,10 @@ public static RoutingContext getRoutingContextAttribute(AuthenticationRequest re return request.getAttribute(ROUTING_CONTEXT_ATTRIBUTE); } + public static RoutingContext getRoutingContextAttribute(SecurityIdentity identity) { + return identity.getAttribute(RoutingContext.class.getName()); + } + public static RoutingContext getRoutingContextAttribute(Map authenticationRequestAttributes) { return (RoutingContext) authenticationRequestAttributes.get(ROUTING_CONTEXT_ATTRIBUTE); } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java index 9fb0da9f63b0a..e651aead7c94a 100644 --- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/MtlsAuthenticationMechanism.java @@ -39,6 +39,7 @@ * The authentication handler responsible for mTLS client authentication */ public class MtlsAuthenticationMechanism implements HttpAuthenticationMechanism { + public static final int PRIORITY = 3000; private static final String ROLES_MAPPER_ATTRIBUTE = "roles_mapper"; private Function> certificateToRoles = null; @@ -83,6 +84,11 @@ public Uni getCredentialTransport(RoutingContext contex return Uni.createFrom().item(new HttpCredentialTransport(HttpCredentialTransport.Type.X509, "X509")); } + @Override + public int getPriority() { + return PRIORITY; + } + void setCertificateToRolesMapper(Function> certificateToRoles) { this.certificateToRoles = certificateToRoles; } diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RoutingContextAwareSecurityIdentity.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RoutingContextAwareSecurityIdentity.java new file mode 100644 index 0000000000000..c7d5850b72bb1 --- /dev/null +++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/RoutingContextAwareSecurityIdentity.java @@ -0,0 +1,87 @@ +package io.quarkus.vertx.http.runtime.security; + +import java.security.Permission; +import java.security.Principal; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import io.quarkus.security.credential.Credential; +import io.quarkus.security.identity.SecurityIdentity; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +final class RoutingContextAwareSecurityIdentity implements SecurityIdentity { + + private static final String ROUTING_CONTEXT_KEY = RoutingContext.class.getName(); + private final SecurityIdentity delegate; + private final RoutingContext routingContext; + + private RoutingContextAwareSecurityIdentity(SecurityIdentity delegate, RoutingContext routingContext) { + this.delegate = delegate; + this.routingContext = routingContext; + } + + static SecurityIdentity addRoutingCtxToIdentityIfMissing(SecurityIdentity delegate, RoutingContext routingContext) { + if (delegate != null && delegate.getAttribute(ROUTING_CONTEXT_KEY) == null) { + return new RoutingContextAwareSecurityIdentity(delegate, routingContext); + } + return delegate; + } + + @Override + public Principal getPrincipal() { + return delegate.getPrincipal(); + } + + @Override + public boolean isAnonymous() { + return delegate.isAnonymous(); + } + + @Override + public Set getRoles() { + return delegate.getRoles(); + } + + @Override + public boolean hasRole(String s) { + return delegate.hasRole(s); + } + + @Override + public T getCredential(Class aClass) { + return delegate.getCredential(aClass); + } + + @Override + public Set getCredentials() { + return delegate.getCredentials(); + } + + @SuppressWarnings("unchecked") + @Override + public T getAttribute(String s) { + if (ROUTING_CONTEXT_KEY.equals(s)) { + return (T) routingContext; + } + return delegate.getAttribute(s); + } + + @Override + public Map getAttributes() { + // we always recreate the map as it could have changed in the delegate + var delegateAttributes = delegate.getAttributes(); + if (delegateAttributes == null || delegateAttributes.isEmpty()) { + return Map.of(ROUTING_CONTEXT_KEY, routingContext); + } + var result = new HashMap<>(delegateAttributes); + result.put(ROUTING_CONTEXT_KEY, routingContext); + return result; + } + + @Override + public Uni checkPermission(Permission permission) { + return delegate.checkPermission(permission); + } +} diff --git a/integration-tests/oidc-mtls/pom.xml b/integration-tests/oidc-mtls/pom.xml new file mode 100644 index 0000000000000..2edad5c91ad20 --- /dev/null +++ b/integration-tests/oidc-mtls/pom.xml @@ -0,0 +1,154 @@ + + + 4.0.0 + + + quarkus-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-oidc-mtls + Quarkus - Integration Tests - OIDC and mTLS authentication + OIDC and mTLS authentication integration tests module + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-tls-registry + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-test-keycloak-server + test + + + io.rest-assured + rest-assured + test + + + + io.quarkus + quarkus-rest-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-oidc-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-tls-registry-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + maven-surefire-plugin + + true + + + + maven-failsafe-plugin + + true + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + test-keycloak + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + diff --git a/integration-tests/oidc-mtls/src/main/java/io/quarkus/it/oidc/OidcMtlsEndpoint.java b/integration-tests/oidc-mtls/src/main/java/io/quarkus/it/oidc/OidcMtlsEndpoint.java new file mode 100644 index 0000000000000..53319e396b3f8 --- /dev/null +++ b/integration-tests/oidc-mtls/src/main/java/io/quarkus/it/oidc/OidcMtlsEndpoint.java @@ -0,0 +1,28 @@ +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.credential.CertificateCredential; +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/service") +public class OidcMtlsEndpoint { + + @Inject + SecurityIdentity identity; + + @Inject + JsonWebToken accessToken; + + @GET + @Path("name") + public String getName() { + var cred = identity.getCredential(CertificateCredential.class).getCertificate(); + return "Identities: " + cred.getSubjectX500Principal().getName().split(",")[0] + ", " + + accessToken.getName(); + } +} diff --git a/integration-tests/oidc-mtls/src/main/resources/application.properties b/integration-tests/oidc-mtls/src/main/resources/application.properties new file mode 100644 index 0000000000000..69d52fd93aa24 --- /dev/null +++ b/integration-tests/oidc-mtls/src/main/resources/application.properties @@ -0,0 +1,11 @@ +quarkus.http.tls-configuration-name=oidc-mtls +quarkus.tls.oidc-mtls.key-store.jks.path=server-keystore.jks +quarkus.tls.oidc-mtls.key-store.jks.password=secret +quarkus.tls.oidc-mtls.trust-store.jks.path=server-truststore.jks +quarkus.tls.oidc-mtls.trust-store.jks.password=password + +quarkus.http.auth.inclusive=true + +quarkus.http.ssl.client-auth=REQUIRED +quarkus.http.insecure-requests=DISABLED +quarkus.native.additional-build-args=-H:IncludeResources=.*\\.jks diff --git a/integration-tests/oidc-mtls/src/main/resources/server-keystore.jks b/integration-tests/oidc-mtls/src/main/resources/server-keystore.jks new file mode 100644 index 0000000000000..da33e8e7a1668 Binary files /dev/null and b/integration-tests/oidc-mtls/src/main/resources/server-keystore.jks differ diff --git a/integration-tests/oidc-mtls/src/main/resources/server-truststore.jks b/integration-tests/oidc-mtls/src/main/resources/server-truststore.jks new file mode 100644 index 0000000000000..8ec8e126507b6 Binary files /dev/null and b/integration-tests/oidc-mtls/src/main/resources/server-truststore.jks differ diff --git a/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsIT.java b/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsIT.java new file mode 100644 index 0000000000000..c7030f1942f36 --- /dev/null +++ b/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsIT.java @@ -0,0 +1,7 @@ +package io.quarkus.it.oidc; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class OidcMtlsIT extends OidcMtlsTest { +} \ No newline at end of file diff --git a/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java b/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java new file mode 100644 index 0000000000000..ce4b2cd482cad --- /dev/null +++ b/integration-tests/oidc-mtls/src/test/java/io/quarkus/it/oidc/OidcMtlsTest.java @@ -0,0 +1,109 @@ +package io.quarkus.it.oidc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import org.junit.jupiter.api.Test; + +import io.quarkus.oidc.common.runtime.OidcConstants; +import io.quarkus.runtime.util.ClassPathUtils; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.keycloak.client.KeycloakTestClient; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.KeyStoreOptions; +import io.vertx.ext.web.client.WebClientOptions; +import io.vertx.mutiny.ext.web.client.HttpResponse; +import io.vertx.mutiny.ext.web.client.WebClient; + +@QuarkusTest +public class OidcMtlsTest { + + @TestHTTPResource(ssl = true) + URL url; + + KeycloakTestClient keycloakClient = new KeycloakTestClient(); + + @Test + public void testGetIdentityNames() throws Exception { + Vertx vertx = Vertx.vertx(); + try { + WebClientOptions options = createWebClientOptions(); + WebClient webClient = WebClient.create(new io.vertx.mutiny.core.Vertx(vertx), options); + + // HTTP 200 + HttpResponse resp = webClient.get("/service/name") + .putHeader("Authorization", OidcConstants.BEARER_SCHEME + " " + keycloakClient.getAccessToken("alice")) + .send().await() + .indefinitely(); + assertEquals(200, resp.statusCode()); + String name = resp.bodyAsString(); + assertEquals("Identities: CN=client, alice", name); + + // HTTP 401, invalid token + resp = webClient.get("/service/name") + .putHeader("Authorization", OidcConstants.BEARER_SCHEME + " " + "123") + .send().await() + .indefinitely(); + assertEquals(401, resp.statusCode()); + } finally { + vertx.close(); + } + } + + private WebClientOptions createWebClientOptions() throws Exception { + WebClientOptions webClientOptions = new WebClientOptions().setDefaultHost(url.getHost()) + .setDefaultPort(url.getPort()).setSsl(true).setVerifyHost(false); + + byte[] keyStoreData = getFileContent(Paths.get("client-keystore.jks")); + KeyStoreOptions keyStoreOptions = new KeyStoreOptions() + .setPassword("password") + .setValue(Buffer.buffer(keyStoreData)) + .setType("JKS"); + webClientOptions.setKeyCertOptions(keyStoreOptions); + + byte[] trustStoreData = getFileContent(Paths.get("client-truststore.jks")); + KeyStoreOptions trustStoreOptions = new KeyStoreOptions() + .setPassword("secret") + .setValue(Buffer.buffer(trustStoreData)) + .setType("JKS"); + webClientOptions.setTrustOptions(trustStoreOptions); + + return webClientOptions; + } + + private static byte[] getFileContent(Path path) throws IOException { + byte[] data; + final InputStream resource = Thread.currentThread().getContextClassLoader() + .getResourceAsStream(ClassPathUtils.toResourceName(path)); + if (resource != null) { + try (InputStream is = resource) { + data = doRead(is); + } + } else { + try (InputStream is = Files.newInputStream(path)) { + data = doRead(is); + } + } + return data; + } + + private static byte[] doRead(InputStream is) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[1024]; + int r; + while ((r = is.read(buf)) > 0) { + out.write(buf, 0, r); + } + return out.toByteArray(); + } + +} diff --git a/integration-tests/oidc-mtls/src/test/resources/client-keystore.jks b/integration-tests/oidc-mtls/src/test/resources/client-keystore.jks new file mode 100644 index 0000000000000..cf6d6ba454864 Binary files /dev/null and b/integration-tests/oidc-mtls/src/test/resources/client-keystore.jks differ diff --git a/integration-tests/oidc-mtls/src/test/resources/client-truststore.jks b/integration-tests/oidc-mtls/src/test/resources/client-truststore.jks new file mode 100644 index 0000000000000..da33e8e7a1668 Binary files /dev/null and b/integration-tests/oidc-mtls/src/test/resources/client-truststore.jks differ diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index f77304827c2ca..1f821ced8132e 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -256,6 +256,7 @@ oidc-client-registration oidc-client-reactive oidc-client-wiremock + oidc-mtls oidc-token-propagation oidc-token-propagation-reactive openapi