From 6d2d3b9a6929c44a22b17cfe36a72bb48879375b Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Fri, 29 Oct 2021 13:06:38 -0600 Subject: [PATCH] Separate Resource Server Reactive Docs Issue gh-10367 --- docs/modules/ROOT/nav.adoc | 6 +- .../ROOT/pages/reactive/oauth2/index.adoc | 2 +- .../reactive/oauth2/resource-server.adoc | 1907 ----------------- .../oauth2/resource-server/bearer-tokens.adoc | 122 ++ .../oauth2/resource-server/index.adoc | 15 + .../reactive/oauth2/resource-server/jwt.adoc | 882 ++++++++ .../oauth2/resource-server/multitenancy.adoc | 117 + .../oauth2/resource-server/opaque-token.adoc | 775 +++++++ 8 files changed, 1917 insertions(+), 1909 deletions(-) delete mode 100644 docs/modules/ROOT/pages/reactive/oauth2/resource-server.adoc create mode 100644 docs/modules/ROOT/pages/reactive/oauth2/resource-server/bearer-tokens.adoc create mode 100644 docs/modules/ROOT/pages/reactive/oauth2/resource-server/index.adoc create mode 100644 docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc create mode 100644 docs/modules/ROOT/pages/reactive/oauth2/resource-server/multitenancy.adoc create mode 100644 docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 084ff60b50..c0ade22026 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -109,7 +109,11 @@ ** xref:reactive/oauth2/index.adoc[OAuth2] *** xref:reactive/oauth2/login.adoc[OAuth2 Log In] *** xref:reactive/oauth2/oauth2-client.adoc[OAuth2 Client] -*** xref:reactive/oauth2/resource-server.adoc[OAuth2 Resource Server] +*** xref:reactive/oauth2/resource-server/index.adoc[OAuth2 Resource Server] +**** xref:reactive/oauth2/resource-server/jwt.adoc[JWT] +**** xref:reactive/oauth2/resource-server/opaque-token.adoc[Opaque Token] +**** xref:reactive/oauth2/resource-server/multitenancy.adoc[Multitenancy] +**** xref:reactive/oauth2/resource-server/bearer-tokens.adoc[Bearer Tokens] ** xref:reactive/exploits/index.adoc[Protection Against Exploits] *** xref:reactive/exploits/csrf.adoc[CSRF] *** xref:reactive/exploits/headers.adoc[Headers] diff --git a/docs/modules/ROOT/pages/reactive/oauth2/index.adoc b/docs/modules/ROOT/pages/reactive/oauth2/index.adoc index 6b47bd0412..af06df5136 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/index.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/index.adoc @@ -5,4 +5,4 @@ Spring Security provides OAuth2 and WebFlux integration for reactive application * xref:reactive/oauth2/login.adoc[OAuth2 Log In] - Authenticating with an OAuth2 or OpenID Connect 1.0 Provider * xref:reactive/oauth2/oauth2-client.adoc[OAuth2 Client] - Making requests to an OAuth2 Resource Server -* xref:reactive/oauth2/resource-server.adoc[OAuth2 Resource Server] - Protecting a REST endpoint using OAuth2 +* xref:reactive/oauth2/resource-server/index.adoc[OAuth2 Resource Server] - Protecting a REST endpoint using OAuth2 diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server.adoc deleted file mode 100644 index 907e3b502d..0000000000 --- a/docs/modules/ROOT/pages/reactive/oauth2/resource-server.adoc +++ /dev/null @@ -1,1907 +0,0 @@ -[[webflux-oauth2-resource-server]] -= OAuth 2.0 Resource Server - -Spring Security supports protecting endpoints using two forms of OAuth 2.0 https://tools.ietf.org/html/rfc6750.html[Bearer Tokens]: - -* https://tools.ietf.org/html/rfc7519[JWT] -* Opaque Tokens - -This is handy in circumstances where an application has delegated its authority management to an https://tools.ietf.org/html/rfc6749[authorization server] (for example, Okta or Ping Identity). -This authorization server can be consulted by resource servers to authorize requests. - -[NOTE] -==== -A complete working example for {gh-samples-url}/reactive/webflux/java/oauth2/resource-server[*JWTs*] is available in the {gh-samples-url}[Spring Security repository]. -==== - -[[webflux-oauth2resourceserver-jwt-minimaldependencies]] -== Minimal Dependencies for JWT - -Most Resource Server support is collected into `spring-security-oauth2-resource-server`. -However, the support for decoding and verifying JWTs is in `spring-security-oauth2-jose`, meaning that both are necessary in order to have a working resource server that supports JWT-encoded Bearer Tokens. - -[[webflux-oauth2resourceserver-jwt-minimalconfiguration]] -== Minimal Configuration for JWTs - -When using https://spring.io/projects/spring-boot[Spring Boot], configuring an application as a resource server consists of two basic steps. -First, include the needed dependencies and second, indicate the location of the authorization server. - -=== Specifying the Authorization Server - -In a Spring Boot application, to specify which authorization server to use, simply do: - -[source,yml] ----- -spring: - security: - oauth2: - resourceserver: - jwt: - issuer-uri: https://idp.example.com/issuer ----- - -Where `https://idp.example.com/issuer` is the value contained in the `iss` claim for JWT tokens that the authorization server will issue. -Resource Server will use this property to further self-configure, discover the authorization server's public keys, and subsequently validate incoming JWTs. - -[NOTE] -To use the `issuer-uri` property, it must also be true that one of `https://idp.example.com/issuer/.well-known/openid-configuration`, `https://idp.example.com/.well-known/openid-configuration/issuer`, or `https://idp.example.com/.well-known/oauth-authorization-server/issuer` is a supported endpoint for the authorization server. -This endpoint is referred to as a https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Provider Configuration] endpoint or a https://tools.ietf.org/html/rfc8414#section-3[Authorization Server Metadata] endpoint. - -And that's it! - -=== Startup Expectations - -When this property and these dependencies are used, Resource Server will automatically configure itself to validate JWT-encoded Bearer Tokens. - -It achieves this through a deterministic startup process: - -1. Hit the Provider Configuration or Authorization Server Metadata endpoint, processing the response for the `jwks_url` property -2. Configure the validation strategy to query `jwks_url` for valid public keys -3. Configure the validation strategy to validate each JWTs `iss` claim against `https://idp.example.com`. - -A consequence of this process is that the authorization server must be up and receiving requests in order for Resource Server to successfully start up. - -[NOTE] -If the authorization server is down when Resource Server queries it (given appropriate timeouts), then startup will fail. - -=== Runtime Expectations - -Once the application is started up, Resource Server will attempt to process any request containing an `Authorization: Bearer` header: - -[source,html] ----- -GET / HTTP/1.1 -Authorization: Bearer some-token-value # Resource Server will process this ----- - -So long as this scheme is indicated, Resource Server will attempt to process the request according to the Bearer Token specification. - -Given a well-formed JWT, Resource Server will: - -1. Validate its signature against a public key obtained from the `jwks_url` endpoint during startup and matched against the JWTs header -2. Validate the JWTs `exp` and `nbf` timestamps and the JWTs `iss` claim, and -3. Map each scope to an authority with the prefix `SCOPE_`. - -[NOTE] -As the authorization server makes available new keys, Spring Security will automatically rotate the keys used to validate the JWT tokens. - -The resulting `Authentication#getPrincipal`, by default, is a Spring Security `Jwt` object, and `Authentication#getName` maps to the JWT's `sub` property, if one is present. - -From here, consider jumping to: - -<> - -<> - -[[webflux-oauth2resourceserver-jwt-jwkseturi]] -=== Specifying the Authorization Server JWK Set Uri Directly - -If the authorization server doesn't support any configuration endpoints, or if Resource Server must be able to start up independently from the authorization server, then the `jwk-set-uri` can be supplied as well: - -[source,yaml] ----- -spring: - security: - oauth2: - resourceserver: - jwt: - issuer-uri: https://idp.example.com - jwk-set-uri: https://idp.example.com/.well-known/jwks.json ----- - -[NOTE] -The JWK Set uri is not standardized, but can typically be found in the authorization server's documentation - -Consequently, Resource Server will not ping the authorization server at startup. -We still specify the `issuer-uri` so that Resource Server still validates the `iss` claim on incoming JWTs. - -[NOTE] -This property can also be supplied directly on the <>. - -[[webflux-oauth2resourceserver-jwt-sansboot]] -=== Overriding or Replacing Boot Auto Configuration - -There are two ``@Bean``s that Spring Boot generates on Resource Server's behalf. - -The first is a `SecurityWebFilterChain` that configures the app as a resource server. When including `spring-security-oauth2-jose`, this `SecurityWebFilterChain` looks like: - -.Resource Server SecurityWebFilterChain -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(OAuth2ResourceServerSpec::jwt) - return http.build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - jwt { } - } - } -} ----- -==== - -If the application doesn't expose a `SecurityWebFilterChain` bean, then Spring Boot will expose the above default one. - -Replacing this is as simple as exposing the bean within the application: - -.Replacing SecurityWebFilterChain -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .pathMatchers("/message/**").hasAuthority("SCOPE_message:read") - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(withDefaults()) - ); - return http.build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize("/message/**", hasAuthority("SCOPE_message:read")) - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - jwt { } - } - } -} ----- -==== - -The above requires the scope of `message:read` for any URL that starts with `/messages/`. - -Methods on the `oauth2ResourceServer` DSL will also override or replace auto configuration. - -For example, the second `@Bean` Spring Boot creates is a `ReactiveJwtDecoder`, which decodes `String` tokens into validated instances of `Jwt`: - -.ReactiveJwtDecoder -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveJwtDecoder jwtDecoder() { - return ReactiveJwtDecoders.fromIssuerLocation(issuerUri); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return ReactiveJwtDecoders.fromIssuerLocation(issuerUri) -} ----- -==== - -[NOTE] -Calling `{security-api-url}org/springframework/security/oauth2/jwt/ReactiveJwtDecoders.html#fromIssuerLocation-java.lang.String-[ReactiveJwtDecoders#fromIssuerLocation]` is what invokes the Provider Configuration or Authorization Server Metadata endpoint in order to derive the JWK Set Uri. -If the application doesn't expose a `ReactiveJwtDecoder` bean, then Spring Boot will expose the above default one. - -And its configuration can be overridden using `jwkSetUri()` or replaced using `decoder()`. - -[[webflux-oauth2resourceserver-jwt-jwkseturi-dsl]] -==== Using `jwkSetUri()` - -An authorization server's JWK Set Uri can be configured <> or it can be supplied in the DSL: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> jwt - .jwkSetUri("https://idp.example.com/.well-known/jwks.json") - ) - ); - return http.build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - jwt { - jwkSetUri = "https://idp.example.com/.well-known/jwks.json" - } - } - } -} ----- -==== - -Using `jwkSetUri()` takes precedence over any configuration property. - -[[webflux-oauth2resourceserver-jwt-decoder-dsl]] -==== Using `decoder()` - -More powerful than `jwkSetUri()` is `decoder()`, which will completely replace any Boot auto configuration of `JwtDecoder`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> jwt - .decoder(myCustomDecoder()) - ) - ); - return http.build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - jwt { - jwtDecoder = myCustomDecoder() - } - } - } -} ----- -==== - -This is handy when deeper configuration, like <>, is necessary. - -[[webflux-oauth2resourceserver-decoder-bean]] -==== Exposing a `ReactiveJwtDecoder` `@Bean` - -Or, exposing a `ReactiveJwtDecoder` `@Bean` has the same effect as `decoder()`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveJwtDecoder jwtDecoder() { - return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return ReactiveJwtDecoders.fromIssuerLocation(issuerUri) -} ----- -==== - -[[webflux-oauth2resourceserver-jwt-decoder-algorithm]] -== Configuring Trusted Algorithms - -By default, `NimbusReactiveJwtDecoder`, and hence Resource Server, will only trust and verify tokens using `RS256`. - -You can customize this via <> or <>. - -[[webflux-oauth2resourceserver-jwt-boot-algorithm]] -=== Via Spring Boot - -The simplest way to set the algorithm is as a property: - -[source,yaml] ----- -spring: - security: - oauth2: - resourceserver: - jwt: - jws-algorithm: RS512 - jwk-set-uri: https://idp.example.org/.well-known/jwks.json ----- - -[[webflux-oauth2resourceserver-jwt-decoder-builder]] -=== Using a Builder - -For greater power, though, we can use a builder that ships with `NimbusReactiveJwtDecoder`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -ReactiveJwtDecoder jwtDecoder() { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithm(RS512).build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithm(RS512).build() -} ----- -==== - -Calling `jwsAlgorithm` more than once will configure `NimbusReactiveJwtDecoder` to trust more than one algorithm, like so: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -ReactiveJwtDecoder jwtDecoder() { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build() -} ----- -==== - -Or, you can call `jwsAlgorithms`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -ReactiveJwtDecoder jwtDecoder() { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithms(algorithms -> { - algorithms.add(RS512); - algorithms.add(ES512); - }).build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithms { - it.add(RS512) - it.add(ES512) - } - .build() -} ----- -==== - -[[webflux-oauth2resourceserver-jwt-decoder-public-key]] -=== Trusting a Single Asymmetric Key - -Simpler than backing a Resource Server with a JWK Set endpoint is to hard-code an RSA public key. -The public key can be provided via <> or by <>. - -[[webflux-oauth2resourceserver-jwt-decoder-public-key-boot]] -==== Via Spring Boot - -Specifying a key via Spring Boot is quite simple. -The key's location can be specified like so: - -[source,yaml] ----- -spring: - security: - oauth2: - resourceserver: - jwt: - public-key-location: classpath:my-key.pub ----- - -Or, to allow for a more sophisticated lookup, you can post-process the `RsaKeyConversionServicePostProcessor`: - -.BeanFactoryPostProcessor -==== -.Java -[source,java,role="primary"] ----- -@Bean -BeanFactoryPostProcessor conversionServiceCustomizer() { - return beanFactory -> - beanFactory.getBean(RsaKeyConversionServicePostProcessor.class) - .setResourceLoader(new CustomResourceLoader()); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun conversionServiceCustomizer(): BeanFactoryPostProcessor { - return BeanFactoryPostProcessor { beanFactory: ConfigurableListableBeanFactory -> - beanFactory.getBean() - .setResourceLoader(CustomResourceLoader()) - } -} ----- -==== - -Specify your key's location: - -[source,yaml] ----- -key.location: hfds://my-key.pub ----- - -And then autowire the value: - -==== -.Java -[source,java,role="primary"] ----- -@Value("${key.location}") -RSAPublicKey key; ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Value("\${key.location}") -val key: RSAPublicKey? = null ----- -==== - -[[webflux-oauth2resourceserver-jwt-decoder-public-key-builder]] -==== Using a Builder - -To wire an `RSAPublicKey` directly, you can simply use the appropriate `NimbusReactiveJwtDecoder` builder, like so: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveJwtDecoder jwtDecoder() { - return NimbusReactiveJwtDecoder.withPublicKey(this.key).build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return NimbusReactiveJwtDecoder.withPublicKey(key).build() -} ----- -==== - -[[webflux-oauth2resourceserver-jwt-decoder-secret-key]] -=== Trusting a Single Symmetric Key - -Using a single symmetric key is also simple. -You can simply load in your `SecretKey` and use the appropriate `NimbusReactiveJwtDecoder` builder, like so: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveJwtDecoder jwtDecoder() { - return NimbusReactiveJwtDecoder.withSecretKey(this.key).build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - return NimbusReactiveJwtDecoder.withSecretKey(this.key).build() -} ----- -==== - -[[webflux-oauth2resourceserver-jwt-authorization]] -=== Configuring Authorization - -A JWT that is issued from an OAuth 2.0 Authorization Server will typically either have a `scope` or `scp` attribute, indicating the scopes (or authorities) it's been granted, for example: - -`{ ..., "scope" : "messages contacts"}` - -When this is the case, Resource Server will attempt to coerce these scopes into a list of granted authorities, prefixing each scope with the string "SCOPE_". - -This means that to protect an endpoint or method with a scope derived from a JWT, the corresponding expressions should include this prefix: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .mvcMatchers("/contacts/**").hasAuthority("SCOPE_contacts") - .mvcMatchers("/messages/**").hasAuthority("SCOPE_messages") - .anyExchange().authenticated() - ) - .oauth2ResourceServer(OAuth2ResourceServerSpec::jwt); - return http.build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize("/contacts/**", hasAuthority("SCOPE_contacts")) - authorize("/messages/**", hasAuthority("SCOPE_messages")) - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - jwt { } - } - } -} ----- -==== - -Or similarly with method security: - -==== -.Java -[source,java,role="primary"] ----- -@PreAuthorize("hasAuthority('SCOPE_messages')") -public Flux getMessages(...) {} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@PreAuthorize("hasAuthority('SCOPE_messages')") -fun getMessages(): Flux { } ----- -==== - -[[webflux-oauth2resourceserver-jwt-authorization-extraction]] -==== Extracting Authorities Manually - -However, there are a number of circumstances where this default is insufficient. -For example, some authorization servers don't use the `scope` attribute, but instead have their own custom attribute. -Or, at other times, the resource server may need to adapt the attribute or a composition of attributes into internalized authorities. - -To this end, the DSL exposes `jwtAuthenticationConverter()`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> jwt - .jwtAuthenticationConverter(grantedAuthoritiesExtractor()) - ) - ); - return http.build(); -} - -Converter> grantedAuthoritiesExtractor() { - JwtAuthenticationConverter jwtAuthenticationConverter = - new JwtAuthenticationConverter(); - jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter - (new GrantedAuthoritiesExtractor()); - return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - jwt { - jwtAuthenticationConverter = grantedAuthoritiesExtractor() - } - } - } -} - -fun grantedAuthoritiesExtractor(): Converter> { - val jwtAuthenticationConverter = JwtAuthenticationConverter() - jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(GrantedAuthoritiesExtractor()) - return ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter) -} ----- -==== - -which is responsible for converting a `Jwt` into an `Authentication`. -As part of its configuration, we can supply a subsidiary converter to go from `Jwt` to a `Collection` of granted authorities. - -That final converter might be something like `GrantedAuthoritiesExtractor` below: - -==== -.Java -[source,java,role="primary"] ----- -static class GrantedAuthoritiesExtractor - implements Converter> { - - public Collection convert(Jwt jwt) { - Collection authorities = (Collection) - jwt.getClaims().getOrDefault("mycustomclaim", Collections.emptyList()); - - return authorities.stream() - .map(Object::toString) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -internal class GrantedAuthoritiesExtractor : Converter> { - override fun convert(jwt: Jwt): Collection { - val authorities: List = jwt.claims - .getOrDefault("mycustomclaim", emptyList()) as List - return authorities - .map { it.toString() } - .map { SimpleGrantedAuthority(it) } - } -} ----- -==== - -For more flexibility, the DSL supports entirely replacing the converter with any class that implements `Converter>`: - -==== -.Java -[source,java,role="primary"] ----- -static class CustomAuthenticationConverter implements Converter> { - public AbstractAuthenticationToken convert(Jwt jwt) { - return Mono.just(jwt).map(this::doConversion); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -internal class CustomAuthenticationConverter : Converter> { - override fun convert(jwt: Jwt): Mono { - return Mono.just(jwt).map(this::doConversion) - } -} ----- -==== - -[[webflux-oauth2resourceserver-jwt-validation]] -=== Configuring Validation - -Using <>, indicating the authorization server's issuer uri, Resource Server will default to verifying the `iss` claim as well as the `exp` and `nbf` timestamp claims. - -In circumstances where validation needs to be customized, Resource Server ships with two standard validators and also accepts custom `OAuth2TokenValidator` instances. - -[[webflux-oauth2resourceserver-jwt-validation-clockskew]] -==== Customizing Timestamp Validation - -JWT's typically have a window of validity, with the start of the window indicated in the `nbf` claim and the end indicated in the `exp` claim. - -However, every server can experience clock drift, which can cause tokens to appear expired to one server, but not to another. -This can cause some implementation heartburn as the number of collaborating servers increases in a distributed system. - -Resource Server uses `JwtTimestampValidator` to verify a token's validity window, and it can be configured with a `clockSkew` to alleviate the above problem: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -ReactiveJwtDecoder jwtDecoder() { - NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) - ReactiveJwtDecoders.fromIssuerLocation(issuerUri); - - OAuth2TokenValidator withClockSkew = new DelegatingOAuth2TokenValidator<>( - new JwtTimestampValidator(Duration.ofSeconds(60)), - new IssuerValidator(issuerUri)); - - jwtDecoder.setJwtValidator(withClockSkew); - - return jwtDecoder; -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - val jwtDecoder = ReactiveJwtDecoders.fromIssuerLocation(issuerUri) as NimbusReactiveJwtDecoder - val withClockSkew: OAuth2TokenValidator = DelegatingOAuth2TokenValidator( - JwtTimestampValidator(Duration.ofSeconds(60)), - JwtIssuerValidator(issuerUri)) - jwtDecoder.setJwtValidator(withClockSkew) - return jwtDecoder -} ----- -==== - -[NOTE] -By default, Resource Server configures a clock skew of 60 seconds. - -[[webflux-oauth2resourceserver-validation-custom]] -==== Configuring a Custom Validator - -Adding a check for the `aud` claim is simple with the `OAuth2TokenValidator` API: - -==== -.Java -[source,java,role="primary"] ----- -public class AudienceValidator implements OAuth2TokenValidator { - OAuth2Error error = new OAuth2Error("invalid_token", "The required audience is missing", null); - - public OAuth2TokenValidatorResult validate(Jwt jwt) { - if (jwt.getAudience().contains("messaging")) { - return OAuth2TokenValidatorResult.success(); - } else { - return OAuth2TokenValidatorResult.failure(error); - } - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -class AudienceValidator : OAuth2TokenValidator { - var error: OAuth2Error = OAuth2Error("invalid_token", "The required audience is missing", null) - override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { - return if (jwt.audience.contains("messaging")) { - OAuth2TokenValidatorResult.success() - } else { - OAuth2TokenValidatorResult.failure(error) - } - } -} ----- -==== - -Then, to add into a resource server, it's a matter of specifying the `ReactiveJwtDecoder` instance: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -ReactiveJwtDecoder jwtDecoder() { - NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) - ReactiveJwtDecoders.fromIssuerLocation(issuerUri); - - OAuth2TokenValidator audienceValidator = new AudienceValidator(); - OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri); - OAuth2TokenValidator withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); - - jwtDecoder.setJwtValidator(withAudience); - - return jwtDecoder; -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun jwtDecoder(): ReactiveJwtDecoder { - val jwtDecoder = ReactiveJwtDecoders.fromIssuerLocation(issuerUri) as NimbusReactiveJwtDecoder - val audienceValidator: OAuth2TokenValidator = AudienceValidator() - val withIssuer: OAuth2TokenValidator = JwtValidators.createDefaultWithIssuer(issuerUri) - val withAudience: OAuth2TokenValidator = DelegatingOAuth2TokenValidator(withIssuer, audienceValidator) - jwtDecoder.setJwtValidator(withAudience) - return jwtDecoder -} ----- -==== - -[[webflux-oauth2resourceserver-opaque-minimaldependencies]] -=== Minimal Dependencies for Introspection -As described in xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-minimaldependencies[Minimal Dependencies for JWT] most of Resource Server support is collected in `spring-security-oauth2-resource-server`. -However unless a custom <> is provided, the Resource Server will fallback to ReactiveOpaqueTokenIntrospector. -Meaning that both `spring-security-oauth2-resource-server` and `oauth2-oidc-sdk` are necessary in order to have a working minimal Resource Server that supports opaque Bearer Tokens. -Please refer to `spring-security-oauth2-resource-server` in order to determin the correct version for `oauth2-oidc-sdk`. - -[[webflux-oauth2resourceserver-opaque-minimalconfiguration]] -=== Minimal Configuration for Introspection - -Typically, an opaque token can be verified via an https://tools.ietf.org/html/rfc7662[OAuth 2.0 Introspection Endpoint], hosted by the authorization server. -This can be handy when revocation is a requirement. - -When using https://spring.io/projects/spring-boot[Spring Boot], configuring an application as a resource server that uses introspection consists of two basic steps. -First, include the needed dependencies and second, indicate the introspection endpoint details. - -[[webflux-oauth2resourceserver-opaque-introspectionuri]] -==== Specifying the Authorization Server - -To specify where the introspection endpoint is, simply do: - -[source,yaml] ----- -security: - oauth2: - resourceserver: - opaque-token: - introspection-uri: https://idp.example.com/introspect - client-id: client - client-secret: secret ----- - -Where `https://idp.example.com/introspect` is the introspection endpoint hosted by your authorization server and `client-id` and `client-secret` are the credentials needed to hit that endpoint. - -Resource Server will use these properties to further self-configure and subsequently validate incoming JWTs. - -[NOTE] -When using introspection, the authorization server's word is the law. -If the authorization server responses that the token is valid, then it is. - -And that's it! - -==== Startup Expectations - -When this property and these dependencies are used, Resource Server will automatically configure itself to validate Opaque Bearer Tokens. - -This startup process is quite a bit simpler than for JWTs since no endpoints need to be discovered and no additional validation rules get added. - -==== Runtime Expectations - -Once the application is started up, Resource Server will attempt to process any request containing an `Authorization: Bearer` header: - -[source,http] ----- -GET / HTTP/1.1 -Authorization: Bearer some-token-value # Resource Server will process this ----- - -So long as this scheme is indicated, Resource Server will attempt to process the request according to the Bearer Token specification. - -Given an Opaque Token, Resource Server will - -1. Query the provided introspection endpoint using the provided credentials and the token -2. Inspect the response for an `{ 'active' : true }` attribute -3. Map each scope to an authority with the prefix `SCOPE_` - -The resulting `Authentication#getPrincipal`, by default, is a Spring Security `{security-api-url}org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.html[OAuth2AuthenticatedPrincipal]` object, and `Authentication#getName` maps to the token's `sub` property, if one is present. - -From here, you may want to jump to: - -* <> -* <> -* <> - -[[webflux-oauth2resourceserver-opaque-attributes]] -=== Looking Up Attributes Post-Authentication - -Once a token is authenticated, an instance of `BearerTokenAuthentication` is set in the `SecurityContext`. - -This means that it's available in `@Controller` methods when using `@EnableWebFlux` in your configuration: - -==== -.Java -[source,java,role="primary"] ----- -@GetMapping("/foo") -public Mono foo(BearerTokenAuthentication authentication) { - return Mono.just(authentication.getTokenAttributes().get("sub") + " is the subject"); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@GetMapping("/foo") -fun foo(authentication: BearerTokenAuthentication): Mono { - return Mono.just(authentication.tokenAttributes["sub"].toString() + " is the subject") -} ----- -==== - -Since `BearerTokenAuthentication` holds an `OAuth2AuthenticatedPrincipal`, that also means that it's available to controller methods, too: - -==== -.Java -[source,java,role="primary"] ----- -@GetMapping("/foo") -public Mono foo(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { - return Mono.just(principal.getAttribute("sub") + " is the subject"); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@GetMapping("/foo") -fun foo(@AuthenticationPrincipal principal: OAuth2AuthenticatedPrincipal): Mono { - return Mono.just(principal.getAttribute("sub").toString() + " is the subject") -} ----- -==== - -==== Looking Up Attributes Via SpEL - -Of course, this also means that attributes can be accessed via SpEL. - -For example, if using `@EnableReactiveMethodSecurity` so that you can use `@PreAuthorize` annotations, you can do: - -==== -.Java -[source,java,role="primary"] ----- -@PreAuthorize("principal?.attributes['sub'] == 'foo'") -public Mono forFoosEyesOnly() { - return Mono.just("foo"); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@PreAuthorize("principal.attributes['sub'] == 'foo'") -fun forFoosEyesOnly(): Mono { - return Mono.just("foo") -} ----- -==== - -[[webflux-oauth2resourceserver-opaque-sansboot]] -=== Overriding or Replacing Boot Auto Configuration - -There are two ``@Bean``s that Spring Boot generates on Resource Server's behalf. - -The first is a `SecurityWebFilterChain` that configures the app as a resource server. -When use Opaque Token, this `SecurityWebFilterChain` looks like: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::opaqueToken) - return http.build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - opaqueToken { } - } - } -} ----- -==== - -If the application doesn't expose a `SecurityWebFilterChain` bean, then Spring Boot will expose the above default one. - -Replacing this is as simple as exposing the bean within the application: - -.Replacing SecurityWebFilterChain -==== -.Java -[source,java,role="primary"] ----- -@EnableWebFluxSecurity -public class MyCustomSecurityConfiguration { - @Bean - SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .pathMatchers("/messages/**").hasAuthority("SCOPE_message:read") - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .opaqueToken(opaqueToken -> opaqueToken - .introspector(myIntrospector()) - ) - ); - return http.build(); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize("/messages/**", hasAuthority("SCOPE_message:read")) - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - opaqueToken { - introspector = myIntrospector() - } - } - } -} ----- -==== - -The above requires the scope of `message:read` for any URL that starts with `/messages/`. - -Methods on the `oauth2ResourceServer` DSL will also override or replace auto configuration. - -For example, the second `@Bean` Spring Boot creates is a `ReactiveOpaqueTokenIntrospector`, which decodes `String` tokens into validated instances of `OAuth2AuthenticatedPrincipal`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveOpaqueTokenIntrospector introspector() { - return new NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun introspector(): ReactiveOpaqueTokenIntrospector { - return NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) -} ----- -==== - -If the application doesn't expose a `ReactiveOpaqueTokenIntrospector` bean, then Spring Boot will expose the above default one. - -And its configuration can be overridden using `introspectionUri()` and `introspectionClientCredentials()` or replaced using `introspector()`. - -[[webflux-oauth2resourceserver-opaque-introspectionuri-dsl]] -==== Using `introspectionUri()` - -An authorization server's Introspection Uri can be configured <> or it can be supplied in the DSL: - -==== -.Java -[source,java,role="primary"] ----- -@EnableWebFluxSecurity -public class DirectlyConfiguredIntrospectionUri { - @Bean - SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .opaqueToken(opaqueToken -> opaqueToken - .introspectionUri("https://idp.example.com/introspect") - .introspectionClientCredentials("client", "secret") - ) - ); - return http.build(); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - opaqueToken { - introspectionUri = "https://idp.example.com/introspect" - introspectionClientCredentials("client", "secret") - } - } - } -} ----- -==== - -Using `introspectionUri()` takes precedence over any configuration property. - -[[webflux-oauth2resourceserver-opaque-introspector-dsl]] -==== Using `introspector()` - -More powerful than `introspectionUri()` is `introspector()`, which will completely replace any Boot auto configuration of `ReactiveOpaqueTokenIntrospector`: - -==== -.Java -[source,java,role="primary"] ----- -@EnableWebFluxSecurity -public class DirectlyConfiguredIntrospector { - @Bean - SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .opaqueToken(opaqueToken -> opaqueToken - .introspector(myCustomIntrospector()) - ) - ); - return http.build(); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - opaqueToken { - introspector = myCustomIntrospector() - } - } - } -} ----- -==== - -This is handy when deeper configuration, like <>or <> is necessary. - -[[webflux-oauth2resourceserver-opaque-introspector-bean]] -==== Exposing a `ReactiveOpaqueTokenIntrospector` `@Bean` - -Or, exposing a `ReactiveOpaqueTokenIntrospector` `@Bean` has the same effect as `introspector()`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveOpaqueTokenIntrospector introspector() { - return new NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun introspector(): ReactiveOpaqueTokenIntrospector { - return NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) -} ----- -==== - -[[webflux-oauth2resourceserver-opaque-authorization]] -=== Configuring Authorization - -An OAuth 2.0 Introspection endpoint will typically return a `scope` attribute, indicating the scopes (or authorities) it's been granted, for example: - -`{ ..., "scope" : "messages contacts"}` - -When this is the case, Resource Server will attempt to coerce these scopes into a list of granted authorities, prefixing each scope with the string "SCOPE_". - -This means that to protect an endpoint or method with a scope derived from an Opaque Token, the corresponding expressions should include this prefix: - -==== -.Java -[source,java,role="primary"] ----- -@EnableWebFluxSecurity -public class MappedAuthorities { - @Bean - SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { - http - .authorizeExchange(exchange -> exchange - .pathMatchers("/contacts/**").hasAuthority("SCOPE_contacts") - .pathMatchers("/messages/**").hasAuthority("SCOPE_messages") - .anyExchange().authenticated() - ) - .oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::opaqueToken); - return http.build(); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { - return http { - authorizeExchange { - authorize("/contacts/**", hasAuthority("SCOPE_contacts")) - authorize("/messages/**", hasAuthority("SCOPE_messages")) - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - opaqueToken { } - } - } -} ----- -==== - -Or similarly with method security: - -==== -.Java -[source,java,role="primary"] ----- -@PreAuthorize("hasAuthority('SCOPE_messages')") -public Flux getMessages(...) {} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@PreAuthorize("hasAuthority('SCOPE_messages')") -fun getMessages(): Flux { } ----- -==== - -[[webflux-oauth2resourceserver-opaque-authorization-extraction]] -==== Extracting Authorities Manually - -By default, Opaque Token support will extract the scope claim from an introspection response and parse it into individual `GrantedAuthority` instances. - -For example, if the introspection response were: - -[source,json] ----- -{ - "active" : true, - "scope" : "message:read message:write" -} ----- - -Then Resource Server would generate an `Authentication` with two authorities, one for `message:read` and the other for `message:write`. - -This can, of course, be customized using a custom `ReactiveOpaqueTokenIntrospector` that takes a look at the attribute set and converts in its own way: - -==== -.Java -[source,java,role="primary"] ----- -public class CustomAuthoritiesOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); - - public Mono introspect(String token) { - return this.delegate.introspect(token) - .map(principal -> new DefaultOAuth2AuthenticatedPrincipal( - principal.getName(), principal.getAttributes(), extractAuthorities(principal))); - } - - private Collection extractAuthorities(OAuth2AuthenticatedPrincipal principal) { - List scopes = principal.getAttribute(OAuth2IntrospectionClaimNames.SCOPE); - return scopes.stream() - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -class CustomAuthoritiesOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") - override fun introspect(token: String): Mono { - return delegate.introspect(token) - .map { principal: OAuth2AuthenticatedPrincipal -> - DefaultOAuth2AuthenticatedPrincipal( - principal.name, principal.attributes, extractAuthorities(principal)) - } - } - - private fun extractAuthorities(principal: OAuth2AuthenticatedPrincipal): Collection { - val scopes = principal.getAttribute>(OAuth2IntrospectionClaimNames.SCOPE) - return scopes - .map { SimpleGrantedAuthority(it) } - } -} ----- -==== - -Thereafter, this custom introspector can be configured simply by exposing it as a `@Bean`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveOpaqueTokenIntrospector introspector() { - return new CustomAuthoritiesOpaqueTokenIntrospector(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun introspector(): ReactiveOpaqueTokenIntrospector { - return CustomAuthoritiesOpaqueTokenIntrospector() -} ----- -==== - -[[webflux-oauth2resourceserver-opaque-jwt-introspector]] -=== Using Introspection with JWTs - -A common question is whether or not introspection is compatible with JWTs. -Spring Security's Opaque Token support has been designed to not care about the format of the token -- it will gladly pass any token to the introspection endpoint provided. - -So, let's say that you've got a requirement that requires you to check with the authorization server on each request, in case the JWT has been revoked. - -Even though you are using the JWT format for the token, your validation method is introspection, meaning you'd want to do: - -[source,yaml] ----- -spring: - security: - oauth2: - resourceserver: - opaque-token: - introspection-uri: https://idp.example.org/introspection - client-id: client - client-secret: secret ----- - -In this case, the resulting `Authentication` would be `BearerTokenAuthentication`. -Any attributes in the corresponding `OAuth2AuthenticatedPrincipal` would be whatever was returned by the introspection endpoint. - -But, let's say that, oddly enough, the introspection endpoint only returns whether or not the token is active. -Now what? - -In this case, you can create a custom `ReactiveOpaqueTokenIntrospector` that still hits the endpoint, but then updates the returned principal to have the JWTs claims as the attributes: - -==== -.Java -[source,java,role="primary"] ----- -public class JwtOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); - private ReactiveJwtDecoder jwtDecoder = new NimbusReactiveJwtDecoder(new ParseOnlyJWTProcessor()); - - public Mono introspect(String token) { - return this.delegate.introspect(token) - .flatMap(principal -> this.jwtDecoder.decode(token)) - .map(jwt -> new DefaultOAuth2AuthenticatedPrincipal(jwt.getClaims(), NO_AUTHORITIES)); - } - - private static class ParseOnlyJWTProcessor implements Converter> { - public Mono convert(JWT jwt) { - try { - return Mono.just(jwt.getJWTClaimsSet()); - } catch (Exception ex) { - return Mono.error(ex); - } - } - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -class JwtOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") - private val jwtDecoder: ReactiveJwtDecoder = NimbusReactiveJwtDecoder(ParseOnlyJWTProcessor()) - override fun introspect(token: String): Mono { - return delegate.introspect(token) - .flatMap { jwtDecoder.decode(token) } - .map { jwt: Jwt -> DefaultOAuth2AuthenticatedPrincipal(jwt.claims, NO_AUTHORITIES) } - } - - private class ParseOnlyJWTProcessor : Converter> { - override fun convert(jwt: JWT): Mono { - return try { - Mono.just(jwt.jwtClaimsSet) - } catch (e: Exception) { - Mono.error(e) - } - } - } -} ----- -==== - -Thereafter, this custom introspector can be configured simply by exposing it as a `@Bean`: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public ReactiveOpaqueTokenIntrospector introspector() { - return new JwtOpaqueTokenIntropsector(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun introspector(): ReactiveOpaqueTokenIntrospector { - return JwtOpaqueTokenIntrospector() -} ----- -==== - -[[webflux-oauth2resourceserver-opaque-userinfo]] -=== Calling a `/userinfo` Endpoint - -Generally speaking, a Resource Server doesn't care about the underlying user, but instead about the authorities that have been granted. - -That said, at times it can be valuable to tie the authorization statement back to a user. - -If an application is also using `spring-security-oauth2-client`, having set up the appropriate `ClientRegistrationRepository`, then this is quite simple with a custom `OpaqueTokenIntrospector`. -This implementation below does three things: - -* Delegates to the introspection endpoint, to affirm the token's validity -* Looks up the appropriate client registration associated with the `/userinfo` endpoint -* Invokes and returns the response from the `/userinfo` endpoint - -==== -.Java -[source,java,role="primary"] ----- -public class UserInfoOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private final ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); - private final ReactiveOAuth2UserService oauth2UserService = - new DefaultReactiveOAuth2UserService(); - - private final ReactiveClientRegistrationRepository repository; - - // ... constructor - - @Override - public Mono introspect(String token) { - return Mono.zip(this.delegate.introspect(token), this.repository.findByRegistrationId("registration-id")) - .map(t -> { - OAuth2AuthenticatedPrincipal authorized = t.getT1(); - ClientRegistration clientRegistration = t.getT2(); - Instant issuedAt = authorized.getAttribute(ISSUED_AT); - Instant expiresAt = authorized.getAttribute(OAuth2IntrospectionClaimNames.EXPIRES_AT); - OAuth2AccessToken accessToken = new OAuth2AccessToken(BEARER, token, issuedAt, expiresAt); - return new OAuth2UserRequest(clientRegistration, accessToken); - }) - .flatMap(this.oauth2UserService::loadUser); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -class UserInfoOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") - private val oauth2UserService: ReactiveOAuth2UserService = DefaultReactiveOAuth2UserService() - private val repository: ReactiveClientRegistrationRepository? = null - - // ... constructor - override fun introspect(token: String?): Mono { - return Mono.zip(delegate.introspect(token), repository!!.findByRegistrationId("registration-id")) - .map { t: Tuple2 -> - val authorized = t.t1 - val clientRegistration = t.t2 - val issuedAt: Instant? = authorized.getAttribute(ISSUED_AT) - val expiresAt: Instant? = authorized.getAttribute(OAuth2IntrospectionClaimNames.EXPIRES_AT) - val accessToken = OAuth2AccessToken(BEARER, token, issuedAt, expiresAt) - OAuth2UserRequest(clientRegistration, accessToken) - } - .flatMap { userRequest: OAuth2UserRequest -> oauth2UserService.loadUser(userRequest) } - } -} ----- -==== - -If you aren't using `spring-security-oauth2-client`, it's still quite simple. -You will simply need to invoke the `/userinfo` with your own instance of `WebClient`: - -==== -.Java -[source,java,role="primary"] ----- -public class UserInfoOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { - private final ReactiveOpaqueTokenIntrospector delegate = - new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); - private final WebClient rest = WebClient.create(); - - @Override - public Mono introspect(String token) { - return this.delegate.introspect(token) - .map(this::makeUserInfoRequest); - } -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -class UserInfoOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { - private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") - private val rest: WebClient = WebClient.create() - - override fun introspect(token: String): Mono { - return delegate.introspect(token) - .map(this::makeUserInfoRequest) - } -} ----- -==== - -Either way, having created your `ReactiveOpaqueTokenIntrospector`, you should publish it as a `@Bean` to override the defaults: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -ReactiveOpaqueTokenIntrospector introspector() { - return new UserInfoOpaqueTokenIntrospector(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun introspector(): ReactiveOpaqueTokenIntrospector { - return UserInfoOpaqueTokenIntrospector() -} ----- -==== - -[[webflux-oauth2resourceserver-multitenancy]] -== Multi-tenancy - -A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier. - -For example, your resource server may accept bearer tokens from two different authorization servers. -Or, your authorization server may represent a multiplicity of issuers. - -In each case, there are two things that need to be done and trade-offs associated with how you choose to do them: - -1. Resolve the tenant -2. Propagate the tenant - -=== Resolving the Tenant By Claim - -One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the `JwtIssuerReactiveAuthenticationManagerResolver`, like so: - -==== -.Java -[source,java,role="primary"] ----- -JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerReactiveAuthenticationManagerResolver - ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo"); - -http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .authenticationManagerResolver(authenticationManagerResolver) - ); ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -val customAuthenticationManagerResolver = JwtIssuerReactiveAuthenticationManagerResolver("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo") - -return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - authenticationManagerResolver = customAuthenticationManagerResolver - } -} ----- -==== - -This is nice because the issuer endpoints are loaded lazily. -In fact, the corresponding `JwtReactiveAuthenticationManager` is instantiated only when the first request with the corresponding issuer is sent. -This allows for an application startup that is independent from those authorization servers being up and available. - -==== Dynamic Tenants - -Of course, you may not want to restart the application each time a new tenant is added. -In this case, you can configure the `JwtIssuerReactiveAuthenticationManagerResolver` with a repository of `ReactiveAuthenticationManager` instances, which you can edit at runtime, like so: - -==== -.Java -[source,java,role="primary"] ----- -private Mono addManager( - Map authenticationManagers, String issuer) { - - return Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(issuer)) - .subscribeOn(Schedulers.boundedElastic()) - .map(JwtReactiveAuthenticationManager::new) - .doOnNext(authenticationManager -> authenticationManagers.put(issuer, authenticationManager)); -} - -// ... - -JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = - new JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get); - -http - .authorizeExchange(exchanges -> exchanges - .anyExchange().authenticated() - ) - .oauth2ResourceServer(oauth2 -> oauth2 - .authenticationManagerResolver(authenticationManagerResolver) - ); ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -private fun addManager( - authenticationManagers: MutableMap, issuer: String): Mono { - return Mono.fromCallable { ReactiveJwtDecoders.fromIssuerLocation(issuer) } - .subscribeOn(Schedulers.boundedElastic()) - .map { jwtDecoder: ReactiveJwtDecoder -> JwtReactiveAuthenticationManager(jwtDecoder) } - .doOnNext { authenticationManager: JwtReactiveAuthenticationManager -> authenticationManagers[issuer] = authenticationManager } -} - -// ... - -var customAuthenticationManagerResolver = JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get) -return http { - authorizeExchange { - authorize(anyExchange, authenticated) - } - oauth2ResourceServer { - authenticationManagerResolver = customAuthenticationManagerResolver - } -} ----- -==== - -In this case, you construct `JwtIssuerReactiveAuthenticationManagerResolver` with a strategy for obtaining the `ReactiveAuthenticationManager` given the issuer. -This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime. - -NOTE: It would be unsafe to simply take any issuer and construct an `ReactiveAuthenticationManager` from it. -The issuer should be one that the code can verify from a trusted source like an allowed list of issuers. - -[[webflux-oauth2resourceserver-bearertoken-resolver]] -== Bearer Token Resolution - -By default, Resource Server looks for a bearer token in the `Authorization` header. -This, however, can be customized. - -For example, you may have a need to read the bearer token from a custom header. -To achieve this, you can wire an instance of `ServerBearerTokenAuthenticationConverter` into the DSL, as you can see in the following example: - -.Custom Bearer Token Header -==== -.Java -[source,java,role="primary"] ----- -ServerBearerTokenAuthenticationConverter converter = new ServerBearerTokenAuthenticationConverter(); -converter.setBearerTokenHeaderName(HttpHeaders.PROXY_AUTHORIZATION); -http - .oauth2ResourceServer(oauth2 -> oauth2 - .bearerTokenConverter(converter) - ); ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -val converter = ServerBearerTokenAuthenticationConverter() -converter.setBearerTokenHeaderName(HttpHeaders.PROXY_AUTHORIZATION) -return http { - oauth2ResourceServer { - bearerTokenConverter = converter - } -} ----- -==== - -== Bearer Token Propagation - -Now that you're in possession of a bearer token, it might be handy to pass that to downstream services. -This is quite simple with `{security-api-url}org/springframework/security/oauth2/server/resource/web/reactive/function/client/ServerBearerExchangeFilterFunction.html[ServerBearerExchangeFilterFunction]`, which you can see in the following example: - -==== -.Java -[source,java,role="primary"] ----- -@Bean -public WebClient rest() { - return WebClient.builder() - .filter(new ServerBearerExchangeFilterFunction()) - .build(); -} ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -@Bean -fun rest(): WebClient { - return WebClient.builder() - .filter(ServerBearerExchangeFilterFunction()) - .build() -} ----- -==== - -When the above `WebClient` is used to perform requests, Spring Security will look up the current `Authentication` and extract any `{security-api-url}org/springframework/security/oauth2/core/AbstractOAuth2Token.html[AbstractOAuth2Token]` credential. -Then, it will propagate that token in the `Authorization` header. - -For example: - -==== -.Java -[source,java,role="primary"] ----- -this.rest.get() - .uri("https://other-service.example.com/endpoint") - .retrieve() - .bodyToMono(String.class) ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -this.rest.get() - .uri("https://other-service.example.com/endpoint") - .retrieve() - .bodyToMono() ----- -==== - -Will invoke the `https://other-service.example.com/endpoint`, adding the bearer token `Authorization` header for you. - -In places where you need to override this behavior, it's a simple matter of supplying the header yourself, like so: - -==== -.Java -[source,java,role="primary"] ----- -this.rest.get() - .uri("https://other-service.example.com/endpoint") - .headers(headers -> headers.setBearerAuth(overridingToken)) - .retrieve() - .bodyToMono(String.class) ----- - -.Kotlin -[source,kotlin,role="secondary"] ----- -rest.get() - .uri("https://other-service.example.com/endpoint") - .headers { it.setBearerAuth(overridingToken) } - .retrieve() - .bodyToMono() ----- -==== - -In this case, the filter will fall back and simply forward the request onto the rest of the web filter chain. - -[NOTE] -Unlike the https://docs.spring.io/spring-security/site/docs/current-SNAPSHOT/api/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.html[OAuth 2.0 Client filter function], this filter function makes no attempt to renew the token, should it be expired. -To obtain this level of support, please use the OAuth 2.0 Client filter. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/bearer-tokens.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/bearer-tokens.adoc new file mode 100644 index 0000000000..30a4c8fd07 --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/bearer-tokens.adoc @@ -0,0 +1,122 @@ += OAuth 2.0 Resource Server Bearer Tokens + +[[webflux-oauth2resourceserver-bearertoken-resolver]] +== Bearer Token Resolution + +By default, Resource Server looks for a bearer token in the `Authorization` header. +This, however, can be customized. + +For example, you may have a need to read the bearer token from a custom header. +To achieve this, you can wire an instance of `ServerBearerTokenAuthenticationConverter` into the DSL, as you can see in the following example: + +.Custom Bearer Token Header +==== +.Java +[source,java,role="primary"] +---- +ServerBearerTokenAuthenticationConverter converter = new ServerBearerTokenAuthenticationConverter(); +converter.setBearerTokenHeaderName(HttpHeaders.PROXY_AUTHORIZATION); +http + .oauth2ResourceServer(oauth2 -> oauth2 + .bearerTokenConverter(converter) + ); +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +val converter = ServerBearerTokenAuthenticationConverter() +converter.setBearerTokenHeaderName(HttpHeaders.PROXY_AUTHORIZATION) +return http { + oauth2ResourceServer { + bearerTokenConverter = converter + } +} +---- +==== + +== Bearer Token Propagation + +Now that you're in possession of a bearer token, it might be handy to pass that to downstream services. +This is quite simple with `{security-api-url}org/springframework/security/oauth2/server/resource/web/reactive/function/client/ServerBearerExchangeFilterFunction.html[ServerBearerExchangeFilterFunction]`, which you can see in the following example: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public WebClient rest() { + return WebClient.builder() + .filter(new ServerBearerExchangeFilterFunction()) + .build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun rest(): WebClient { + return WebClient.builder() + .filter(ServerBearerExchangeFilterFunction()) + .build() +} +---- +==== + +When the above `WebClient` is used to perform requests, Spring Security will look up the current `Authentication` and extract any `{security-api-url}org/springframework/security/oauth2/core/AbstractOAuth2Token.html[AbstractOAuth2Token]` credential. +Then, it will propagate that token in the `Authorization` header. + +For example: + +==== +.Java +[source,java,role="primary"] +---- +this.rest.get() + .uri("https://other-service.example.com/endpoint") + .retrieve() + .bodyToMono(String.class) +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +this.rest.get() + .uri("https://other-service.example.com/endpoint") + .retrieve() + .bodyToMono() +---- +==== + +Will invoke the `https://other-service.example.com/endpoint`, adding the bearer token `Authorization` header for you. + +In places where you need to override this behavior, it's a simple matter of supplying the header yourself, like so: + +==== +.Java +[source,java,role="primary"] +---- +this.rest.get() + .uri("https://other-service.example.com/endpoint") + .headers(headers -> headers.setBearerAuth(overridingToken)) + .retrieve() + .bodyToMono(String.class) +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +rest.get() + .uri("https://other-service.example.com/endpoint") + .headers { it.setBearerAuth(overridingToken) } + .retrieve() + .bodyToMono() +---- +==== + +In this case, the filter will fall back and simply forward the request onto the rest of the web filter chain. + +[NOTE] +Unlike the https://docs.spring.io/spring-security/site/docs/current-SNAPSHOT/api/org/springframework/security/oauth2/client/web/reactive/function/client/ServerOAuth2AuthorizedClientExchangeFilterFunction.html[OAuth 2.0 Client filter function], this filter function makes no attempt to renew the token, should it be expired. +To obtain this level of support, please use the OAuth 2.0 Client filter. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/index.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/index.adoc new file mode 100644 index 0000000000..7d22aa4a6f --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/index.adoc @@ -0,0 +1,15 @@ +[[webflux-oauth2-resource-server]] += OAuth 2.0 Resource Server + +Spring Security supports protecting endpoints using two forms of OAuth 2.0 https://tools.ietf.org/html/rfc6750.html[Bearer Tokens]: + +* https://tools.ietf.org/html/rfc7519[JWT] +* Opaque Tokens + +This is handy in circumstances where an application has delegated its authority management to an https://tools.ietf.org/html/rfc6749[authorization server] (for example, Okta or Ping Identity). +This authorization server can be consulted by resource servers to authorize requests. + +[NOTE] +==== +A complete working example for {gh-samples-url}/reactive/webflux/java/oauth2/resource-server[*JWTs*] is available in the {gh-samples-url}[Spring Security repository]. +==== diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc new file mode 100644 index 0000000000..ffac9a718d --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc @@ -0,0 +1,882 @@ += OAuth 2.0 Resource Server JWT + +[[webflux-oauth2resourceserver-jwt-minimaldependencies]] +== Minimal Dependencies for JWT + +Most Resource Server support is collected into `spring-security-oauth2-resource-server`. +However, the support for decoding and verifying JWTs is in `spring-security-oauth2-jose`, meaning that both are necessary in order to have a working resource server that supports JWT-encoded Bearer Tokens. + +[[webflux-oauth2resourceserver-jwt-minimalconfiguration]] +== Minimal Configuration for JWTs + +When using https://spring.io/projects/spring-boot[Spring Boot], configuring an application as a resource server consists of two basic steps. +First, include the needed dependencies and second, indicate the location of the authorization server. + +=== Specifying the Authorization Server + +In a Spring Boot application, to specify which authorization server to use, simply do: + +[source,yml] +---- +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://idp.example.com/issuer +---- + +Where `https://idp.example.com/issuer` is the value contained in the `iss` claim for JWT tokens that the authorization server will issue. +Resource Server will use this property to further self-configure, discover the authorization server's public keys, and subsequently validate incoming JWTs. + +[NOTE] +To use the `issuer-uri` property, it must also be true that one of `https://idp.example.com/issuer/.well-known/openid-configuration`, `https://idp.example.com/.well-known/openid-configuration/issuer`, or `https://idp.example.com/.well-known/oauth-authorization-server/issuer` is a supported endpoint for the authorization server. +This endpoint is referred to as a https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig[Provider Configuration] endpoint or a https://tools.ietf.org/html/rfc8414#section-3[Authorization Server Metadata] endpoint. + +And that's it! + +=== Startup Expectations + +When this property and these dependencies are used, Resource Server will automatically configure itself to validate JWT-encoded Bearer Tokens. + +It achieves this through a deterministic startup process: + +1. Hit the Provider Configuration or Authorization Server Metadata endpoint, processing the response for the `jwks_url` property +2. Configure the validation strategy to query `jwks_url` for valid public keys +3. Configure the validation strategy to validate each JWTs `iss` claim against `https://idp.example.com`. + +A consequence of this process is that the authorization server must be up and receiving requests in order for Resource Server to successfully start up. + +[NOTE] +If the authorization server is down when Resource Server queries it (given appropriate timeouts), then startup will fail. + +=== Runtime Expectations + +Once the application is started up, Resource Server will attempt to process any request containing an `Authorization: Bearer` header: + +[source,html] +---- +GET / HTTP/1.1 +Authorization: Bearer some-token-value # Resource Server will process this +---- + +So long as this scheme is indicated, Resource Server will attempt to process the request according to the Bearer Token specification. + +Given a well-formed JWT, Resource Server will: + +1. Validate its signature against a public key obtained from the `jwks_url` endpoint during startup and matched against the JWTs header +2. Validate the JWTs `exp` and `nbf` timestamps and the JWTs `iss` claim, and +3. Map each scope to an authority with the prefix `SCOPE_`. + +[NOTE] +As the authorization server makes available new keys, Spring Security will automatically rotate the keys used to validate the JWT tokens. + +The resulting `Authentication#getPrincipal`, by default, is a Spring Security `Jwt` object, and `Authentication#getName` maps to the JWT's `sub` property, if one is present. + +From here, consider jumping to: + +<> + +<> + +[[webflux-oauth2resourceserver-jwt-jwkseturi]] +=== Specifying the Authorization Server JWK Set Uri Directly + +If the authorization server doesn't support any configuration endpoints, or if Resource Server must be able to start up independently from the authorization server, then the `jwk-set-uri` can be supplied as well: + +[source,yaml] +---- +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://idp.example.com + jwk-set-uri: https://idp.example.com/.well-known/jwks.json +---- + +[NOTE] +The JWK Set uri is not standardized, but can typically be found in the authorization server's documentation + +Consequently, Resource Server will not ping the authorization server at startup. +We still specify the `issuer-uri` so that Resource Server still validates the `iss` claim on incoming JWTs. + +[NOTE] +This property can also be supplied directly on the <>. + +[[webflux-oauth2resourceserver-jwt-sansboot]] +=== Overriding or Replacing Boot Auto Configuration + +There are two ``@Bean``s that Spring Boot generates on Resource Server's behalf. + +The first is a `SecurityWebFilterChain` that configures the app as a resource server. When including `spring-security-oauth2-jose`, this `SecurityWebFilterChain` looks like: + +.Resource Server SecurityWebFilterChain +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(OAuth2ResourceServerSpec::jwt) + return http.build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + jwt { } + } + } +} +---- +==== + +If the application doesn't expose a `SecurityWebFilterChain` bean, then Spring Boot will expose the above default one. + +Replacing this is as simple as exposing the bean within the application: + +.Replacing SecurityWebFilterChain +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .pathMatchers("/message/**").hasAuthority("SCOPE_message:read") + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(withDefaults()) + ); + return http.build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize("/message/**", hasAuthority("SCOPE_message:read")) + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + jwt { } + } + } +} +---- +==== + +The above requires the scope of `message:read` for any URL that starts with `/messages/`. + +Methods on the `oauth2ResourceServer` DSL will also override or replace auto configuration. + +For example, the second `@Bean` Spring Boot creates is a `ReactiveJwtDecoder`, which decodes `String` tokens into validated instances of `Jwt`: + +.ReactiveJwtDecoder +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveJwtDecoder jwtDecoder() { + return ReactiveJwtDecoders.fromIssuerLocation(issuerUri); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return ReactiveJwtDecoders.fromIssuerLocation(issuerUri) +} +---- +==== + +[NOTE] +Calling `{security-api-url}org/springframework/security/oauth2/jwt/ReactiveJwtDecoders.html#fromIssuerLocation-java.lang.String-[ReactiveJwtDecoders#fromIssuerLocation]` is what invokes the Provider Configuration or Authorization Server Metadata endpoint in order to derive the JWK Set Uri. +If the application doesn't expose a `ReactiveJwtDecoder` bean, then Spring Boot will expose the above default one. + +And its configuration can be overridden using `jwkSetUri()` or replaced using `decoder()`. + +[[webflux-oauth2resourceserver-jwt-jwkseturi-dsl]] +==== Using `jwkSetUri()` + +An authorization server's JWK Set Uri can be configured <> or it can be supplied in the DSL: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .jwkSetUri("https://idp.example.com/.well-known/jwks.json") + ) + ); + return http.build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + jwt { + jwkSetUri = "https://idp.example.com/.well-known/jwks.json" + } + } + } +} +---- +==== + +Using `jwkSetUri()` takes precedence over any configuration property. + +[[webflux-oauth2resourceserver-jwt-decoder-dsl]] +==== Using `decoder()` + +More powerful than `jwkSetUri()` is `decoder()`, which will completely replace any Boot auto configuration of `JwtDecoder`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .decoder(myCustomDecoder()) + ) + ); + return http.build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + jwt { + jwtDecoder = myCustomDecoder() + } + } + } +} +---- +==== + +This is handy when deeper configuration, like <>, is necessary. + +[[webflux-oauth2resourceserver-decoder-bean]] +==== Exposing a `ReactiveJwtDecoder` `@Bean` + +Or, exposing a `ReactiveJwtDecoder` `@Bean` has the same effect as `decoder()`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveJwtDecoder jwtDecoder() { + return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return ReactiveJwtDecoders.fromIssuerLocation(issuerUri) +} +---- +==== + +[[webflux-oauth2resourceserver-jwt-decoder-algorithm]] +== Configuring Trusted Algorithms + +By default, `NimbusReactiveJwtDecoder`, and hence Resource Server, will only trust and verify tokens using `RS256`. + +You can customize this via <> or <>. + +[[webflux-oauth2resourceserver-jwt-boot-algorithm]] +=== Via Spring Boot + +The simplest way to set the algorithm is as a property: + +[source,yaml] +---- +spring: + security: + oauth2: + resourceserver: + jwt: + jws-algorithm: RS512 + jwk-set-uri: https://idp.example.org/.well-known/jwks.json +---- + +[[webflux-oauth2resourceserver-jwt-decoder-builder]] +=== Using a Builder + +For greater power, though, we can use a builder that ships with `NimbusReactiveJwtDecoder`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ReactiveJwtDecoder jwtDecoder() { + return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + .jwsAlgorithm(RS512).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + .jwsAlgorithm(RS512).build() +} +---- +==== + +Calling `jwsAlgorithm` more than once will configure `NimbusReactiveJwtDecoder` to trust more than one algorithm, like so: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ReactiveJwtDecoder jwtDecoder() { + return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build() +} +---- +==== + +Or, you can call `jwsAlgorithms`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ReactiveJwtDecoder jwtDecoder() { + return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + .jwsAlgorithms(algorithms -> { + algorithms.add(RS512); + algorithms.add(ES512); + }).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + .jwsAlgorithms { + it.add(RS512) + it.add(ES512) + } + .build() +} +---- +==== + +[[webflux-oauth2resourceserver-jwt-decoder-public-key]] +=== Trusting a Single Asymmetric Key + +Simpler than backing a Resource Server with a JWK Set endpoint is to hard-code an RSA public key. +The public key can be provided via <> or by <>. + +[[webflux-oauth2resourceserver-jwt-decoder-public-key-boot]] +==== Via Spring Boot + +Specifying a key via Spring Boot is quite simple. +The key's location can be specified like so: + +[source,yaml] +---- +spring: + security: + oauth2: + resourceserver: + jwt: + public-key-location: classpath:my-key.pub +---- + +Or, to allow for a more sophisticated lookup, you can post-process the `RsaKeyConversionServicePostProcessor`: + +.BeanFactoryPostProcessor +==== +.Java +[source,java,role="primary"] +---- +@Bean +BeanFactoryPostProcessor conversionServiceCustomizer() { + return beanFactory -> + beanFactory.getBean(RsaKeyConversionServicePostProcessor.class) + .setResourceLoader(new CustomResourceLoader()); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun conversionServiceCustomizer(): BeanFactoryPostProcessor { + return BeanFactoryPostProcessor { beanFactory: ConfigurableListableBeanFactory -> + beanFactory.getBean() + .setResourceLoader(CustomResourceLoader()) + } +} +---- +==== + +Specify your key's location: + +[source,yaml] +---- +key.location: hfds://my-key.pub +---- + +And then autowire the value: + +==== +.Java +[source,java,role="primary"] +---- +@Value("${key.location}") +RSAPublicKey key; +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Value("\${key.location}") +val key: RSAPublicKey? = null +---- +==== + +[[webflux-oauth2resourceserver-jwt-decoder-public-key-builder]] +==== Using a Builder + +To wire an `RSAPublicKey` directly, you can simply use the appropriate `NimbusReactiveJwtDecoder` builder, like so: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveJwtDecoder jwtDecoder() { + return NimbusReactiveJwtDecoder.withPublicKey(this.key).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return NimbusReactiveJwtDecoder.withPublicKey(key).build() +} +---- +==== + +[[webflux-oauth2resourceserver-jwt-decoder-secret-key]] +=== Trusting a Single Symmetric Key + +Using a single symmetric key is also simple. +You can simply load in your `SecretKey` and use the appropriate `NimbusReactiveJwtDecoder` builder, like so: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveJwtDecoder jwtDecoder() { + return NimbusReactiveJwtDecoder.withSecretKey(this.key).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + return NimbusReactiveJwtDecoder.withSecretKey(this.key).build() +} +---- +==== + +[[webflux-oauth2resourceserver-jwt-authorization]] +=== Configuring Authorization + +A JWT that is issued from an OAuth 2.0 Authorization Server will typically either have a `scope` or `scp` attribute, indicating the scopes (or authorities) it's been granted, for example: + +`{ ..., "scope" : "messages contacts"}` + +When this is the case, Resource Server will attempt to coerce these scopes into a list of granted authorities, prefixing each scope with the string "SCOPE_". + +This means that to protect an endpoint or method with a scope derived from a JWT, the corresponding expressions should include this prefix: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .mvcMatchers("/contacts/**").hasAuthority("SCOPE_contacts") + .mvcMatchers("/messages/**").hasAuthority("SCOPE_messages") + .anyExchange().authenticated() + ) + .oauth2ResourceServer(OAuth2ResourceServerSpec::jwt); + return http.build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize("/contacts/**", hasAuthority("SCOPE_contacts")) + authorize("/messages/**", hasAuthority("SCOPE_messages")) + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + jwt { } + } + } +} +---- +==== + +Or similarly with method security: + +==== +.Java +[source,java,role="primary"] +---- +@PreAuthorize("hasAuthority('SCOPE_messages')") +public Flux getMessages(...) {} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@PreAuthorize("hasAuthority('SCOPE_messages')") +fun getMessages(): Flux { } +---- +==== + +[[webflux-oauth2resourceserver-jwt-authorization-extraction]] +==== Extracting Authorities Manually + +However, there are a number of circumstances where this default is insufficient. +For example, some authorization servers don't use the `scope` attribute, but instead have their own custom attribute. +Or, at other times, the resource server may need to adapt the attribute or a composition of attributes into internalized authorities. + +To this end, the DSL exposes `jwtAuthenticationConverter()`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt + .jwtAuthenticationConverter(grantedAuthoritiesExtractor()) + ) + ); + return http.build(); +} + +Converter> grantedAuthoritiesExtractor() { + JwtAuthenticationConverter jwtAuthenticationConverter = + new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter + (new GrantedAuthoritiesExtractor()); + return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + jwt { + jwtAuthenticationConverter = grantedAuthoritiesExtractor() + } + } + } +} + +fun grantedAuthoritiesExtractor(): Converter> { + val jwtAuthenticationConverter = JwtAuthenticationConverter() + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(GrantedAuthoritiesExtractor()) + return ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter) +} +---- +==== + +which is responsible for converting a `Jwt` into an `Authentication`. +As part of its configuration, we can supply a subsidiary converter to go from `Jwt` to a `Collection` of granted authorities. + +That final converter might be something like `GrantedAuthoritiesExtractor` below: + +==== +.Java +[source,java,role="primary"] +---- +static class GrantedAuthoritiesExtractor + implements Converter> { + + public Collection convert(Jwt jwt) { + Collection authorities = (Collection) + jwt.getClaims().getOrDefault("mycustomclaim", Collections.emptyList()); + + return authorities.stream() + .map(Object::toString) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +internal class GrantedAuthoritiesExtractor : Converter> { + override fun convert(jwt: Jwt): Collection { + val authorities: List = jwt.claims + .getOrDefault("mycustomclaim", emptyList()) as List + return authorities + .map { it.toString() } + .map { SimpleGrantedAuthority(it) } + } +} +---- +==== + +For more flexibility, the DSL supports entirely replacing the converter with any class that implements `Converter>`: + +==== +.Java +[source,java,role="primary"] +---- +static class CustomAuthenticationConverter implements Converter> { + public AbstractAuthenticationToken convert(Jwt jwt) { + return Mono.just(jwt).map(this::doConversion); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +internal class CustomAuthenticationConverter : Converter> { + override fun convert(jwt: Jwt): Mono { + return Mono.just(jwt).map(this::doConversion) + } +} +---- +==== + +[[webflux-oauth2resourceserver-jwt-validation]] +=== Configuring Validation + +Using <>, indicating the authorization server's issuer uri, Resource Server will default to verifying the `iss` claim as well as the `exp` and `nbf` timestamp claims. + +In circumstances where validation needs to be customized, Resource Server ships with two standard validators and also accepts custom `OAuth2TokenValidator` instances. + +[[webflux-oauth2resourceserver-jwt-validation-clockskew]] +==== Customizing Timestamp Validation + +JWT's typically have a window of validity, with the start of the window indicated in the `nbf` claim and the end indicated in the `exp` claim. + +However, every server can experience clock drift, which can cause tokens to appear expired to one server, but not to another. +This can cause some implementation heartburn as the number of collaborating servers increases in a distributed system. + +Resource Server uses `JwtTimestampValidator` to verify a token's validity window, and it can be configured with a `clockSkew` to alleviate the above problem: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ReactiveJwtDecoder jwtDecoder() { + NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) + ReactiveJwtDecoders.fromIssuerLocation(issuerUri); + + OAuth2TokenValidator withClockSkew = new DelegatingOAuth2TokenValidator<>( + new JwtTimestampValidator(Duration.ofSeconds(60)), + new IssuerValidator(issuerUri)); + + jwtDecoder.setJwtValidator(withClockSkew); + + return jwtDecoder; +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + val jwtDecoder = ReactiveJwtDecoders.fromIssuerLocation(issuerUri) as NimbusReactiveJwtDecoder + val withClockSkew: OAuth2TokenValidator = DelegatingOAuth2TokenValidator( + JwtTimestampValidator(Duration.ofSeconds(60)), + JwtIssuerValidator(issuerUri)) + jwtDecoder.setJwtValidator(withClockSkew) + return jwtDecoder +} +---- +==== + +[NOTE] +By default, Resource Server configures a clock skew of 60 seconds. + +[[webflux-oauth2resourceserver-validation-custom]] +==== Configuring a Custom Validator + +Adding a check for the `aud` claim is simple with the `OAuth2TokenValidator` API: + +==== +.Java +[source,java,role="primary"] +---- +public class AudienceValidator implements OAuth2TokenValidator { + OAuth2Error error = new OAuth2Error("invalid_token", "The required audience is missing", null); + + public OAuth2TokenValidatorResult validate(Jwt jwt) { + if (jwt.getAudience().contains("messaging")) { + return OAuth2TokenValidatorResult.success(); + } else { + return OAuth2TokenValidatorResult.failure(error); + } + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +class AudienceValidator : OAuth2TokenValidator { + var error: OAuth2Error = OAuth2Error("invalid_token", "The required audience is missing", null) + override fun validate(jwt: Jwt): OAuth2TokenValidatorResult { + return if (jwt.audience.contains("messaging")) { + OAuth2TokenValidatorResult.success() + } else { + OAuth2TokenValidatorResult.failure(error) + } + } +} +---- +==== + +Then, to add into a resource server, it's a matter of specifying the `ReactiveJwtDecoder` instance: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ReactiveJwtDecoder jwtDecoder() { + NimbusReactiveJwtDecoder jwtDecoder = (NimbusReactiveJwtDecoder) + ReactiveJwtDecoders.fromIssuerLocation(issuerUri); + + OAuth2TokenValidator audienceValidator = new AudienceValidator(); + OAuth2TokenValidator withIssuer = JwtValidators.createDefaultWithIssuer(issuerUri); + OAuth2TokenValidator withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator); + + jwtDecoder.setJwtValidator(withAudience); + + return jwtDecoder; +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): ReactiveJwtDecoder { + val jwtDecoder = ReactiveJwtDecoders.fromIssuerLocation(issuerUri) as NimbusReactiveJwtDecoder + val audienceValidator: OAuth2TokenValidator = AudienceValidator() + val withIssuer: OAuth2TokenValidator = JwtValidators.createDefaultWithIssuer(issuerUri) + val withAudience: OAuth2TokenValidator = DelegatingOAuth2TokenValidator(withIssuer, audienceValidator) + jwtDecoder.setJwtValidator(withAudience) + return jwtDecoder +} +---- +==== diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/multitenancy.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/multitenancy.adoc new file mode 100644 index 0000000000..9138926b79 --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/multitenancy.adoc @@ -0,0 +1,117 @@ += OAuth 2.0 Resource Server Multitenancy + +[[webflux-oauth2resourceserver-multitenancy]] +== Multi-tenancy + +A resource server is considered multi-tenant when there are multiple strategies for verifying a bearer token, keyed by some tenant identifier. + +For example, your resource server may accept bearer tokens from two different authorization servers. +Or, your authorization server may represent a multiplicity of issuers. + +In each case, there are two things that need to be done and trade-offs associated with how you choose to do them: + +1. Resolve the tenant +2. Propagate the tenant + +=== Resolving the Tenant By Claim + +One way to differentiate tenants is by the issuer claim. Since the issuer claim accompanies signed JWTs, this can be done with the `JwtIssuerReactiveAuthenticationManagerResolver`, like so: + +==== +.Java +[source,java,role="primary"] +---- +JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerReactiveAuthenticationManagerResolver + ("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo"); + +http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .authenticationManagerResolver(authenticationManagerResolver) + ); +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +val customAuthenticationManagerResolver = JwtIssuerReactiveAuthenticationManagerResolver("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo") + +return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + authenticationManagerResolver = customAuthenticationManagerResolver + } +} +---- +==== + +This is nice because the issuer endpoints are loaded lazily. +In fact, the corresponding `JwtReactiveAuthenticationManager` is instantiated only when the first request with the corresponding issuer is sent. +This allows for an application startup that is independent from those authorization servers being up and available. + +==== Dynamic Tenants + +Of course, you may not want to restart the application each time a new tenant is added. +In this case, you can configure the `JwtIssuerReactiveAuthenticationManagerResolver` with a repository of `ReactiveAuthenticationManager` instances, which you can edit at runtime, like so: + +==== +.Java +[source,java,role="primary"] +---- +private Mono addManager( + Map authenticationManagers, String issuer) { + + return Mono.fromCallable(() -> ReactiveJwtDecoders.fromIssuerLocation(issuer)) + .subscribeOn(Schedulers.boundedElastic()) + .map(JwtReactiveAuthenticationManager::new) + .doOnNext(authenticationManager -> authenticationManagers.put(issuer, authenticationManager)); +} + +// ... + +JwtIssuerReactiveAuthenticationManagerResolver authenticationManagerResolver = + new JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get); + +http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .authenticationManagerResolver(authenticationManagerResolver) + ); +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +private fun addManager( + authenticationManagers: MutableMap, issuer: String): Mono { + return Mono.fromCallable { ReactiveJwtDecoders.fromIssuerLocation(issuer) } + .subscribeOn(Schedulers.boundedElastic()) + .map { jwtDecoder: ReactiveJwtDecoder -> JwtReactiveAuthenticationManager(jwtDecoder) } + .doOnNext { authenticationManager: JwtReactiveAuthenticationManager -> authenticationManagers[issuer] = authenticationManager } +} + +// ... + +var customAuthenticationManagerResolver = JwtIssuerReactiveAuthenticationManagerResolver(authenticationManagers::get) +return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + authenticationManagerResolver = customAuthenticationManagerResolver + } +} +---- +==== + +In this case, you construct `JwtIssuerReactiveAuthenticationManagerResolver` with a strategy for obtaining the `ReactiveAuthenticationManager` given the issuer. +This approach allows us to add and remove elements from the repository (shown as a `Map` in the snippet) at runtime. + +NOTE: It would be unsafe to simply take any issuer and construct an `ReactiveAuthenticationManager` from it. +The issuer should be one that the code can verify from a trusted source like an allowed list of issuers. diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc new file mode 100644 index 0000000000..70b6bd5133 --- /dev/null +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/opaque-token.adoc @@ -0,0 +1,775 @@ += OAuth 2.0 Resource Server Opaque Token + +[[webflux-oauth2resourceserver-opaque-minimaldependencies]] +== Minimal Dependencies for Introspection +As described in xref:servlet/oauth2/resource-server/jwt.adoc#oauth2resourceserver-jwt-minimaldependencies[Minimal Dependencies for JWT] most of Resource Server support is collected in `spring-security-oauth2-resource-server`. +However unless a custom <> is provided, the Resource Server will fallback to ReactiveOpaqueTokenIntrospector. +Meaning that both `spring-security-oauth2-resource-server` and `oauth2-oidc-sdk` are necessary in order to have a working minimal Resource Server that supports opaque Bearer Tokens. +Please refer to `spring-security-oauth2-resource-server` in order to determin the correct version for `oauth2-oidc-sdk`. + +[[webflux-oauth2resourceserver-opaque-minimalconfiguration]] +== Minimal Configuration for Introspection + +Typically, an opaque token can be verified via an https://tools.ietf.org/html/rfc7662[OAuth 2.0 Introspection Endpoint], hosted by the authorization server. +This can be handy when revocation is a requirement. + +When using https://spring.io/projects/spring-boot[Spring Boot], configuring an application as a resource server that uses introspection consists of two basic steps. +First, include the needed dependencies and second, indicate the introspection endpoint details. + +[[webflux-oauth2resourceserver-opaque-introspectionuri]] +=== Specifying the Authorization Server + +To specify where the introspection endpoint is, simply do: + +[source,yaml] +---- +security: + oauth2: + resourceserver: + opaque-token: + introspection-uri: https://idp.example.com/introspect + client-id: client + client-secret: secret +---- + +Where `https://idp.example.com/introspect` is the introspection endpoint hosted by your authorization server and `client-id` and `client-secret` are the credentials needed to hit that endpoint. + +Resource Server will use these properties to further self-configure and subsequently validate incoming JWTs. + +[NOTE] +When using introspection, the authorization server's word is the law. +If the authorization server responses that the token is valid, then it is. + +And that's it! + +=== Startup Expectations + +When this property and these dependencies are used, Resource Server will automatically configure itself to validate Opaque Bearer Tokens. + +This startup process is quite a bit simpler than for JWTs since no endpoints need to be discovered and no additional validation rules get added. + +=== Runtime Expectations + +Once the application is started up, Resource Server will attempt to process any request containing an `Authorization: Bearer` header: + +[source,http] +---- +GET / HTTP/1.1 +Authorization: Bearer some-token-value # Resource Server will process this +---- + +So long as this scheme is indicated, Resource Server will attempt to process the request according to the Bearer Token specification. + +Given an Opaque Token, Resource Server will + +1. Query the provided introspection endpoint using the provided credentials and the token +2. Inspect the response for an `{ 'active' : true }` attribute +3. Map each scope to an authority with the prefix `SCOPE_` + +The resulting `Authentication#getPrincipal`, by default, is a Spring Security `{security-api-url}org/springframework/security/oauth2/core/OAuth2AuthenticatedPrincipal.html[OAuth2AuthenticatedPrincipal]` object, and `Authentication#getName` maps to the token's `sub` property, if one is present. + +From here, you may want to jump to: + +* <> +* <> +* <> + +[[webflux-oauth2resourceserver-opaque-attributes]] +== Looking Up Attributes Post-Authentication + +Once a token is authenticated, an instance of `BearerTokenAuthentication` is set in the `SecurityContext`. + +This means that it's available in `@Controller` methods when using `@EnableWebFlux` in your configuration: + +==== +.Java +[source,java,role="primary"] +---- +@GetMapping("/foo") +public Mono foo(BearerTokenAuthentication authentication) { + return Mono.just(authentication.getTokenAttributes().get("sub") + " is the subject"); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@GetMapping("/foo") +fun foo(authentication: BearerTokenAuthentication): Mono { + return Mono.just(authentication.tokenAttributes["sub"].toString() + " is the subject") +} +---- +==== + +Since `BearerTokenAuthentication` holds an `OAuth2AuthenticatedPrincipal`, that also means that it's available to controller methods, too: + +==== +.Java +[source,java,role="primary"] +---- +@GetMapping("/foo") +public Mono foo(@AuthenticationPrincipal OAuth2AuthenticatedPrincipal principal) { + return Mono.just(principal.getAttribute("sub") + " is the subject"); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@GetMapping("/foo") +fun foo(@AuthenticationPrincipal principal: OAuth2AuthenticatedPrincipal): Mono { + return Mono.just(principal.getAttribute("sub").toString() + " is the subject") +} +---- +==== + +=== Looking Up Attributes Via SpEL + +Of course, this also means that attributes can be accessed via SpEL. + +For example, if using `@EnableReactiveMethodSecurity` so that you can use `@PreAuthorize` annotations, you can do: + +==== +.Java +[source,java,role="primary"] +---- +@PreAuthorize("principal?.attributes['sub'] = 'foo'") +public Mono forFoosEyesOnly() { + return Mono.just("foo"); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@PreAuthorize("principal.attributes['sub'] = 'foo'") +fun forFoosEyesOnly(): Mono { + return Mono.just("foo") +} +---- +==== + +[[webflux-oauth2resourceserver-opaque-sansboot]] +== Overriding or Replacing Boot Auto Configuration + +There are two ``@Bean``s that Spring Boot generates on Resource Server's behalf. + +The first is a `SecurityWebFilterChain` that configures the app as a resource server. +When use Opaque Token, this `SecurityWebFilterChain` looks like: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::opaqueToken) + return http.build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + opaqueToken { } + } + } +} +---- +==== + +If the application doesn't expose a `SecurityWebFilterChain` bean, then Spring Boot will expose the above default one. + +Replacing this is as simple as exposing the bean within the application: + +.Replacing SecurityWebFilterChain +==== +.Java +[source,java,role="primary"] +---- +@EnableWebFluxSecurity +public class MyCustomSecurityConfiguration { + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .pathMatchers("/messages/**").hasAuthority("SCOPE_message:read") + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .opaqueToken(opaqueToken -> opaqueToken + .introspector(myIntrospector()) + ) + ); + return http.build(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize("/messages/**", hasAuthority("SCOPE_message:read")) + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + opaqueToken { + introspector = myIntrospector() + } + } + } +} +---- +==== + +The above requires the scope of `message:read` for any URL that starts with `/messages/`. + +Methods on the `oauth2ResourceServer` DSL will also override or replace auto configuration. + +For example, the second `@Bean` Spring Boot creates is a `ReactiveOpaqueTokenIntrospector`, which decodes `String` tokens into validated instances of `OAuth2AuthenticatedPrincipal`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveOpaqueTokenIntrospector introspector() { + return new NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): ReactiveOpaqueTokenIntrospector { + return NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) +} +---- +==== + +If the application doesn't expose a `ReactiveOpaqueTokenIntrospector` bean, then Spring Boot will expose the above default one. + +And its configuration can be overridden using `introspectionUri()` and `introspectionClientCredentials()` or replaced using `introspector()`. + +[[webflux-oauth2resourceserver-opaque-introspectionuri-dsl]] +=== Using `introspectionUri()` + +An authorization server's Introspection Uri can be configured <> or it can be supplied in the DSL: + +==== +.Java +[source,java,role="primary"] +---- +@EnableWebFluxSecurity +public class DirectlyConfiguredIntrospectionUri { + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .opaqueToken(opaqueToken -> opaqueToken + .introspectionUri("https://idp.example.com/introspect") + .introspectionClientCredentials("client", "secret") + ) + ); + return http.build(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + opaqueToken { + introspectionUri = "https://idp.example.com/introspect" + introspectionClientCredentials("client", "secret") + } + } + } +} +---- +==== + +Using `introspectionUri()` takes precedence over any configuration property. + +[[webflux-oauth2resourceserver-opaque-introspector-dsl]] +=== Using `introspector()` + +More powerful than `introspectionUri()` is `introspector()`, which will completely replace any Boot auto configuration of `ReactiveOpaqueTokenIntrospector`: + +==== +.Java +[source,java,role="primary"] +---- +@EnableWebFluxSecurity +public class DirectlyConfiguredIntrospector { + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchanges -> exchanges + .anyExchange().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .opaqueToken(opaqueToken -> opaqueToken + .introspector(myCustomIntrospector()) + ) + ); + return http.build(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + opaqueToken { + introspector = myCustomIntrospector() + } + } + } +} +---- +==== + +This is handy when deeper configuration, like <>or <> is necessary. + +[[webflux-oauth2resourceserver-opaque-introspector-bean]] +=== Exposing a `ReactiveOpaqueTokenIntrospector` `@Bean` + +Or, exposing a `ReactiveOpaqueTokenIntrospector` `@Bean` has the same effect as `introspector()`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveOpaqueTokenIntrospector introspector() { + return new NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): ReactiveOpaqueTokenIntrospector { + return NimbusReactiveOpaqueTokenIntrospector(introspectionUri, clientId, clientSecret) +} +---- +==== + +[[webflux-oauth2resourceserver-opaque-authorization]] +== Configuring Authorization + +An OAuth 2.0 Introspection endpoint will typically return a `scope` attribute, indicating the scopes (or authorities) it's been granted, for example: + +`{ ..., "scope" : "messages contacts"}` + +When this is the case, Resource Server will attempt to coerce these scopes into a list of granted authorities, prefixing each scope with the string "SCOPE_". + +This means that to protect an endpoint or method with a scope derived from an Opaque Token, the corresponding expressions should include this prefix: + +==== +.Java +[source,java,role="primary"] +---- +@EnableWebFluxSecurity +public class MappedAuthorities { + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange(exchange -> exchange + .pathMatchers("/contacts/**").hasAuthority("SCOPE_contacts") + .pathMatchers("/messages/**").hasAuthority("SCOPE_messages") + .anyExchange().authenticated() + ) + .oauth2ResourceServer(ServerHttpSecurity.OAuth2ResourceServerSpec::opaqueToken); + return http.build(); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize("/contacts/**", hasAuthority("SCOPE_contacts")) + authorize("/messages/**", hasAuthority("SCOPE_messages")) + authorize(anyExchange, authenticated) + } + oauth2ResourceServer { + opaqueToken { } + } + } +} +---- +==== + +Or similarly with method security: + +==== +.Java +[source,java,role="primary"] +---- +@PreAuthorize("hasAuthority('SCOPE_messages')") +public Flux getMessages(...) {} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@PreAuthorize("hasAuthority('SCOPE_messages')") +fun getMessages(): Flux { } +---- +==== + +[[webflux-oauth2resourceserver-opaque-authorization-extraction]] +=== Extracting Authorities Manually + +By default, Opaque Token support will extract the scope claim from an introspection response and parse it into individual `GrantedAuthority` instances. + +For example, if the introspection response were: + +[source,json] +---- +{ + "active" : true, + "scope" : "message:read message:write" +} +---- + +Then Resource Server would generate an `Authentication` with two authorities, one for `message:read` and the other for `message:write`. + +This can, of course, be customized using a custom `ReactiveOpaqueTokenIntrospector` that takes a look at the attribute set and converts in its own way: + +==== +.Java +[source,java,role="primary"] +---- +public class CustomAuthoritiesOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { + private ReactiveOpaqueTokenIntrospector delegate = + new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + + public Mono introspect(String token) { + return this.delegate.introspect(token) + .map(principal -> new DefaultOAuth2AuthenticatedPrincipal( + principal.getName(), principal.getAttributes(), extractAuthorities(principal))); + } + + private Collection extractAuthorities(OAuth2AuthenticatedPrincipal principal) { + List scopes = principal.getAttribute(OAuth2IntrospectionClaimNames.SCOPE); + return scopes.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +class CustomAuthoritiesOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { + private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + override fun introspect(token: String): Mono { + return delegate.introspect(token) + .map { principal: OAuth2AuthenticatedPrincipal -> + DefaultOAuth2AuthenticatedPrincipal( + principal.name, principal.attributes, extractAuthorities(principal)) + } + } + + private fun extractAuthorities(principal: OAuth2AuthenticatedPrincipal): Collection { + val scopes = principal.getAttribute>(OAuth2IntrospectionClaimNames.SCOPE) + return scopes + .map { SimpleGrantedAuthority(it) } + } +} +---- +==== + +Thereafter, this custom introspector can be configured simply by exposing it as a `@Bean`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveOpaqueTokenIntrospector introspector() { + return new CustomAuthoritiesOpaqueTokenIntrospector(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): ReactiveOpaqueTokenIntrospector { + return CustomAuthoritiesOpaqueTokenIntrospector() +} +---- +==== + +[[webflux-oauth2resourceserver-opaque-jwt-introspector]] +== Using Introspection with JWTs + +A common question is whether or not introspection is compatible with JWTs. +Spring Security's Opaque Token support has been designed to not care about the format of the token -- it will gladly pass any token to the introspection endpoint provided. + +So, let's say that you've got a requirement that requires you to check with the authorization server on each request, in case the JWT has been revoked. + +Even though you are using the JWT format for the token, your validation method is introspection, meaning you'd want to do: + +[source,yaml] +---- +spring: + security: + oauth2: + resourceserver: + opaque-token: + introspection-uri: https://idp.example.org/introspection + client-id: client + client-secret: secret +---- + +In this case, the resulting `Authentication` would be `BearerTokenAuthentication`. +Any attributes in the corresponding `OAuth2AuthenticatedPrincipal` would be whatever was returned by the introspection endpoint. + +But, let's say that, oddly enough, the introspection endpoint only returns whether or not the token is active. +Now what? + +In this case, you can create a custom `ReactiveOpaqueTokenIntrospector` that still hits the endpoint, but then updates the returned principal to have the JWTs claims as the attributes: + +==== +.Java +[source,java,role="primary"] +---- +public class JwtOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { + private ReactiveOpaqueTokenIntrospector delegate = + new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private ReactiveJwtDecoder jwtDecoder = new NimbusReactiveJwtDecoder(new ParseOnlyJWTProcessor()); + + public Mono introspect(String token) { + return this.delegate.introspect(token) + .flatMap(principal -> this.jwtDecoder.decode(token)) + .map(jwt -> new DefaultOAuth2AuthenticatedPrincipal(jwt.getClaims(), NO_AUTHORITIES)); + } + + private static class ParseOnlyJWTProcessor implements Converter> { + public Mono convert(JWT jwt) { + try { + return Mono.just(jwt.getJWTClaimsSet()); + } catch (Exception ex) { + return Mono.error(ex); + } + } + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +class JwtOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { + private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val jwtDecoder: ReactiveJwtDecoder = NimbusReactiveJwtDecoder(ParseOnlyJWTProcessor()) + override fun introspect(token: String): Mono { + return delegate.introspect(token) + .flatMap { jwtDecoder.decode(token) } + .map { jwt: Jwt -> DefaultOAuth2AuthenticatedPrincipal(jwt.claims, NO_AUTHORITIES) } + } + + private class ParseOnlyJWTProcessor : Converter> { + override fun convert(jwt: JWT): Mono { + return try { + Mono.just(jwt.jwtClaimsSet) + } catch (e: Exception) { + Mono.error(e) + } + } + } +} +---- +==== + +Thereafter, this custom introspector can be configured simply by exposing it as a `@Bean`: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public ReactiveOpaqueTokenIntrospector introspector() { + return new JwtOpaqueTokenIntropsector(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): ReactiveOpaqueTokenIntrospector { + return JwtOpaqueTokenIntrospector() +} +---- +==== + +[[webflux-oauth2resourceserver-opaque-userinfo]] +== Calling a `/userinfo` Endpoint + +Generally speaking, a Resource Server doesn't care about the underlying user, but instead about the authorities that have been granted. + +That said, at times it can be valuable to tie the authorization statement back to a user. + +If an application is also using `spring-security-oauth2-client`, having set up the appropriate `ClientRegistrationRepository`, then this is quite simple with a custom `OpaqueTokenIntrospector`. +This implementation below does three things: + +* Delegates to the introspection endpoint, to affirm the token's validity +* Looks up the appropriate client registration associated with the `/userinfo` endpoint +* Invokes and returns the response from the `/userinfo` endpoint + +==== +.Java +[source,java,role="primary"] +---- +public class UserInfoOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { + private final ReactiveOpaqueTokenIntrospector delegate = + new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private final ReactiveOAuth2UserService oauth2UserService = + new DefaultReactiveOAuth2UserService(); + + private final ReactiveClientRegistrationRepository repository; + + // ... constructor + + @Override + public Mono introspect(String token) { + return Mono.zip(this.delegate.introspect(token), this.repository.findByRegistrationId("registration-id")) + .map(t -> { + OAuth2AuthenticatedPrincipal authorized = t.getT1(); + ClientRegistration clientRegistration = t.getT2(); + Instant issuedAt = authorized.getAttribute(ISSUED_AT); + Instant expiresAt = authorized.getAttribute(OAuth2IntrospectionClaimNames.EXPIRES_AT); + OAuth2AccessToken accessToken = new OAuth2AccessToken(BEARER, token, issuedAt, expiresAt); + return new OAuth2UserRequest(clientRegistration, accessToken); + }) + .flatMap(this.oauth2UserService::loadUser); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +class UserInfoOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { + private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val oauth2UserService: ReactiveOAuth2UserService = DefaultReactiveOAuth2UserService() + private val repository: ReactiveClientRegistrationRepository? = null + + // ... constructor + override fun introspect(token: String?): Mono { + return Mono.zip(delegate.introspect(token), repository!!.findByRegistrationId("registration-id")) + .map { t: Tuple2 -> + val authorized = t.t1 + val clientRegistration = t.t2 + val issuedAt: Instant? = authorized.getAttribute(ISSUED_AT) + val expiresAt: Instant? = authorized.getAttribute(OAuth2IntrospectionClaimNames.EXPIRES_AT) + val accessToken = OAuth2AccessToken(BEARER, token, issuedAt, expiresAt) + OAuth2UserRequest(clientRegistration, accessToken) + } + .flatMap { userRequest: OAuth2UserRequest -> oauth2UserService.loadUser(userRequest) } + } +} +---- +==== + +If you aren't using `spring-security-oauth2-client`, it's still quite simple. +You will simply need to invoke the `/userinfo` with your own instance of `WebClient`: + +==== +.Java +[source,java,role="primary"] +---- +public class UserInfoOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector { + private final ReactiveOpaqueTokenIntrospector delegate = + new NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret"); + private final WebClient rest = WebClient.create(); + + @Override + public Mono introspect(String token) { + return this.delegate.introspect(token) + .map(this::makeUserInfoRequest); + } +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +class UserInfoOpaqueTokenIntrospector : ReactiveOpaqueTokenIntrospector { + private val delegate: ReactiveOpaqueTokenIntrospector = NimbusReactiveOpaqueTokenIntrospector("https://idp.example.org/introspect", "client", "secret") + private val rest: WebClient = WebClient.create() + + override fun introspect(token: String): Mono { + return delegate.introspect(token) + .map(this::makeUserInfoRequest) + } +} +---- +==== + +Either way, having created your `ReactiveOpaqueTokenIntrospector`, you should publish it as a `@Bean` to override the defaults: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +ReactiveOpaqueTokenIntrospector introspector() { + return new UserInfoOpaqueTokenIntrospector(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun introspector(): ReactiveOpaqueTokenIntrospector { + return UserInfoOpaqueTokenIntrospector() +} +---- +====