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

Multi-tenant ready OAuth2 client implementations #12862

Open
ch4mpy opened this issue Mar 12, 2023 · 1 comment
Open

Multi-tenant ready OAuth2 client implementations #12862

ch4mpy opened this issue Mar 12, 2023 · 1 comment
Labels
status: waiting-for-triage An issue we've not yet triaged type: enhancement A general enhancement

Comments

@ch4mpy
Copy link
Contributor

ch4mpy commented Mar 12, 2023

Expected Behavior

When a user is authenticated with several OPs, it should be possible to access his different identities and also its different access / ID tokens from the authorized client repository.

Current Behavior

Let's consider the following client configuration:

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak-a:
            issuer-uri: ${keycloak-realm-a-issuer}
          keycloak-b:
            issuer-uri: ${keycloak-realm-b-issuer}
        registration:
          keycloak-confidential-realm-a:
            authorization-grant-type: authorization_code
            client-name: Keycloak for A
            client-id: a-confidential
            client-secret: ${a-confidential-secret}
            provider: keycloak-a
            scope: openid,profile,email,offline_access,roles
          keycloak-confidential-realm-b:
            authorization-grant-type: authorization_code
            client-name: Keycloak for B
            client-id: b-confidential
            client-secret: ${b-confidential-secret}
            provider: keycloak-b
            scope: openid,profile,email,offline_access,roles

When the user authenticates on "Keycloak for A", he gets an OAuth2AuthenticationToken with an OidcUser principal which has among its properties: sub-a subject and an ID token with ${keycloak-realm-a-issuer} issuer. Also, an "authorized client" is added to the repository with keycloak-confidential-realm-a registration ID and sub-a.

Now, if after that he authenticates on "Keycloak for B" (additionally, without a logout from "Keycloak for A"), he gets a new OAuth2AuthenticationToken with a new OidcUser principal which has among its properties: sub-b subject and an ID token with ${keycloak-realm-b-issuer} issuer. Also, an additional "authorized client" is added to the repository with keycloak-confidential-realm-b registration ID and sub-b.

The main problem I have is that it is pretty difficult to retrieve the authorized client to issue request with tokens from ${keycloak-realm-a-issuer} as the Authentication in the security context knows about the sub-b only (and the repository expects sub-a as name to retrieve the authorized client for keycloak-confidential-realm-a registration).

Context

I am trying to migrate Angular applications configured as public OAuth2 client to the BFF pattern (Angular app secured with sessions on a middleware configured as OAuth2 confidential client, in my case: spring-cloud-gateway with TokenRelay filter) .

Some of this applications allow users to authenticate with several identity providers to query different set of services. For instance a doctor querying both health-care professionals and employers resource-servers. As the business required that resource servers and OP related to health-care were isolated from the rest of the information system, an OAuth2 client consuming the two set of services must acquire identities from the two OPs (the general purpose one and the health-care one) and send its REST queries to resource servers with the right access token.

The problem could be the same in applications proposing to add identities from social providers to activate optional features related to social graphs, Google APIs or whatever, with the client calling directly those APIs (each API provider trusts his own OP and would hardly accept access tokens issued by another one).

Work Arounds

I'm hacking a multi tenant client environment by using

  • some support code to store the various Authentication instances in session (one per client registration ID)
  • AOP to instrument OAuth2AuthorizedClientRepository / ServerOAuth2AuthorizedClientRepository and switching on the fly the Authentication to match the client registration.

Servlet

Store the several Authentication instances in session

public class MultiTenantOAuth2PrincipalSupport {
	private static final String OAUTH2_USERS_KEY = "com.c4-soft.spring-addons.oauth2.client.principal-by-client-registration-id";

	@SuppressWarnings("unchecked")
	public static Map<String, Authentication> getAuthenticationsByClientRegistrationId(HttpSession session) {
		return Optional.ofNullable((Map<String, Authentication>) session.getAttribute(OAUTH2_USERS_KEY)).orElse(new HashMap<String, Authentication>());
	}

	public static Optional<Authentication> getAuthentication(HttpSession session, String clientRegistrationId) {
		return Optional.ofNullable(getAuthenticationsByClientRegistrationId(session).get(clientRegistrationId));
	}

	public static synchronized void add(HttpSession session, String clientRegistrationId, Authentication auth) {
		final var identities = getAuthenticationsByClientRegistrationId(session);
		identities.put(clientRegistrationId, auth);
		session.setAttribute(OAUTH2_USERS_KEY, identities);
	}

