OAuth2 resource servers

As a reminder, requests to an OAuth2 resource server are authorized with access tokens validated using JWT decoders - or introspection, but which should probably avoid that because of inherent latency and scalability issues.

Resource servers only care if tokens are valid and if they should grant access to resources based on the claims associated with them. Resource servers are not concerned with how tokens are obtained. Consequently, login and logout are not part of resource server configuration.

Resource Server Security(Web)FilterChain

If spring-boot-starter-oauth2-resource-server is on the classpath and unless com.c4-soft.springaddons.oidc.resourceserver.enabled=false, a Security(Web)FilterChain is created with the following default configuration:

  • @Order(Ordered.LOWEST_PRECEDENCE) and no security matcher (acts as default, processing all requests which weren’t intercepted by any other Security(Web)FilterChain with higher precedence)
  • stateless (no session and CSRF protection disabled)
  • respond with 401 to unauthorized requests
  • access token introspection if spring.security.oauth2.resourceserver.opaquetoken.introspection-uri is set and JWT decoder otherwise
  • CORS disabled (as a reminder, cors properties configure a global filter)
  • anonymous access allowed to pre-flight requests for the path-matchers listed in cors properties, as well as all requests with a path matching an entry in permit-all; all other requests requiring a valid authentication

Authorities Converter

Spring security implements Role Based Access Control (RBAC) with so called GrantedAuthority (accessed through Authentication#getAuthorities).

Neither OpenID nor OAuth2 include a specification for RBAC (as explained in the FAQ, scopes are not roles). About every OpenID Provider implements RBAC, but they have to use private claims for that. As each OP uses its own private claim(s), mapping OP roles to Spring authorities requires to adapt to each issuer.

By default, spring-addons-starter-oidc uses ConfigurableClaimSetAuthoritiesConverter which uses properties defined for each OP. ByIssuerOpenidProviderPropertiesResolver, the default resolver, uses the access token iss claim (issuer) to select which properties to provide to the authorities mapper.

For each OpenID Provider (OP), you can define as many claim groups as you like, and for each group:

  • path: a JSON path to the claim(s) to be mapped as authorities. You may use tools like https://jsonpath.com/ to test your JSON path against your access tokens payload (extracted with tools like https://jwt.io)
  • prefix: an optional prefix to add to the OP roles (default is empty). For instance, you might add a ROLE_ prefix to use expressions like hasRole('admin') instead of hasAuthority('admin') (provided that the role provided by the authorization server is not ROLE_admin already, of course).
  • caze: optionally force roles to upper-case or lower-case (default being to keep it as provided)

Sample configuration for two different OPs:

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: https://oidc.c4-soft.com/auth/realms/master
          authorities:
          - path: $.realm_access.roles
          - path: $.resource_access.*.roles
        - iss: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl
          authorities:
          - path: $.cognito:groups
            prefix: EXTERNAL_
            caze: upper

In the above:

  • for tokens with "iss": "https://oidc.c4-soft.com/auth/realms/master", authorities will be mapped from realm roles and all available client roles from the token, without any transformation.
  • for tokens with "iss": "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl", authorities will be mapped from cognito:groups claim, forcing it to upper-case and adding the EXTERNAL_ prefix ("cognito:groups": ["machin", "truc"] will be turned into ["EXTERNAL_MACHIN", "EXTERNAL_TRUC"])

To use another authorities mapper, expose a @Bean of type Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> (ClaimSetAuthoritiesConverter is just that, with a name), whatever the bean name: the default backs off. With several such beans, the @Primary one or else the one named authoritiesConverter is used, and the context fails to start otherwise (see Several authentication converters, the rule is the same).

To change how authorities mapping properties are resolved (for instance if you are using some “dynamic” multi-tenancy and can’t know the possible issuers when writing the conf), expose a @Bean of type OpenidProviderPropertiesResolver.

Authentication Converter

Spring Security Authentication is more than just a container for authorities: it also holds user unique identifier (name) and, in the case of a resource server, the access token claims.

By default, spring-addons-starter-oidc uses a (Reactive)JwtAbstractAuthenticationTokenConverter or (Reactive)OpaqueTokenAuthenticationConverter implementation delegating authorities conversion to a @Bean in the context (see previous section). The default output are as usual:

  • JwtAuthenticationToken when a JWT decoder is used
  • BearerTokenAuthentication with introspection.

By exposing a custom authentication converter bean, you can use your own Authentication implementation. Here is a sample for a servlet with JWT decoder(s), switching to OAuthentication<OpenidToken> from spring-addons-oauth2 (typed accessors to OpenID claims, getBearerHeader(), …):

@Bean
JwtAbstractAuthenticationTokenConverter authenticationConverter(
        Converter<Map<String, Object>, Collection<? extends GrantedAuthority>> authoritiesConverter,
        OpenidProviderPropertiesResolver opPropertiesResolver) {
    return jwt -> {
        final var usernameClaim = opPropertiesResolver.resolve(jwt.getClaims())
            .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims()))
            .getUsernameClaim();
        final var token = new OpenidToken(jwt.getClaims(), usernameClaim, jwt.getTokenValue());
        return new OAuthentication<>(token, authoritiesConverter.convert(jwt.getClaims()));
    };
}

