Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How-to: Implement multi-tenancy #663

Closed
arfatbk opened this issue Mar 27, 2022 · 14 comments
Closed

How-to: Implement multi-tenancy #663

arfatbk opened this issue Mar 27, 2022 · 14 comments
Assignees
Labels
type: enhancement A general enhancement
Milestone

Comments

@arfatbk
Copy link
Contributor

arfatbk commented Mar 27, 2022

I am thinking of A single Authorization server in an organization providing identity federation for multiple clients(tenants), where tenant data should be isolated from each other.

Other OAuth servers like KeyCloak provide Multi-tenancy. https://www.keycloak.org/docs/latest/securing_apps/index.html#_multi_tenancy

One of the following approaches can be configured:

Separate Schema – one schema per tenant in the same physical database instance
Separate Database – one separate physical database instance per tenant
Partitioned (Discriminator) Data – the data for each tenant is partitioned by a discriminator value(ex. A column for tenant identifier)

Organization can choose which of the above approach is suitable for any given tenant. For example 'A' tenants wants it's data completed isolated and is ok with separate physical database. Where 'B' tenant is ok with separate schema in shared physical database etc.

I am not sure if it is in roadmap or as a framework we want to implement this. Can anyone please direct me to resources if you have one that would be awesome.
Thanks

Related gh-499

@arfatbk arfatbk added the type: enhancement A general enhancement label Mar 27, 2022
@jgrandja jgrandja changed the title Support for multi-tenancy out of the box How-to: Implement multi-tenancy Mar 29, 2022
@jgrandja
Copy link
Collaborator

@arfatbk Support for multi-tenancy would be implemented in a product implementation of an OAuth/OIDC server, e.g. Keycloak. This isn't a feature the framework would implement.

I changed the title to a "How-to Guide" to see if there is demand from the community for this. If there is enough upvotes, we can consider adding a How-to guide.

I would also recommend posting this question on Stack Overflow and linking the post back to this issue.

@sjohnr
Copy link
Member

sjohnr commented Mar 30, 2022

Here's the stack overflow question related to this issue. It looks like I suggested @arfatbk open an issue. I think it's a good idea to see if the community has interest via upvotes (👍).

@Basit-Mahmood
Copy link

@sjohnr Hi. Hope you are doing well. Actually I was implementing it and I stuck at a point. I am not sure it is right place to ask if I stuck at some point or what. Anyways for AuthorizationCode grant type flow. I am sending request parameter name tenantDatabaseName. What I did I added a filter in my AuthoirzationServer Configuration. http.addFilterBefore(new TenantFilter(), OAuth2AuthorizationRequestRedirectFilter.class); Here is the configuration.

`@Import({OAuth2RegisteredClientConfiguration.class})
 public class AuthorizationServerConfiguration {
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
	
        OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
        http.apply(authorizationServerConfigurer.tokenEndpoint((tokenEndpoint) -> tokenEndpoint.accessTokenRequestConverter(
		new DelegatingAuthenticationConverter(Arrays.asList(
			new OAuth2AuthorizationCodeAuthenticationConverter(),
			new OAuth2RefreshTokenAuthenticationConverter(),
			new OAuth2ClientCredentialsAuthenticationConverter(),
			new OAuth2ResourceOwnerPasswordAuthenticationConverter()))
	)));
	
        authorizationServerConfigurer.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI));
        RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
	
        http
	    .requestMatcher(endpointsMatcher)
	    .authorizeRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
	    .csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
	    .apply(authorizationServerConfigurer)
	    .and()
	    .apply(new FederatedIdentityConfigurer());
	
	    http.addFilterBefore(new TenantFilter(), OAuth2AuthorizationRequestRedirectFilter.class);
	    SecurityFilterChain securityFilterChain = http.formLogin(Customizer.withDefaults()).build();
	    addCustomOAuth2ResourceOwnerPasswordAuthenticationProvider(http);
	    return securityFilterChain;
    }
        .....
}

`
Here is the filter

`public class TenantFilter extends OncePerRequestFilter {
     @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(Constant.TENANT_DATABASE_NAME);
	    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);
    }
}`

Now when request comes for authorization code grant type flow. This filter intercept the request. But in OAuth2AuthorizationEndpointFilter it is check if user is authenticated. The following line is present in OAuth2AuthorizationEndpointFilter.java's doFilterInternal() method

` if (!authorizationCodeRequestAuthenticationResult.isAuthenticated()) {	
    // If the Principal (Resource Owner) is not authenticated then
    // pass through the chain with the expectation that the authentication process
    // will commence via AuthenticationEntryPoint
    filterChain.doFilter(request, response);
    return;
}`

