Link for Authorization server: https://github.com/spring-projects/spring-authorization-server
This project pretty much has everything in terms of OAuth and Identity provider. My question is, How to achieve multi-tenancy at the Identity provider
level.
I know there are multiple ways to achieve multi-tenancy in general.
The scenario I am interested in is this:
dedicated Front-end app(per tenant)
and negotiate access tokens from Identity provider
Identity provider
then identifies tenant (Based on header/ Domain name) and generates access token
with tenant_id
access token
then is passed on to down-stream services, which intern can extract tenant_id
and decide the data sourceI have a general idea about all the above steps, but I am not sure about point 4.
I am not sure How to configure different data sources for different tenants on the Identity Provider? How to add tenant_id in Token?
Link to the issue: https://github.com/spring-projects/spring-authorization-server/issues/663#issue-1182431313
This is not related to Spring auth Server, but related to approaches that we can think for point # 4
I remember the last time we implemented a similar approach, where we had below options
More details can be read from here
This is really a good question and I really want to know how to do it in new Authorization Server in a proper way. In Spring Resource Server there is a section about Multitenancy. I did it successfully.
As far as new Spring Authorization Server multitenancy concerns. I have also done it for the password and the Client Credentials grant type.
But please note that although it is working but how perfect is this. I don't know because I just did it for learning purpose. It's just a sample. I will also post it on my github when I would do it for the authorization code grant type.
I am assuming that the master and tenant database configuration has been done. I can not provide the whole code here because it's lot of code. I will just provide the relevant snippets. But here is just the sample
@Configuration
@Import({MasterDatabaseConfiguration.class, TenantDatabaseConfiguration.class})
public class DatabaseConfiguration {
}
I used the separate database. What I did I used something like the following in the AuthorizationServerConfiguration.
@Import({OAuth2RegisteredClientConfiguration.class})
public class AuthorizationServerConfiguration {
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
....
http.addFilterBefore(new TenantFilter(), OAuth2AuthorizationRequestRedirectFilter.class);
SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build();
addCustomOAuth2ResourceOwnerPasswordAuthenticationProvider(http);
return securityFilterChain;
}
}
Here is my TenantFilter code
public class TenantFilter extends OncePerRequestFilter {
private static final Logger LOGGER = LogManager.getLogger();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestUrl = request.getRequestURL().toString();
if (!requestUrl.endsWith("/oauth2/jwks")) {
String tenantDatabaseName = request.getParameter("tenantDatabaseName");
if(StringUtils.hasText(tenantDatabaseName)) {
LOGGER.info("tenantDatabaseName request parameter is found");
TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
} else {
LOGGER.info("No tenantDatabaseName request parameter is found");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{'error': 'No tenant request parameter supplied'}");
response.getWriter().flush();
return;
}
}
filterChain.doFilter(request, response);
}
public static String getFullURL(HttpServletRequest request) {
StringBuilder requestURL = new StringBuilder(request.getRequestURL().toString());
String queryString = request.getQueryString();
if (queryString == null) {
return requestURL.toString();
} else {
return requestURL.append('?').append(queryString).toString();
}
}
}
Here is the TenantDBContextHolder class
public class TenantDBContextHolder {
private static final ThreadLocal<String> TENANT_DB_CONTEXT_HOLDER = new ThreadLocal<>();
public static void setCurrentDb(String dbType) {
TENANT_DB_CONTEXT_HOLDER.set(dbType);
}
public static String getCurrentDb() {
return TENANT_DB_CONTEXT_HOLDER.get();
}
public static void clear() {
TENANT_DB_CONTEXT_HOLDER.remove();
}
}
Now as there is already configuration for master and tenant database. In these configurations we also check for the TenantDBContextHolder class that it contains the value or not. Because when request comes for token then we check the request and set it in TenantDBContextHolder. So base on this thread local variable right database is connected and the token issue to the right database. Then in the token customizer. You can use something like the following
public class UsernamePasswordAuthenticationTokenJwtCustomizerHandler extends AbstractJwtCustomizerHandler {
....
@Override
protected void customizeJwt(JwtEncodingContext jwtEncodingContext) {
....
String tenantDatabaseName = TenantDBContextHolder.getCurrentDb();
if (StringUtils.hasText(tenantDatabaseName)) {
URL issuerURL = jwtClaimSetBuilder.build().getIssuer();
String issuer = issuerURL + "/" + tenantDatabaseName;
jwtClaimSetBuilder.claim(JwtClaimNames.ISS, issuer);
}
jwtClaimSetBuilder.claims(claims ->
userAttributes.entrySet().stream()
.forEach(entry -> claims.put(entry.getKey(), entry.getValue()))
);
}
}
Now I am assuming that the Resource Server is also configure for multitenancy. Here is the link Spring Security Resource Server Multitenancy . Basically You have to configure two beans for multitenancy like the following
public class OAuth2ResourceServerConfiguration {
....
@Bean
public JWTProcessor<SecurityContext> jwtProcessor(JWTClaimsSetAwareJWSKeySelector<SecurityContext> keySelector) {
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWTClaimsSetAwareJWSKeySelector(keySelector);
return jwtProcessor;
}
@Bean
public JwtDecoder jwtDecoder(JWTProcessor<SecurityContext> jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefault(), jwtValidator);
decoder.setJwtValidator(validator);
return decoder;
}
}
Now two classes for spring. From which you can get the tenant Identifier from your token.
@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
private final TenantDataSourceRepository tenantDataSourceRepository;
private final Map<String, JwtIssuerValidator> validators = new ConcurrentHashMap<>();
....
@Override
public OAuth2TokenValidatorResult validate(Jwt token) {
String issuerURL = toTenant(token);
JwtIssuerValidator jwtIssuerValidator = validators.computeIfAbsent(issuerURL, this::fromTenant);
OAuth2TokenValidatorResult oauth2TokenValidatorResult = jwtIssuerValidator.validate(token);
String tenantDatabaseName = JwtService.getTenantDatabaseName(token);
TenantDBContextHolder.setCurrentDb(tenantDatabaseName);
return oauth2TokenValidatorResult;
}
private String toTenant(Jwt jwt) {
return jwt.getIssuer().toString();
}
private JwtIssuerValidator fromTenant(String tenant) {
String issuerURL = tenant;
String tenantDatabaseName = JwtService.getTenantDatabaseName(issuerURL);
TenantDataSource tenantDataSource = tenantDataSourceRepository.findByDatabaseName(tenantDatabaseName);
if (tenantDataSource == null) {
throw new IllegalArgumentException("unknown tenant");
}
JwtIssuerValidator jwtIssuerValidator = new JwtIssuerValidator(issuerURL);
return jwtIssuerValidator;
}
}
Similarly
@Component
public class TenantJWSKeySelector implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
....
@Override
public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext) throws KeySourceException {
String tenant = toTenantDatabaseName(jwtClaimsSet);
JWSKeySelector<SecurityContext> jwtKeySelector = selectors.computeIfAbsent(tenant, this::fromTenant);
List<? extends Key> jwsKeys = jwtKeySelector.selectJWSKeys(jwsHeader, securityContext);
return jwsKeys;
}
private String toTenantDatabaseName(JWTClaimsSet claimSet) {
String issuerURL = (String) claimSet.getClaim("iss");
String tenantDatabaseName = JwtService.getTenantDatabaseName(issuerURL);
return tenantDatabaseName;
}
private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
TenantDataSource tenantDataSource = tenantDataSourceRepository.findByDatabaseName(tenant);
if (tenantDataSource == null) {
throw new IllegalArgumentException("unknown tenant");
}
JWSKeySelector<SecurityContext> jwtKeySelector = fromUri(jwkSetUri);
return jwtKeySelector;
}
private JWSKeySelector<SecurityContext> fromUri(String uri) {
try {
return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri));
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
}
}
Now what about authorization code grant type grant type flow. I get the tenant identifier in this case too. But when it redirects me to login page then I lost the tenant identifier because I think it creates a new request for the login page from the authorization code request. Anyways I am not sure about it because I have to look into the code of authorization code flow that what it is actually doing. So my tenant identifier is losing when it redirects me to login page.
But in case of password grant type and client credentials grant type there is no redirection so I get the tenant identifier in later stages and I can successfully use it to put into my token claims.
Then on the resource server I get the issuer url. Get the tenant identifier from the issuer url. Verify it. And it connects to the tenant database on resource server.
How I tested it. I used the spring client. You can customize the request for authorization code flow. Password and client credentials to include the custom parameters.
Thanks.
The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.