-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Closes gh-663
- Loading branch information
Showing
8 changed files
with
723 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
123 changes: 123 additions & 0 deletions
123
docs/modules/ROOT/pages/guides/how-to-multitenancy.adoc
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SecurityContext>`] | ||
|
||
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<SecurityContext>` 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<SecurityContext>` 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()`. |
55 changes: 55 additions & 0 deletions
55
docs/src/main/java/sample/multitenancy/DataSourceConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
|
||
} |
103 changes: 103 additions & 0 deletions
103
docs/src/main/java/sample/multitenancy/JWKSourceConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SecurityContext> jwkSource() { | ||
Map<String, JWKSet> 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<SecurityContext> { // <3> | ||
private final Map<String, JWKSet> jwkSetMap; | ||
|
||
private DelegatingJWKSource(Map<String, JWKSet> jwkSetMap) { | ||
this.jwkSetMap = jwkSetMap; | ||
} | ||
|
||
@Override | ||
public List<JWK> 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<String, JWKSet> entry : this.jwkSetMap.entrySet()) { | ||
if (issuer.endsWith(entry.getKey())) { | ||
return entry.getValue(); | ||
} | ||
} | ||
return null; | ||
} | ||
|
||
} | ||
|
||
} |
98 changes: 98 additions & 0 deletions
98
docs/src/main/java/sample/multitenancy/OAuth2AuthorizationConsentServiceConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String, OAuth2AuthorizationConsentService> 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<String, OAuth2AuthorizationConsentService> authorizationConsentServiceMap; | ||
|
||
private DelegatingOAuth2AuthorizationConsentService(Map<String, OAuth2AuthorizationConsentService> 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<String, OAuth2AuthorizationConsentService> entry : this.authorizationConsentServiceMap.entrySet()) { | ||
if (issuer.endsWith(entry.getKey())) { | ||
return entry.getValue(); | ||
} | ||
} | ||
return null; | ||
} | ||
|
||
} | ||
|
||
} |
Oops, something went wrong.