See the resource-server sample for this bean in a runnable application.

The bean does not have to implement the spring-addons interface, nor to have a particular name: the default backs off as soon as a bean of type Converter<Jwt, ? extends AbstractAuthenticationToken> (Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> in a reactive application) is defined, a plain JwtAuthenticationConverter for instance. Same for the introspection converter with OpaqueTokenAuthenticationConverter (or its reactive counterpart).

Several authentication converters

When more than one bean of the type above is defined, the one injected in the auto-configured filter chain is, in this order:

  1. the @Primary one;
  2. the one named like the default bean: jwtAuthenticationConverter for JWT decoding, introspectionAuthenticationConverter for introspection;
  3. otherwise, the context fails to start with a NoUniqueBeanDefinitionException listing the candidates.

The test annotations apply the same rule, and add an authenticationConverterBeanName attribute to run another one in a given test.

Multi-Tenancy

Multi-tenancy is supported for resource servers with JWT decoders.

The core component for multi-tenancy is the OpenidProviderPropertiesResolver which is in charge of resolving the configuration properties used to build JWT decoders (and the validators embedded in it).

ByIssuerOpenidProviderPropertiesResolver, the default OpenidProviderPropertiesResolver, offers support for “static” multi-tenancy (when you know at configuration time all of the issuers you trust).

You may implement “dynamic” multi-tenancy by exposing an OpenidProviderPropertiesResolver bean of your own. Such a bean could, for instance, resolve the properties necessary to build new JWT decoders based on the issuer claim:

  • starting with something special
  • being part of specific (sub)domains
  • whatever else like matching a pattern, be validated by a service querying a database, …

As a sample, here is how you could accept tokens from any realm of a Keycloak instance, even if this realm is created after your resource server started:

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: https://oidc.c4-soft.com/auth/realms/
          authorities:
          - path: $.realm_access.roles
@Component
public class IssuerStartsWithOpenidProviderPropertiesResolver implements OpenidProviderPropertiesResolver {
    private final SpringAddonsOidcProperties properties;

    public IssuerStartsWithOpenidProviderPropertiesResolver(SpringAddonsOidcProperties properties) {
        this.properties = properties;
    }

    @Override
    public Optional<OpenidProviderProperties> resolve(Map<String, Object> claimSet) {
        final var tokenIss = Optional
            .ofNullable(claimSet.get(JwtClaimNames.ISS))
            .map(Object::toString)
            .orElseThrow(() -> new RuntimeException("Invalid token: missing issuer"));
        return properties.getOps().stream().filter(opProps -> {
            final var opBaseHref = Optional.ofNullable(opProps.getIss()).map(URI::toString).orElse(null);
            if (!StringUtils.hasText(opBaseHref)) {
                return false;
            }
            return tokenIss.startsWith(opBaseHref);
        }).findAny();
    }
}

That way, any token with an issuer claim starting with https://oidc.c4-soft.com/auth/realms/ would be accepted and mapped to an Authentication instance using the same converter and configuration properties as any other token issued for any realm by the same Keycloak instance.

Access token typ header (RFC 9068)

The auto-configured JWT decoders validate the typ JOSE header of access tokens against the typ property of the OpenID Provider entry, a list compared case-insensitively. The default is jwt,at+jwt: plain JWTs (typ: JWT, or no typ header, what Spring Security’s default validators accept) and RFC 9068 access tokens (typ: at+jwt).

To accept only tokens typed at+jwt, which also rejects tokens without a typ header:

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: https://oidc.c4-soft.com/auth/realms/master
          typ: at+jwt

A token with a missing or blank typ header is accepted only when the list contains JWT (or application/jwt). As RFC 7515 requires, a value and the same value with an application/ prefix are the same media type: at+jwt also accepts application/at+jwt. The list accepts any other value, and an empty list restores the default.

This property checks the header only: RFC 9068 also requires claims (client_id, jti, …) that the default validators do not check. To check them too, keep the default validators and add yours:

@Bean
SpringAddonsJwtDecoderFactory springAddonsJwtDecoderFactory() {
    return new DefaultSpringAddonsJwtDecoderFactory() {
        @Override
        public NimbusJwtDecoder create(Optional<URI> jwkSetUri, Optional<URI> issuer, Optional<String> audience, List<String> typ) {
            final var decoder = super.create(jwkSetUri, issuer, audience, typ);
            decoder.setJwtValidator(SpringAddonsJwtValidators.createDefault(
                issuer,
                audience,
                typ,
                new JwtClaimValidator<String>("client_id", StringUtils::hasText),
                new JwtClaimValidator<String>(JwtClaimNames.JTI, StringUtils::hasText)));
            return decoder;
        }
    };
}

In a reactive application, extend DefaultSpringAddonsReactiveJwtDecoderFactory, whose four-argument create returns a NimbusReactiveJwtDecoder. Spring Security’s JwtValidators.createAtJwtValidator() is not a drop-in replacement: it replaces all the default validators, so the X.509 certificate thumbprint check (RFC 8705) is lost and the typ property is ignored.

