Skip to content

Commit

Permalink
Merge pull request #34833 from michalvavrik/feature/oidc-tenant-annot…
Browse files Browse the repository at this point in the history
…ation-on-endpoints-rr

Use OIDC Tenant annotation to resolve tenants
  • Loading branch information
sberyozkin authored Jul 21, 2023
2 parents 6c880fb + a5daa31 commit bc6c54c
Show file tree
Hide file tree
Showing 22 changed files with 640 additions and 123 deletions.
50 changes: 19 additions & 31 deletions docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -672,52 +672,40 @@ In this example, the value of the last request path segment is a tenant ID, but
[[annotations-tenant-resolver]]
=== Resolve with annotations

You can use the annotations and CDI interceptors for resolving the tenant identifiers as an alternative to using
`quarkus.oidc.TenantResolver`. This can be done by setting the value for the key `OidcUtils.TENANT_ID_ATTRIBUTE` on
the current `RoutingContext`.

Assuming your application supports two OIDC tenants (`hr`, and default) first you need to define one
annotation per tenant ID other than default:
You can use the `io.quarkus.oidc.Tenant` annotation for resolving the tenant identifiers as an alternative to using `io.quarkus.oidc.TenantResolver`.

[NOTE]
====
Proactive HTTP authentication must be disabled (`quarkus.http.auth.proactive=false`) for this to work. For more information, see xref:security-proactive-authentication.adoc[Proactive authentication].
====

[source,java]
----
@Inherited
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface HrTenant {
}
----

Next, you'll need one interceptor for each of those annotations:
Assuming your application supports two OIDC tenants (`hr`, and default), all resource methods and classes
carrying `@Tenant("hr")` will be authenticated using the OIDC provider configured by `quarkus.oidc.hr.auth-server-url`,
while all other classes and methods will still be authenticated using the default OIDC provider.

