From 70c90cbb43807ffa2b32d37cf52e90b1f79a436f Mon Sep 17 00:00:00 2001 From: ch4mpy Date: Thu, 8 Feb 2024 21:17:02 -1000 Subject: [PATCH] OpenidProviderPropertiesResolver --- README.MD | 239 +------- .../tutorials/WebSecurityConfig.java | 122 +--- .../src/main/resources/application.yml | 17 +- ...eServerWithOAuthenticationApplication.java | 59 +- .../SecurityConfig.java | 48 +- .../SecurityConfig.java | 42 +- .../SecurityConfig.java | 39 +- .../SecurityConfig.java | 34 +- spring-addons-starter-oidc/README.MD | 555 ++++++++++++++++++ .../AuthoritiesMappingPropertiesResolver.java | 10 - ...rAuthoritiesMappingPropertiesResolver.java | 23 - ...ssuerOpenidProviderPropertiesResolver.java | 28 + ...figurableClaimSetAuthoritiesConverter.java | 15 +- .../OpenidProviderPropertiesResolver.java | 14 + ...orizationServerConfigurationException.java | 14 - ...NotAConfiguredOpenidProviderException.java | 18 + .../SpringAddonsOidcProperties.java | 30 - .../ReactiveConfigurationSupport.java | 8 +- .../ReactiveSpringAddonsOidcBeans.java | 23 +- ...a => ServerHttpSecurityPostProcessor.java} | 2 +- .../ClientHttpSecurityPostProcessor.java | 6 - ...ClientServerHttpSecurityPostProcessor.java | 6 + ...eSpringAddonsOidcClientWithLoginBeans.java | 8 +- ...SpringAddonsReactiveJwtDecoderFactory.java | 72 +++ ...artsWithAuthenticationManagerResolver.java | 64 -- ...tiveJWTClaimsSetAuthenticationManager.java | 116 ++++ ...veSpringAddonsOidcResourceServerBeans.java | 80 +-- ...erverServerHttpSecurityPostProcessor.java} | 4 +- ...ctiveJwtAuthenticationManagerResolver.java | 43 ++ ...SpringAddonsReactiveJwtDecoderFactory.java | 21 + ...or.java => HttpSecurityPostProcessor.java} | 2 +- .../synchronised/SpringAddonsOidcBeans.java | 21 +- .../ClientHttpSecurityPostProcessor.java | 4 +- .../DefaultSpringAddonsJwtDecoderFactory.java | 70 +++ ...artsWithAuthenticationManagerResolver.java | 60 -- .../JWTClaimsSetAuthenticationManager.java | 109 ++++ ...sourceServerHttpSecurityPostProcessor.java | 4 +- ...ddonsJwtAuthenticationManagerResolver.java | 41 ++ .../SpringAddonsJwtDecoderFactory.java | 21 + .../SpringAddonsOidcResourceServerBeans.java | 73 +-- ...bleJwtGrantedAuthoritiesConverterTest.java | 2 +- 41 files changed, 1333 insertions(+), 834 deletions(-) create mode 100644 spring-addons-starter-oidc/README.MD delete mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java delete mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerOpenidProviderPropertiesResolver.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/OpenidProviderPropertiesResolver.java delete mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/MissingAuthorizationServerConfigurationException.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/NotAConfiguredOpenidProviderException.java rename spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/{HttpSecurityPostProcessor.java => ServerHttpSecurityPostProcessor.java} (89%) delete mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientHttpSecurityPostProcessor.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientServerHttpSecurityPostProcessor.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/DefaultSpringAddonsReactiveJwtDecoderFactory.java delete mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java rename spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/{ResourceServerHttpSecurityPostProcessor.java => ResourceServerServerHttpSecurityPostProcessor.java} (65%) create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtDecoderFactory.java rename spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/{ServerHttpSecurityPostProcessor.java => HttpSecurityPostProcessor.java} (81%) create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/DefaultSpringAddonsJwtDecoderFactory.java delete mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java create mode 100644 spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtDecoderFactory.java diff --git a/README.MD b/README.MD index 08f6ebcb4..e6e372f95 100644 --- a/README.MD +++ b/README.MD @@ -81,242 +81,15 @@ What are the identified risks of using the resources from such a repo and how ca - an increasing number of user inspect it and open issues or PRs when detecting a problem (the community is probably much bigger than your team working at detecting Spring Security configuration issues in your own projects) - having code centralised at one place and reused at many places reduces the risk of a careless mistake in one of your app -## 1. Spring Boot Starter +## 1. spring-addons-starter-oidc **This starter is designed to push auto-configuration to the next level** and does nothing more than helping you to configure Spring Security beans using application properties. `spring-addons-oidc-starter` does not replace `spring-boot-starter-oauth2-resource-server` and `spring-boot-starter-oauth2-client`, it uses application properties to configure a few beans designed to be picked by Spring Boot official "starters". The aim is to reduce Java code and ease application deployment across environments. In most cases, you should need 0 Java conf. An effort was made to make [tutorials](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials), Javadoc as informative as possible. Please refer there for more details. -If you are curious enough, you might inspect what is auto-configured (and under which conditions) by reading the source code, starting from the [org.springframework.boot.autoconfigure.AutoConfiguration.imports](https://github.com/ch4mpy/spring-addons/blob/master/spring-addons-starter-oidc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports) file, which is the Spring Boot standard entry-point defining what is loaded when a jar is on the classpath. +Please refer to [the module README](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc) for features, FAQ and usage You can refer to [module, dependency, class and other diagrams](https://sourcespy.com/github/ch4mpyspringaddons/) for a general overview of the repository. -### 1.1. Usage -**If you are not absolutely sure why you need an OAuth2 client (with `oauth2Login` in Spring, but secured with sessions, not access tokens) or an OAuth2 resource server configuration (secured with access tokens, not sessions, but without `oauth2Login`), please read the [OAuth2 essentials section](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials#1-oauth2-essentials) of the tutorials.** This might save you a lot of time and effort. - -Add `com.c4-soft.springaddons:spring-addons-starter-oidc` to your dependencies, in addition to `org.springframework.boot:spring-boot-starter-oauth2-client` or `org.springframework.boot:spring-boot-starter-oauth2-resource-server`. - -If configuring an OAuth2 client (with `oauth2Login`), define the standard Spring Boot `provider` and `registration` properties for OAuth2 clients. - -If configuring an OAuth2 resource server with access token introspection, define the standard Spring Boot `opaquetoken` properties. - -Then, define the relevant `com.c4-soft.springaddons.oidc` properties for your use case. There are many complete [samples](https://github.com/ch4mpy/spring-addons/tree/master/samples) and [tutorials](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials) you should refer to, but here are a few demos for different use-cases and OpenID Providers: - -#### 1.1.1. Resource Server with JWT decoder -For a REST API secured with JWT access tokens, you need: -```xml - - org.springframework.boot - - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - - - - com.c4-soft.springaddons - spring-addons-starter-oidc - -``` -And -```yaml -com: - c4-soft: - springaddons: - oidc: - ops: - - iss: https://oidc.c4-soft.com/auth/realms/master - username-claim: preferred_username - authorities: - - path: $.realm_access.roles - - path: $.resource_access.*.roles - resourceserver: - permit-all: - - "/greet/public" - cors: - - path: /** - allowed-origin-patterns: http://localhost:4200 -``` -Above configuration will create an application without sessions nor CSRF protection, and 401 will be answered to unauthorized requests to protected resources. - -#### 1.1.2. Client -For an app serving Thymeleaf templates with login and logout: -```xml - - org.springframework.boot - - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-thymeleaf - - - org.springframework.boot - spring-boot-starter-oauth2-client - - - - com.c4-soft.springaddons - spring-addons-starter-oidc - -``` -And -```yaml -cognito-issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl -cognito-client-id: change-me -cognito-secret: change-me - -spring: - security: - oauth2: - client: - provider: - cognito: - issuer-uri: ${cognito-issuer} - registration: - cognito-authorization-code: - authorization-grant-type: authorization_code - client-id: ${cognito-client-id} - client-secret: ${cognito-secret} - provider: cognito - scope: openid,profile,email,offline_access -com: - c4-soft: - springaddons: - oidc: - ops: - - iss: ${cognito-issuer} - username-claim: username - authorities: - - path: cognito:groups - client: - security-matchers: - - /** - permit-all: - - /login/** - - /oauth2/** - - / - # Auth0 and Cognito do not follow strictly the OpenID RP-Initiated Logout spec and need specific configuration - oauth2-logout: - cognito-authorization-code: - uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout - client-id-request-param: client_id - post-logout-uri-request-param: logout_uri -``` -Above configuration will create an application secured with sessions (not access tokens), with CSRF protection enabled, and unauthorized requests to protected resources will be redirected to login. - -#### 1.1.3. Client and Resource Server -For an app exposing publicly both -- Thymeleaf templates secured with session (with login and logout), all templates being served with `/ui` prefix (but index which is at `/`) -- a REST API secured with access token -```xml - - org.springframework.boot - - spring-boot-starter-web - - - org.springframework.boot - - spring-boot-starter-webflux - - - org.springframework.boot - spring-boot-starter-thymeleaf - - - org.springframework.boot - spring-boot-starter-oauth2-client - - - org.springframework.boot - spring-boot-starter-oauth2-resource-server - - - - com.c4-soft.springaddons - spring-addons-starter-oidc - -``` -And -```yaml -auth0-issuer: https://oidc.c4-soft.com/auth/realms/master -auth0-client-id: change-me -auth0-secret: change-me - -spring: - security: - oauth2: - client: - provider: - auth0: - issuer-uri: ${auth0-issuer} - registration: - auth0-authorization-code: - authorization-grant-type: authorization_code - client-id: ${auth0-client-id} - client-secret: ${auth0-secret} - provider: auth0 - scope: openid,profile,email,offline_access -com: - c4-soft: - springaddons: - oidc: - ops: - - iss: ${auth0-issuer} - username-claim: $['https://c4-soft.com/user']['name'] - authorities: - - path: $['https://c4-soft.com/user']['roles'] - - path: $.permissions - client: - security-matchers: - - /login/** - - /oauth2/** - - /logout - - / - - /ui/** - permit-all: - - /login/** - - /oauth2/** - - / - # Auth0 and Cognito do not follow strictly the OpenID RP-Initiated Logout spec and need specific configuration - oauth2-logout: - auth0-authorization-code: - uri: ${auth0-issuer}v2/logout - client-id-request-param: client_id - post-logout-uri-request-param: returnTo - # Auth0 requires an "audience" parameter in authorization-code request to deliver JWTs - authorization-request-params: - auth0-authorization-code: - - name: audience - value: demo.c4-soft.com - resourceserver: - permit-all: - - "/greet/public" -``` -With the above configuration, two distinct security filter-chains will be defined: -- a client one with sessions (and CSRF protection enabled), intercepting all requests to UI templates as well as those involved in login and logout, and redirecting to login unauthorized requests to protected templates. -- a resource server one acting as default (with lowest precedence to process all requests that were not matched with client filter-chain `securityMatchers`), without sessions (requests are secured with JWT access tokens) nor CSRF protections, and returning 401 to unauthorized requests to protected resources. - -### 1.2. Customizing Auto-Configuration -First use your IDE auto-completion to check if there isn't an existing application property covering your needs: a lot is configurable from properties, and all properties are documented. - -You can override about any `@Bean` defined by spring-addons (almost all are `@ConditionalOnMissingBean`). Here are a few handy ones: -- `(Reactive)JwtAbstractAuthenticationTokenConverter`: take control on the `Authentication` instance built after a JWT was successfully decoded and validated -- `(Reactive)OpaqueTokenAuthenticationConverter`: take control on the `Authentication` instance built after an access token was successfully introspected -- `ClaimSetAuthoritiesConverter`: opt-out the `ConfigurableClaimSetAuthoritiesConverter`, responsible for authorities mapping -- `GrantedAuthoritiesMapper`: in OAuth2 clients, opt-out the default `GrantedAuthoritiesMapper` (which delegates authorities mapping to the `ConfigurableClaimSetAuthoritiesConverter` just above) -- `(Reactive)AuthenticationManagerResolver`: opt-out the authentication manager implementing static multi-tenancy for resource servers with JWT decoders -- `ResourceServerAuthorizeExchangeSpecPostProcessor`, `ClientAuthorizeExchangeSpecPostProcessor`, `ClientAuthorizeExchangeSpecPostProcessor` or `ResourceServerAuthorizeExchangeSpecPostProcessor`: fine-grained access control from configuration (an alternative is using `@Enable(Reactive)MethodSecurity` and `@PreAuthorize` on controller methods) -- `ResourceServerHttpSecurityPostProcessor` or `ClientHttpSecurityPostProcessor`: post-process spring-addons auto-configured `SecurityFilterChains` (this enables to change absolutely anything from it). - -### 1.3. Disabling `spring-addons-oidc-starter` -The easiest way is to exclude it from the classpath, but you may also turn the auto-configuration off by: -- setting `com.c4-soft.springaddons.oidc.resourceserver.enabled` to `false` (this disables the resource server `SecurityFilterChain` bean instantiation, as well as all of its default dependencies) -- leaving `com.c4-soft.springaddons.oidc.client.securityMatcher` empty (this disables the client `SecurityFilterChain` bean instantiation, as well as all of its default dependencies) - ## 2. Unit & Integration Testing With Security Testing method security (`@PreAuthorize`, `@PostFilter`, etc.) requires to configure the security context. `Spring-security-test` provides with `MockMvc` request post-processors and `WebTestClient` mutators to do so, but this requires the context of a request, which limits its usage to testing secured controllers. @@ -482,7 +255,7 @@ These starters are designed to push auto-configuration one step further. In most I could forget to update README before releasing, so please refer to [maven central](https://repo1.maven.org/maven2/com/c4-soft/springaddons/spring-addons/) to pick latest available release ```xml - 7.4.1 + 7.5.0 @@ -518,6 +291,12 @@ I could forget to update README before releasing, so please refer to [maven cent ### 5.1. `7.x` Branch +### `7.5.0` +- Create [spring-addons-starter-oidc README](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc) +- Replace `AuthoritiesMappingPropertiesResolver` with `OpenidProviderPropertiesResolver` +- `OpenidProviderPropertiesResolver` makes multi-tenancy much simpler to implement, including in "dynamic" scenarios (see [spring-addons-starter-oidc README](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc#1-1-4)) +- Fix names of `(Server)HttpSecurityPostProcessor` (synchronised impl where prefixed with `Server` which it shouldn't and reactive weren't when it should) + ### `7.4.1` - [gh-183](https://github.com/ch4mpy/spring-addons/issues/183) Allow anonymous CORS preflight requests (`OPTIONS` requests to a path configured with CORS) - [gh-184](https://github.com/ch4mpy/spring-addons/issues/184) Configuration properties to add parameters to token requests (necessary for instance to add an `audience` when using client-credentials with Auth0) diff --git a/samples/tutorials/resource-server_multitenant_dynamic/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java b/samples/tutorials/resource-server_multitenant_dynamic/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java index d8edb224f..b4aa0dd84 100644 --- a/samples/tutorials/resource-server_multitenant_dynamic/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java +++ b/samples/tutorials/resource-server_multitenant_dynamic/src/main/java/com/c4soft/springaddons/tutorials/WebSecurityConfig.java @@ -1,128 +1,44 @@ package com.c4soft.springaddons.tutorials; import java.net.URI; -import java.net.URISyntaxException; -import java.util.Collection; import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; +import java.util.Optional; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.jwt.JwtDecoders; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; -import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; import org.springframework.stereotype.Component; -import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.util.StringUtils; -import com.c4_soft.springaddons.security.oidc.OAuthentication; -import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; -import com.c4_soft.springaddons.security.oidc.starter.properties.MissingAuthorizationServerConfigurationException; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.OpenidProviderProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; -import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; - -import jakarta.servlet.http.HttpServletRequest; @Configuration @EnableMethodSecurity public class WebSecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - DynamicTenantProperties addonsProperties) { - return jwt -> { - final var issProperties = addonsProperties.getOpProperties(jwt.getClaims().get(JwtClaimNames.ISS).toString()); - return new OAuthentication<>( - new OpenidClaimSet(jwt.getClaims(), issProperties.getUsernameClaim()), - authoritiesConverter.convert(jwt.getClaims()), - jwt.getTokenValue()); - }; - } - - private static URI baseUri(URI uri) { - if (uri == null) { - return null; - } - try { - return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), null, null, null); - } catch (URISyntaxException e) { - throw new InvalidIssuerException(uri.toString()); - } - } - @Primary @Component - static class DynamicTenantProperties extends SpringAddonsOidcProperties { + public class IssuerStartsWithOpenidProviderPropertiesResolver implements OpenidProviderPropertiesResolver { + private final SpringAddonsOidcProperties properties; - @Override - public OpenidProviderProperties getOpProperties(String issOrJwks) throws MissingAuthorizationServerConfigurationException { - return super.getOpProperties(baseUri(URI.create(issOrJwks)).toString()); - } - - } - - @Component - static class DynamicTenantsAuthenticationManagerResolver implements AuthenticationManagerResolver { - private final Set issuerBaseUris; - private final Converter jwtAuthenticationConverter; - private final Map jwtManagers = new ConcurrentHashMap<>(); - private final JwtIssuerAuthenticationManagerResolver delegate = new JwtIssuerAuthenticationManagerResolver( - (AuthenticationManagerResolver) this::getAuthenticationManager); - - public DynamicTenantsAuthenticationManagerResolver( - SpringAddonsOidcProperties addonsProperties, - Converter jwtAuthenticationConverter) { - this.issuerBaseUris = addonsProperties - .getOps() - .stream() - .map(OpenidProviderProperties::getIss) - .map(WebSecurityConfig::baseUri) - .map(URI::toString) - .collect(Collectors.toSet()); - this.jwtAuthenticationConverter = jwtAuthenticationConverter; + public IssuerStartsWithOpenidProviderPropertiesResolver(SpringAddonsOidcProperties properties) { + this.properties = properties; } @Override - public AuthenticationManager resolve(HttpServletRequest context) { - return delegate.resolve(context); - } - - public AuthenticationManager getAuthenticationManager(String issuerUriString) { - final var issuerBaseUri = baseUri(URI.create(issuerUriString)).toString(); - if (!issuerBaseUris.contains(issuerBaseUri)) { - throw new InvalidIssuerException(issuerUriString); - } - if (!this.jwtManagers.containsKey(issuerUriString)) { - this.jwtManagers.put(issuerUriString, getProvider(issuerUriString)); - } - return jwtManagers.get(issuerUriString)::authenticate; - } - - private JwtAuthenticationProvider getProvider(String issuerUriString) { - var provider = new JwtAuthenticationProvider(JwtDecoders.fromIssuerLocation(issuerUriString)); - provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); - return provider; - } - } - - @ResponseStatus(code = HttpStatus.UNAUTHORIZED) - static class InvalidIssuerException extends RuntimeException { - private static final long serialVersionUID = 4431133205219303797L; - - public InvalidIssuerException(String issuerUriString) { - super("Issuer %s is not trusted".formatted(issuerUriString)); + public Optional resolve(Map claimSet) { + final var tokenIss = Optional + .ofNullable(claimSet.get(JwtClaimNames.ISS)) + .map(Object::toString) + .orElseThrow(() -> new RuntimeException("Invalid token: missing issuer")); + return properties.getOps().stream().filter(opProps -> { + final var opBaseHref = Optional.ofNullable(opProps.getIss()).map(URI::toString).orElse(null); + if (StringUtils.isEmpty(opBaseHref)) { + return false; + } + return tokenIss.startsWith(opBaseHref); + }).findAny(); } } } diff --git a/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml b/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml index c82d6fa15..20a41ed1e 100644 --- a/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml +++ b/samples/tutorials/resource-server_multitenant_dynamic/src/main/resources/application.yml @@ -1,10 +1,10 @@ scheme: http -origins: ${scheme}://localhost:4200 -keycloak-port: 8442 +keycloak-port: 8080 server: error: include-message: always + port: 7084 ssl: enabled: false @@ -22,19 +22,8 @@ com: authorities: - path: $.realm_access.roles - path: $.resource_access.*.roles - - iss: https://cognito-idp.us-west-2.amazonaws.com - username-claim: username - authorities: - - path: cognito:groups - - iss: https://dev-ch4mpy.eu.auth0.com - username-claim: $['https://c4-soft.com/user']['name'] - authorities: - - path: $['https://c4-soft.com/user']['roles'] - - path: $.permissions resourceserver: - cors: - - path: /** - allowed-origin-patterns: ${origins} + enabled: false permit-all: - "/actuator/health/readiness" - "/actuator/health/liveness" diff --git a/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java b/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java index 87975cb07..266cc88ec 100644 --- a/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java +++ b/samples/tutorials/resource-server_with_oauthentication/src/main/java/com/c4soft/springaddons/tutorials/ResourceServerWithOAuthenticationApplication.java @@ -13,12 +13,12 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; @@ -28,43 +28,42 @@ import io.swagger.v3.oas.annotations.security.OAuthScope; import io.swagger.v3.oas.annotations.security.SecurityScheme; -@SecurityScheme( - name = "authorization-code", - type = SecuritySchemeType.OAUTH2, - flows = @OAuthFlows( - authorizationCode = @OAuthFlow( - authorizationUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/auth", - tokenUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/token", - scopes = { @OAuthScope(name = "openid"), @OAuthScope(name = "profile") }))) +@SecurityScheme(name = "authorization-code", type = SecuritySchemeType.OAUTH2, flows = @OAuthFlows(authorizationCode = @OAuthFlow(authorizationUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/auth", tokenUrl = "https://oidc.c4-soft.com/auth/realms/master/protocol/openid-connect/token", scopes = { + @OAuthScope(name = "openid"), + @OAuthScope(name = "profile") }))) @SpringBootApplication public class ResourceServerWithOAuthenticationApplication { - public static void main(String[] args) { - SpringApplication.run(ResourceServerWithOAuthenticationApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(ResourceServerWithOAuthenticationApplication.class, args); + } - @Configuration - @EnableMethodSecurity - public static class SecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { - return jwt -> new OAuthentication<>( - new OpenidClaimSet(jwt.getClaims(), addonsProperties.getOpProperties(jwt.getClaims().get(JwtClaimNames.ISS)).getUsernameClaim()), - authoritiesConverter.convert(jwt.getClaims()), - jwt.getTokenValue()); - } + @Configuration + @EnableMethodSecurity + public static class SecurityConfig { + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> { + final var opProperties = opPropertiesResolver + .resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); + final var claims = new OpenidClaimSet(jwt.getClaims(), opProperties.getUsernameClaim()); + final var authorities = authoritiesConverter.convert(jwt.getClaims()); + return new OAuthentication<>(claims, authorities, jwt.getTokenValue()); + }; + } - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/actuator/**")).hasAuthority("OBSERVABILITY:read") .requestMatchers(new AntPathRequestMatcher("/actuator/**")).hasAuthority("OBSERVABILITY:write") .anyRequest().authenticated(); // @formatter:on - } - } + } + } } diff --git a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java index 47dd9d264..1f75d69a4 100644 --- a/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java +++ b/samples/webflux-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_jwtauthenticationtoken/SecurityConfig.java @@ -10,12 +10,12 @@ import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenAuthenticationConverter; import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerAuthorizeExchangeSpecPostProcessor; import reactor.core.publisher.Mono; @@ -24,30 +24,30 @@ @Configuration public class SecurityConfig { - @Bean - ReactiveOpaqueTokenAuthenticationConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { - return ( - String introspectedToken, - OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> Mono - .just( - new OAuthentication<>( - new OpenidClaimSet( - authenticatedPrincipal.getAttributes(), - addonsProperties.getOpProperties(authenticatedPrincipal.getAttributes().get(JwtClaimNames.ISS)) - .getUsernameClaim()), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), - introspectedToken)); - } - - @Bean - ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { - // @formatter:off + @Bean + ReactiveOpaqueTokenAuthenticationConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> Mono + .just( + new OAuthentication<>( + new OpenidClaimSet( + authenticatedPrincipal.getAttributes(), + opPropertiesResolver + .resolve(authenticatedPrincipal.getAttributes()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(authenticatedPrincipal.getAttributes())) + .getUsernameClaim()), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), + introspectedToken)); + } + + @Bean + ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { + // @formatter:off return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec .pathMatchers("/secured-route").hasRole("AUTHORIZED_PERSONNEL") .anyExchange().authenticated(); // @formatter:on - } + } -} \ No newline at end of file +} diff --git a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java index 72178c647..17ac7fed0 100644 --- a/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java +++ b/samples/webflux-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webflux_oidcauthentication/SecurityConfig.java @@ -9,11 +9,11 @@ import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.JwtClaimNames; import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ReactiveJwtAbstractAuthenticationTokenConverter; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerAuthorizeExchangeSpecPostProcessor; @@ -23,24 +23,30 @@ @Configuration public class SecurityConfig { - @Bean - ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { - return jwt -> Mono.just( - new OAuthentication<>( - new OpenidClaimSet(jwt.getClaims(), addonsProperties.getOpProperties(jwt.getClaims().get(JwtClaimNames.ISS)).getUsernameClaim()), - authoritiesConverter.convert(jwt.getClaims()), - jwt.getTokenValue())); - } - - @Bean - ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { - // @formatter:off + @Bean + ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> Mono + .just( + new OAuthentication<>( + new OpenidClaimSet( + jwt.getClaims(), + opPropertiesResolver + .resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) + .getUsernameClaim()), + authoritiesConverter.convert(jwt.getClaims()), + jwt.getTokenValue())); + } + + @Bean + ResourceServerAuthorizeExchangeSpecPostProcessor authorizeExchangeSpecPostProcessor() { + // @formatter:off return (ServerHttpSecurity.AuthorizeExchangeSpec spec) -> spec .pathMatchers("/secured-route").hasRole("AUTHORIZED_PERSONNEL") .anyExchange().authenticated(); // @formatter:on - } + } -} \ No newline at end of file +} diff --git a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java index b0d4c6e06..59b4b82c6 100644 --- a/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java +++ b/samples/webmvc-introspecting-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java @@ -11,36 +11,39 @@ import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal; -import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; @Configuration @EnableMethodSecurity public class SecurityConfig { - @Bean - OpaqueTokenAuthenticationConverter opaqueTokenAuthenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { - return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( - new OpenidClaimSet( - authenticatedPrincipal.getAttributes(), - addonsProperties.getOpProperties(authenticatedPrincipal.getAttributes().get(JwtClaimNames.ISS)).getUsernameClaim()), - authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), - introspectedToken); - }; + @Bean + OpaqueTokenAuthenticationConverter opaqueTokenAuthenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return (String introspectedToken, OAuth2AuthenticatedPrincipal authenticatedPrincipal) -> new OAuthentication<>( + new OpenidClaimSet( + authenticatedPrincipal.getAttributes(), + opPropertiesResolver + .resolve(authenticatedPrincipal.getAttributes()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(authenticatedPrincipal.getAttributes())) + .getUsernameClaim()), + authoritiesConverter.convert(authenticatedPrincipal.getAttributes()), + introspectedToken); + }; - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(new AntPathRequestMatcher("/secured-route")).hasRole("AUTHORIZED_PERSONNEL") .anyRequest().authenticated(); // @formatter:on - } -} \ No newline at end of file + } +} diff --git a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java index 0cbba6bfb..b97f79d49 100644 --- a/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java +++ b/samples/webmvc-jwt-oauthentication/src/main/java/com/c4_soft/springaddons/samples/webmvc_oidcauthentication/SecurityConfig.java @@ -10,35 +10,37 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.JwtClaimNames; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import com.c4_soft.springaddons.security.oidc.OAuthentication; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JwtAbstractAuthenticationTokenConverter; import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.ResourceServerExpressionInterceptUrlRegistryPostProcessor; @Configuration @EnableMethodSecurity public class SecurityConfig { - @Bean - JwtAbstractAuthenticationTokenConverter authenticationConverter( - Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { - return jwt -> new OAuthentication<>( - new OpenidClaimSet(jwt.getClaims(), addonsProperties.getOpProperties(jwt.getClaims().get(JwtClaimNames.ISS)).getUsernameClaim()), - authoritiesConverter.convert(jwt.getClaims()), - jwt.getTokenValue()); + @Bean + JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> new OAuthentication<>( + new OpenidClaimSet( + jwt.getClaims(), + opPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())).getUsernameClaim()), + authoritiesConverter.convert(jwt.getClaims()), + jwt.getTokenValue()); - } + } - @Bean - ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { - // @formatter:off + @Bean + ResourceServerExpressionInterceptUrlRegistryPostProcessor expressionInterceptUrlRegistryPostProcessor() { + // @formatter:off return (AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry registry) -> registry .requestMatchers(new AntPathRequestMatcher("/secured-route")).hasRole("AUTHORIZED_PERSONNEL") .anyRequest().authenticated(); // @formatter:on - } -} \ No newline at end of file + } +} diff --git a/spring-addons-starter-oidc/README.MD b/spring-addons-starter-oidc/README.MD new file mode 100644 index 000000000..b176de879 --- /dev/null +++ b/spring-addons-starter-oidc/README.MD @@ -0,0 +1,555 @@ +# spring-addons-starter-oidc + +This project is a Spring Boot starter to use in addition to `spring-boot-starter-oauth2-client` or `spring-boot-starter-oauth2-resource-server` to further **ease OAuth2 configuration with any OpenID Provider, and potentially several heterogeneous ones at a time**. + +Jump to: +- [1. Features](#features) + * [1.1. OAuth2 Resource Servers](#features-resource-server) + - [1.1.1. Resource Server `Security(Web)FilterChain`](#1-1-1) + - [1.1.2. Authorities Converter](#1-1-2) + - [1.1.3. Authentication Converter](#1-1-3) + - [1.1.4. Multi-Tenancy](#1-1-4) + - [1.1.5. Access Control](#1-1-5) + - [1.1.6. CORS Configuration](#1-1-6) + - [1.1.7. Post-Process the Resource Server Filer-Chain](#1-1-7) + * [1.2. OAuth2 client](#features-client) + - [1.2.1. Client `Security(Web)FilterChain`](#1-2-1) + - [1.2.2. Setting a Base URI for the Client](#1-2-2) + - [1.2.3. Authorization Code](#1-2-3) + - [1.2.4. RP-Initiated Logout](#1-2-4) + - [1.2.5. Back-Channel Logout](#1-2-5) + - [1.2.6. Authorities Converter](#1-2-6) + - [1.2.7. CSRF protection](#1-2-7) + - [1.2.8. Multi-Tenancy](#1-2-8) + - [1.2.9. Access Control](#1-2-9) + - [1.2.10. Add Parameters to Token Requests](#1-2-10) + - [1.2.11. Post-Process the Client Filer-Chain](#1-2-11) +- [2. Frequently Asked Questions](#faq) +- [3. Usage](#usage) + * [3.1. Resource Server](#3-1) + * [3.2. Client](#3-2) + * [3.3. Client and Resource Server](#3-3) + +## 1. Features +### 1.1. OAuth2 Resource Servers +As a reminder, requests to an OAuth2 resource server are authorized with access tokens, which are validated either with introspection or JWT decoders. + +Resource servers only care if tokens are valid and if it should grant access to resources based on the claims associated to it. Resource servers are not concerned by how token are obtained. As a consequence, login and logout are not relevant in resource server configuration. + +#### 1.1.1. Resource Server `Security(Web)FilterChain` +If `spring-boot-starter-oauth2-resource-server` is on the classpath and unless `com.c4-soft.springaddons.oidc.resourceserver.enabled=false`, a `Security(Web)FilterChain` is created with the following default configuration: +- `@Order(Ordered.LOWEST_PRECEDENCE)` and no security matcher (acts as default, processing all requests which weren't intercepted by any other `Security(Web)FilterChain` with higher precedence) +- stateless (no session and CSRF protection disabled) +- respond with 401 to unauthorized requests +- access token introspection if `spring.security.oauth2.resourceserver.opaquetoken.introspection-uri` is set and JWT decoder otherwise +- CORS disabled (unless some `cors` properties are provided) +- anonymous access allowed to pre-flight requests as well as all requests with a path matching an entry in `permit-all`; all others requests requiring a valid authentication + +#### 1.1.2. Authorities Converter +Spring security implements Role Based Access Control (RBAC) with so called `GrantedAuthority` (accessed through `Authentication#getAuthorities`). + +Neither OpenID nor OAuth2 include a specification for RBAC (as explained in the FAQ, scopes are not roles). About every OpenID Provider implements RBAC, but they have to use private claims for that. As each OP uses its own private claim(s), mapping OP *roles* to Spring *authorities* requires to adapt to each issuer. + +By default, `spring-addons-starter-oidc` uses `ConfigurableClaimSetAuthoritiesConverter` which uses properties defined for each OP. `ByIssuerAuthoritiesMappingPropertiesResolver`, the default resolver, uses the access token `iss` claim (*issuer*) to select which properties to provide to the authorities mapper. + +For each OpenID Provider (OP), you can define as many claim groups as you like, and for each group: +- `path`: a [JSON path](https://github.com/json-path/JsonPath) to the claim(s) to be mapped as authorities. You may use tools like [https://jsonpath.com/](https://jsonpath.com/) to test your JSON path against your access tokens payload (extracted with tools like [https://jwt.io](https://jwt.io)) +- `prefix`: an optional prefix to add to the OP roles (default is empty). For instance, you might add a `ROLE_` prefix to use expressions like `hasRole('admin')` instead of `hasAuthority('admin')` (provided that the role provided by the authorization server is not `ROLE_admin` already, of course). +- `caze`: optionally force roles to upper-case or lower-case (default being to keep it as provided) + +Sample configuration for two different OPs: +```yaml +com: + c4-soft: + springaddons: + oidc: + ops: + - iss: https://oidc.c4-soft.com/auth/realms/master + authorities: + - path: $.realm_access.roles + - path: $.resource_access.*.roles + - iss: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl + authorities: + - path: $.cognito:groups + prefix: EXTERNAL_ + caze: upper +``` +In the above: +- for tokens with `"iss": "https://oidc.c4-soft.com/auth/realms/master"`, authorities will be mapped from *realm roles* and all available *client roles* from the token, without any transformation. +- for tokens with `"iss": "https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl"`, authorities will be mapped from *cognito:groups* claim, forcing it to upper-case and adding the `EXTERNAL_` prefix (`"cognito:groups": ["machin", "truc"]` will be turned into `["EXTERNAL_MACHIN", "EXTERNAL_TRUC"]`) + +To use another authorities mapper, expose a `@Bean` of type `ClaimSetAuthoritiesConverter`. + +To change how authorities mapping properties are resolved (for instance if you are using some "dynamic" multi-tenancy and can't know the possible issuers when writing the conf), expose a `@Bean` of type `AuthoritiesMappingPropertiesResolver`. + +#### 1.1.3. Authentication Converter +Spring Security `Authentication` is more than just a container for *authorities*: it also holds user unique identifier (`name`) and, in the case of a resource server, the access token claims. + +By default, `spring-addons-starter-oidc` uses a `(Reactive)JwtAbstractAuthenticationTokenConverter` or `(Reactive)OpaqueTokenAuthenticationConverter` implementation delegating authorities conversion to a `@Bean` in the context (see previous section). The default output are as usual: +- `JwtAuthenticationToken` when a JWT decoder is used +- `BearerTokenAuthentication` with introspection. + +By exposing a custom authentication converter bean, you can use your own `Authentication` implementation. Here is a sample for a servlet with JWT decoder(s): +```java +@Bean +JwtAbstractAuthenticationTokenConverter authenticationConverter( + Converter, Collection> authoritiesConverter, + OpenidProviderPropertiesResolver opPropertiesResolver) { + return jwt -> { + final var opProperties = opPropertiesResolver.resolve(jwt.getClaims()); + final var claims = new OpenidClaimSet(jwt.getClaims(), opProperties.getUsernameClaim()); + final var authorities = authoritiesConverter.convert(jwt.getClaims()); + return new OAuthentication<>(claims, authorities, jwt.getTokenValue()); + }; +} +``` + +#### 1.1.4. Multi-Tenancy +Multi-tenancy is supported for resource servers with JWT decoders. + +The core component for multi-tenancy is the `OpenidProviderPropertiesResolver` which is in charge of resolving the configuration properties used to build JWT decoders (and the validators embedded in it). + +`ByIssuerOpenidProviderPropertiesResolver`, the default `OpenidProviderPropertiesResolver`, offers support for "static" multi-tenancy (when you know at configuration time all of the issuers you trust). + +You may implement "dynamic" multi-tenancy by exposing an `OpenidProviderPropertiesResolver` bean of your own. Such a bean could, for instance, resolve the properties necessary to build new JWT decoders based on the issuer claim: +- starting with something special +- being part of specific (sub)domains +- whatever else like matching a pattern, be validated by a service querying a database, ... + +As a sample, here is how you could accept tokens from any realm of a Keycloak instance, even if this realm is created after your resource server started: +```yaml +com: + c4-soft: + springaddons: + oidc: + ops: + - iss: https://oidc.c4-soft.com/auth/realms/ + authorities: + - path: $.realm_access.roles +``` +```java +@Component +public class IssuerStartsWithOpenidProviderPropertiesResolver implements OpenidProviderPropertiesResolver { + private final SpringAddonsOidcProperties properties; + + public IssuerStartsWithOpenidProviderPropertiesResolver(SpringAddonsOidcProperties properties) { + this.properties = properties; + } + + @Override + public Optional resolve(Map claimSet) { + final var tokenIss = Optional + .ofNullable(claimSet.get(JwtClaimNames.ISS)) + .map(Object::toString) + .orElseThrow(() -> new RuntimeException("Invalid token: missing issuer")); + return properties.getOps().stream().filter(opProps -> { + final var opBaseHref = Optional.ofNullable(opProps.getIss()).map(URI::toString).orElse(null); + if (StringUtils.isEmpty(opBaseHref)) { + return false; + } + return tokenIss.startsWith(opBaseHref); + }).findAny(); + } +} +``` +That way, any token with an issuer claim starting with `https://oidc.c4-soft.com/auth/realms/` would be accepted and mapped to an `Authentication` instance using the same converter and configuration properties as any other token issued for any realm by the same Keycloak instance. + +#### 1.1.5. Access Control +The default access rule is set to `isAuthenticated()` with two exceptions: +- routes matching the path-matchers listed in `permit-all` property for which anonymous requests are allowed +- pre-flight requests, unless disabled in `cors` properties (`OPTIONS` requests to routes matching the path-matchers listed in CORS properties). + +The most convenient way to define fine-grained access control is probably to `@Enable(Reactive)MethodSecurity` and to decorate `@RestController` methods with `@PreAuthorize`. + +For those preferring access control in configuration (or when you don't write the endpoint yourself), you can expose a `@Bean` of type `ResourceServerExpressionInterceptUrlRegistryPostProcessor` or `ResourceServerAuthorizeExchangeSpecPostProcessor`. + +#### 1.1.6. CORS Configuration +If no `cors` property group is present, CORS is disabled. Otherwise, CORS is configured according to provided properties. + +According to CORS spec, pre-flight requests should not be authorized. **As opposed to Spring Security default behavior, `spring-addons-starter-oidc` allows anonymous `OPTIONS` requests for the path-matchers in `cors` properties groups.** + +#### 1.1.7. Post-Process the Resource Server Filer-Chain +By exposing a `ResourceServer(Server)HttpSecurityPostProcessor` bean, you get complete control of the `(Server)HttpSecurity` configured in the `resourceServerSecurityFilterChain` just before it is built. This allows to change about anything that was pre-configured. + +### 1.2. OAuth2 Clients +OAuth2 clients are applications fetching tokens from an authorization server to later authorize queries to a resource server. + +#### 1.2.1. Client `Security(Web)FilterChain` +A client filter-chain is created if (and only if) all the following conditions are met: +- `spring-boot-starter-oauth2-client` is on the classpath +- `spring.security.oauth2.client.registration` contains at least one entry with `authorization-grant-type=authorization_code` +- `com.c4-soft.springaddons.oidc.client.security-matchers` is not empty + +This filter-chain is configured with the following defaults: +- `@Order(Ordered.LOWEST_PRECEDENCE + 1)` +- the security-matcher in the conf is applied +- stateful (session and CSRF protection enabled) +- oauth2Login +- RP-Initiated Logout +- CORS disabled (unless some `cors` properties are provided) +- anonymous access allowed to pre-flight requests as well as all requests with a path matching an entry in `permit-all`; all others requests requiring a valid authentication + +#### 1.2.2. Setting a Base URI for the Client +Authorization-code flow and RP-Initiated Logout involve some redirection to the authorization server and then back to the client. + +Spring Security generates this redirection URIs, but sometimes, it is convenient to have it point to another host than the client itself: a gateway, reverse-proxy, ingress or whatever. `com.c4-soft.springaddons.oidc.client.client-uri` serves that purpose. + +#### 1.2.3. Authorization Code +Added features are of main interest for a remote frontend connected to a Spring backend with `oauth2Login` (case of OAuth2 BFF). + +Customization can be achieved by exposing a variety of beans: +- `PreAuthorizationCodeRedirectStrategy`: the default is `SpringAddonsPreAuthorizationCodeRedirectStrategy` which allows to change the status of the redirection to the authorization server when initiating an authorization code flow (`com.c4-soft.springaddons.oidc.client.oauth2-redirections.pre-authorization-code` property or `X-RESPONSE-STATUS` header). This can be of use for frontends wishing to switch the HTTP client on the fly (like a mobile app using a programmatic client with a session on the Spring client and a web-view or system browser with another session for login on the authorization server) +- `OAuth2AuthorizationRequestResolver`: the default is `SpringAddonsOAuth2AuthorizationRequestResolver` which: + * switches the client base URI (if requested) + * saves in session the post-login success and failure URI (provided as `X-POST-LOGIN-SUCCESS-URI` & `X-POST-LOGIN-FAILURE-URI` headers or `post_login_success_uri` & `post_login_failure_uri` request params) + * adds optional requests params defined under `com.c4-soft.springaddons.oidc.client.authorization-request-params.{registrationId}`. Auth0 for instance, requires an `audience` parameter to be added to authorization request (see FAQ for a sample). +- `AuthenticationSuccessHandler`: the default restores the post-login success URI saved in session and applies the `com.c4-soft.springaddons.oidc.client.oauth2-redirections.post-authorization-code` +- `AuthenticationFailureHandler`: the default restores the post-login failure URI saved in session and applies the `com.c4-soft.springaddons.oidc.client.oauth2-redirections.post-authorization-code` + +#### 1.2.4. RP-Initiated Logout +Most OpenID Providers implement the RP-Initiated Logout and expose an `end_session_endpoint` in OpenID configuration, but some, like Auth0 and Cognito don't: they have a logout mechanism which works mostly like RP-Initiated Logout but with an endpoint URI and request params to find in docs. + +Sample for registrations called `cognito-user` and `auth0-user`: +```yaml +com: + c4-soft: + springaddons: + oidc: + client: + oauth2-logout: + cognito-user: + uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout + client-id-request-param: client_id + post-logout-uri-request-param: logout_uri + auth0-user: + uri: ${auth0-issuer}v2/logout + client-id-request-param: client_id + post-logout-uri-request-param: returnTo +``` + +#### 1.2.5. Back-Channel Logout +Back-Channel Logout is an OpenID standard allowing a client to be notified by the authorization server with logout events initiated by other client. + +To use this feature, the client must expose a dedicated endpoint and Spring Security implements it since version `6.2`. + +`com.c4-soft.springaddons.oidc.client.back-channel-logout.enabled=true` activates Spring Security client implementation for Back-Channel Logout. + +#### 1.2.6. Authorities Converter +By default, a `GrantedAuthoritiesMapper` using the authorities converter bean in the application context. The default for this authorities mapper is shared with resource servers: `ConfigurableClaimSetAuthoritiesConverter`. The configuration for this converter is resolved by the `OpenidProviderPropertiesResolver` in the context. + +#### 1.2.7. CSRF protection +Requests to an OAuth2 client are authorized with session cookies, which exposes it to CSRF attacks. As a consequence, **CSRF protection should always be enabled on OAuth2 clients**. + +The default is the with `spring-addons-starter-oidc` as it is with `spring-boot-starter-oauth2-client` (session). + +When setting `com.c4-soft.springaddons.oidc.client.csrf=cookie-accessible-from-js`, as needed by single-page and mobile applications, the CSRF token is exposed in a token and the required filter is registered. + +#### 1.2.8. Multi-Tenancy +Native support for client multi-tenancy is rather limited in Spring Security: it is expected that we can provide the user with a choice of authorization servers to authenticate against, but it is not that a single user session holds several numeric identities at once: the Authentication in the security context will have the name and authorities from the last authentication. + +There is an experimental support for keeping an `Authentication` instance per client registration with `com.c4-soft.springaddons.oidc.client.multi-tenancy-enabled=true`. Please explore `(Reactive)SpringAddonsAop` to figure out what it does and how it works. + +#### 1.2.9. Access Control +The default access rule is set to `isAuthenticated()` with two exceptions: +- routes matching the path-matchers listed in `permit-all` property for which anonymous requests are allowed +- pre-flight requests, unless disabled in `cors` properties (`OPTIONS` requests to routes matching the path-matchers listed in CORS properties). + +The most convenient way to define fine-grained access control is probably to `@Enable(Reactive)MethodSecurity` and to decorate `@RestController` methods with `@PreAuthorize`. + +For those preferring access control in configuration (or when you don't write the endpoint yourself), you can expose a `@Bean` of type `ClientExpressionInterceptUrlRegistryPostProcessor` or `ClientAuthorizeExchangeSpecPostProcessor`. + +#### 1.2.10. Add Parameters to Token Requests +Some OpenID Providers require some extra parameters on the token endpoint. Auth0 for instance expects an `audience` parameter with client-credential token requests. Such parameters can be defined under `com.c4-soft.springaddons.oidc.client.token-request-params.{registrationId}` (see FAQ for a sample). + +#### 1.2.11. Post-Process the Client Filer-Chain +By exposing a `Client(Server)HttpSecurityPostProcessor` bean, you get complete control of the `(Server)HttpSecurity` configured in the `clientSecurityFilterChain` just before it is built. This allows to change about anything that was pre-configured. + +## 2. Frequently Asked Questions + +### What exactly is auto-configured? +To get an exhaustive insight of what is loaded, start with `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` which is the standard Spring Boot resource. It lists `@AutoConfiguration` files that Spring Boot will use when building the application context. + +You may check the implementation of the condition on each `@AutoConfiguration` (and imported `@Configuration`) to limit your investigations to what is actually loaded in your application. + +### Are all those beans defined by `spring-addons-starter-oidc` added to my application context? +**No!** Only a few are. All beans are conditional and only a few are instantiated. Which ones exactly depend on the application type (servlet or reactive), dependencies (resource server, client or both), properties and explicit beans definitions (most `spring-addons-starter-oidc` beans are `@ConditionalOnMissingBean`). + +### What is the difference between scopes and roles? +In RBAC, a *role* defines some permissions for the user, when an OAuth2 scope defines what a resource owner allows an OAuth2 client on his behalf on the resource server(s) included in the audience. You can think of the scope as a mask to apply on user roles. Scopes are of interest mainly when you want to give users control on which software can access which of their resources. + +### How to configure a resource server to accept tokens issued for Keycloak *realms* created at runtime? +See the Multi-Tenancy section in resource server features. + +### How to provide the `audience` parameter required by Auth0 for user login? +Add it to `com.c4-soft.springaddons.oidc.client.authorization-request-params.{registrationId}`. For instance, given an `auth0-users` registration in boot properties: +```yaml +issuer: https://dev-ch4mpy.eu.auth0.com/ + +spring: + security: + oauth2: + client: + provider: + auth0: + issuer-uri: ${issuer} + registration: + auth0-user: + provider: auth0 + client-id: change-me + client-secret: change-me + authorization-grant-type: authorization_code + scope: openid, offline_access +com: + c4-soft: + springaddons: + oidc: + client: + authorization-request-params: + auth0-user: + - name: audience + value: demo.c4-soft.com +``` +Note the `auth0-user` registration ID used in both Spring Boot `registration` and addons `authorization-request-params` + +### How to provide the `audience` parameter reuqired by Auth0 for client-credentials? +Add it to `com.c4-soft.springaddons.oidc.client.token-request-params.{registrationId}`. For instance, given an `auth0-api` registration in boot preperties: +```yaml +issuer: https://dev-ch4mpy.eu.auth0.com/ + +spring: + security: + oauth2: + client: + provider: + auth0: + issuer-uri: ${issuer} + registration: + auth0-api: + provider: auth0 + client-id: change-me + client-secret: change-me + authorization-grant-type: client_credentials + scope: read:users +com: + c4-soft: + springaddons: + oidc: + client: + token-request-params: + auth0-api: + - name: audience + value: demo.c4-soft.com +``` +Note the `auth0-api` registration ID used in both Spring Boot `registration` and addons `token-request-params` + +### 3. Basic Usage +This section describes only the most basic usage. For advanced auto-configuration and defaults overrides, please refer to section [1. Features](https://github.com/ch4mpy/spring-addons/tree/master/spring-addons-starter-oidc#spring-addons-starter-oidc). + +**If you are not absolutely sure why you need an OAuth2 client with `oauth2Login` (secured with sessions, not access tokens) or an OAuth2 resource server configuration (secured with access tokens, not sessions, but without `oauth2Login`), please read the [OAuth2 essentials section](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials#1-oauth2-essentials) of the tutorials.** This might save you a lot of time and effort. + +Add `com.c4-soft.springaddons:spring-addons-starter-oidc` to your dependencies, in addition to `org.springframework.boot:spring-boot-starter-oauth2-client` or `org.springframework.boot:spring-boot-starter-oauth2-resource-server`. + +If configuring an OAuth2 client (with `oauth2Login`), define the standard Spring Boot `provider` and `registration` properties for OAuth2 clients. + +If configuring an OAuth2 resource server with access token introspection, define the standard Spring Boot `opaquetoken` properties. + +Then, define the relevant `com.c4-soft.springaddons.oidc` properties for your use case. There are many complete [samples](https://github.com/ch4mpy/spring-addons/tree/master/samples) and [tutorials](https://github.com/ch4mpy/spring-addons/tree/master/samples/tutorials) you should refer to, but here are a few demos for different use-cases and OpenID Providers: + +### 3.1. Resource Server with JWT decoder +For a REST API secured with JWT access tokens, you need: +```xml + + org.springframework.boot + + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + com.c4-soft.springaddons + spring-addons-starter-oidc + +``` +And +```yaml +com: + c4-soft: + springaddons: + oidc: + ops: + - iss: https://oidc.c4-soft.com/auth/realms/master + username-claim: preferred_username + authorities: + - path: $.realm_access.roles + - path: $.resource_access.*.roles + resourceserver: + permit-all: + - "/greet/public" + cors: + - path: /** + allowed-origin-patterns: http://localhost:4200 +``` +Above configuration will create an application without sessions nor CSRF protection, and 401 will be answered to unauthorized requests to protected resources. + +### 3.2. Client +For an app serving Thymeleaf templates with login and logout: +```xml + + org.springframework.boot + + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + com.c4-soft.springaddons + spring-addons-starter-oidc + +``` +And +```yaml +cognito-issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl +cognito-client-id: change-me +cognito-secret: change-me + +spring: + security: + oauth2: + client: + provider: + cognito: + issuer-uri: ${cognito-issuer} + registration: + cognito-authorization-code: + authorization-grant-type: authorization_code + client-id: ${cognito-client-id} + client-secret: ${cognito-secret} + provider: cognito + scope: openid,profile,email,offline_access +com: + c4-soft: + springaddons: + oidc: + ops: + - iss: ${cognito-issuer} + username-claim: username + authorities: + - path: cognito:groups + client: + security-matchers: + - /** + permit-all: + - /login/** + - /oauth2/** + - / + # Auth0 and Cognito do not follow strictly the OpenID RP-Initiated Logout spec and need specific configuration + oauth2-logout: + cognito-authorization-code: + uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout + client-id-request-param: client_id + post-logout-uri-request-param: logout_uri +``` +Above configuration will create an application secured with sessions (not access tokens), with CSRF protection enabled, and unauthorized requests to protected resources will be redirected to login. + +### 3.3. Client and Resource Server +For an app exposing publicly both +- Thymeleaf templates secured with session (with login and logout), all templates being served with `/ui` prefix (but index which is at `/`) +- a REST API secured with access token +```xml + + org.springframework.boot + + spring-boot-starter-web + + + org.springframework.boot + + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + com.c4-soft.springaddons + spring-addons-starter-oidc + +``` +And +```yaml +auth0-issuer: https://oidc.c4-soft.com/auth/realms/master +auth0-client-id: change-me +auth0-secret: change-me + +spring: + security: + oauth2: + client: + provider: + auth0: + issuer-uri: ${auth0-issuer} + registration: + auth0-authorization-code: + authorization-grant-type: authorization_code + client-id: ${auth0-client-id} + client-secret: ${auth0-secret} + provider: auth0 + scope: openid,profile,email,offline_access +com: + c4-soft: + springaddons: + oidc: + ops: + - iss: ${auth0-issuer} + username-claim: $['https://c4-soft.com/user']['name'] + authorities: + - path: $['https://c4-soft.com/user']['roles'] + - path: $.permissions + client: + security-matchers: + - /login/** + - /oauth2/** + - /logout + - / + - /ui/** + permit-all: + - /login/** + - /oauth2/** + - / + # Auth0 and Cognito do not follow strictly the OpenID RP-Initiated Logout spec and need specific configuration + oauth2-logout: + auth0-authorization-code: + uri: ${auth0-issuer}v2/logout + client-id-request-param: client_id + post-logout-uri-request-param: returnTo + # Auth0 requires an "audience" parameter in authorization-code request to deliver JWTs + authorization-request-params: + auth0-authorization-code: + - name: audience + value: demo.c4-soft.com + resourceserver: + permit-all: + - "/greet/public" +``` +With the above configuration, two distinct security filter-chains will be defined: +- a client one with sessions (and CSRF protection enabled), intercepting all requests to UI templates as well as those involved in login and logout, and redirecting to login unauthorized requests to protected templates. +- a resource server one acting as default (with lowest precedence to process all requests that were not matched with client filter-chain `securityMatchers`), without sessions (requests are secured with JWT access tokens) nor CSRF protections, and returning 401 to unauthorized requests to protected resources. \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java deleted file mode 100644 index 0c7482c30..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/AuthoritiesMappingPropertiesResolver.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter; - -import java.util.List; -import java.util.Map; - -import com.c4_soft.springaddons.security.oidc.starter.properties.SimpleAuthoritiesMappingProperties; - -public interface AuthoritiesMappingPropertiesResolver { - List resolve(Map claimSet); -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java deleted file mode 100644 index 653aaaf6e..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerAuthoritiesMappingPropertiesResolver.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import org.springframework.security.oauth2.jwt.JwtClaimNames; - -import com.c4_soft.springaddons.security.oidc.starter.properties.SimpleAuthoritiesMappingProperties; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; - -import lombok.RequiredArgsConstructor; - -@RequiredArgsConstructor -public class ByIssuerAuthoritiesMappingPropertiesResolver implements AuthoritiesMappingPropertiesResolver { - private final SpringAddonsOidcProperties properties; - - @Override - public List resolve(Map claimSet) { - final var iss = Optional.ofNullable(claimSet.get(JwtClaimNames.ISS)).orElse(null); - return properties.getOpProperties(iss).getAuthorities(); - } -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerOpenidProviderPropertiesResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerOpenidProviderPropertiesResolver.java new file mode 100644 index 000000000..6681cf011 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ByIssuerOpenidProviderPropertiesResolver.java @@ -0,0 +1,28 @@ +package com.c4_soft.springaddons.security.oidc.starter; + +import java.net.URI; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.security.oauth2.jwt.JwtClaimNames; + +import com.c4_soft.springaddons.security.oidc.starter.properties.OpenidProviderProperties; +import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ByIssuerOpenidProviderPropertiesResolver implements OpenidProviderPropertiesResolver { + private final SpringAddonsOidcProperties properties; + + @Override + public Optional resolve(Map claimSet) { + final var iss = Optional.ofNullable(claimSet.get(JwtClaimNames.ISS)).map(Object::toString).orElse(null); + return properties + .getOps() + .stream() + .filter(issuerProps -> Objects.equals(Optional.ofNullable(issuerProps.getIss()).map(URI::toString).orElse(null), iss)) + .findAny(); + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java index 3f03d0d5c..e430e8a16 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableClaimSetAuthoritiesConverter.java @@ -9,8 +9,8 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.util.StringUtils; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.properties.SimpleAuthoritiesMappingProperties; -import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.PathNotFoundException; @@ -21,7 +21,8 @@ * Portable converter to extract Spring-security authorities from OAuth2 claims. *