SpringAddons(Reactive)JwtDecoderFactory has a single method, create(jwkSetUri, issuer, audience, typ), so a factory bean of your own can be a lambda. The typ property has no effect on the tokens such a factory decodes unless it uses its typ parameter. A lambda written for the three-argument create of earlier versions only needs the new parameter:

@Bean
SpringAddonsJwtDecoderFactory springAddonsJwtDecoderFactory() {
    // was: (jwkSetUri, issuer, audience) -> ...
    return (jwkSetUri, issuer, audience, typ) -> myJwtDecoder(jwkSetUri, issuer, audience);
}

The Javadoc of the interfaces also shows a typ aware Nimbus(Reactive)JwtDecoder built without the default factories.

Audience and authorized parties (azp)

A resource server should accept only the access tokens meant for it (RFC 9068, section 4, RFC 9700, section 2.3). The aud property of an OpenID Provider entry makes the auto-configured decoders require a value in the aud claim. When the auto-configured JWT authentication manager resolver is used and an entry with an iss or a jwk-set-uri has no aud, a warning is logged at startup: the access tokens of that issuer are accepted whatever their audience.

With Keycloak, the audience alone is not enough. The default roles client scope holds the audience resolve mapper, which adds a client to aud whenever the token carries one of that client’s roles. A client added later to the realm with the defaults (full scope, roles scope) therefore gets tokens with the API in aud for every user who holds an API role. The authorized-parties property closes that gap: the client IDs the access tokens must have been issued to.

com:
  c4-soft:
    springaddons:
      oidc:
        ops:
        - iss: https://oidc.c4-soft.com/auth/realms/master
          aud: demo-api
          authorized-parties: gateway, bruno

When the list is set, a token is accepted only if its azp claim is one of the listed client IDs or, when it has no azp (missing, null or blank), if its client_id claim (the claim RFC 9068 defines) is one of them. A token with an azp which is not listed is refused, whatever its client_id. The values are compared exactly (case-sensitive), blank entries are ignored, and an empty list (the default) disables the check. A refused token gets a 401 with WWW-Authenticate: Bearer error="invalid_token", like any invalid token, and the error description does not echo the claim value.

Unlike aud and typ, which the decoder factory applies, the check is made by the auto-configured authentication manager resolvers, on the tokens decoded by any SpringAddons(Reactive)JwtDecoderFactory, a factory of your own included: the factory does not receive the list. A (Reactive)AuthenticationManagerResolver bean of your own replaces the auto-configured one: the check then applies only if yours delegates to SpringAddons(Reactive)JwtAuthenticationManagerResolver or to (Reactive)JWTClaimsSetAuthenticationManager, even when it builds its decoders with the factory. It applies to JWT decoders only, not to token introspection.

A decoder built outside these resolvers can get the check from its validators (authorizedPartyValidator returns an empty Optional when no client ID is configured):

final var validator = SpringAddonsJwtValidators.authorizedPartyValidator(opProperties.getAuthorizedParties())
    .map(azpValidator -> SpringAddonsJwtValidators.createDefault(issuer, audience, typ, azpValidator))
    .orElseGet(() -> SpringAddonsJwtValidators.createDefault(issuer, audience, typ));
decoder.setJwtValidator(validator);

A decoder whose validators cannot be set can pass each decoded token to SpringAddonsJwtValidators.requireValid(jwt, azpValidator), which throws the JwtValidationException Spring Security turns into a 401.

Access Control

The default access rule is set to isAuthenticated() with two exceptions:

  • routes matching the path-matchers listed in permit-all property for which anonymous requests are allowed
  • pre-flight requests, unless disabled in cors properties (OPTIONS requests to routes matching the path-matchers listed in CORS properties).

The most convenient way to define fine-grained access control is probably to @Enable(Reactive)MethodSecurity and to decorate @RestController methods with @PreAuthorize.

For those preferring access control in configuration (or when you don’t write the endpoint yourself), you can expose a @Bean of type ResourceServerExpressionInterceptUrlRegistryPostProcessor or ResourceServerAuthorizeExchangeSpecPostProcessor.

CORS Configuration

Starting from version 7.8.7, the CORS configuration is made using a global filter, the behavior of which is controlled with application properties:

com:
  c4-soft:
    springaddons:
      oidc:
        cors:
        - path: /machin/**
          allowed-origin-patterns: "*"
        - path: /truc/**
          allowed-origin-patterns:
          - "http://localhost:4200"
          - "http://*.chose.com"

spring-addons CORS filter backs off if:

  • CORS configuration properties are absent (no CORS properties -> no CORS filter)
  • a Cors(Web)Filter bean is registered already in application configuration

By default, anonymous OPTIONS requests are allowed for all path-matchers in cors properties groups.

Post-Process the Resource Server Filer-Chain

By exposing a ResourceServer(Server)HttpSecurityPostProcessor bean, you get complete control of the (Server)HttpSecurity configured in the resourceServerSecurityFilterChain just before it is built. This allows for changes to anything that was pre-configured.