So now it throws the exception. Ultimately comes to LoginUrlAuthenticationEntryPoint and it redirects to Login Page using DefaultRedirectStrategy. As it is redirecting so now there is no tenantDatabaseName request parameter. But I overridden the method. Here what I did. Basically I am just appending the request parameter. So when it redirects I get the parameter.

`public class TenantLoginUrlAuthenticationEntryPoint extends LoginUrlAuthenticationEntryPoint {

    public TenantLoginUrlAuthenticationEntryPoint(String loginFormUrl) {
            super(loginFormUrl);
    }

    @Override
    protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException exception) {
	String tenantDatabaseNameParamValue = request.getParameter(Constant.TENANT_DATABASE_NAME);
	String redirect = super.determineUrlToUseForThisRequest(request, response, exception);
	String url = UriComponentsBuilder.fromPath(redirect).queryParam(Constant.TENANT_DATABASE_NAME, tenantDatabaseNameParamValue).toUriString();
	return url;
    }
}`

Then in the Login controller I get the parameter.

`@Controller
 public class LoginController {
    @GetMapping("/login")
    public String login(@Valid @ModelAttribute RedirectModel redirectModel,  Model model, BindingResult result) {
	if (!result.hasErrors()) {
		String tenantDatabaseName = redirectModel.getTenantDatabaseName();
		String currentDb = TenantDBContextHolder.getCurrentDb();
		LOGGER.info("Current database is {}", currentDb);
		LOGGER.info("Putting {} as tenant database name in model. So it can be set as a hidden form element ", tenantDatabaseName);
		model.addAttribute(Constant.TENANT_DATABASE_NAME, tenantDatabaseName);
	}
	return "login";
    }
 }`

Now I set it as a hidden form element. Then I created a custom filter. Added it before UsernamePasswordAuthrntiationFilter. I get the parameter value. I set it in the Thread Local Context and it works. I mean it connects to right database.

Now after validating user it goes back to client and again comes to server with the tenantDatabaseName request paamter. But this time it shows the Consent page. Now here is the code in the OAuth2AuthorizationEndpointFilter. It can be seen that in the sendAuthorizationConsent() method it is adding request parameters client_id, scope and state. And I get these parameters in the Consent Controller.

`public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilter {
     @Override
     protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
         throws ServletException, IOException {
         if (!this.authorizationEndpointMatcher.matches(request)) {
	      filterChain.doFilter(request, response);
	      return;
	}

	try {
            ....
            if (authorizationCodeRequestAuthenticationResult.isConsentRequired()) {
	        sendAuthorizationConsent(request, response, authorizationCodeRequestAuthentication, authorizationCodeRequestAuthenticationResult);
		return;
	    }
        }
    }

    private void sendAuthorizationConsent(HttpServletRequest request, HttpServletResponse response,
	OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication,
	OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult) throws IOException {

	String clientId = authorizationCodeRequestAuthenticationResult.getClientId();
	Authentication principal = (Authentication) authorizationCodeRequestAuthenticationResult.getPrincipal();
	Set<String> requestedScopes = authorizationCodeRequestAuthentication.getScopes();
	Set<String> authorizedScopes = authorizationCodeRequestAuthenticationResult.getScopes();
	String state = authorizationCodeRequestAuthenticationResult.getState();

	if (hasConsentUri()) {
		String redirectUri = UriComponentsBuilder.fromUriString(resolveConsentUri(request))
				.queryParam(OAuth2ParameterNames.SCOPE, String.join(" ", requestedScopes))
				.queryParam(OAuth2ParameterNames.CLIENT_ID, clientId)
				.queryParam(OAuth2ParameterNames.STATE, state)
				.toUriString();
		this.redirectStrategy.sendRedirect(request, response, redirectUri);
	} else {
		DefaultConsentPage.displayConsent(request, response, clientId, principal, requestedScopes, authorizedScopes, state);
	}
    }

 }
`

My question is how can I customized the sendAuthorizationConsent() method so I can include my tenantDatabeName request parameter too. ? Is there any way that I can modify AuthorizationServer configuration or am I doing it in the right way. Is there any better way of doing it ?

Like in the configuration we are customizing the consent page authorizationServerConfigurer.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage(CUSTOM_CONSENT_PAGE_URI));

Is there similar way I can customize this ?

If you can assist me it would be very helpful.

Thanks

@sjohnr
Copy link
Member

sjohnr commented Aug 29, 2022