*

- * It is designed to work with {@link SpringAddonsOidcProperties} which enable to configure: + * It relies on {@link OpenidProviderPropertiesResolver} to resolve the configuration properties for the provided claims (and throws if it is not resolved). + * This properties enable to configure: *

*
    *
  • source claims (which claims to pick authorities from, dot.separated.path is supported)
  • @@ -33,17 +34,13 @@ */ @RequiredArgsConstructor public class ConfigurableClaimSetAuthoritiesConverter implements ClaimSetAuthoritiesConverter { - private final AuthoritiesMappingPropertiesResolver authoritiesMappingPropertiesProvider; - - public ConfigurableClaimSetAuthoritiesConverter(SpringAddonsOidcProperties properties) { - this.authoritiesMappingPropertiesProvider = new ByIssuerAuthoritiesMappingPropertiesResolver(properties); - } + private final OpenidProviderPropertiesResolver opPropertiesResolver; @Override public Collection convert(Map source) { - final var authoritiesMappingProperties = authoritiesMappingPropertiesProvider.resolve(source); + final var opProperties = opPropertiesResolver.resolve(source).orElseThrow(() -> new NotAConfiguredOpenidProviderException(source)); // @formatter:off - return authoritiesMappingProperties.stream() + return opProperties.getAuthorities().stream() .flatMap(authoritiesMappingProps -> getAuthorities(source, authoritiesMappingProps)) .map(r -> (GrantedAuthority) new SimpleGrantedAuthority(r)).toList(); // @formatter:on diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/OpenidProviderPropertiesResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/OpenidProviderPropertiesResolver.java new file mode 100644 index 000000000..b52db57c0 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/OpenidProviderPropertiesResolver.java @@ -0,0 +1,14 @@ +package com.c4_soft.springaddons.security.oidc.starter; + +import java.util.Map; +import java.util.Optional; + +import com.c4_soft.springaddons.security.oidc.starter.properties.OpenidProviderProperties; + +/** + * Resolves OpenID Provider configuration properties from OAuth2 / OpenID claims (decoded from a JWT, introspected from an opaque token or + * retrieved from userinfo endpoint) + */ +public interface OpenidProviderPropertiesResolver { + Optional resolve(Map claimSet); +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/MissingAuthorizationServerConfigurationException.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/MissingAuthorizationServerConfigurationException.java deleted file mode 100644 index f50f1b30b..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/MissingAuthorizationServerConfigurationException.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter.properties; - -import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ResponseStatus; - -@ResponseStatus(HttpStatus.UNAUTHORIZED) -public class MissingAuthorizationServerConfigurationException extends RuntimeException { - private static final long serialVersionUID = 5189849969622154264L; - - public MissingAuthorizationServerConfigurationException(String jwtIssuer) { - super("Check application properties: %s is not a trusted issuer".formatted(jwtIssuer)); - } - -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/NotAConfiguredOpenidProviderException.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/NotAConfiguredOpenidProviderException.java new file mode 100644 index 000000000..9bff9a525 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/NotAConfiguredOpenidProviderException.java @@ -0,0 +1,18 @@ +package com.c4_soft.springaddons.security.oidc.starter.properties; + +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.UNAUTHORIZED) +public class NotAConfiguredOpenidProviderException extends RuntimeException { + private static final long serialVersionUID = 5189849969622154264L; + + public NotAConfiguredOpenidProviderException(Map claims) { + super( + "Could not resolve OpenID Provider configuration properties from a JWT with %s as issuer and %s as audience" + .formatted(claims.get("iss"), claims.get("aud"))); + } + +} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java index 3c84b734b..5469585be 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/properties/SpringAddonsOidcProperties.java @@ -1,9 +1,6 @@ package com.c4_soft.springaddons.security.oidc.starter.properties; -import java.net.URI; import java.util.List; -import java.util.Objects; -import java.util.Optional; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -53,31 +50,4 @@ public class SpringAddonsOidcProperties { */ private SpringAddonsOidcResourceServerProperties resourceserver = new SpringAddonsOidcResourceServerProperties(); - /** - * @param iss the issuer URI string - * @return configuration properties associated with the provided issuer URI - * @throws MissingAuthorizationServerConfigurationException if configuration properties don not have an entry for the exact issuer (even trailing slash is - * important) - */ - public OpenidProviderProperties getOpProperties(String iss) throws MissingAuthorizationServerConfigurationException { - return ops - .stream() - .filter(issuerProps -> Objects.equals(Optional.ofNullable(issuerProps.getIss()).map(URI::toString).orElse(null), iss)) - .findAny() - .orElseThrow(() -> new MissingAuthorizationServerConfigurationException(iss)); - } - - /** - * @param iss the issuer URL - * @return configuration properties associated with the provided issuer URI - * @throws MissingAuthorizationServerConfigurationException if configuration properties don not have an entry for the exact issuer (even trailing slash is - * important) - */ - public OpenidProviderProperties getOpProperties(Object iss) throws MissingAuthorizationServerConfigurationException { - if (iss == null && ops.size() == 1) { - return ops.get(0); - } - return getOpProperties(Optional.ofNullable(iss).map(Object::toString).orElse(null)); - } - } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java index 2ac1b3a77..59dddbebb 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveConfigurationSupport.java @@ -25,9 +25,9 @@ import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcClientProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcResourceServerProperties; import com.c4_soft.springaddons.security.oidc.starter.reactive.client.ClientAuthorizeExchangeSpecPostProcessor; -import com.c4_soft.springaddons.security.oidc.starter.reactive.client.ClientHttpSecurityPostProcessor; +import com.c4_soft.springaddons.security.oidc.starter.reactive.client.ClientServerHttpSecurityPostProcessor; import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerAuthorizeExchangeSpecPostProcessor; -import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerHttpSecurityPostProcessor; +import com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver.ResourceServerServerHttpSecurityPostProcessor; import reactor.core.publisher.Mono; @@ -40,7 +40,7 @@ public static ServerHttpSecurity configureResourceServer( ServerAuthenticationEntryPoint authenticationEntryPoint, Optional accessDeniedHandler, ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ResourceServerHttpSecurityPostProcessor httpPostProcessor) { + ResourceServerServerHttpSecurityPostProcessor httpPostProcessor) { ReactiveConfigurationSupport.configureCors(http, addonsResourceServerProperties.getCors()); ReactiveConfigurationSupport.configureState(http, addonsResourceServerProperties.isStatlessSessions(), addonsResourceServerProperties.getCsrf()); @@ -66,7 +66,7 @@ public static ServerHttpSecurity configureClient( ServerProperties serverProperties, SpringAddonsOidcClientProperties addonsClientProperties, ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ClientHttpSecurityPostProcessor httpPostProcessor) { + ClientServerHttpSecurityPostProcessor httpPostProcessor) { ReactiveConfigurationSupport.configureCors(http, addonsClientProperties.getCors()); ReactiveConfigurationSupport.configureState(http, false, addonsClientProperties.getCsrf()); diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveSpringAddonsOidcBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveSpringAddonsOidcBeans.java index bd7c95397..1b8152f96 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveSpringAddonsOidcBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ReactiveSpringAddonsOidcBeans.java @@ -29,8 +29,11 @@ import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenAuthenticationConverter; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.starter.ByIssuerOpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.ClaimSetAuthoritiesConverter; import com.c4_soft.springaddons.security.oidc.starter.ConfigurableClaimSetAuthoritiesConverter; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultGrantedAuthoritiesMapperCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultJwtAbstractAuthenticationTokenConverterCondition; @@ -50,6 +53,12 @@ @Slf4j public class ReactiveSpringAddonsOidcBeans { + @ConditionalOnMissingBean + OpenidProviderPropertiesResolver authoritiesMappingPropertiesProvider(SpringAddonsOidcProperties addonsProperties) { + log.debug("Building default AuthoritiesMappingPropertiesResolver with: {}", addonsProperties.getOps()); + return new ByIssuerOpenidProviderPropertiesResolver(addonsProperties); + } + /** * Retrieves granted authorities from the Jwt (from its private claims or with the help of an external service) * @@ -58,9 +67,8 @@ public class ReactiveSpringAddonsOidcBeans { */ @ConditionalOnMissingBean @Bean - ClaimSetAuthoritiesConverter authoritiesConverter(SpringAddonsOidcProperties addonsProperties) { - log.debug("Building default CorsConfigurationSource with: {}", addonsProperties); - return new ConfigurableClaimSetAuthoritiesConverter(addonsProperties); + ClaimSetAuthoritiesConverter authoritiesConverter(OpenidProviderPropertiesResolver authoritiesMappingPropertiesProvider) { + return new ConfigurableClaimSetAuthoritiesConverter(authoritiesMappingPropertiesProvider); } /** @@ -74,13 +82,18 @@ ClaimSetAuthoritiesConverter authoritiesConverter(SpringAddonsOidcProperties add @Bean ReactiveJwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { + OpenidProviderPropertiesResolver opPropertiesResolver) { return jwt -> Mono .just( new JwtAuthenticationToken( jwt, authoritiesConverter.convert(jwt.getClaims()), - new OpenidClaimSet(jwt.getClaims(), addonsProperties.getOpProperties(jwt.getIssuer()).getUsernameClaim()).getName())); + new OpenidClaimSet( + jwt.getClaims(), + opPropertiesResolver + .resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())) + .getUsernameClaim()).getName())); } /** diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/HttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ServerHttpSecurityPostProcessor.java similarity index 89% rename from spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/HttpSecurityPostProcessor.java rename to spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ServerHttpSecurityPostProcessor.java index f25485c1c..2e6ab6a47 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/HttpSecurityPostProcessor.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/ServerHttpSecurityPostProcessor.java @@ -8,6 +8,6 @@ * * @author ch4mp */ -public interface HttpSecurityPostProcessor { +public interface ServerHttpSecurityPostProcessor { ServerHttpSecurity process(ServerHttpSecurity serverHttpSecurity); } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientHttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientHttpSecurityPostProcessor.java deleted file mode 100644 index 682ce5ca6..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientHttpSecurityPostProcessor.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter.reactive.client; - -import com.c4_soft.springaddons.security.oidc.starter.reactive.HttpSecurityPostProcessor; - -public interface ClientHttpSecurityPostProcessor extends HttpSecurityPostProcessor { -} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientServerHttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientServerHttpSecurityPostProcessor.java new file mode 100644 index 000000000..310a4e05b --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ClientServerHttpSecurityPostProcessor.java @@ -0,0 +1,6 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.client; + +import com.c4_soft.springaddons.security.oidc.starter.reactive.ServerHttpSecurityPostProcessor; + +public interface ClientServerHttpSecurityPostProcessor extends ServerHttpSecurityPostProcessor { +} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java index 3899788f7..7726b5862 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/client/ReactiveSpringAddonsOidcClientWithLoginBeans.java @@ -62,7 +62,7 @@ *
  • clientAuthorizePostProcessor: a {@link ClientAuthorizeExchangeSpecPostProcessor} post processor to fine tune access control from java * configuration. It applies to all routes not listed in "permit-all" property configuration. Default requires users to be * authenticated.
  • - *
  • clientHttpPostProcessor: a {@link ClientHttpSecurityPostProcessor} to override anything from above auto-configuration. It is called + *
  • clientHttpPostProcessor: a {@link ClientServerHttpSecurityPostProcessor} to override anything from above auto-configuration. It is called * just before the security filter-chain is returned. Default is a no-op.
  • *
  • authorizationRequestResolver: a {@link ServerOAuth2AuthorizationRequestResolver} to add custom parameters (from application * properties) to authorization code request
  • @@ -91,7 +91,7 @@ public class ReactiveSpringAddonsOidcClientWithLoginBeans { *
  • CSRF protection as defined in spring-addons client properties (enabled by default in this filter-chain).
  • *
  • allow access to unauthorized requests to path matchers listed in spring-security client "permit-all" property
  • *
  • as usual, apply {@link ClientAuthorizeExchangeSpecPostProcessor} for access control configuration from Java conf and - * {@link ClientHttpSecurityPostProcessor} to override anything from the auto-configuration listed above
  • + * {@link ClientServerHttpSecurityPostProcessor} to override anything from the auto-configuration listed above *
* * @param http the security filter-chain builder to configure @@ -128,7 +128,7 @@ SecurityWebFilterChain clientFilterChain( Optional authenticationFailureHandler, ServerLogoutSuccessHandler logoutSuccessHandler, ClientAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ClientHttpSecurityPostProcessor httpPostProcessor, + ClientServerHttpSecurityPostProcessor httpPostProcessor, Optional logoutHandler, Customizer oidcLogoutCustomizer) throws Exception { @@ -226,7 +226,7 @@ ClientAuthorizeExchangeSpecPostProcessor clientAuthorizePostProcessor() { */ @ConditionalOnMissingBean @Bean - ClientHttpSecurityPostProcessor clientHttpPostProcessor() { + ClientServerHttpSecurityPostProcessor clientHttpPostProcessor() { return serverHttpSecurity -> serverHttpSecurity; } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/DefaultSpringAddonsReactiveJwtDecoderFactory.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/DefaultSpringAddonsReactiveJwtDecoderFactory.java new file mode 100644 index 000000000..4b37332e2 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/DefaultSpringAddonsReactiveJwtDecoderFactory.java @@ -0,0 +1,72 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; + +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ResponseStatus; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; + +import lombok.RequiredArgsConstructor; + +/** + *

+ * Provides with a JwtDecoder (configured with the required validators). Both JWK-set and issuer URIs are optional, but at least one must be provided. + *

+ *

+ * Uses {@link OpenidProviderPropertiesResolver} to resolve the matching OpenID Provider configuration properties and throws an exception if none are found (the + * token issuer is not trusted). + *

+ */ +@RequiredArgsConstructor +public class DefaultSpringAddonsReactiveJwtDecoderFactory implements SpringAddonsReactiveJwtDecoderFactory { + + @Override + public ReactiveJwtDecoder create(Optional jwkSetUri, Optional issuer, Optional audience) { + + final var decoder = jwkSetUri.isPresent() + ? NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri.get().toString()).build() + : NimbusReactiveJwtDecoder + .withIssuerLocation(issuer.orElseThrow(() -> new InvalidReactiveJwtDecoderCreationParametersException()).toString()) + .build(); + + final OAuth2TokenValidator defaultValidator = issuer + .map(URI::toString) + .map(JwtValidators::createDefaultWithIssuer) + .orElse(JwtValidators.createDefault()); + + // @formatter:off + final OAuth2TokenValidator jwtValidator = audience + .filter(StringUtils::hasText) + .map(opAudience -> new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud) -> aud != null && aud.contains(opAudience))) + .map(audValidator -> (OAuth2TokenValidator) new DelegatingOAuth2TokenValidator<>(List.of(defaultValidator, audValidator))) + .orElse(defaultValidator); + // @formatter:on + + decoder.setJwtValidator(jwtValidator); + + return decoder; + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + static class InvalidReactiveJwtDecoderCreationParametersException extends RuntimeException { + private static final long serialVersionUID = 3575615882241560832L; + + public InvalidReactiveJwtDecoderCreationParametersException() { + super("At least one of jwkSetUri or issuer must be provided"); + } + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java deleted file mode 100644 index 72c28fc28..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveIssuerStartsWithAuthenticationManagerResolver.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.ReactiveAuthenticationManager; -import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; -import org.springframework.web.bind.annotation.ResponseStatus; - -import reactor.core.publisher.Mono; - -/** - * Dynamic multi-tenancy based on issuer prefix (for instance, trust all reams from a given Keycloak Server) - * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> - */ -public class ReactiveIssuerStartsWithAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver { - - private final String issuerPrefix; - private final Converter> authenticationConverter; - private final Map jwtManagers = new ConcurrentHashMap<>(); - - /** - * @param issuerPrefix what access tokens iss claim must start with - * @param authenticationConverter converter from a valid {@link Jwt} to an {@link AbstractAuthenticationToken} instance - */ - public ReactiveIssuerStartsWithAuthenticationManagerResolver( - String issuerPrefix, - Converter> authenticationConverter) { - super(); - this.issuerPrefix = issuerPrefix.toString(); - this.authenticationConverter = authenticationConverter; - } - - @Override - public Mono resolve(String issuer) { - if (!jwtManagers.containsKey(issuer)) { - if (!issuer.startsWith(issuerPrefix)) { - throw new UnknownIssuerException(issuer); - } - final var decoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build(); - var provider = new JwtReactiveAuthenticationManager(decoder); - provider.setJwtAuthenticationConverter(authenticationConverter); - jwtManagers.put(issuer, provider::authenticate); - } - return Mono.just(jwtManagers.get(issuer)); - - } - - @ResponseStatus(HttpStatus.UNAUTHORIZED) - static class UnknownIssuerException extends RuntimeException { - private static final long serialVersionUID = 4177339081914400888L; - - public UnknownIssuerException(String issuer) { - super("Unknown issuer: %s".formatted(issuer)); - } - } -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java new file mode 100644 index 000000000..fa131027e --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveJWTClaimsSetAuthenticationManager.java @@ -0,0 +1,116 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; + +import java.net.URI; +import java.text.ParseException; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; +import org.springframework.util.Assert; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; +import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JWTClaimsSetAuthenticationManager.JWTClaimsSetAuthenticationManagerResolver; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; + +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +/** + *

+ * An {@link AuthenticationManager} relying on {@link JWTClaimsSetAuthenticationManagerResolver}, itself using {@link SpringAddonsReactiveJwtDecoderFactory} and + * a {@link Converter Converter<Jwt, Mono<? extends AbstractAuthenticationToken>>}. + *

+ *

+ * {@link DefaultSpringAddonsReactiveJwtDecoderFactory}, the default {@link SpringAddonsReactiveJwtDecoderFactory} throws an exception if the OpenID Provider + * configuration properties could not be resolved from the JWT claims. + *

+ * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ +public class ReactiveJWTClaimsSetAuthenticationManager implements ReactiveAuthenticationManager { + + private final ReactiveJWTClaimsSetAuthenticationManagerResolver jwtAuthenticationManagerResolver; + + public ReactiveJWTClaimsSetAuthenticationManager( + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsReactiveJwtDecoderFactory jwtDecoderFactory, + Converter> jwtAuthenticationConverter) { + this.jwtAuthenticationManagerResolver = new ReactiveJWTClaimsSetAuthenticationManagerResolver( + opPropertiesResolver, + jwtDecoderFactory, + jwtAuthenticationConverter); + } + + @Override + public Mono authenticate(Authentication authentication) throws AuthenticationException { + Assert.isTrue(authentication instanceof BearerTokenAuthenticationToken, "Authentication must be of type BearerTokenAuthenticationToken"); + JWTClaimsSet jwtClaimSet; + try { + jwtClaimSet = JWTParser.parse(((BearerTokenAuthenticationToken) authentication).getToken()).getJWTClaimsSet(); + } catch (ParseException e) { + throw new InvalidBearerTokenException("Could not retrieve JWT claim-set"); + } + return this.jwtAuthenticationManagerResolver.resolve(jwtClaimSet).flatMap(authenticationManager -> { + if (authenticationManager == null) { + throw new InvalidBearerTokenException("Could not resolve the Authentication manager for the provided JWT"); + } + return authenticationManager.authenticate(authentication); + }); + } + + /** + *

+ * An {@link ReactiveAuthenticationManagerResolver} for resource servers using JWT decoder(s). It relies on a {@link SpringAddonsReactiveJwtDecoderFactory} + * and a {@link Converter Converter<Jwt, Mono<? extends AbstractAuthenticationToken>>} + *

+ *

+ * {@link DefaultSpringAddonsReactiveJwtDecoderFactory}, the default {@link SpringAddonsReactiveJwtDecoderFactory} throws an exception if the OpenID + * Provider configuration properties could not be resolved from the JWT claims. + *

+ * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ + @RequiredArgsConstructor + public static class ReactiveJWTClaimsSetAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver { + + private final OpenidProviderPropertiesResolver opPropertiesResolver; + private final SpringAddonsReactiveJwtDecoderFactory jwtDecoderFactory; + private final Converter> jwtAuthenticationConverter; + private final Map jwtManagers = new ConcurrentHashMap<>(); + + @Override + public Mono resolve(JWTClaimsSet jwt) { + final var issuer = jwt.getIssuer(); + if (!jwtManagers.containsKey(issuer)) { + final var opProperties = opPropertiesResolver + .resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); + + final var decoder = jwtDecoderFactory + .create( + Optional.ofNullable(opProperties.getJwkSetUri()), + Optional.of(URI.create(jwt.getIssuer().toString())), + Optional.of(opProperties.getAud())); + + var provider = new JwtReactiveAuthenticationManager(decoder); + provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); + jwtManagers.put(issuer, provider::authenticate); + } + return Mono.just(jwtManagers.get(issuer)); + } + } + +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java index 94b60f438..77861db40 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ReactiveSpringAddonsOidcResourceServerBeans.java @@ -1,17 +1,11 @@ package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; -import java.net.URI; import java.nio.charset.Charset; -import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; @@ -22,21 +16,12 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.jwt.JwtClaimValidator; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerReactiveAuthenticationManagerResolver; -import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.ReactiveOpaqueTokenIntrospector; import org.springframework.security.web.AuthenticationEntryPoint; @@ -45,10 +30,10 @@ import org.springframework.security.web.server.ServerAuthenticationEntryPoint; import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; import org.springframework.security.web.server.csrf.CsrfToken; -import org.springframework.util.StringUtils; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.CookieCsrfCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationManagerResolverCondition; @@ -59,7 +44,6 @@ import com.c4_soft.springaddons.security.oidc.starter.reactive.ReactiveConfigurationSupport; import com.c4_soft.springaddons.security.oidc.starter.reactive.ReactiveSpringAddonsOidcBeans; -import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; /** @@ -91,7 +75,6 @@ @EnableWebFluxSecurity @AutoConfiguration @ImportAutoConfiguration(ReactiveSpringAddonsOidcBeans.class) -@Slf4j public class ReactiveSpringAddonsOidcResourceServerBeans { /** @@ -101,7 +84,7 @@ public class ReactiveSpringAddonsOidcResourceServerBeans { *

*

* You should consider to set security matcher to all other {@link SecurityWebFilterChain} beans and provide a - * {@link ResourceServerHttpSecurityPostProcessor} bean to override anything from this bean + * {@link ResourceServerServerHttpSecurityPostProcessor} bean to override anything from this bean *

* . * @@ -123,7 +106,7 @@ SecurityWebFilterChain springAddonsJwtResourceServerSecurityFilterChain( ServerProperties serverProperties, SpringAddonsOidcProperties addonsProperties, ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ResourceServerHttpSecurityPostProcessor httpPostProcessor, + ResourceServerServerHttpSecurityPostProcessor httpPostProcessor, ReactiveAuthenticationManagerResolver authenticationManagerResolver, ServerAuthenticationEntryPoint authenticationEntryPoint, Optional accessDeniedHandler) { @@ -149,7 +132,7 @@ SecurityWebFilterChain springAddonsJwtResourceServerSecurityFilterChain( *

*

* You should consider to set security matcher to all other {@link SecurityWebFilterChain} beans and provide a - * {@link ResourceServerHttpSecurityPostProcessor} bean to override anything from this bean + * {@link ResourceServerServerHttpSecurityPostProcessor} bean to override anything from this bean *

* . * @@ -172,7 +155,7 @@ SecurityWebFilterChain springAddonsIntrospectingResourceServerSecurityFilterChai ServerProperties serverProperties, SpringAddonsOidcProperties addonsProperties, ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor, - ResourceServerHttpSecurityPostProcessor httpPostProcessor, + ResourceServerServerHttpSecurityPostProcessor httpPostProcessor, ReactiveOpaqueTokenAuthenticationConverter introspectionAuthenticationConverter, ReactiveOpaqueTokenIntrospector opaqueTokenIntrospector, ServerAuthenticationEntryPoint authenticationEntryPoint, @@ -214,7 +197,7 @@ ResourceServerAuthorizeExchangeSpecPostProcessor authorizePostProcessor() { */ @ConditionalOnMissingBean @Bean - ResourceServerHttpSecurityPostProcessor httpPostProcessor() { + ResourceServerServerHttpSecurityPostProcessor httpPostProcessor() { return serverHttpSecurity -> serverHttpSecurity; } @@ -229,55 +212,10 @@ ResourceServerHttpSecurityPostProcessor httpPostProcessor() { @Conditional(DefaultAuthenticationManagerResolverCondition.class) @Bean ReactiveAuthenticationManagerResolver authenticationManagerResolver( - OAuth2ResourceServerProperties auth2ResourceServerProperties, - SpringAddonsOidcProperties addonsProperties, + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsReactiveJwtDecoderFactory jwtDecoderFactory, Converter> jwtAuthenticationConverter) { - final var jwtProps = Optional.ofNullable(auth2ResourceServerProperties).map(OAuth2ResourceServerProperties::getJwt); - // @formatter:off - Optional.ofNullable(jwtProps.map(OAuth2ResourceServerProperties.Jwt::getIssuerUri).orElse(jwtProps.map(OAuth2ResourceServerProperties.Jwt::getJwkSetUri).orElse(null))) - .filter(StringUtils::hasLength) - .ifPresent(jwtConf -> { - log.warn("spring.security.oauth2.resourceserver configuration will be ignored in favor of com.c4-soft.springaddons.oidc"); - }); - // @formatter:on - - final Map> jwtManagers = addonsProperties - .getOps() - .stream() - .collect(Collectors.toMap(issuer -> issuer.getIss().toString(), issuer -> { - final var decoder = issuer.getJwkSetUri() != null && StringUtils.hasLength(issuer.getJwkSetUri().toString()) - ? NimbusReactiveJwtDecoder.withJwkSetUri(issuer.getJwkSetUri().toString()).build() - : NimbusReactiveJwtDecoder.withIssuerLocation(issuer.getIss().toString()).build(); - - final OAuth2TokenValidator defaultValidator = Optional - .ofNullable(issuer.getIss()) - .map(URI::toString) - .map(JwtValidators::createDefaultWithIssuer) - .orElse(JwtValidators.createDefault()); - - // If the spring-addons conf for resource server contains a non empty audience, add an audience validator - // @formatter:off - final OAuth2TokenValidator jwtValidator = Optional.ofNullable(issuer.getAud()) - .filter(StringUtils::hasText) - .map(audience -> new JwtClaimValidator>( - JwtClaimNames.AUD, - (aud) -> aud != null && aud.contains(audience))) - .map(audValidator -> (OAuth2TokenValidator) new DelegatingOAuth2TokenValidator<>(List.of(defaultValidator, audValidator))) - .orElse(defaultValidator); - // @formatter:on - - decoder.setJwtValidator(jwtValidator); - var provider = new JwtReactiveAuthenticationManager(decoder); - provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); - return Mono.just(provider); - })); - - log - .debug( - "Building default JwtIssuerReactiveAuthenticationManagerResolver with: {} {}", - auth2ResourceServerProperties.getJwt(), - Stream.of(addonsProperties.getOps()).toList()); - return new JwtIssuerReactiveAuthenticationManagerResolver(issuerLocation -> jwtManagers.getOrDefault(issuerLocation, Mono.empty())); + return new SpringAddonsReactiveJwtAuthenticationManagerResolver(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); } /** diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ResourceServerHttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ResourceServerServerHttpSecurityPostProcessor.java similarity index 65% rename from spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ResourceServerHttpSecurityPostProcessor.java rename to spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ResourceServerServerHttpSecurityPostProcessor.java index 7a167d4dc..d5f48b452 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ResourceServerHttpSecurityPostProcessor.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/ResourceServerServerHttpSecurityPostProcessor.java @@ -2,7 +2,7 @@ import org.springframework.security.config.web.server.ServerHttpSecurity; -import com.c4_soft.springaddons.security.oidc.starter.reactive.HttpSecurityPostProcessor; +import com.c4_soft.springaddons.security.oidc.starter.reactive.ServerHttpSecurityPostProcessor; /** * Process {@link ServerHttpSecurity} of default security filter-chain after it was processed by spring-addons. This enables to override anything that was @@ -10,5 +10,5 @@ * * @author ch4mp */ -public interface ResourceServerHttpSecurityPostProcessor extends HttpSecurityPostProcessor { +public interface ResourceServerServerHttpSecurityPostProcessor extends ServerHttpSecurityPostProcessor { } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java new file mode 100644 index 000000000..2e531e8e7 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtAuthenticationManagerResolver.java @@ -0,0 +1,43 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.server.ServerWebExchange; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JWTClaimsSetAuthenticationManager.JWTClaimsSetAuthenticationManagerResolver; +import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.SpringAddonsJwtDecoderFactory; + +import reactor.core.publisher.Mono; + +/** + *

+ * An {@link ReactiveAuthenticationManagerResolver} always resolving the same {@link ReactiveJWTClaimsSetAuthenticationManager} which relies on + * {@link JWTClaimsSetAuthenticationManagerResolver}, itself using {@link SpringAddonsJwtDecoderFactory} and a {@link Converter Converter@lt;Jwt, + * AbstractAuthenticationToken>}. + *

+ *

+ * {@link DefaultSpringAddonsReactiveJwtDecoderFactory}, the default {@link SpringAddonsReactiveJwtDecoderFactory} throws an exception if the OpenID Provider + * configuration properties could not be resolved from the JWT claims. + *

+ * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ +public class SpringAddonsReactiveJwtAuthenticationManagerResolver implements ReactiveAuthenticationManagerResolver { + private final ReactiveJWTClaimsSetAuthenticationManager authenticationManager; + + public SpringAddonsReactiveJwtAuthenticationManagerResolver( + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsReactiveJwtDecoderFactory jwtDecoderFactory, + Converter> jwtAuthenticationConverter) { + authenticationManager = new ReactiveJWTClaimsSetAuthenticationManager(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); + } + + @Override + public Mono resolve(ServerWebExchange context) { + return Mono.just(authenticationManager); + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtDecoderFactory.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtDecoderFactory.java new file mode 100644 index 000000000..b89cc4847 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/reactive/resourceserver/SpringAddonsReactiveJwtDecoderFactory.java @@ -0,0 +1,21 @@ +package com.c4_soft.springaddons.security.oidc.starter.reactive.resourceserver; + +import java.net.URI; +import java.util.Optional; + +import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; + +/** + *

+ * Provides with a JwtDecoder (configured with the required validators). Both JWK-set and issuer URIs are optional, but at least one should be provided. + *

+ *

+ * {@link DefaultSpringAddonsReactiveJwtDecoderFactory}, the default implementation uses {@link OpenidProviderPropertiesResolver} to resolve the matching OpenID Provider + * configuration properties and throws an exception if none are found (the token issuer is not trusted). + *

+ */ +public interface SpringAddonsReactiveJwtDecoderFactory { + ReactiveJwtDecoder create(Optional jwkSetUri, Optional issuer, Optional audience); +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServerHttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/HttpSecurityPostProcessor.java similarity index 81% rename from spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServerHttpSecurityPostProcessor.java rename to spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/HttpSecurityPostProcessor.java index 837c05246..9c6a85d03 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/ServerHttpSecurityPostProcessor.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/HttpSecurityPostProcessor.java @@ -2,6 +2,6 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; -public interface ServerHttpSecurityPostProcessor { +public interface HttpSecurityPostProcessor { HttpSecurity process(HttpSecurity httpSecurity) throws Exception; } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/SpringAddonsOidcBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/SpringAddonsOidcBeans.java index 9dd5d39ed..deecffac0 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/SpringAddonsOidcBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/SpringAddonsOidcBeans.java @@ -32,8 +32,11 @@ import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import com.c4_soft.springaddons.security.oidc.OpenidClaimSet; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.ByIssuerOpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.ClaimSetAuthoritiesConverter; import com.c4_soft.springaddons.security.oidc.starter.ConfigurableClaimSetAuthoritiesConverter; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultGrantedAuthoritiesMapperCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultJwtAbstractAuthenticationTokenConverterCondition; @@ -51,6 +54,12 @@ @Slf4j public class SpringAddonsOidcBeans { + @ConditionalOnMissingBean + OpenidProviderPropertiesResolver authoritiesMappingPropertiesProvider(SpringAddonsOidcProperties addonsProperties) { + log.debug("Building default AuthoritiesMappingPropertiesResolver with: {}", addonsProperties.getOps()); + return new ByIssuerOpenidProviderPropertiesResolver(addonsProperties); + } + /** * Retrieves granted authorities from a claims-set (decoded from JWT, introspected or obtained from userinfo end-point) * @@ -59,9 +68,8 @@ public class SpringAddonsOidcBeans { */ @ConditionalOnMissingBean @Bean - ClaimSetAuthoritiesConverter authoritiesConverter(SpringAddonsOidcProperties addonsProperties) { - log.debug("Building default ConfigurableClaimSetAuthoritiesConverter with: {}", addonsProperties.getOps()); - return new ConfigurableClaimSetAuthoritiesConverter(addonsProperties); + ClaimSetAuthoritiesConverter authoritiesConverter(OpenidProviderPropertiesResolver authoritiesMappingPropertiesProvider) { + return new ConfigurableClaimSetAuthoritiesConverter(authoritiesMappingPropertiesProvider); } /** @@ -75,11 +83,14 @@ ClaimSetAuthoritiesConverter authoritiesConverter(SpringAddonsOidcProperties add @Bean JwtAbstractAuthenticationTokenConverter jwtAuthenticationConverter( Converter, Collection> authoritiesConverter, - SpringAddonsOidcProperties addonsProperties) { + OpenidProviderPropertiesResolver opPropertiesResolver) { return jwt -> new JwtAuthenticationToken( jwt, authoritiesConverter.convert(jwt.getClaims()), - new OpenidClaimSet(jwt.getClaims(), addonsProperties.getOpProperties(jwt.getIssuer()).getUsernameClaim()).getName()); + new OpenidClaimSet( + jwt.getClaims(), + opPropertiesResolver.resolve(jwt.getClaims()).orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())).getUsernameClaim()) + .getName()); } /** diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/ClientHttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/ClientHttpSecurityPostProcessor.java index 0aa4ee38d..f3b6d40b3 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/ClientHttpSecurityPostProcessor.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/client/ClientHttpSecurityPostProcessor.java @@ -1,11 +1,11 @@ package com.c4_soft.springaddons.security.oidc.starter.synchronised.client; -import com.c4_soft.springaddons.security.oidc.starter.synchronised.ServerHttpSecurityPostProcessor; +import com.c4_soft.springaddons.security.oidc.starter.synchronised.HttpSecurityPostProcessor; /** * A post-processor to override anything from spring-addons client security filter-chain auto-configuration. * * @author Jerome Wacongne ch4mp@c4-soft.com */ -public interface ClientHttpSecurityPostProcessor extends ServerHttpSecurityPostProcessor { +public interface ClientHttpSecurityPostProcessor extends HttpSecurityPostProcessor { } \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/DefaultSpringAddonsJwtDecoderFactory.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/DefaultSpringAddonsJwtDecoderFactory.java new file mode 100644 index 000000000..1d838298c --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/DefaultSpringAddonsJwtDecoderFactory.java @@ -0,0 +1,70 @@ +package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; + +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ResponseStatus; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; + +import lombok.RequiredArgsConstructor; + +/** + *

+ * Provides with a JwtDecoder (configured with the required validators). Both JWK-set and issuer URIs are optional, but at least one must be provided. + *

+ *

+ * Uses {@link OpenidProviderPropertiesResolver} to resolve the matching OpenID Provider configuration properties and throws an exception if none are found + * (the token issuer is not trusted). + *

+ */ +@RequiredArgsConstructor +public class DefaultSpringAddonsJwtDecoderFactory implements SpringAddonsJwtDecoderFactory { + + @Override + public JwtDecoder create(Optional jwkSetUri, Optional issuer, Optional audience) { + + final var decoder = jwkSetUri.isPresent() + ? NimbusJwtDecoder.withJwkSetUri(jwkSetUri.get().toString()).build() + : NimbusJwtDecoder.withIssuerLocation(issuer.orElseThrow(() -> new InvalidJwtDecoderCreationParametersException()).toString()).build(); + + final OAuth2TokenValidator defaultValidator = issuer + .map(URI::toString) + .map(JwtValidators::createDefaultWithIssuer) + .orElse(JwtValidators.createDefault()); + + // @formatter:off + final OAuth2TokenValidator jwtValidator = audience + .filter(StringUtils::hasText) + .map(opAudience -> new JwtClaimValidator>( + JwtClaimNames.AUD, + (aud) -> aud != null && aud.contains(opAudience))) + .map(audValidator -> (OAuth2TokenValidator) new DelegatingOAuth2TokenValidator<>(List.of(defaultValidator, audValidator))) + .orElse(defaultValidator); + // @formatter:on + + decoder.setJwtValidator(jwtValidator); + + return decoder; + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + static class InvalidJwtDecoderCreationParametersException extends RuntimeException { + private static final long serialVersionUID = 3575615882241560832L; + + public InvalidJwtDecoderCreationParametersException() { + super("At least one of jwkSetUri or issuer must be provided"); + } + } +} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java deleted file mode 100644 index b71d0fb22..000000000 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/IssuerStartsWithAuthenticationManagerResolver.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.http.HttpStatus; -import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationManagerResolver; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; -import org.springframework.web.bind.annotation.ResponseStatus; - -/** - * Dynamic multi-tenancy based on issuer prefix (for instance, trust all reams from a given Keycloak Server) - * - * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> - */ -public class IssuerStartsWithAuthenticationManagerResolver implements AuthenticationManagerResolver { - - private final String issuerPrefix; - private final Converter authenticationConverter; - private final Map jwtManagers = new ConcurrentHashMap<>(); - - /** - * @param issuerPrefix what access tokens iss claim must start with - * @param authenticationConverter converter from a valid {@link Jwt} to an {@link AbstractAuthenticationToken} instance - */ - public IssuerStartsWithAuthenticationManagerResolver(String issuerPrefix, Converter authenticationConverter) { - super(); - this.issuerPrefix = issuerPrefix.toString(); - this.authenticationConverter = authenticationConverter; - } - - @Override - public AuthenticationManager resolve(String issuer) { - if (!jwtManagers.containsKey(issuer)) { - if (!issuer.startsWith(issuerPrefix)) { - throw new UnknownIssuerException(issuer); - } - final var decoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); - var provider = new JwtAuthenticationProvider(decoder); - provider.setJwtAuthenticationConverter(authenticationConverter); - jwtManagers.put(issuer, provider::authenticate); - } - return jwtManagers.get(issuer); - - } - - @ResponseStatus(HttpStatus.UNAUTHORIZED) - static class UnknownIssuerException extends RuntimeException { - private static final long serialVersionUID = -7140122776788781704L; - - public UnknownIssuerException(String issuer) { - super("Unknown issuer: %s".formatted(issuer)); - } - } -} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java new file mode 100644 index 000000000..5c129aac3 --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/JWTClaimsSetAuthenticationManager.java @@ -0,0 +1,109 @@ +package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; + +import java.net.URI; +import java.text.ParseException; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.util.Assert; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.properties.NotAConfiguredOpenidProviderException; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; + +import lombok.RequiredArgsConstructor; + +/** + *

+ * An {@link AuthenticationManager} relying on {@link JWTClaimsSetAuthenticationManagerResolver}, itself using {@link SpringAddonsJwtDecoderFactory} and a + * {@link Converter Converter<Jwt, AbstractAuthenticationToken>}. + *

+ *

+ * {@link DefaultSpringAddonsJwtDecoderFactory}, the default {@link SpringAddonsJwtDecoderFactory} throws an exception if the OpenID Provider configuration + * properties could not be resolved from the JWT claims. + *

+ * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ +public class JWTClaimsSetAuthenticationManager implements AuthenticationManager { + + private final JWTClaimsSetAuthenticationManagerResolver jwtAuthenticationManagerResolver; + + public JWTClaimsSetAuthenticationManager( + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsJwtDecoderFactory jwtDecoderFactory, + Converter jwtAuthenticationConverter) { + this.jwtAuthenticationManagerResolver = new JWTClaimsSetAuthenticationManagerResolver( + opPropertiesResolver, + jwtDecoderFactory, + jwtAuthenticationConverter); + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + Assert.isTrue(authentication instanceof BearerTokenAuthenticationToken, "Authentication must be of type BearerTokenAuthenticationToken"); + JWTClaimsSet jwtClaimSet; + try { + jwtClaimSet = JWTParser.parse(((BearerTokenAuthenticationToken) authentication).getToken()).getJWTClaimsSet(); + } catch (ParseException e) { + throw new InvalidBearerTokenException("Could not retrieve JWT claim-set"); + } + AuthenticationManager authenticationManager = this.jwtAuthenticationManagerResolver.resolve(jwtClaimSet); + if (authenticationManager == null) { + throw new InvalidBearerTokenException("Could not resolve the authentication manager for the provided JWT"); + } + return authenticationManager.authenticate(authentication); + } + + /** + *

+ * An {@link AuthenticationManagerResolver} for resource servers using JWT decoder(s). It relies on a {@link SpringAddonsJwtDecoderFactory} and a + * {@link Converter Converter<Jwt, AbstractAuthenticationToken>} + *

+ *

+ * {@link DefaultSpringAddonsJwtDecoderFactory}, the default {@link SpringAddonsJwtDecoderFactory} throws an exception if the OpenID Provider configuration + * properties could not be resolved from the JWT claims. + *

+ * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ + @RequiredArgsConstructor + public static class JWTClaimsSetAuthenticationManagerResolver implements AuthenticationManagerResolver { + + private final OpenidProviderPropertiesResolver opPropertiesResolver; + private final SpringAddonsJwtDecoderFactory jwtDecoderFactory; + private final Converter jwtAuthenticationConverter; + private final Map jwtManagers = new ConcurrentHashMap<>(); + + @Override + public AuthenticationManager resolve(JWTClaimsSet jwt) { + final var issuer = jwt.getIssuer(); + if (!jwtManagers.containsKey(issuer)) { + final var opProperties = opPropertiesResolver + .resolve(jwt.getClaims()) + .orElseThrow(() -> new NotAConfiguredOpenidProviderException(jwt.getClaims())); + + final var decoder = jwtDecoderFactory + .create(Optional.ofNullable(opProperties.getJwkSetUri()), Optional.of(URI.create(jwt.getIssuer())), Optional.of(opProperties.getAud())); + + var provider = new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); + jwtManagers.put(issuer, provider::authenticate); + } + return jwtManagers.get(issuer); + } + } + +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/ResourceServerHttpSecurityPostProcessor.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/ResourceServerHttpSecurityPostProcessor.java index 129533ab3..4afb859bb 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/ResourceServerHttpSecurityPostProcessor.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/ResourceServerHttpSecurityPostProcessor.java @@ -2,7 +2,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import com.c4_soft.springaddons.security.oidc.starter.synchronised.ServerHttpSecurityPostProcessor; +import com.c4_soft.springaddons.security.oidc.starter.synchronised.HttpSecurityPostProcessor; /** * Process {@link HttpSecurity} of default security filter-chain after it was processed by spring-addons. This enables to override anything that was @@ -10,5 +10,5 @@ * * @author ch4mp */ -public interface ResourceServerHttpSecurityPostProcessor extends ServerHttpSecurityPostProcessor { +public interface ResourceServerHttpSecurityPostProcessor extends HttpSecurityPostProcessor { } diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java new file mode 100644 index 000000000..f088a5b5d --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtAuthenticationManagerResolver.java @@ -0,0 +1,41 @@ +package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.oauth2.jwt.Jwt; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; +import com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver.JWTClaimsSetAuthenticationManager.JWTClaimsSetAuthenticationManagerResolver; + +import jakarta.servlet.http.HttpServletRequest; + +/** + *

+ * An {@link AuthenticationManagerResolver} always resolving the same {@link JWTClaimsSetAuthenticationManager} which relies on + * {@link JWTClaimsSetAuthenticationManagerResolver}, itself using {@link SpringAddonsJwtDecoderFactory} and a {@link Converter Converter@lt;Jwt, + * AbstractAuthenticationToken>}. + *

+ *

+ * {@link DefaultSpringAddonsJwtDecoderFactory}, the default {@link SpringAddonsJwtDecoderFactory} throws an exception if the OpenID Provider configuration + * properties could not be resolved from the JWT claims. + *

+ * + * @author Jérôme Wacongne <ch4mp#64;c4-soft.com> + */ +public class SpringAddonsJwtAuthenticationManagerResolver implements AuthenticationManagerResolver { + private final JWTClaimsSetAuthenticationManager authenticationManager; + + public SpringAddonsJwtAuthenticationManagerResolver( + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsJwtDecoderFactory jwtDecoderFactory, + Converter jwtAuthenticationConverter) { + this.authenticationManager = new JWTClaimsSetAuthenticationManager(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); + } + + @Override + public AuthenticationManager resolve(HttpServletRequest context) { + return authenticationManager; + } +} diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtDecoderFactory.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtDecoderFactory.java new file mode 100644 index 000000000..ea8a7603a --- /dev/null +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsJwtDecoderFactory.java @@ -0,0 +1,21 @@ +package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; + +import java.net.URI; +import java.util.Optional; + +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; + +/** + *

+ * Provides with a JwtDecoder (configured with the required validators). Both JWK-set and issuer URIs are optional, but at least one should be provided. + *

+ *

+ * {@link DefaultSpringAddonsJwtDecoderFactory}, the default implementation uses {@link OpenidProviderPropertiesResolver} to resolve the matching OpenID Provider + * configuration properties and throws an exception if none are found (the token issuer is not trusted). + *

+ */ +public interface SpringAddonsJwtDecoderFactory { + JwtDecoder create(Optional jwkSetUri, Optional issuer, Optional audience); +} \ No newline at end of file diff --git a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java index e9850ca5f..c744fb46d 100644 --- a/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java +++ b/spring-addons-starter-oidc/src/main/java/com/c4_soft/springaddons/security/oidc/starter/synchronised/resourceserver/SpringAddonsOidcResourceServerBeans.java @@ -1,18 +1,12 @@ package com.c4_soft.springaddons.security.oidc.starter.synchronised.resourceserver; -import java.net.URI; -import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; -import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; @@ -22,30 +16,22 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AbstractAuthenticationToken; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManagerResolver; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; -import org.springframework.security.oauth2.core.OAuth2TokenValidator; import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtClaimNames; -import org.springframework.security.oauth2.jwt.JwtClaimValidator; import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; -import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter; import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.server.SecurityWebFilterChain; -import org.springframework.util.StringUtils; +import com.c4_soft.springaddons.security.oidc.starter.OpenidProviderPropertiesResolver; import com.c4_soft.springaddons.security.oidc.starter.properties.SpringAddonsOidcProperties; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.DefaultAuthenticationManagerResolverCondition; import com.c4_soft.springaddons.security.oidc.starter.properties.condition.bean.IsIntrospectingResourceServerCondition; @@ -55,7 +41,6 @@ import com.c4_soft.springaddons.security.oidc.starter.synchronised.SpringAddonsOidcBeans; import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; /** *

@@ -91,7 +76,6 @@ @EnableWebSecurity @AutoConfiguration @ImportAutoConfiguration(SpringAddonsOidcBeans.class) -@Slf4j public class SpringAddonsOidcResourceServerBeans { /** *

@@ -202,63 +186,18 @@ ResourceServerHttpSecurityPostProcessor httpPostProcessor() { /** * Provides with multi-tenancy: builds a AuthenticationManagerResolver per provided OIDC issuer URI * - * @param auth2ResourceServerProperties "spring.security.oauth2.resourceserver" configuration properties - * @param addonsProperties "com.c4-soft.springaddons.oidc" configuration properties + * @param opPropertiesResolver a resolver for OpenID Provider configuration properties + * @param jwtDecoderFactory something to build a JWT decoder from OpenID Provider configuration properties * @param jwtAuthenticationConverter converts from a {@link Jwt} to an {@link Authentication} implementation * @return Multi-tenant {@link AuthenticationManagerResolver} (one for each configured issuer) */ @Conditional(DefaultAuthenticationManagerResolverCondition.class) @Bean AuthenticationManagerResolver authenticationManagerResolver( - OAuth2ResourceServerProperties auth2ResourceServerProperties, - SpringAddonsOidcProperties addonsProperties, + OpenidProviderPropertiesResolver opPropertiesResolver, + SpringAddonsJwtDecoderFactory jwtDecoderFactory, Converter jwtAuthenticationConverter) { - final var jwtProps = Optional.ofNullable(auth2ResourceServerProperties).map(OAuth2ResourceServerProperties::getJwt); - // @formatter:off - Optional.ofNullable(jwtProps.map(OAuth2ResourceServerProperties.Jwt::getIssuerUri)).orElse(jwtProps.map(OAuth2ResourceServerProperties.Jwt::getJwkSetUri)) - .filter(StringUtils::hasLength) - .ifPresent(jwtConf -> { - log.warn("spring.security.oauth2.resourceserver configuration will be ignored in favor of com.c4-soft.springaddons.oidc"); - }); - // @formatter:on - - final Map jwtManagers = addonsProperties - .getOps() - .stream() - .collect(Collectors.toMap(issuer -> issuer.getIss().toString(), issuer -> { - final var decoder = issuer.getJwkSetUri() != null && StringUtils.hasLength(issuer.getJwkSetUri().toString()) - ? NimbusJwtDecoder.withJwkSetUri(issuer.getJwkSetUri().toString()).build() - : NimbusJwtDecoder.withIssuerLocation(issuer.getIss().toString()).build(); - - final OAuth2TokenValidator defaultValidator = Optional - .ofNullable(issuer.getIss()) - .map(URI::toString) - .map(JwtValidators::createDefaultWithIssuer) - .orElse(JwtValidators.createDefault()); - - // @formatter:off - final OAuth2TokenValidator jwtValidator = Optional.ofNullable(issuer.getAud()) - .filter(StringUtils::hasText) - .map(audience -> new JwtClaimValidator>( - JwtClaimNames.AUD, - (aud) -> aud != null && aud.contains(audience))) - .map(audValidator -> (OAuth2TokenValidator) new DelegatingOAuth2TokenValidator<>(List.of(defaultValidator, audValidator))) - .orElse(defaultValidator); - // @formatter:on - - decoder.setJwtValidator(jwtValidator); - var provider = new JwtAuthenticationProvider(decoder); - provider.setJwtAuthenticationConverter(jwtAuthenticationConverter); - return provider::authenticate; - })); - - log - .debug( - "Building default JwtIssuerAuthenticationManagerResolver with: ", - auth2ResourceServerProperties.getJwt(), - Stream.of(addonsProperties.getOps()).toList()); - - return new JwtIssuerAuthenticationManagerResolver((AuthenticationManagerResolver) jwtManagers::get); + return new SpringAddonsJwtAuthenticationManagerResolver(opPropertiesResolver, jwtDecoderFactory, jwtAuthenticationConverter); } @ConditionalOnMissingBean diff --git a/spring-addons-starter-oidc/src/test/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableJwtGrantedAuthoritiesConverterTest.java b/spring-addons-starter-oidc/src/test/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableJwtGrantedAuthoritiesConverterTest.java index 0e8faeb46..69b4c7072 100644 --- a/spring-addons-starter-oidc/src/test/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableJwtGrantedAuthoritiesConverterTest.java +++ b/spring-addons-starter-oidc/src/test/java/com/c4_soft/springaddons/security/oidc/starter/ConfigurableJwtGrantedAuthoritiesConverterTest.java @@ -53,7 +53,7 @@ public void test() throws URISyntaxException { final var properties = new SpringAddonsOidcProperties(); properties.setOps(List.of(issuerProperties)); - final var converter = new ConfigurableClaimSetAuthoritiesConverter(properties); + final var converter = new ConfigurableClaimSetAuthoritiesConverter(new ByIssuerOpenidProviderPropertiesResolver(properties)); final var claimSet = new OpenidClaimSet(jwt.getClaims()); // Assert mapping with default properties