From 76322dcfde601564d871e30c2d2bceb151fdbbd4 Mon Sep 17 00:00:00 2001 From: Joe Grandja <10884212+jgrandja@users.noreply.github.com> Date: Fri, 26 Apr 2024 18:30:30 -0400 Subject: [PATCH] Add How-to: Implement Multitenancy Closes gh-663 --- docs/modules/ROOT/nav.adoc | 1 + .../pages/guides/how-to-multitenancy.adoc | 123 ++++++++++++++++ .../sample/multitenancy/DataSourceConfig.java | 55 ++++++++ .../sample/multitenancy/JWKSourceConfig.java | 103 ++++++++++++++ ...uth2AuthorizationConsentServiceConfig.java | 98 +++++++++++++ .../OAuth2AuthorizationServiceConfig.java | 107 ++++++++++++++ .../RegisteredClientRepositoryConfig.java | 131 ++++++++++++++++++ .../multitenancy/MultitenancyTests.java | 105 ++++++++++++++ 8 files changed, 723 insertions(+) create mode 100644 docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc create mode 100644 docs/src/main/java/sample/multitenancy/DataSourceConfig.java create mode 100644 docs/src/main/java/sample/multitenancy/JWKSourceConfig.java create mode 100644 docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java create mode 100644 docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java create mode 100644 docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java create mode 100644 docs/src/test/java/sample/multitenancy/MultitenancyTests.java diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 05f3af62..cf3f176f 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -8,6 +8,7 @@ ** xref:guides/how-to-pkce.adoc[] ** xref:guides/how-to-social-login.adoc[] ** xref:guides/how-to-ext-grant-type.adoc[] +** xref:guides/how-to-multitenancy.adoc[] ** xref:guides/how-to-userinfo.adoc[] ** xref:guides/how-to-jpa.adoc[] ** xref:guides/how-to-custom-claims-authorities.adoc[] diff --git a/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc b/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc new file mode 100644 index 00000000..bca13ff7 --- /dev/null +++ b/docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc @@ -0,0 +1,123 @@ + +[[how-to-multitenancy]] += How-to: Implement Multitenancy +:index-link: ../how-to.html +:docs-dir: .. + +This guide shows how to customize Spring Authorization Server to support multiple issuers per host in a multi-tenant hosting configuration. + +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. + +For example, an https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest[OpenID Provider Configuration Request] "http://localhost:9000/issuer1/.well-known/openid-configuration" or an https://datatracker.ietf.org/doc/html/rfc8414#section-3.1[Authorization Server Metadata Request] "http://localhost:9000/.well-known/oauth-authorization-server/issuer1" would return the following configuration metadata: + +[source,json] +---- +{ + "issuer": "http://localhost:9000/issuer1", + "authorization_endpoint": "http://localhost:9000/issuer1/oauth2/authorize", + "token_endpoint": "http://localhost:9000/issuer1/oauth2/token", + "jwks_uri": "http://localhost:9000/issuer1/oauth2/jwks", + "revocation_endpoint": "http://localhost:9000/issuer1/oauth2/revoke", + "introspection_endpoint": "http://localhost:9000/issuer1/oauth2/introspect", + ... +} +---- + +NOTE: The base URL of the xref:protocol-endpoints.adoc[Protocol Endpoints] is the issuer identifier value. + +Essentially, an issuer identifier with a path component represents the _"tenant identifier"_. + +The components that require multi-tenant capability are: + +* xref:guides/how-to-multitenancy.adoc#multi-tenant-registered-client-repository[`RegisteredClientRepository`] +* xref:guides/how-to-multitenancy.adoc#multi-tenant-oauth2-authorization-service[`OAuth2AuthorizationService`] +* xref:guides/how-to-multitenancy.adoc#multi-tenant-oauth2-authorization-consent-service[`OAuth2AuthorizationConsentService`] +* xref:guides/how-to-multitenancy.adoc#multi-tenant-jwk-source[`JWKSource`] + +For each of these components, an implementation of a composite can be provided that delegates to the concrete component associated to the _"requested"_ issuer identifier. + +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 + +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: + +.RegisteredClientRepositoryConfig +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java[] +---- + +TIP: Click on the "Expand folded text" icon in the code sample above to display the full example. + +<1> A `JdbcRegisteredClientRepository` instance mapped to issuer identifier `issuer1` and using a dedicated `DataSource`. +<2> A `JdbcRegisteredClientRepository` instance mapped to issuer identifier `issuer2` and using a dedicated `DataSource`. +<3> A composite implementation of a `RegisteredClientRepository` that delegates to a `JdbcRegisteredClientRepository` mapped to the _"requested"_ issuer identifier. +<4> Obtain the `JdbcRegisteredClientRepository` that is mapped to the _"requested"_ issuer identifier indicated by `AuthorizationServerContext.getIssuer()`. + +IMPORTANT: Explicitly configuring the issuer identifier via `AuthorizationServerSettings.builder().issuer("http://localhost:9000")` forces to a single-tenant configuration. Avoid explicitly configuring the issuer identifier when using a multi-tenant hosting configuration. + +In the preceding example, each of the `JdbcRegisteredClientRepository` instances are configured with a `JdbcTemplate` and associated `DataSource`. +This is important in a multi-tenant configuration as a primary requirement is to have the ability to isolate the data from each tenant. + +Configuring a dedicated `DataSource` for each component instance provides the flexibility to isolate the data in its own schema within the same database instance or alternatively isolate the data in a separate database instance altogether. + +The following example shows a sample configuration of 2x `DataSource` `@Bean` (one for each tenant) that are used by the multi-tenant capable components: + +.DataSourceConfig +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/DataSourceConfig.java[] +---- + +<1> Use a separate H2 database instance using `issuer1-db` as the name. +<2> Use a separate H2 database instance using `issuer2-db` as the name. + +[[multi-tenant-oauth2-authorization-service]] +== 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: + +.OAuth2AuthorizationServiceConfig +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java[] +---- + +<1> A `JdbcOAuth2AuthorizationService` instance mapped to issuer identifier `issuer1` and using a dedicated `DataSource`. +<2> A `JdbcOAuth2AuthorizationService` instance mapped to issuer identifier `issuer2` and using a dedicated `DataSource`. +<3> A composite implementation of an `OAuth2AuthorizationService` that delegates to a `JdbcOAuth2AuthorizationService` mapped to the _"requested"_ issuer identifier. +<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 + +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: + +.OAuth2AuthorizationConsentServiceConfig +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java[] +---- + +<1> A `JdbcOAuth2AuthorizationConsentService` instance mapped to issuer identifier `issuer1` and using a dedicated `DataSource`. +<2> A `JdbcOAuth2AuthorizationConsentService` instance mapped to issuer identifier `issuer2` and using a dedicated `DataSource`. +<3> A composite implementation of an `OAuth2AuthorizationConsentService` that delegates to a `JdbcOAuth2AuthorizationConsentService` mapped to the _"requested"_ issuer identifier. +<4> Obtain the `JdbcOAuth2AuthorizationConsentService` that is mapped to the _"requested"_ issuer identifier indicated by `AuthorizationServerContext.getIssuer()`. + +[[multi-tenant-jwk-source]] +== 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: + +.JWKSourceConfig +[source,java] +---- +include::{examples-dir}/main/java/sample/multitenancy/JWKSourceConfig.java[] +---- + +<1> A `JWKSet` instance mapped to issuer identifier `issuer1`. +<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()`. diff --git a/docs/src/main/java/sample/multitenancy/DataSourceConfig.java b/docs/src/main/java/sample/multitenancy/DataSourceConfig.java new file mode 100644 index 00000000..d7e5db92 --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/DataSourceConfig.java @@ -0,0 +1,55 @@ +/* + * 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 org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; + +@Configuration(proxyBeanMethods = false) +public class DataSourceConfig { + + @Bean("issuer1-data-source") + public EmbeddedDatabase issuer1DataSource() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .setName("issuer1-db") // <1> + .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 + } + + @Bean("issuer2-data-source") + public EmbeddedDatabase issuer2DataSource() { + // @formatter:off + return new EmbeddedDatabaseBuilder() + .setName("issuer2-db") // <2> + .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 + } + +} diff --git a/docs/src/main/java/sample/multitenancy/JWKSourceConfig.java b/docs/src/main/java/sample/multitenancy/JWKSourceConfig.java new file mode 100644 index 00000000..5791bd68 --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/JWKSourceConfig.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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import com.nimbusds.jose.KeySourceException; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSelector; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.JWKSource; +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> + + return new DelegatingJWKSource(jwkSetMap); + } + + // @fold: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 + + private static class DelegatingJWKSource implements JWKSource { // <3> + private final Map jwkSetMap; + + private DelegatingJWKSource(Map jwkSetMap) { + this.jwkSetMap = jwkSetMap; + } + + @Override + public List get(JWKSelector jwkSelector, SecurityContext context) throws KeySourceException { + JWKSet jwkSet = getJwkSet(); + return (jwkSet != null) ? jwkSelector.select(jwkSet) : Collections.emptyList(); + } + + 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; + } + + } + +} diff --git a/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java new file mode 100644 index 00000000..d1bc8208 --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java @@ -0,0 +1,98 @@ +/* + * 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.HashMap; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +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 { + + @Bean + public OAuth2AuthorizationConsentService authorizationConsentService( + @Qualifier("issuer1-data-source") DataSource issuer1DataSource, + @Qualifier("issuer2-data-source") DataSource issuer2DataSource, + 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)); + + return new DelegatingOAuth2AuthorizationConsentService(authorizationConsentServiceMap); + } + + private static class DelegatingOAuth2AuthorizationConsentService implements OAuth2AuthorizationConsentService { // <3> + private final Map authorizationConsentServiceMap; + + private DelegatingOAuth2AuthorizationConsentService(Map authorizationConsentServiceMap) { + this.authorizationConsentServiceMap = authorizationConsentServiceMap; + } + + @Override + public void save(OAuth2AuthorizationConsent authorizationConsent) { + OAuth2AuthorizationConsentService authorizationConsentService = getAuthorizationConsentService(); + if (authorizationConsentService != null) { + authorizationConsentService.save(authorizationConsent); + } + } + + @Override + public void remove(OAuth2AuthorizationConsent authorizationConsent) { + OAuth2AuthorizationConsentService authorizationConsentService = getAuthorizationConsentService(); + if (authorizationConsentService != null) { + authorizationConsentService.remove(authorizationConsent); + } + } + + @Override + public OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) { + OAuth2AuthorizationConsentService authorizationConsentService = getAuthorizationConsentService(); + return (authorizationConsentService != null) ? + authorizationConsentService.findById(registeredClientId, principalName) : + null; + } + + 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; + } + + } + +} diff --git a/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java new file mode 100644 index 00000000..e18cc9fb --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/OAuth2AuthorizationServiceConfig.java @@ -0,0 +1,107 @@ +/* + * 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.HashMap; +import java.util.Map; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; +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 { + + @Bean + public OAuth2AuthorizationService authorizationService( + @Qualifier("issuer1-data-source") DataSource issuer1DataSource, + @Qualifier("issuer2-data-source") DataSource issuer2DataSource, + 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)); + + return new DelegatingOAuth2AuthorizationService(authorizationServiceMap); + } + + private static class DelegatingOAuth2AuthorizationService implements OAuth2AuthorizationService { // <3> + private final Map authorizationServiceMap; + + private DelegatingOAuth2AuthorizationService(Map authorizationServiceMap) { + this.authorizationServiceMap = authorizationServiceMap; + } + + @Override + public void save(OAuth2Authorization authorization) { + OAuth2AuthorizationService authorizationService = getAuthorizationService(); + if (authorizationService != null) { + authorizationService.save(authorization); + } + } + + @Override + public void remove(OAuth2Authorization authorization) { + OAuth2AuthorizationService authorizationService = getAuthorizationService(); + if (authorizationService != null) { + authorizationService.remove(authorization); + } + } + + @Override + public OAuth2Authorization findById(String id) { + OAuth2AuthorizationService authorizationService = getAuthorizationService(); + return (authorizationService != null) ? + authorizationService.findById(id) : + null; + } + + @Override + public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) { + OAuth2AuthorizationService authorizationService = getAuthorizationService(); + return (authorizationService != null) ? + authorizationService.findByToken(token, tokenType) : + null; + } + + 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; + } + + } + +} diff --git a/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java b/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java new file mode 100644 index 00000000..c761a13a --- /dev/null +++ b/docs/src/main/java/sample/multitenancy/RegisteredClientRepositoryConfig.java @@ -0,0 +1,131 @@ +/* + * 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.HashMap; +import java.util.Map; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +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 { + + @Bean + public RegisteredClientRepository registeredClientRepository( + @Qualifier("issuer1-data-source") DataSource issuer1DataSource, + @Qualifier("issuer2-data-source") DataSource issuer2DataSource) { + + JdbcRegisteredClientRepository issuer1RegisteredClientRepository = + new JdbcRegisteredClientRepository(new JdbcTemplate(issuer1DataSource)); // <1> + + // @fold:on + // @formatter:off + issuer1RegisteredClientRepository.save( + RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("client-1") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("scope-1") + .build() + ); + // @formatter:on + // @fold:off + + JdbcRegisteredClientRepository issuer2RegisteredClientRepository = + new JdbcRegisteredClientRepository(new JdbcTemplate(issuer2DataSource)); // <2> + + // @fold:on + // @formatter:off + issuer2RegisteredClientRepository.save( + RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("client-2") + .clientSecret("{noop}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("scope-2") + .build() + ); + // @formatter:on + // @fold:off + + Map registeredClientRepositoryMap = new HashMap<>(); + registeredClientRepositoryMap.put("issuer1", issuer1RegisteredClientRepository); + registeredClientRepositoryMap.put("issuer2", issuer2RegisteredClientRepository); + + return new DelegatingRegisteredClientRepository(registeredClientRepositoryMap); + } + + private static class DelegatingRegisteredClientRepository implements RegisteredClientRepository { // <3> + private final Map registeredClientRepositoryMap; + + private DelegatingRegisteredClientRepository(Map registeredClientRepositoryMap) { + this.registeredClientRepositoryMap = registeredClientRepositoryMap; + } + + @Override + public void save(RegisteredClient registeredClient) { + RegisteredClientRepository registeredClientRepository = getRegisteredClientRepository(); + if (registeredClientRepository != null) { + registeredClientRepository.save(registeredClient); + } + } + + @Override + public RegisteredClient findById(String id) { + RegisteredClientRepository registeredClientRepository = getRegisteredClientRepository(); + return (registeredClientRepository != null) ? + registeredClientRepository.findById(id) : + null; + } + + @Override + public RegisteredClient findByClientId(String clientId) { + RegisteredClientRepository registeredClientRepository = getRegisteredClientRepository(); + return (registeredClientRepository != null) ? + registeredClientRepository.findByClientId(clientId) : + null; + } + + 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; + } + + } + +} diff --git a/docs/src/test/java/sample/multitenancy/MultitenancyTests.java b/docs/src/test/java/sample/multitenancy/MultitenancyTests.java new file mode 100644 index 00000000..4028d1a0 --- /dev/null +++ b/docs/src/test/java/sample/multitenancy/MultitenancyTests.java @@ -0,0 +1,105 @@ +/* + * 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 org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.http.MediaType; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for the guide How-to: Implement Multitenancy. + * + * @author Joe Grandja + */ +@SpringBootTest(classes = {MultitenancyTests.AuthorizationServerConfig.class} ) +@AutoConfigureMockMvc +public class MultitenancyTests { + + @Autowired + private MockMvc mvc; + + @Test + public void requestWhenTokenRequestForIssuer1ThenTokenResponse() throws Exception { + // @formatter:off + this.mvc.perform(post("/issuer1/oauth2/token") + .with(httpBasic("client-1", "secret")) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope-1") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()); + // @formatter:on + } + + @Test + public void requestWhenTokenRequestForIssuer1WithInvalidClientThenUnauthorized() throws Exception { + // @formatter:off + this.mvc.perform(post("/issuer1/oauth2/token") + .with(httpBasic("client-2", "secret")) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope-2") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @Test + public void requestWhenTokenRequestForIssuer2ThenTokenResponse() throws Exception { + // @formatter:off + this.mvc.perform(post("/issuer2/oauth2/token") + .with(httpBasic("client-2", "secret")) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope-2") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()); + // @formatter:on + } + + @Test + public void requestWhenTokenRequestForIssuer2WithInvalidClientThenUnauthorized() throws Exception { + // @formatter:off + this.mvc.perform(post("/issuer2/oauth2/token") + .with(httpBasic("client-1", "secret")) + .param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()) + .param(OAuth2ParameterNames.SCOPE, "scope-1") + .contentType(MediaType.APPLICATION_FORM_URLENCODED_VALUE)) + .andExpect(status().isUnauthorized()); + // @formatter:on + } + + @EnableAutoConfiguration(exclude = JpaRepositoriesAutoConfiguration.class) + @EnableWebSecurity + @ComponentScan + static class AuthorizationServerConfig { + } + +}