[source,java]
----
package io.quarkus.it.keycloak;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.interceptor.Interceptor;
import io.quarkus.oidc.Tenant;
import io.quarkus.security.Authenticated;
import io.quarkus.oidc.TenantResolverInterceptor;
@Authenticated
@Path("/api/hello")
public class HelloResource {
@HrTenant
@Interceptor
public class HrInterceptor extends TenantResolverInterceptor {
@Override
protected String getTenantId() {
return "hr";
@Tenant("hr") <1>
@GET
@Produces(MediaType.TEXT_PLAIN)
public String sayHello() {
return "Hello!";
}
}
----

Now all methods and classes carrying `@HrTenant` will be authenticated using the OIDC provider configured by
`quarkus.oidc.hr.auth-server-url`, while all other classes and methods will still be authenticated using the default
OIDC provider.
<1> The `io.quarkus.oidc.Tenant` annotation must be placed either on resource class or resource method.

[[tenant-config-resolver]]
== Dynamic tenant configuration resolution
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
package io.quarkus.oidc.deployment;

import static io.quarkus.vertx.http.deployment.EagerSecurityInterceptorCandidateBuildItem.hasProperEndpointModifiers;
import static org.jboss.jandex.AnnotationTarget.Kind.CLASS;
import static org.jboss.jandex.AnnotationTarget.Kind.METHOD;

import java.util.HashMap;
import java.util.Map;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.stream.Collectors;

import jakarta.inject.Singleton;

import org.eclipse.microprofile.jwt.Claim;
import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationTarget;
import org.jboss.jandex.DotName;
import org.jboss.jandex.IndexView;
import org.jboss.jandex.MethodInfo;
import org.jboss.logging.Logger;

import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.arc.deployment.SynthesisFinishedBuildItem;
Expand All @@ -19,10 +32,12 @@
import io.quarkus.deployment.annotations.Consume;
import io.quarkus.deployment.annotations.ExecutionTime;
import io.quarkus.deployment.annotations.Record;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.ExtensionSslNativeSupportBuildItem;
import io.quarkus.deployment.builditem.RuntimeConfigSetupCompleteBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.oidc.SecurityEvent;
import io.quarkus.oidc.Tenant;
import io.quarkus.oidc.TokenIntrospectionCache;
import io.quarkus.oidc.UserInfoCache;
import io.quarkus.oidc.runtime.BackChannelLogoutHandler;
Expand All @@ -41,15 +56,20 @@
import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer;
import io.quarkus.runtime.TlsConfig;
import io.quarkus.vertx.core.deployment.CoreVertxBuildItem;
import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorCandidateBuildItem;
import io.quarkus.vertx.http.deployment.SecurityInformationBuildItem;
import io.quarkus.vertx.http.runtime.HttpBuildTimeConfig;
import io.smallrye.jwt.auth.cdi.ClaimValueProducer;
import io.smallrye.jwt.auth.cdi.CommonJwtProducer;
import io.smallrye.jwt.auth.cdi.JsonValueProducer;
import io.smallrye.jwt.auth.cdi.RawClaimTypeProducer;
import io.vertx.ext.web.RoutingContext;

@BuildSteps(onlyIf = OidcBuildStep.IsEnabled.class)
public class OidcBuildStep {
public static final DotName DOTNAME_SECURITY_EVENT = DotName.createSimple(SecurityEvent.class.getName());
private static final DotName TENANT_NAME = DotName.createSimple(Tenant.class);
private static final Logger LOG = Logger.getLogger(OidcBuildStep.class);

@BuildStep
public void provideSecurityInformation(BuildProducer<SecurityInformationBuildItem> securityInformationProducer) {
Expand Down Expand Up @@ -136,6 +156,84 @@ public void findSecurityEventObservers(
recorder.setSecurityEventObserved(isSecurityEventObserved);
}

@BuildStep
@Record(ExecutionTime.STATIC_INIT)
public void produceTenantResolverInterceptors(CombinedIndexBuildItem indexBuildItem,
Capabilities capabilities, OidcRecorder recorder,
BuildProducer<EagerSecurityInterceptorCandidateBuildItem> producer,
HttpBuildTimeConfig buildTimeConfig) {
if (!buildTimeConfig.auth.proactive
&& (capabilities.isPresent(Capability.RESTEASY_REACTIVE) || capabilities.isPresent(Capability.RESTEASY))) {
// provide method interceptor that will be run before security checks

// collect endpoint candidates
IndexView index = indexBuildItem.getIndex();
Map<MethodInfo, String> candidateToTenant = new HashMap<>();

for (AnnotationInstance annotation : index.getAnnotations(TENANT_NAME)) {

// validate tenant id
AnnotationTarget target = annotation.target();
if (annotation.value() == null || annotation.value().asString().isEmpty()) {
LOG.warnf("Annotation instance @Tenant placed on %s did not provide valid tenant", toTargetName(target));
continue;
}

// collect annotation instance methods
String tenant = annotation.value().asString();
if (target.kind() == METHOD) {
MethodInfo method = target.asMethod();
if (hasProperEndpointModifiers(method)) {
candidateToTenant.put(method, tenant);
} else {
LOG.warnf("Method %s is not valid endpoint, but is annotated with the '@Tenant' annotation",
toTargetName(target));
}
} else if (target.kind() == CLASS) {
// collect endpoint candidates; we only collect candidates, extensions like
// RESTEasy Reactive and others are still in control of endpoint selection and interceptors
// are going to be applied only on the actual endpoints
for (MethodInfo method : target.asClass().methods()) {
if (hasProperEndpointModifiers(method)) {
candidateToTenant.put(method, tenant);
}
}
}
}

// create 'interceptor' for each tenant that puts tenant id into routing context
if (!candidateToTenant.isEmpty()) {

Map<String, Consumer<RoutingContext>> tenantToInterceptor = candidateToTenant
.values()
.stream()
.distinct()
.map(tenant -> Map.entry(tenant, recorder.createTenantResolverInterceptor(tenant)))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

candidateToTenant.forEach((method, tenant) -> {

// transform method info to description
String[] paramTypes = method.parameterTypes().stream().map(t -> t.name().toString()).toArray(String[]::new);
String className = method.declaringClass().name().toString();
String methodName = method.name();
var description = recorder.methodInfoToDescription(className, methodName, paramTypes);

producer.produce(new EagerSecurityInterceptorCandidateBuildItem(method, description,
tenantToInterceptor.get(tenant)));
});
}
}
}

private static String toTargetName(AnnotationTarget target) {
if (target.kind() == CLASS) {
return target.asClass().name().toString();
} else {
return target.asMethod().declaringClass().name().toString() + "#" + target.asMethod().name();
}
}

public static class IsEnabled implements BooleanSupplier {
OidcBuildTimeConfig config;

Expand Down
20 changes: 20 additions & 0 deletions extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Tenant.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.quarkus.oidc;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Annotation which can be used to associate tenant configurations with Jakarta REST resources and resource methods.
*/
@Target({ TYPE, METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface Tenant {
/**
* Identifies an OIDC tenant configurations.
*/
String value();
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

Expand All @@ -28,15 +29,18 @@
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.runtime.ExecutorRecorder;
import io.quarkus.runtime.LaunchMode;
import io.quarkus.runtime.RuntimeValue;
import io.quarkus.runtime.TlsConfig;
import io.quarkus.runtime.annotations.Recorder;
import io.quarkus.runtime.configuration.ConfigurationException;
import io.quarkus.security.spi.runtime.MethodDescription;
import io.smallrye.jwt.algorithm.KeyEncryptionAlgorithm;
import io.smallrye.jwt.util.KeyUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.core.Vertx;
import io.vertx.core.json.JsonObject;
import io.vertx.core.net.ProxyOptions;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.client.WebClientOptions;
import io.vertx.mutiny.ext.web.client.WebClient;

Expand Down Expand Up @@ -85,6 +89,10 @@ public Uni<TenantConfigContext> apply(OidcTenantConfig config) {
};
}

public RuntimeValue<MethodDescription> methodInfoToDescription(String className, String methodName, String[] paramTypes) {
return new RuntimeValue<>(new MethodDescription(className, methodName, paramTypes));
}

private Uni<TenantConfigContext> createDynamicTenantContext(Vertx vertx,
OidcTenantConfig oidcConfig, TlsConfig tlsConfig, String tenantId) {

Expand Down Expand Up @@ -478,4 +486,13 @@ private static OidcConfigurationMetadata createLocalMetadata(OidcTenantConfig oi
introspectionUri, authorizationUri, jwksUri, userInfoUri, endSessionUri,
oidcConfig.token.issuer.orElse(null));
}

public Consumer<RoutingContext> createTenantResolverInterceptor(String tenantId) {
return new Consumer<RoutingContext>() {
@Override
public void accept(RoutingContext routingContext) {
routingContext.put(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId);
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;

import org.jboss.jandex.ClassInfo;
Expand All @@ -27,6 +28,7 @@
import io.quarkus.resteasy.runtime.AuthenticationFailedExceptionMapper;
import io.quarkus.resteasy.runtime.AuthenticationRedirectExceptionMapper;
import io.quarkus.resteasy.runtime.CompositeExceptionMapper;
import io.quarkus.resteasy.runtime.EagerSecurityFilter;
import io.quarkus.resteasy.runtime.ExceptionMapperRecorder;
import io.quarkus.resteasy.runtime.ForbiddenExceptionMapper;
import io.quarkus.resteasy.runtime.JaxRsSecurityConfig;
Expand All @@ -39,6 +41,7 @@
import io.quarkus.resteasy.runtime.vertx.JsonObjectWriter;
import io.quarkus.resteasy.server.common.deployment.ResteasyDeploymentBuildItem;
import io.quarkus.security.spi.AdditionalSecuredMethodsBuildItem;
import io.quarkus.vertx.http.deployment.EagerSecurityInterceptorBuildItem;
import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem;
import io.quarkus.vertx.http.deployment.devmode.NotFoundPageDisplayableEndpointBuildItem;
import io.quarkus.vertx.http.deployment.devmode.RouteDescriptionBuildItem;
Expand Down Expand Up @@ -88,7 +91,8 @@ void setUpDenyAllJaxRs(CombinedIndexBuildItem index,
*/
@BuildStep
void setUpSecurity(BuildProducer<ResteasyJaxrsProviderBuildItem> providers,
BuildProducer<AdditionalBeanBuildItem> additionalBeanBuildItem, Capabilities capabilities) {
BuildProducer<AdditionalBeanBuildItem> additionalBeanBuildItem, Capabilities capabilities,
Optional<EagerSecurityInterceptorBuildItem> eagerSecurityInterceptors) {
providers.produce(new ResteasyJaxrsProviderBuildItem(UnauthorizedExceptionMapper.class.getName()));
providers.produce(new ResteasyJaxrsProviderBuildItem(ForbiddenExceptionMapper.class.getName()));
providers.produce(new ResteasyJaxrsProviderBuildItem(AuthenticationFailedExceptionMapper.class.getName()));
Expand All @@ -98,6 +102,10 @@ void setUpSecurity(BuildProducer<ResteasyJaxrsProviderBuildItem> providers,
if (capabilities.isPresent(Capability.SECURITY)) {
providers.produce(new ResteasyJaxrsProviderBuildItem(SecurityContextFilter.class.getName()));
additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(SecurityContextFilter.class));
if (eagerSecurityInterceptors.isPresent()) {
providers.produce(new ResteasyJaxrsProviderBuildItem(EagerSecurityFilter.class.getName()));
additionalBeanBuildItem.produce(AdditionalBeanBuildItem.unremovableOf(EagerSecurityFilter.class));
}
}
}

Expand Down
Loading

0 comments on commit bc6c54c

Please sign in to comment.