RKS
RKS

Reputation: 11

Multi-tenant Spring Webflux microservice with Keycloak OAuth/OIDC

Use Case:

Requirements:

Issue Faced:

import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoders;
import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Component
public class TenantAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver<ServerWebExchange> {
    private static final String ACCOUNT_URI_PREFIX = "/accounts/";
    private static final String ACCOUNTS = "/accounts";
    private static final String EMPTY_STRING = "";
    private final Map<String, String> tenants = new HashMap<>();
    private final Map<String, JwtReactiveAuthenticationManager> authenticationManagers = new HashMap<>();

    public TenantAuthenticationManagerResolver() {
        this.tenants.put("neo4j", "http://localhost:8080/realms/realm1");
        this.tenants.put("testac", "http://localhost:8080/realms/realm2");
    }

    @Override
    public Mono<ReactiveAuthenticationManager> resolve(ServerWebExchange exchange) {
        return Mono.just(this.authenticationManagers.computeIfAbsent(toTenant(exchange), this::fromTenant));
    }

    private String toTenant(ServerWebExchange exchange) {
        try {
            String tenant = "system";
            ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();
            if (path.startsWith(ACCOUNT_URI_PREFIX)) {
                tenant = extractAccountFromPath(path);
            }
            return tenant;
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }

    private JwtReactiveAuthenticationManager fromTenant(String tenant) {
        return Optional.ofNullable(this.tenants.get(tenant))
                .map(ReactiveJwtDecoders::fromIssuerLocation)
                .map(JwtReactiveAuthenticationManager::new)
                .orElseThrow(() -> new IllegalArgumentException("Unknown tenant"));
    }

    private String extractAccountFromPath(String path) {
        String removeAccountTag = path.replace(ACCOUNTS, EMPTY_STRING);
        int indexOfSlash = removeAccountTag.indexOf("/");
        return removeAccountTag.substring(indexOfSlash + 1, removeAccountTag.indexOf("/", indexOfSlash + 1));
    }
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfig {
    @Autowired
    TenantAuthenticationManagerResolver authenticationManagerResolver;

    @Bean
    SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http
                .csrf().disable()
                .authorizeExchange()
                .pathMatchers("/health").hasAnyAuthority("ROLE_USER")
                .anyExchange().authenticated()
                .and()
                .oauth2Client()
                .and()
                .oauth2Login()
                .and()
                .oauth2ResourceServer()
                .authenticationManagerResolver(authenticationManagerResolver);

        return http.build();
    }
}
server.port=8090
logging.level.org.springframework.security=DEBUG

spring.security.oauth2.client.registration.keycloak.provider=keycloak
spring.security.oauth2.client.registration.keycloak.client-id=test-client
spring.security.oauth2.client.registration.keycloak.client-secret=ZV4kAKjeNW2KEnYejojOCsi0vqt9vMiS
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.scope=openid
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/keycloak
spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8080/realms/master

So it seems that multi-tenancy is not working as expected and it is only working for the tenant configured in application.properties.

spring.security.oauth2.client.registration.keycloak.keystore=C:\\Work\\test-client.jks
spring.security.oauth2.client.registration.keycloak.keystore-type=JKS
spring.security.oauth2.client.registration.keycloak.keystore-password=changeit
spring.security.oauth2.client.registration.keycloak.key-password=changeit
spring.security.oauth2.client.registration.keycloak.key-alias=proactive-outreach-admin
spring.security.oauth2.client.registration.keycloak.truststore=C:\\Work\\test-client.jks
spring.security.oauth2.client.registration.keycloak.truststore-password=changeit

Note: JWKS is not event working for master realm configured in application.properties.

Need help here as I am stuck for many days without any breakthrough. Let me know if any more information is required.

Upvotes: 1

Views: 1277

Answers (1)

ch4mp
ch4mp

Reputation: 12835

Client V.S. resource-server configuration

As a reminder, clients consume resources (send REST requests) and resource-servers serve resources (respond to this REST requests).

Tokens acquisition and refreshing is the responsibility of clients, not resource-servers.

Your REST API, being an OAuth2 resource-server, should not care about login, logout nor OAuth2 flows. From its point of view, all that matters is if a request is authorized with an access-token emitted by one of the issuers (tenants) it trusts. Remove .oauth2Client() and .oauth2Login() from your resource-server security filter-chain.

WebClient, @FeignClient, RestTemplate or whatever you use for inter micro-service requests are OAuth2 clients. It should be configured with either client-credentials (to emit requests in its own name) or forward the access-token from the original request (to emit a request on behalf of the authenticated user).

Your React app might be an OAuth2 "public" client. An other option is adopting the BFF pattern where the application in the browser in not OAuth2 at all (it is secured with good old session) and a middleware on the server (possibly spring-cloud-gateway) is the OAuth2 client, keeping the tokens and translating security from session to access-token before forwarding requests to resource-server. In both cases (public client and BFF) the OAuth2 client will use authorization-code flow to authenticate users and then refresh-token to maintain valid access-tokens. The user redirection to the authorization-server should be initiated by the OAuth2 client and the authorization-code should be returned to this same client.