@Basit-Mahmood, thanks for getting in touch, but it feels like this is a question that would be better suited to Stack Overflow. We prefer to use GitHub issues only for bugs and enhancements. Feel free to update this issue with a link to the re-posted question (so that other people can find it) and I will be happy to take a look.

If you'd prefer to discuss the issue more to get ideas, I'd also be happy to discuss it with you on the #spring-security gitter channel.

I should mention that I have not actually implemented SAS as a multi-tenant application either, so I can only provide ideas for you at the moment.

@Basit-Mahmood
Copy link

Basit-Mahmood commented Aug 30, 2022

@sjohnr Thank you so much for the reply. Yes I need your idea how to get through it. I remember when I was trying to implement password grant type then I stuck that how can I add Password Converter using the Authorization server configuration. Then you replied that I can either use the following code

` http.apply(authorizationServerConfigurer.withObjectPostProcessor(new ObjectPostProcessor<OAuth2TokenEndpointFilter>() {
	@Override
	public <O extends OAuth2TokenEndpointFilter> O postProcess(O oauth2TokenEndpointFilter) {
		oauth2TokenEndpointFilter.setAuthenticationConverter(new DelegatingAuthenticationConverter(
			Arrays.asList(
				new OAuth2AuthorizationCodeAuthenticationConverter(),
				new OAuth2RefreshTokenAuthenticationConverter(),
				new OAuth2ClientCredentialsAuthenticationConverter(),
			        new OAuth2ResourceOwnerPasswordAuthenticationConverter())));
			return oauth2TokenEndpointFilter;
		}
	    })
	);
`

or this

` http.apply(authorizationServerConfigurer.tokenEndpoint((tokenEndpoint) -> tokenEndpoint.accessTokenRequestConverter(
	new DelegatingAuthenticationConverter(Arrays.asList(
		new OAuth2AuthorizationCodeAuthenticationConverter(),
		new OAuth2RefreshTokenAuthenticationConverter(),
		new OAuth2ClientCredentialsAuthenticationConverter(),
		new OAuth2ResourceOwnerPasswordAuthenticationConverter()))
)));
`

And this was very helpful. Similarly I want to ask is there any way that I can customize the OAuth2AuthorizationEndpointFilter using some AuthorizationServerConfiguration ? If there is any.

If customizing is not possible in this version of Authorization Server Configuration. Then I think may be for now I can change the end point /oauth2/authorize and provide another filter. And this new filter would call instead of the original one for the authorization code grant type flow. I think now in Authorization Server Configuration we can change the end points. But I am not sure that if we change the end point then we also need to provide another filter or it just override the DEFAULT_AUTHORIZATION_ENDPOINT_URI in OAuth2AuthorizationEndpointFilter.

Actually I am asking it here because As Authorization Server Server is expanding. Which is very great. May be my question can trigger something that you guys think, ok we can include it in the later version of Authorization Server because it can be useful.
It is just my thinking.

Thanks

@sjohnr
Copy link
Member

sjohnr commented Aug 30, 2022

Thanks @Basit-Mahmood. I appreciate how excited you are to contribute!

I do want to be conscious of the fact that extended discussions about how to do something in your own application are often off-topic for the issue at hand, which feels like the case here at the moment. We could circle back to this issue with ideas we come up with together though. For now, please post a question on stackoverflow or start a thread on gitter and I will jump right in to discuss it with you!

@zmlgit
Copy link

zmlgit commented Dec 23, 2022

I expand the RegisteredClient to TenantRegisteredClient with a tenantId field, then the clients could use stardar third party library for exchange, but I'm facing a problem, I don't know how to pick the tenantId from the securityContext, because when
the request redirected to /login page, the SecurityContext becomes a AnonymousAuthenticationToken

@Basit-Mahmood
Copy link

Basit-Mahmood commented Dec 24, 2022

@zmlgit you can take a look at the stack overflow post Stack Overflow I described their that how to get the tenant Id for authorization code grant type flow when it redirects to login page and after login. May be it would help you.

Thanks & Regards
Basit Mahmood Ahmed

@sjohnr sjohnr self-assigned this Jan 3, 2023
@jgrandja jgrandja added type: documentation A documentation update and removed type: enhancement A general enhancement labels May 27, 2023
@Yneth
Copy link

Yneth commented May 27, 2023

I expand the RegisteredClient to TenantRegisteredClient with a tenantId field, then the clients could use stardar third party library for exchange, but I'm facing a problem, I don't know how to pick the tenantId from the securityContext, because when the request redirected to /login page, the SecurityContext becomes a AnonymousAuthenticationToken