	public static synchronized void remove(HttpSession session, String clientRegistrationId) {
		final var identities = getAuthenticationsByClientRegistrationId(session);
		identities.remove(clientRegistrationId);
		session.setAttribute(OAUTH2_USERS_KEY, identities);
	}
}

Hack the authorized client repo

@Aspect
@Component
@RequiredArgsConstructor
public static class AuthorizedClientAspect {
	private final OAuth2AuthorizedClientRepository authorizedClientRepo;

	@Pointcut("within(org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository+) && execution(* *.loadAuthorizedClient(..))")
	public void loadAuthorizedClient() {
	}

	@Pointcut("within(org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository+) && execution(* *.saveAuthorizedClient(..))")
	public void saveAuthorizedClient() {
	}

	@Pointcut("within(org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository+) && execution(* *.removeAuthorizedClient(..))")
	public void removeAuthorizedClient() {
	}

	@Pointcut("within(org.springframework.security.web.authentication.logout.LogoutHandler+) && execution(* *.logout(..))")
	public void logout() {
	}

	@Around("loadAuthorizedClient()")
	public Object aroundLoadAuthorizedClient(ProceedingJoinPoint jp) throws Throwable {
		var clientRegistrationId = (String) jp.getArgs()[0];
		// var principal = (Authentication) jp.getArgs()[1];
		var request = (jakarta.servlet.http.HttpServletRequest) jp.getArgs()[2];

		final var args = Stream.of(jp.getArgs()).toArray(Object[]::new);
		args[1] = MultiTenantOAuth2PrincipalSupport.getAuthentication(request.getSession(), clientRegistrationId).orElse((Authentication) jp.getArgs()[1]);

		return jp.proceed(args);
	}

	@AfterReturning("saveAuthorizedClient()")
	public void afterSaveAuthorizedClient(JoinPoint jp) {
		var authorizedClient = (OAuth2AuthorizedClient) jp.getArgs()[0];
		var principal = (Authentication) jp.getArgs()[1];
		var request = (jakarta.servlet.http.HttpServletRequest) jp.getArgs()[2];
		// var response = (jakarta.servlet.http.HttpServletResponse) jp.getArgs()[3];

		final var registrationId = authorizedClient.getClientRegistration().getRegistrationId();
		MultiTenantOAuth2PrincipalSupport.add(request.getSession(), registrationId, principal);

	}

	@Around("removeAuthorizedClient()")
	public Object aroundRemoveAuthorizedClient(ProceedingJoinPoint jp) throws Throwable {
		var clientRegistrationId = (String) jp.getArgs()[0];
		// var principal = (Authentication) jp.getArgs()[1];
		var request = (jakarta.servlet.http.HttpServletRequest) jp.getArgs()[2];
		// var response = (jakarta.servlet.http.HttpServletResponse) jp.getArgs()[3];

		final var args = Stream.of(jp.getArgs()).toArray(Object[]::new);
		args[1] = MultiTenantOAuth2PrincipalSupport.getAuthentication(request.getSession(), clientRegistrationId).orElse((Authentication) jp.getArgs()[1]);

		MultiTenantOAuth2PrincipalSupport.remove(request.getSession(), clientRegistrationId);

		return jp.proceed(args);
	}

	@Before("logout()")
	public void beforeServerLogoutHandlerLogout(JoinPoint jp) {
		var request = (jakarta.servlet.http.HttpServletRequest) jp.getArgs()[0];
		var response = (jakarta.servlet.http.HttpServletResponse) jp.getArgs()[1];
		for (var e : MultiTenantOAuth2PrincipalSupport.getAuthenticationsByClientRegistrationId(request.getSession()).entrySet()) {
			authorizedClientRepo.removeAuthorizedClient(e.getKey(), e.getValue(), request, response);
		}
	}
}

Reactive applications

Store the several Authentication instances in session

public class ReactiveMultiTenantOAuth2PrincipalSupport {
	private static final String OAUTH2_USERS_KEY = "com.c4-soft.spring-addons.oauth2.client.principal-by-client-registration-id";

	@SuppressWarnings("unchecked")
	public static Map<String, Authentication> getAuthenticationsByClientRegistrationId(WebSession session) {
		return Optional.ofNullable((Map<String, Authentication>) session.getAttribute(OAUTH2_USERS_KEY)).orElse(new HashMap<String, Authentication>());
	}

	public static Optional<Authentication> getAuthentication(WebSession session, String clientRegistrationId) {
		return Optional.ofNullable(getAuthenticationsByClientRegistrationId(session).get(clientRegistrationId));
	}

	public static synchronized void add(WebSession session, String clientRegistrationId, Authentication auth) {
		final var identities = getAuthenticationsByClientRegistrationId(session);
		identities.put(clientRegistrationId, auth);
		session.getAttributes().put(OAUTH2_USERS_KEY, identities);
	}