  • If you choose to implement a public client in React, use a client lib for that (search with React associated with either OAuth2, OpenID or OIDC)
  • If you choose the BFF pattern, refer to the doc of the implementation you pick (spring-cloud-gateway can be configured with spring-boot-starter-oauth2-client).

An app needs to be both a client with .oauth2Login() and a resource-server only when it serves server-side rendered UI (with Thymeleaf, JSF and so on) and publicly exposes a REST API. In that case, define two separated security filter-chains as exposed in this other answer: Use Keycloak Spring Adapter with Spring Boot 3

Multi-tenancy in Webflux resource-server with JWT decoder

You already follow the recommended way to implement multi-tenancy on a resource-server: override the default authentication-manager resolver with one capable of providing the right authentication-manager depending on the request: http.oauth2ResourceServer().authenticationManagerResolver(authenticationManagerResolver)

I'm not sure using a part of the request path is the best option: a JWT comes with an issuer claim which precisely contains which authorization-server emitted this token. This is the way I implement in spring-addons-webflux-jwt-resource-server (a thin wrapper around spring-boot-starter-oauth2-resource-server).

Last your code is probably missing an authorities converter (to get authorities from Keycloak realm_access.roles instead of scope) and possibly it would be better to use preferred_username rather than subject as username.

Complete solution with spring-addons-webflux-jwt-resource-server

<dependency>
    <groupId>com.c4-soft.springaddons</groupId>
    <artifactId>spring-addons-webflux-jwt-resource-server</artifactId>
    <version>6.0.16</version>
</dependency>
@EnableReactiveMethodSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public AuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() {
        return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec
                .pathMatchers("/health").hasRole("USER")
                .anyExchange().authenticated();
    }
}
neo4j: http://localhost:8080/realms/realm1
neo4j-client: test-client
testac: http://localhost:8080/realms/realm2
testac-client: test-client

allowed-origins: 
- https://localhost
- https://localhost:8100
- https://localhost:4200