if it is still an issue for you, there is a way you can solve it.
As soon as you go to /oauth2/authorize which redirects you to /oauth2/login your initial request /oauth2/authorize is saved in the HttpSession and you can retrieve your client_id. Lookup RegisteredClient from the database get your tenantId and put it into the HttpSession

@frederikz
Copy link

I successfully use the authorization server in a multitenant environment and now also want to use OpenId Connect functionality and struggle with what is currently offered for configuration. I encode the tenant name in the URL and for other projects like spring saml it is easy for me to implement multi-tenancy as all filters support to set a custom request matcher so I can e.g. set it to /oauth2/{tenantName}/authorize instead of the default /oauth2/authorize and there is some kind of resolver interface like Saml2AuthenticationRequestResolver that in the openid case would resolve the AuthorizationServerContext.

Currently we have a final class AuthorizationServerSettings and a AuthorizationServerContextFilter were you can't influence how a AuthorizationServerContext is built. Also the filters don't support setting customing request matchers and use the settings from AuthorizationServerSettings (apart from OidcProviderConfigurationEndpointFilter).
Thereforce I would propose to introduce an AuthorizationServerContextResolver that is then used in the AuthorizationServerContextFilter to create the AuthorizationServerContext. A developer then could configure its own AuthorizationServerContextResolver to be used. How it could look like: frederikz/spring-authorization-server@a7b256d . Besides retrieving the filter paths from AuthorizationServerSettings - which is a fixed setting and might not be used if you implement your own AuthorizationServerContextFilter - I would propose to support setting custom request matchers for filters. This is especially needed for OidcProviderConfigurationEndpointFilter as currently the OidcProviderConfigurationEndpointConfigurer ignores the issuer path when creating a request matcher for the configuration endpoint.

Without these changes at least I don't see how you can implement multi-tenancy without writing your own OidcProviderConfigurationEndpointConfigurer and rewriting a few filters.

@abilash-sethu
Copy link

+1

@abilash-sethu
Copy link

I expand the RegisteredClient to TenantRegisteredClient with a tenantId field, then the clients could use stardar third party library for exchange, but I'm facing a problem, I don't know how to pick the tenantId from the securityContext, because when the request redirected to /login page, the SecurityContext becomes a AnonymousAuthenticationToken

if it is still an issue for you, there a way you can solve it. As soon as you go to /oauth2/authorizewhich redirects you to /oauth2/login your initial request /oauth2/authorize is save in the HttpSession and you are able to retrieve your client_id. Lookup RegisteredClient from the database and get your tenantId and put it to the HttpSession

@Yneth Can you please advise, How to retrieve this HTTP session object?

@Yneth
Copy link

Yneth commented Jan 18, 2024

@abilash-sethu
sure, here are simplified pseudocode snippets to accomplish it.

public static RequestMatcher unauthenticatedRequestMatcher() {
        RequestMatcher authenticated = authenticatedRequestMatcher();
        return request -> !authenticated.matches(request);
    }

    public static RequestMatcher authenticatedRequestMatcher() {
        return request -> Optional.ofNullable(SecurityContextHolder.getContext())
                .map(SecurityContext::getAuthentication)
                .filter(authentication -> !AnonymousAuthenticationToken.class.isAssignableFrom(authentication.getClass()))
                .filter(Authentication::isAuthenticated)
                .isPresent();
    }


class TenantInitialAuthorizeFilter extends OncePerRequestFilter {
  RequestMatcher requestMatcher = new AndRequestMatcher(new AntRequestMatcher(GET, "/oauth2/authorize"), unauthenticatedRequestMatcher());
 
  void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
        if (requestMatcher.matches(request)) {
            HttpSession session = request.getSession(true);
            String tenantId = resolveTenantId(request);
            session.setAttribute("tenantId", tenantId);   
        }
        filterChain.doFilter(request, response);
    }
    
    String resolveTenantId(HttpServletRequest request) {
       // TODO: your logic to resolve tenantId from the request
    }
}

Then in any place of your service while the session is still active and you have access to HttpServletRequest, you can use the following function:

String getTenantId(HttpServletRequest request) {
  HttpSession session = request.getSession(false);
  if (session == null) return null; // we have no session available
  String tenantId = session.getAttribute("tenantId");
  return tenantId;
}

@abilash-sethu
Copy link

@Yneth Thank you so much for the quick response, This would really help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type: enhancement A general enhancement
Projects
Status: Done
Development

No branches or pull requests

8 participants