	public static synchronized void remove(WebSession session, String clientRegistrationId) {
		final var identities = getAuthenticationsByClientRegistrationId(session);
		identities.remove(clientRegistrationId);
		session.getAttributes().put(OAUTH2_USERS_KEY, identities);
	}
}

Hack the authorized client repo

@Aspect
@Component
@RequiredArgsConstructor
public static class ReactiveAuthorizedClientAspect {
	private final ServerOAuth2AuthorizedClientRepository authorizedClientRepo;

	@Pointcut("within(org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository+) && execution(* *.loadAuthorizedClient(..))")
	public void loadAuthorizedClient() {
	}

	@Pointcut("within(org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository+) && execution(* *.saveAuthorizedClient(..))")
	public void saveAuthorizedClient() {
	}

	@Pointcut("within(org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository+) && execution(* *.removeAuthorizedClient(..))")
	public void removeAuthorizedClient() {
	}

	@Pointcut("within(org.springframework.security.web.server.authentication.logout.ServerLogoutHandler+) && execution(* *.logout(..))")
	public void logout() {
	}

	@SuppressWarnings("unchecked")
	@Around("loadAuthorizedClient()")
	public <T extends OAuth2AuthorizedClient> Mono<T> aroundLoadAuthorizedClient(ProceedingJoinPoint jp) throws Throwable {
		var clientRegistrationId = (String) jp.getArgs()[0];
		// var principal = (Authentication) jp.getArgs()[1];
		var exchange = (ServerWebExchange) jp.getArgs()[2];

		final var args = Stream.of(jp.getArgs()).toArray(Object[]::new);

		return exchange.getSession().flatMap(session -> {
			args[1] = ReactiveMultiTenantOAuth2PrincipalSupport.getAuthentication(session, clientRegistrationId).orElse((Authentication) jp.getArgs()[1]);
			try {
				return (Mono<T>) jp.proceed(args);
			} catch (Throwable e) {
				return Mono.error(e);
			}
		});
	}

	@AfterReturning("saveAuthorizedClient()")
	public void afterSaveAuthorizedClient(JoinPoint jp) {
		var authorizedClient = (OAuth2AuthorizedClient) jp.getArgs()[0];
		var principal = (Authentication) jp.getArgs()[1];
		var exchange = (ServerWebExchange) jp.getArgs()[2];
		exchange.getSession().subscribe(session -> {
			final var registrationId = authorizedClient.getClientRegistration().getRegistrationId();
			ReactiveMultiTenantOAuth2PrincipalSupport.add(session, registrationId, principal);
		});
	}

	@SuppressWarnings("unchecked")
	@Around("removeAuthorizedClient()")
	public Mono<Void> aroundRemoveAuthorizedClient(ProceedingJoinPoint jp) {
		final var args = Stream.of(jp.getArgs()).toArray(Object[]::new);
		var clientRegistrationId = (String) args[0];
		var principal = (Authentication) args[1];
		var exchange = (ServerWebExchange) args[2];
		return exchange.getSession().flatMap(session -> {
			args[1] = ReactiveMultiTenantOAuth2PrincipalSupport.getAuthentication(session, clientRegistrationId).orElse(principal);
			try {
				return (Mono<Void>) jp.proceed(args);
			} catch (Throwable e) {
				return Mono.error(e);
			}
		});
	}

	@Before("logout()")
	public void beforeServerLogoutHandlerLogout(JoinPoint jp) {
		final var args = Stream.of(jp.getArgs()).toArray(Object[]::new);
		var exchange = (WebFilterExchange) args[0];

		exchange.getExchange().getSession().subscribe(session -> {
			ReactiveMultiTenantOAuth2PrincipalSupport.getAuthenticationsByClientRegistrationId(session).entrySet().forEach(e -> {
				authorizedClientRepo.removeAuthorizedClient(e.getKey(), e.getValue(), exchange.getExchange()).subscribe();
			});
		});
	}
}
@ch4mpy ch4mpy added status: waiting-for-triage An issue we've not yet triaged type: enhancement A general enhancement labels Mar 12, 2023
@Giovarco
Copy link

I'm coming from this thread on SO: https://stackoverflow.com/questions/76671272/spring-boot-multi-tenant-api-gateway-how-to-handle-multiple-sessions/76673268#76673268

Having a similar issue as @ch4mpy . Please consider adding proper out-of-the-box support for multi-tenancy because the workaround is quite tedious.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: waiting-for-triage An issue we've not yet triaged type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

2 participants