diff --git a/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc b/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc index bca13ff7..30d21134 100644 --- a/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc +++ b/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc @@ -5,6 +5,15 @@ :docs-dir: .. This guide shows how to customize Spring Authorization Server to support multiple issuers per host in a multi-tenant hosting configuration. +The purpose of this guide is to demonstrate a general pattern for building multi-tenant capable components for Spring Authorization Server, which can also be applied to other components to suit your needs. + +* xref:guides/how-to-multitenancy.adoc#multi-tenant-define-tenant-identifier[Define the tenant identifier] +* xref:guides/how-to-multitenancy.adoc#multi-tenant-create-component-registry[Create a component registry] +* xref:guides/how-to-multitenancy.adoc#multi-tenant-create-components[Create multi-tenant components] +* xref:guides/how-to-multitenancy.adoc#multi-tenant-add-tenants-dynamically[Add tenants dynamically] + +[[multi-tenant-define-tenant-identifier]] +== Define the tenant identifier The xref:protocol-endpoints.adoc#oidc-provider-configuration-endpoint[OpenID Connect 1.0 Provider Configuration Endpoint] and xref:protocol-endpoints.adoc#oauth2-authorization-server-metadata-endpoint[OAuth2 Authorization Server Metadata Endpoint] allow for path components in the issuer identifier value, which effectively enables supporting multiple issuers per host. @@ -27,6 +36,25 @@ NOTE: The base URL of the xref:protocol-endpoints.adoc[Protocol Endpoints] is th Essentially, an issuer identifier with a path component represents the _"tenant identifier"_. +[[multi-tenant-create-component-registry]] +== Create a component registry + +We start by building a simple registry for managing the concrete components for each tenant. +The registry contains the logic for retrieving a concrete implementation of a particular class using the issuer identifier value. + +We will use the following class in each of the delegating implementations below: + +.TenantPerIssuerComponentRegistry +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/TenantPerIssuerComponentRegistry.java[] +---- + +TIP: This registry is designed to allow components to be easily registered at startup to support adding tenants statically, but also supports xref:guides/how-to-multitenancy.adoc#multi-tenant-add-tenants-dynamically[adding tenants dynamically] at runtime. + +[[multi-tenant-create-components]] +== Create multi-tenant components + The components that require multi-tenant capability are: * xref:guides/how-to-multitenancy.adoc#multi-tenant-registered-client-repository[`RegisteredClientRepository`] @@ -39,7 +67,7 @@ For each of these components, an implementation of a composite can be provided t Let's step through a scenario of how to customize Spring Authorization Server to support 2x tenants for each multi-tenant capable component. [[multi-tenant-registered-client-repository]] -== Multi-tenant RegisteredClientRepository +=== Multi-tenant RegisteredClientRepository The following example shows a sample implementation of a xref:core-model-components.adoc#registered-client-repository[`RegisteredClientRepository`] that is composed of 2x `JdbcRegisteredClientRepository` instances, where each instance is mapped to an issuer identifier: @@ -75,7 +103,7 @@ include::{examples-dir}/main/java/sample/multitenancy/DataSourceConfig.java[] <2> Use a separate H2 database instance using `issuer2-db` as the name. [[multi-tenant-oauth2-authorization-service]] -== Multi-tenant OAuth2AuthorizationService +=== Multi-tenant OAuth2AuthorizationService The following example shows a sample implementation of an xref:core-model-components.adoc#oauth2-authorization-service[`OAuth2AuthorizationService`] that is composed of 2x `JdbcOAuth2AuthorizationService` instances, where each instance is mapped to an issuer identifier: @@ -91,7 +119,7 @@ include::{examples-dir}/main/java/sample/multitenancy/OAuth2AuthorizationService <4> Obtain the `JdbcOAuth2AuthorizationService` that is mapped to the _"requested"_ issuer identifier indicated by `AuthorizationServerContext.getIssuer()`. [[multi-tenant-oauth2-authorization-consent-service]] -== Multi-tenant OAuth2AuthorizationConsentService +=== Multi-tenant OAuth2AuthorizationConsentService The following example shows a sample implementation of an xref:core-model-components.adoc#oauth2-authorization-consent-service[`OAuth2AuthorizationConsentService`] that is composed of 2x `JdbcOAuth2AuthorizationConsentService` instances, where each instance is mapped to an issuer identifier: @@ -107,7 +135,7 @@ include::{examples-dir}/main/java/sample/multitenancy/OAuth2AuthorizationConsent <4> Obtain the `JdbcOAuth2AuthorizationConsentService` that is mapped to the _"requested"_ issuer identifier indicated by `AuthorizationServerContext.getIssuer()`. [[multi-tenant-jwk-source]] -== Multi-tenant JWKSource +=== Multi-tenant JWKSource And finally, the following example shows a sample implementation of a `JWKSource` that is composed of 2x `JWKSet` instances, where each instance is mapped to an issuer identifier: @@ -121,3 +149,17 @@ include::{examples-dir}/main/java/sample/multitenancy/JWKSourceConfig.java[] <2> A `JWKSet` instance mapped to issuer identifier `issuer2`. <3> A composite implementation of an `JWKSource` that uses the `JWKSet` mapped to the _"requested"_ issuer identifier. <4> Obtain the `JWKSet` that is mapped to the _"requested"_ issuer identifier indicated by `AuthorizationServerContext.getIssuer()`. + +[[multi-tenant-add-tenants-dynamically]] +== Add Tenants Dynamically + +If the number of tenants is dynamic and can change at runtime, defining each `DataSource` as a `@Bean` may not be feasible. +In this case, the `DataSource` and corresponding components can be registered through other means at application startup and/or runtime. + +The following example shows a Spring `@Service` capable of adding tenants dynamically: + +.TenantService +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/TenantService.java[] +---- diff --git a/docs/src/main/java/sample/multitenancy/JWKSourceConfig.java b/docs/src/main/java/sample/multitenancy/JWKSourceConfig.java index 5791bd68..096347e0 100644 --- a/docs/src/main/java/sample/multitenancy/JWKSourceConfig.java +++ b/docs/src/main/java/sample/multitenancy/JWKSourceConfig.java @@ -20,9 +20,7 @@ import java.security.KeyPairGenerator; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.UUID; import com.nimbusds.jose.KeySourceException; @@ -35,18 +33,16 @@ import com.nimbusds.jose.proc.SecurityContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; @Configuration(proxyBeanMethods = false) public class JWKSourceConfig { @Bean - public JWKSource jwkSource() { - Map jwkSetMap = new HashMap<>(); - jwkSetMap.put("issuer1", new JWKSet(generateRSAJwk())); // <1> - jwkSetMap.put("issuer2", new JWKSet(generateRSAJwk())); // <2> + public JWKSource jwkSource(TenantPerIssuerComponentRegistry componentRegistry) { + componentRegistry.register("issuer1", JWKSet.class, new JWKSet(generateRSAJwk())); // <1> + componentRegistry.register("issuer2", JWKSet.class, new JWKSet(generateRSAJwk())); // <2> - return new DelegatingJWKSource(jwkSetMap); + return new DelegatingJWKSource(componentRegistry); } // @fold:on @@ -72,10 +68,11 @@ public class JWKSourceConfig { // @fold:off private static class DelegatingJWKSource implements JWKSource { // <3> - private final Map jwkSetMap; - private DelegatingJWKSource(Map jwkSetMap) { - this.jwkSetMap = jwkSetMap; + private final TenantPerIssuerComponentRegistry componentRegistry; + + private DelegatingJWKSource(TenantPerIssuerComponentRegistry componentRegistry) { + this.componentRegistry = componentRegistry; } @Override @@ -85,17 +82,7 @@ public class JWKSourceConfig { } private JWKSet getJwkSet() { - if (AuthorizationServerContextHolder.getContext() == null || - AuthorizationServerContextHolder.getContext().getIssuer() == null) { - return null; - } - String issuer = AuthorizationServerContextHolder.getContext().getIssuer(); // <4> - for (Map.Entry entry : this.jwkSetMap.entrySet()) { - if (issuer.endsWith(entry.getKey())) { - return entry.getValue(); - } - } - return null; + return this.componentRegistry.get(JWKSet.class); // <4> } } diff --git a/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java index d1bc8208..062c8a8a 100644 --- a/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java +++ b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java @@ -15,9 +15,6 @@ */ package sample.multitenancy; -import java.util.HashMap; -import java.util.Map; - import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Qualifier; @@ -28,7 +25,6 @@ import org.springframework.security.oauth2.server.authorization.JdbcOAuth2Author import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; @Configuration(proxyBeanMethods = false) public class OAuth2AuthorizationConsentServiceConfig { @@ -37,22 +33,25 @@ public class OAuth2AuthorizationConsentServiceConfig { public OAuth2AuthorizationConsentService authorizationConsentService( @Qualifier("issuer1-data-source") DataSource issuer1DataSource, @Qualifier("issuer2-data-source") DataSource issuer2DataSource, + TenantPerIssuerComponentRegistry componentRegistry, RegisteredClientRepository registeredClientRepository) { - Map authorizationConsentServiceMap = new HashMap<>(); - authorizationConsentServiceMap.put("issuer1", new JdbcOAuth2AuthorizationConsentService( // <1> - new JdbcTemplate(issuer1DataSource), registeredClientRepository)); - authorizationConsentServiceMap.put("issuer2", new JdbcOAuth2AuthorizationConsentService( // <2> - new JdbcTemplate(issuer2DataSource), registeredClientRepository)); + componentRegistry.register("issuer1", OAuth2AuthorizationConsentService.class, + new JdbcOAuth2AuthorizationConsentService( // <1> + new JdbcTemplate(issuer1DataSource), registeredClientRepository)); + componentRegistry.register("issuer2", OAuth2AuthorizationConsentService.class, + new JdbcOAuth2AuthorizationConsentService( // <2> + new JdbcTemplate(issuer2DataSource), registeredClientRepository)); - return new DelegatingOAuth2AuthorizationConsentService(authorizationConsentServiceMap); + return new DelegatingOAuth2AuthorizationConsentService(componentRegistry); } private static class DelegatingOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService { // <3> - private final Map authorizationConsentServiceMap; - private DelegatingOAuth2AuthorizationConsentService(Map authorizationConsentServiceMap) { - this.authorizationConsentServiceMap = authorizationConsentServiceMap; + private final TenantPerIssuerComponentRegistry componentRegistry; + + private DelegatingOAuth2AuthorizationConsentService(TenantPerIssuerComponentRegistry componentRegistry) { + this.componentRegistry = componentRegistry; } @Override @@ -80,17 +79,7 @@ public class OAuth2AuthorizationConsentServiceConfig { } private OAuth2AuthorizationConsentService getAuthorizationConsentService() { - if (AuthorizationServerContextHolder.getContext() == null || - AuthorizationServerContextHolder.getContext().getIssuer() == null) { - return null; - } - String issuer = AuthorizationServerContextHolder.getContext().getIssuer(); // <4> - for (Map.Entry entry : this.authorizationConsentServiceMap.entrySet()) { - if (issuer.endsWith(entry.getKey())) { - return entry.getValue(); - } - } - return null; + return this.componentRegistry.get(OAuth2AuthorizationConsentService.class); // <4> } } diff --git a/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java index e18cc9fb..8529c482 100644 --- a/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java +++ b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java @@ -15,9 +15,6 @@ */ package sample.multitenancy; -import java.util.HashMap; -import java.util.Map; - import javax.sql.DataSource; import org.springframework.beans.factory.annotation.Qualifier; @@ -29,7 +26,6 @@ import org.springframework.security.oauth2.server.authorization.OAuth2Authorizat import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; @Configuration(proxyBeanMethods = false) public class OAuth2AuthorizationServiceConfig { @@ -38,22 +34,25 @@ public class OAuth2AuthorizationServiceConfig { public OAuth2AuthorizationService authorizationService( @Qualifier("issuer1-data-source") DataSource issuer1DataSource, @Qualifier("issuer2-data-source") DataSource issuer2DataSource, + TenantPerIssuerComponentRegistry componentRegistry, RegisteredClientRepository registeredClientRepository) { - Map authorizationServiceMap = new HashMap<>(); - authorizationServiceMap.put("issuer1", new JdbcOAuth2AuthorizationService( // <1> - new JdbcTemplate(issuer1DataSource), registeredClientRepository)); - authorizationServiceMap.put("issuer2", new JdbcOAuth2AuthorizationService( // <2> - new JdbcTemplate(issuer2DataSource), registeredClientRepository)); + componentRegistry.register("issuer1", OAuth2AuthorizationService.class, + new JdbcOAuth2AuthorizationService( // <1> + new JdbcTemplate(issuer1DataSource), registeredClientRepository)); + componentRegistry.register("issuer2", OAuth2AuthorizationService.class, + new JdbcOAuth2AuthorizationService( // <2> + new JdbcTemplate(issuer2DataSource), registeredClientRepository)); - return new DelegatingOAuth2AuthorizationService(authorizationServiceMap); + return new DelegatingOAuth2AuthorizationService(componentRegistry); } private static class DelegatingOAuth2AuthorizationService implements OAuth2AuthorizationService { // <3> - private final Map authorizationServiceMap; - private DelegatingOAuth2AuthorizationService(Map authorizationServiceMap) { - this.authorizationServiceMap = authorizationServiceMap; + private final TenantPerIssuerComponentRegistry componentRegistry; + + private DelegatingOAuth2AuthorizationService(TenantPerIssuerComponentRegistry componentRegistry) { + this.componentRegistry = componentRegistry; } @Override @@ -89,17 +88,7 @@ public class OAuth2AuthorizationServiceConfig { } private OAuth2AuthorizationService getAuthorizationService() { - if (AuthorizationServerContextHolder.getContext() == null || - AuthorizationServerContextHolder.getContext().getIssuer() == null) { - return null; - } - String issuer = AuthorizationServerContextHolder.getContext().getIssuer(); // <4> - for (Map.Entry entry : this.authorizationServiceMap.entrySet()) { - if (issuer.endsWith(entry.getKey())) { - return entry.getValue(); - } - } - return null; + return this.componentRegistry.get(OAuth2AuthorizationService.class); // <4> } } diff --git a/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java b/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java index c761a13a..0a21c63c 100644 --- a/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java +++ b/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java @@ -15,8 +15,6 @@ */ package sample.multitenancy; -import java.util.HashMap; -import java.util.Map; import java.util.UUID; import javax.sql.DataSource; @@ -30,7 +28,6 @@ import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; @Configuration(proxyBeanMethods = false) public class RegisteredClientRepositoryConfig { @@ -38,7 +35,8 @@ public class RegisteredClientRepositoryConfig { @Bean public RegisteredClientRepository registeredClientRepository( @Qualifier("issuer1-data-source") DataSource issuer1DataSource, - @Qualifier("issuer2-data-source") DataSource issuer2DataSource) { + @Qualifier("issuer2-data-source") DataSource issuer2DataSource, + TenantPerIssuerComponentRegistry componentRegistry) { JdbcRegisteredClientRepository issuer1RegisteredClientRepository = new JdbcRegisteredClientRepository(new JdbcTemplate(issuer1DataSource)); // <1> @@ -74,18 +72,18 @@ public class RegisteredClientRepositoryConfig { // @formatter:on // @fold:off - Map registeredClientRepositoryMap = new HashMap<>(); - registeredClientRepositoryMap.put("issuer1", issuer1RegisteredClientRepository); - registeredClientRepositoryMap.put("issuer2", issuer2RegisteredClientRepository); + componentRegistry.register("issuer1", RegisteredClientRepository.class, issuer1RegisteredClientRepository); + componentRegistry.register("issuer2", RegisteredClientRepository.class, issuer2RegisteredClientRepository); - return new DelegatingRegisteredClientRepository(registeredClientRepositoryMap); + return new DelegatingRegisteredClientRepository(componentRegistry); } private static class DelegatingRegisteredClientRepository implements RegisteredClientRepository { // <3> - private final Map registeredClientRepositoryMap; - private DelegatingRegisteredClientRepository(Map registeredClientRepositoryMap) { - this.registeredClientRepositoryMap = registeredClientRepositoryMap; + private final TenantPerIssuerComponentRegistry componentRegistry; + + private DelegatingRegisteredClientRepository(TenantPerIssuerComponentRegistry componentRegistry) { + this.componentRegistry = componentRegistry; } @Override @@ -113,17 +111,7 @@ public class RegisteredClientRepositoryConfig { } private RegisteredClientRepository getRegisteredClientRepository() { - if (AuthorizationServerContextHolder.getContext() == null || - AuthorizationServerContextHolder.getContext().getIssuer() == null) { - return null; - } - String issuer = AuthorizationServerContextHolder.getContext().getIssuer(); // <4> - for (Map.Entry entry : this.registeredClientRepositoryMap.entrySet()) { - if (issuer.endsWith(entry.getKey())) { - return entry.getValue(); - } - } - return null; + return this.componentRegistry.get(RegisteredClientRepository.class); // <4> } } diff --git a/docs/src/main/java/sample/multitenancy/TenantPerIssuerComponentRegistry.java b/docs/src/main/java/sample/multitenancy/TenantPerIssuerComponentRegistry.java new file mode 100644 index 00000000..8ffee7be --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/TenantPerIssuerComponentRegistry.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.multitenancy; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.lang.Nullable; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +@Component +public class TenantPerIssuerComponentRegistry { + private final ConcurrentMap, Object>> registry = new ConcurrentHashMap<>(); + + public void register(String tenantId, Class componentClass, T component) { + Assert.hasText(tenantId, "tenantId cannot be empty"); + Assert.notNull(componentClass, "componentClass cannot be null"); + Assert.notNull(component, "component cannot be null"); + Map, Object> components = this.registry.computeIfAbsent(tenantId, (key) -> new ConcurrentHashMap<>()); + components.put(componentClass, component); + } + + @Nullable + public T get(Class componentClass) { + AuthorizationServerContext context = AuthorizationServerContextHolder.getContext(); + if (context == null || context.getIssuer() == null) { + return null; + } + for (Map.Entry, Object>> entry : this.registry.entrySet()) { + if (context.getIssuer().endsWith(entry.getKey())) { + return componentClass.cast(entry.getValue().get(componentClass)); + } + } + return null; + } +} diff --git a/docs/src/main/java/sample/multitenancy/TenantService.java b/docs/src/main/java/sample/multitenancy/TenantService.java new file mode 100644 index 00000000..866aabd2 --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/TenantService.java @@ -0,0 +1,103 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.multitenancy; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.UUID; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.stereotype.Service; + +@Service +public class TenantService { + + private final TenantPerIssuerComponentRegistry componentRegistry; + + public TenantService(TenantPerIssuerComponentRegistry componentRegistry) { + this.componentRegistry = componentRegistry; + } + + public void createTenant(String tenantId) { + EmbeddedDatabase dataSource = createDataSource(tenantId); + JdbcTemplate jdbcOperations = new JdbcTemplate(dataSource); + + RegisteredClientRepository registeredClientRepository = + new JdbcRegisteredClientRepository(jdbcOperations); + this.componentRegistry.register(tenantId, RegisteredClientRepository.class, registeredClientRepository); + + OAuth2AuthorizationService authorizationService = + new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository); + this.componentRegistry.register(tenantId, OAuth2AuthorizationService.class, authorizationService); + + OAuth2AuthorizationConsentService authorizationConsentService = + new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository); + this.componentRegistry.register(tenantId, OAuth2AuthorizationConsentService.class, authorizationConsentService); + + JWKSet jwkSet = new JWKSet(generateRSAJwk()); + this.componentRegistry.register(tenantId, JWKSet.class, jwkSet); + } + + // @fold:on + private EmbeddedDatabase createDataSource(String tenantId) { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .setName(tenantId) + .setType(EmbeddedDatabaseType.H2) + .setScriptEncoding("UTF-8") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql") + .addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql") + .build(); + // @formatter:on + } + + private static RSAKey generateRSAJwk() { + KeyPair keyPair; + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + keyPair = keyPairGenerator.generateKeyPair(); + } catch (Exception ex) { + throw new IllegalStateException(ex); + } + + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + // @formatter:off + return new RSAKey.Builder(publicKey) + .privateKey(privateKey) + .keyID(UUID.randomUUID().toString()) + .build(); + // @formatter:on + } + // @fold:off + +} diff --git a/docs/src/test/java/sample/multitenancy/TenantServiceTests.java b/docs/src/test/java/sample/multitenancy/TenantServiceTests.java new file mode 100644 index 00000000..98e274f0 --- /dev/null +++ b/docs/src/test/java/sample/multitenancy/TenantServiceTests.java @@ -0,0 +1,86 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.multitenancy; + +import java.util.List; + +import com.nimbusds.jose.jwk.JWKSet; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext; +import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link TenantService}. + * + * @author Steve Riesenberg + */ +public class TenantServiceTests { + + private static final String ISSUER1 = "http://localhost:9000/issuer1"; + + private static final String ISSUER2 = "http://localhost:9000/issuer2"; + + private AuthorizationServerContext authorizationServerContext; + + private TenantPerIssuerComponentRegistry componentRegistry; + + private TenantService tenantService; + + @BeforeEach + public void setUp() { + this.authorizationServerContext = mock(AuthorizationServerContext.class); + this.componentRegistry = new TenantPerIssuerComponentRegistry(); + this.tenantService = new TenantService(this.componentRegistry); + + AuthorizationServerContextHolder.setContext(this.authorizationServerContext); + } + + @AfterEach + public void tearDown() { + AuthorizationServerContextHolder.resetContext(); + } + + @Test + public void testCreateTenantWhenMultipleIssuersThenCreated() { + this.tenantService.createTenant("issuer1"); + this.tenantService.createTenant("issuer2"); + + for (String issuer : List.of(ISSUER1, ISSUER2)) { + when(this.authorizationServerContext.getIssuer()).thenReturn(issuer); + assertThat(this.componentRegistry.get(RegisteredClientRepository.class)) + .isInstanceOf(JdbcRegisteredClientRepository.class); + assertThat(this.componentRegistry.get(OAuth2AuthorizationService.class)) + .isInstanceOf(JdbcOAuth2AuthorizationService.class); + assertThat(this.componentRegistry.get(OAuth2AuthorizationConsentService.class)) + .isInstanceOf(JdbcOAuth2AuthorizationConsentService.class); + assertThat(this.componentRegistry.get(JWKSet.class)).isNotNull(); + } + } + +}