com:
  c4-soft:
    springaddons:
      security:
        cors:
        - path: /**
          allowed-origins: ${allowed-origins}
        issuers:
        - location: ${neo4j}
          username-claim: preferred_username
          authorities:
            claims:
            - realm_access.roles
            - resource_access.${neo4j-client}.role
        - location: ${testac}
          username-claim: preferred_username
          authorities:
            claims:
            - realm_access.roles
            - resource_access.${testac-client}.role
        permit-all: 
        - "/health/readiness"
        - "/health/liveness"

No, I did not forget authentication-manager resolver nor security filter-chain bean with resource-server config, everything is auto-configured from properties. Also, user name and roles should be mapped correctly.

At this point, the REST API should accept request with access-token emitted by any of the configured issuers. Use Postman or any other OAuth2 REST client to try (not your browser).

Configuring WebClient with client-credentials

Expose such a bean:

@Bean
WebClient webClient(
        ClientRegistrationRepository clientRegistrationRepository,
        OAuth2AuthorizedClientService authorizedClientService) {
    var oauth = new ServletOAuth2AuthorizedClientExchangeFilterFunction(
            new AuthorizedClientServiceOAuth2AuthorizedClientManager(
                    clientRegistrationRepository,
                    authorizedClientService));
    oauth.setDefaultClientRegistrationId("internal");
    return WebClient.builder().apply(oauth.oauth2Configuration()).build();
}

With such properties (mind the authorization-grant-type value):

internal: http://localhost:8080/realms/realm1

spring:
  security:
    oauth2:
      client:
        provider:
          internal:
            issuer-uri: ${internal}
        registration:
          internal:
            authorization-grant-type: client_credentials
            client-id: internal
            client-secret: change-me
            provider: internal
            scope: 
            - openid
            - offline_access

Adding client filterchain

If you add client configuration just for OAuth2 login because you try to issue a GET request with your browser, read the first section again and forget about that. Instead, require actual OAuth2 clients to authorize their requests and use a REST client for your tests (capable of fetching tokens and sending GET requests as well as POST, PUT, and DELETE ones). Again, it is client business to handle OAuth2 flows to acquire access-tokens.

But if your application also hosts server-side rendered UI (Thymeleaf, JSP, etc.), then, as exposed in the solution linked above, add a second security filter-chain for your client resources. With "my" starter, just add:

  • those two beans to the SecurityConfig:
@Order(Ordered.HIGHEST_PRECEDENCE)
@Bean
SecurityWebFilterChain uiFilterChain(ServerHttpSecurity http, ServerProperties serverProperties, GrantedAuthoritiesMapper authoritiesMapper)
        throws Exception {
    http.securityMatcher(
            new OrServerWebExchangeMatcher(
                    // UiController pages
                    new PathPatternParserServerWebExchangeMatcher("/ui/**"),
                    // those two are required to access Spring generated login page
                    // and OAuth2 client callback endpoints
                    new PathPatternParserServerWebExchangeMatcher("/login/**"),
                    new PathPatternParserServerWebExchangeMatcher("/oauth2/**")));

    http.oauth2Login(Customizer.withDefaults());

    http.authorizeExchange().pathMatchers("/login/**", "/oauth2/**").permitAll().anyExchange().authenticated();

    return http.build();
}

/**
 * @param  authoritiesConverter We are in spring-addons, we have a {@link ConfigurableClaimSet2AuthoritiesConverter} in the context!
 * @return                      a mapper from oauth2Login result to granted authorities
 */
@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper(Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter) {
    return (authorities) -> {
        Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

        authorities.forEach(authority -> {
            if (authority instanceof OidcUserAuthority oidcAuth) {
                mappedAuthorities.addAll(authoritiesConverter.convert(oidcAuth.getIdToken().getClaims()));

            } else if (authority instanceof OAuth2UserAuthority oauth2Auth) {
                mappedAuthorities.addAll(authoritiesConverter.convert(oauth2Auth.getAttributes()));

            }
        });

        return mappedAuthorities;
    };
}
  • and those properties to the yaml file above:
spring:
  security:
    oauth2:
      client:
        provider:
          neo4j:
            issuer-uri: ${neo4j}
          testac:
            issuer-uri: ${testac}
        registration:
          neo4j-client: 
            authorization-grant-type: authorization_code
            client-id: ${neo4j-client}
            client-secret: change-me
            provider: neo4j
            scope: 
            - openid
            - offline_access
          testac-client: 
            authorization-grant-type: authorization_code
            client-id: ${testac-client}
            client-secret: change-me
            provider: neo4j
            scope: 
            - openid
            - offline_access

Now, OAuth2 login generated page should prompt for which client to use. As clients are configured with different issuers, the client is now multi-tenant too.

Note that the conf above assumes that both clients will use authorization-code flow.

Upvotes: -1

Related Questions