Skip to content

Multi-tenant ready OAuth2 client implementations #12862

Open
@ch4mpy

Description

@ch4mpy

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();
			});
		});
	}
}

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions