Friday, August 26, 2022

OAuth Integration - Service Client

What seemed like a simple endeavor -- learn how to implement OAuth to protect web services and portal applications using Java and Spring -- turned out to be much more complicated than expected. The posts in this series are targeting two groups -- developers with little background in the goals and mechanics of OAuth and more experienced developers who just need help avoiding pitfalls resulting from inconsistencies in implementations of OAuth. The content is divided into multiple posts:

OAuth Integration - Overview
OAuth Integration - Challenges
OAuth Integration - Authorization Server
OAuth Integration - Command Line Validation
OAuth Integration - Resource Server
OAuth Integration - Service Client<---- YOU ARE HERE
OAuth Integration - User Portal Application



6.2 Spring Class Overview for OAuth2 Clients

Testing the OAuth2 protected service in the prior installment of this series helped clarify the work flow between client, Authorization Server and Resource Server involved when executing remote web services. That network flow involves:

  1. sending a grant_type=client_credential request to the token endpoint of the authorization server
  2. retaining the access token returned then embedding it within each outbound request to the resource server
  3. looking for access token expirations or authorization failures from the resource server to trigger the request of a new token from the authorization server

Within the actual client itself, Spring libraries link these core tasks to a larger number of objects optimized to house information needed to interact with the OAuth2 Authorization Server and tailor a larger client process to make the sidebar call to the Authorization Server any time an access token needs to be obtained or refreshed.

Development is complicated by the fact that Spring further bifurcates these helper objects into TWO sets of implementation classes, one set for new fangled Reactive clients and another set for legacy applications using older concepts which are thread-blocking. In a few cases, the classes are further bifurcated to handle cases where the Java program is

A) Reactive operating with requests arriving in a WebServerExchange paradigm OR
B) Reactive operating outside a WebServiceExchange request paradigm (batch program, etc.)

Or

C) non-reactive operating with requests arriving in a ServletContext
D) non-reactive operating with requests arriving outside a ServletContext (batch program, etc.)

Using the most vanilla class names, a process acting as an OAuth2 client must perform the following steps at startup to prepare for accessing an OAuth2 protected endpoint.

  1. scan application.properties for references to client and provider details
  2. load client endpoint parameters as ClientRegistration objects in a ClientRegistrationRepository
  3. create an OAuth2AuthorizedClientService object with a reference to the ClientRegistrationRepositor
  4. create an AuthorizedClientRepository object
  5. create an AuthorizedClientProvider object
  6. create an AuthorizedClientManager object pointing to the ClientRegistrationRepository and AuthorizedClientRepository objects
  7. further configure the AuthorizedClientManager to point to the AuthorizedClientProvider object
  8. create an ExchangeFilterFunction object that points to the ClientConfigurationRepository and AuthorizedClientRepository objects
  9. create a WebClient object using the ExchangeFilterFunction as its filter(xxx) value

That generic process looks like this visually.

The table below provides a description of each generic function (identified by its interface class name) and a color coded summary of implementation classes for either Reactive or non-reactive solutions.

General Interface Class
Legacy Class
Reactive Class
Function
ClientRegistrationRepository / ReactiveClientRegistrationRepository InMemoryClientRegistrationRepository InMemoryReactiveClientRegistrationRepository Houses a collection of ClientRegistration objects reflecting the configuration attributes of each remote OAuth-protected endpoint referenced in application.properties.
OAuth2AuthorizedClientService / ReactiveOAuth2AuthorizedClientService InMemoryOauth2AuthorizedClientService
JdbcOauth2AuthorizedClientService
InMemoryReactiveOAuth2AuthorizedClientService
R2dbcReactiveOAuth2AuthorizedClientService
Provide helper methods simplifying access to ClientRegistration objects held in the AuthorizedClientRepository when clients need to authentication or renew tokens.
OAuth2AuthorizedClientRepository
AuthenticatedPrincipalOauth2AuthorizedClientRepository HttpSessionOauth2AuthorizedClientRepository
Houses a collection of ClientRegistration objects reflecting the configuration attributes of each remote OAuth-protected endpoint referenced in application.properties. USE ONLY FOR ServletContext based applications.
ServerOAuth2AuthorizedClientRepository
AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository
UnAuthenticatedPrincipalServerOAuth2AuthorizedClientRepository
WebSessionServerOAuth2AuthorizedClientRepository
Houses a collection of ClientRegistration objects reflecting the configuration attributes of each remote OAuth-protected endpoint referenced in application.properties. USE ONLY FOR ServerWebExchange based applications.
OAuth2AuthorizedClientManager AuthorizedClientServiceOAuth2AuthorizedClientManager
DefaultOAuth2AuthorizedClientManager
Provides additional helper methods managing the lifecycle of a token as parent "client" object integrates through it to reach a remote web service. The AuthorizedClientService__ version should only be used for apps not operating in a ServletContext scenario The Default___ implementation is only for use in HttpServletRequest contexts.
ReactiveOAuth2AuthorizedClientManager AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager DefaultReactiveOAuth2AuthorizedClientManager Same as OAuth2AuthorizedClientManager except for Reactive applications. The AuthorizedClientService__ version should only be used for apps not operating in a ServerWebExchange scenario The Default___ implementation is only for use in ServerWebExchange contexts. The AuthorizedClientService___ implementation should be for non-servlet, non-Reactive solutions. The DefaultReactiveOAuth2___ implementation should only be used for scenarios using the ServerWebExchange model.
Oauth2AuthorizedClientProvider
AuthorizationCodeOauth2AuthorizedClientProvider ClientCredentialsOauth2AuthorizedClientProvider DelegatingOauth2AuthorizedClientProvider JwtBearerOauth2AuthorizedClientProvider PasswordOauth2AuthorizedClientProvider RefreshTokenOauth2AuthorizedClientProvider
Provides a uniform interface for performing specific OAuth2 interactions for the different grant types an OAuth2 endpoint may require. These implementing subclasses are instantiated based on the grant_type discovered from configuration.
ReactiveOauth2AuthorizedClientProvider
AuthorizationCodeReactiveOauth2AuthorizedClientProvider ClientCredentialsReactiveOauth2AuthorizedClientProvider DelegatingReactiveOauth2AuthorizedClientProvider JwtBearerReactiveOauth2AuthorizedClientProvider PasswordReactiveOauth2AuthorizedClientProvider RefreshReactiveOauth2AuthorizedClientProvider
Provides a uniform interface for performing specific OAuth2 interactions for the different grant types an OAuth2 endpoint may require. These implementing subclasses are instantiated based on the grant_type discovered from configuration
HttpSecurity Object class providing source-level configuration of any item that can be configured via <http> XML namespace in application.properties.
ServerHttpSecurityThe Reactive equivalent of HttpSecurity used to configure security filtering in a reactive application. Supports unique reactive functions related to ServerWebExchange models, etc.

Choosing OAuth2 Classes for Development

Based on the prior section, key questions must be answered about the type of applicatioin being created and its operating model in order to select the correct classes to use when coding any OAuth2 client integrations. The table below attempts to identify the key factors that guide the selection of classes.

Design QuestionDesign Direction
Should OAuth2 client data (registrations, authorized tokens) be cached in memory or via JDBC store? Use InMemory____ classes in most cases.

Consider Jdbc____ or R2dbc___ classes if a process will run with dozens (hundreds?) of instances so tokens can be re-used across clients without loading the Authorization Server.
Is the application designed using Reactive patterns or traditional flows? If Reactive, use classes prefixed or otherwise tagged Reactive, namely:

InMemoryReactiveClientRegistrationRepository

InMemoryReactiveOAuth2AuthorizedClientService
R2dbcReactiveOAuth2AuthorizedClientService

AuthenticatedPrincipalServerOAuth2AuthorizedClientRepository
UnAuthenticatedPrincipalServerOAuth2AuthorizedClientRepository
WebSessionServerOAuth2AuthorizedClientRepository

AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager
DefaultReactiveOAuth2AuthorizedClientManager

ReactiveOauth2AuthorizedClientProvider
NON-REACTIVE - Will the application execute as a Servlet or as a process outside a ServletContext? THIS IS A BIT OF A TRICK QUESTION. If operating outside a servlet context (like a batch job), use

AuthorizedClientServiceOAuth2AuthorizedClientManager

If operating inside a ServletContext where OAuth2 access will be driven by username based information from an external client, use

DefaultOAuth2AuthorizedClientManager

HOWEVER, if using OAUth2 for machine-to-machine integration to a remote endpoint where the OAuth2 credential doesn't change for distinct extneral users, use

AuthorizedClientServiceOAuth2AuthorizedClientManager
REACTIVE - Will the application execute using a ServerWebExchange model or as a process outside a ServerWebExchange model? THIS IS A BIT OF A TRICK QUESTION. If operating outside a servlet context (like a batch job), use

AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager

If operating inside a ServerWebExchange context where OAuth2 access will be driven by username based information from an external client, use

DefaultReactiveOAuth2AuthorizedClientManager

HOWEVER, if using OAUth2 for machine-to-machine integration to a remote endpoint where the OAuth2 credential doesn't change for distinct extneral users, use

AuthorizedClientServiceReactiveOAuth2AuthorizedClientManager

For this example, the answers to these questions are:

  1. InMemory or Jdbc? --- This example is not attempting to illustrate something that will run with dozens of parallel processes so InMemory____ is appropriate.
  2. Reactive or Legacy? --- This example is not attempting to implement a Reactive non-blocking service so a traditional Servlet based controller will be implemented.
  3. LEGACY - ServletContext or Standalone? --- This example implements a machine-to-machine integration using OAuth2 so it will not use external client request security parameters to generate / cache tokens so it should be designed using "standalone" choices.

From these design goals, the example will use the following classes for implementing the OAuth2 client integration:

InMemoryClientRegistrationRepository
InMemoryOAuth2AuthorizedClientService
AuthenticatedPrincipalOAuth2AuthorizedClientRepository
OAuth2AuthorizedClientProvider
AuthorizedClientServiceAuthorizedClientManager
ServletOAuth2AuthorizedClientExchangeFilterFunction
WebClient

Given the choices summarized in the following section about the type of service (legacy) being created, the choice of memory or JDBC persistence of artifacts and creating a standalone service, a punch list of tasks can be provided to clarify the work required to create the service.

  1. define a "client" in the Authorization Server to supply a client_name and client_secret for use in token requests from this client to the Authorization Server (Initially, we will reuse the KuberDependsOauth client previously created)
  2. Use the Spring Initializr at https://start.spring.io to create an empty source tree with the core dependencies required
  3. add entries to the application.properties file defining the characteristics of this local web service and the remote web service and OAuth parameters needed to authenticate access to it.
  4. create a OAuth2ClientConfiguration class with @Configuration annotation -- this class doesn't extend any standard class
  5. within the OAuth2ClientConfiguration class, create methods for creating @Bean resources for the following types determined by our design choices (non-reactive, InMemory caching with machine-to-machine servlet security):

    InMemoryClientRegistrationRepository
    InMemoryOAuth2AuthorizedClientService
    AuthenticatedPrincipalOAuth2AuthorizedClientRepository
    OAuth2AuthorizedClientProvider
    AuthorizedClientServiceAuthorizedClientManager
    ServletOAuth2AuthorizedClientExchangeFilterFunction
    WebClient
  6. create a SecurityConfiguration class with @Component and @EnableWebFluxSecurity annotations -- this class should not use older paradigms extending WebSecurityConfigurerAdapter
  7. create an initial Controller class defining the endpoint for this source web service with whatever authentication / authorization controls it requires
  8. create a method within the Controller class for invoking an WebClient to the OAuth protected endpoint

Defining an Oauth2 GUI Client using Keycloak

The process of defining a client in Keycloak that will use OAuth authentication was covered in the Authorization Server installment of this series. For convenience, the process is recapped here without screen dumps. Here, the userid DependsClientFull is created with a role that will be mapped to provide full web service API access and will have service-account functionality enabled.

  1. Access the Keycload admin console at http://192.168.99.10:8011 and login as the admin user
  2. Select the mdhLabs realm if it is not already the current realm for the admin user
  3. Click on Clients in the left navigation, then click on the Create client button.
  4. On the resulting screen, type in these parameter values then click the Save button. Client ID = DependsClientFull
    Client Protocol = openid-connect
    Root URL = http://192.168.99.10:8033
  5. After the first save confirmation, fill in the following additional parameter values:

    Name = DependsClientFull
    Description = machine-to-machine with mdhlabsfull role
  6. Scroll down to the Capability config section of the Settings tab and ensure Standard flow, Direct access grants and Service account roles are checked then clicke the Save button.
  7. Click on the Service account roles tab, then click the Assign role button then select the mdhlabsfull role and click the Assign button.
  8. Click on the Cllent scopes tab of the new client, then verify profile and roles are listed as included by Default.
  9. Click on the Credentials tab, then click the eye icon next to the client-secret field then copy the clear-text client-secret string to a file where it can be added to application.properties later along with the client-id, token-uri, etc.

Creating the Initial Project Tree

The eventual source tree structure of this web service client is shown below as output from a find command in Linux.
mdh@fedora1:~/gitwork/oauthdependsclient $ find src/main | sort
src/main
src/main/java
src/main/java/com
src/main/java/com/mdhlabs
src/main/java/com/mdhlabs/depends
src/main/java/com/mdhlabs/dependsclient
src/main/java/com/mdhlabs/dependsclient/DependsclientApplication.java
src/main/java/com/mdhlabs/dependsclient/OAuthClientConfiguration.java
src/main/java/com/mdhlabs/dependsclient/SecurityConfiguration.java
src/main/java/com/mdhlabs/dependsclient/services
src/main/java/com/mdhlabs/dependsclient/services/ProjectProxyController.java
src/main/java/com/mdhlabs/depends/models
src/main/java/com/mdhlabs/depends/models/Project.java
src/main/resources
src/main/resources/application.properties
src/main/resources/logback.xml
mdh@fedora1:~/gitwork/oauthdependsclient $

To create a skelelton project, the structure above can be created by hand or the Spring Initializr wizard at https://start.spring.io can be used to create the tree, an initial Maven pom.xml project file and create a zip file that can be unpacked for use within any IDE. For this project, the following selections should be made on the main screen:

Project:	Maven Project
Language:	Java
Spring Boot:	2.7.0
Project Metadata:
   Group:	com.mdhlabs
   Artifact:	dependsclient
   Name:	dependsclient
   Description:	Depends Client example
   Package Name:	com.mdhlabs.dependsclient
   Packaging:	Jar
   Java:	18
Dependencies:
   	Jersey
   	OAuth2 Client
   	Spring Security

The pom.xml file should be updated with the dependencies shown in this full pom.xml file. This adds libraries for logback, various Jwt processing tasks, etc.

<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.mdhlabs</groupId> <artifactId>dependsclient</artifactId> <version>0.0.1-SNAPSHOT</version> <name>dependsclient</name> <description>depends Client example</description> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.1</version> <relativePath/> <!-- lookup parent from repository --> </parent> <properties> <java.version>18</java.version> <checkstyle.config.location>/home/mdh/mdhlabs.checkstyleconfig.xml</checkstyle.config.location> </properties> <dependencies> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-core</artifactId> <version>1.2.11</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> </dependency> <dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.11</version> </dependency> <!-- spring-boot-starter-web = spring-mvc + spring-beans --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.1</version> </dependency> <dependency> <groupId>javax.ws.rs</groupId> <artifactId>javax.ws.rs-api</artifactId> <version>2.0</version> </dependency> <!-- DO NOT USE spring-boot-starter-webflux - injects undesired autoconfig behavior - we only need --> <!-- reactor-jetty and spring-webflux so our "client" can use WebClient for OAUth server interactions --> <dependency> <groupId>io.projectreactor.netty</groupId> <artifactId>reactor-netty</artifactId> <version>1.0.20</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webflux</artifactId> <version>5.3.21</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> <version>2.7.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> </dependencies> <build> <!-- override default naming so we can just use "dependsclient.jar" --> <finalName>dependsclient</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.0</version> <configuration> <release>18</release> <compilerArgs> <arg>-Xlint:all</arg> </compilerArgs> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-checkstyle-plugin</artifactId> <version>3.1.2</version> <dependencies> <dependency> <groupId>com.puppycrawl.tools</groupId> <artifactId>checkstyle</artifactId> <version>8.30</version> </dependency> </dependencies> </plugin> </plugins> </build> <reporting> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-checkstyle-plugin</artifactId> <version>8.30</version> <configuration> <configLocation>/home/mdh/mdhlabs.checkstyleconfig.xml</configLocation> </configuration> <reportSets> <reportSet> <reports> <report>checkstyle</report> </reports> </reportSet> </reportSets> </plugin> </plugins> </reporting> </project>

Defining Core Service Parameters / Logging Configuration

The two most obvious attributes to configure for the service under development specify how it will be available to other clients and where the remote web service endpoint is located that this service will invoke. In addition, as part of well-designed, easy-to-administer software, it would be useful to include settings limiting connection timeout and response timeouts for outbound connections to the target server and the Authorization Server.

These parameters will vary between environments and will be unique for each target service being invoked so they should NOT be hardcoded within the service logic. For consistency, these will be defined using application.properties with application-specific property names. This will support later scripting to set the parameters as part of containerized deployments.

The content is relatively self-explanatory. Looking at the "endpoint" entries as a set, notice how targetaclient and targetbclient act as a grouping name for all of the parameters of a remote service. (Here, they are pointing to same endpoint but the code itself won't know that -- this is just the easiest way to demonstrate configuration of multiple destinations.)

server.servlet.context-path=/depends/proxyapi server.port=8033 # define endpoint parameters for a remote OAuth protected service ("a Resource Server") com.mdhlabs.endpoint.targetaclient.baseuri=http://192.168.99.10:8080/depends/api com.mdhlabs.endpoint.targetaclient.serviceconnecttimeoutmilliseconds=3000 com.mdhlabs.endpoint.targetaclient.serviceresponsetimeoutmilliseconds=7000 com.mdhlabs.endpoint.targetaclient.oauth2connecttimeoutmilliseconds=5000 com.mdhlabs.endpoint.targetaclient.oauth2responsetimeoutmilliseconds=9000 # define endpoint parameters for a SECOND remote OAuth protected service ("a Resource Server") com.mdhlabs.endpoint.targetbclient.baseuri=http://fedora1.mdhlabs.com:8080/depends/api com.mdhlabs.endpoint.targetbclient.serviceconnecttimeoutmilliseconds=3000 com.mdhlabs.endpoint.targetbclient.serviceresponsetimeoutmilliseconds=7000 com.mdhlabs.endpoint.targetbclient.oauth2connecttimeoutmilliseconds=5000 com.mdhlabs.endpoint.targetbclient.oauth2responsetimeoutmilliseconds=9000

While at this phase of work, logging parameters should also be set for the eventual service. This example will use Logback for logging so a configuration file like this will be created as src/main/resource/logback.xml

<configuration debug="false"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d %level %logger - %msg%n</pattern> </encoder> </appender> <appender name="depends" class="ch.qos.logback.core.rolling.RollingFileAppender"> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>/logs/springboot/dependsclientLog.%d{yyyy-MM-dd}.txt</fileNamePattern> <maxHistory>30</maxHistory> <totalSizeCap>3GB</totalSizeCap> </rollingPolicy> <triggeringPolicy class="ch.qos.logback.core.rolling.SizeBasedTriggeringPolicy"> <maxFileSize>3MB</maxFileSize> </triggeringPolicy> <encoder> <pattern>%d %level %logger - %msg%n</pattern> </encoder> </appender> <root level="info"> <appender-ref ref="STDOUT" /> <appender-ref ref="depends" /> </root> <logger name="com.mdhlabs" level="debug" /> <logger name="org.springframework.security" level="debug" /> </configuration>

Defining OAuth2 Integration Parameters (application.properties)

Integration parameters for the remote web service were defined in application.properties in the prior section. Parameters used to connect with the OAuth2 Authorization Server to obtain access tokens and refresh tokens must also be provided. The Oauth2 client library uses a specific pattern of property names to allow required settings to be parsed automatically at client startup. The scheme utilizes two key concepts:

Registration a developer-chosen name that ties together all required properties for a process acting as an OAuth2 client, including the client_id, client_secret, scope values and grant_type values to use when requesting or refreshing tokens
Provider a developer-chosen name that ties together all required properties for a process acting as an OAuth2 client, including the client_id, client_secret, scope values and grant_type values to use when requesting or refreshing tokens.

This extra layer of abstraction for "registration" and "provider" allows a single deployed module of code that might need to integrate with MULTIPLE separate remote endpoints protected using MULTIPLE different Authorization Servers to combine but maintain distinctions between the configuration parameters needed for both endpoints. As an example, imagine a proxy service that must invoke one INTERNAL web service protected with an internal corporate Authorization Server and an EXTERNAL partner web service protected with an EXTERNAL Authorization Server in order to provide a response for a web service call. The solution topology would look like this:

Multiple client topology

The structure of the application.properties content supporting this topology would look like this:

Multiple client configuration

For machine-client scenarios, configurations can be simplified for the following reasons:

  1. Because this is a machine-to-machine integration, the client-name and client-authentication-method parameters do not affect token request flows and do not need to be provided.
  2. Because this is a machine-to-machine integration, the service account associated with the client within the Authorization Server has no human authorizations to approve by scope so the scope parameter is not required.
  3. Because the client will not use the authorization-code flow to obtain a token, the authorization-uri parameter isn't used and does not have to be provided.
  4. Because the client will obtain a key in a single round trip without redirection, the redirect-uri parameter isn't used and does not have to be provided.
  5. The machine client won't validate the token or transform any user information embedded within it before submitting it with requests to the Resource Server so the user-name-attribute, user-info-uri, user-info-authentication-method and jwk-set-uri parameters are not used.

Our example WILL illustration configuring two distinct registration / provider combinations by using a second Keycloak instance called providerx which will use a different client-id / client-secret combination as a completely separate Keycloak instance.

The properties configuration looks like this:

#------------------------------------------------------------------------------------ # properties defining OAuth2 parameters for first service registered as dependsclient/targetaclient #------------------------------------------------------------------------------------ spring.security.oauth2.client.registration.dependsclient.client-id=DependsClientBasic spring.security.oauth2.client.registration.dependsclient.client-secret=WePONVSOYbnWDRbg6Uexrgpmc6cYKEHV spring.security.oauth2.client.registration.dependsclient.provider=mdhlabs spring.security.oauth2.client.registration.dependsclient.authorization-grant-type=client_credentials spring.security.oauth2.client.provider.mdhlabs.token-uri=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token #------------------------------------------------------------------------------------ # properties defining OAuth2 parameters for second service registered as otherclient/targetbclient #------------------------------------------------------------------------------------ spring.security.oauth2.client.registration.otherclient.client-id=ProviderXFullClient spring.security.oauth2.client.registration.otherclient.client-secret=X0Nnc4FhQTUSk21lfGR3tK3a9slazaeE spring.security.oauth2.client.registration.otherclient.provider=providerx spring.security.oauth2.client.registration.otherclient.authorization-grant-type=client_credentials spring.security.oauth2.client.provider.providerx.token-uri=http://192.168.99.10:8011/realms/providerx/protocol/openid-connect/token

Creating the OAuth2ClientConfiguration Class

The OAutthClientConfiguration class will define key objects used by outbound web service requests to obtain a new access token or cache / re-use a token previously obtained. It also provides methods executed at startup that read the configuration properties illustrated in the prior section and populate those objects with the required endpoint and credential information. Finally, it defines WebClient objects which link to these OAuth specific functions. The WebClient is actually used to invoke the remote OAuth protected endpoint and -- with extra OAuth hooks configured -- automatically interact with the Authorization Server when a new token is required or is near / past expiration.

Here are all of the methods in the class (omitting inputs for some for brevity):

public void initializeEndpoints() {
public ClientRegistrationRepository createClientRegistrationRepository() {
public OAuth2AuthorizedClientService createAuthorizedClientService(
public AuthenticatedPrincipalOAuth2AuthorizedClientRepository createAuthorizedClientRepository(
public OAuth2AuthorizedClientManager createAuthorizedClientManager(
public WebClient createDependsWebClient(
public WebClient createTargetBWebClient(

The initializeEndpoints() method loads the com.mdhlabs.endpoint.* settings from application.properties so they are available at the time a WebClient is configured to set timeout limits, etc. The logic reads the properties file, iterates through each setting, matches against those matching com.mdhlabs.endpoint.* then adds each parametername=value pair to a unique hashmap based on the name tied to the entry. After all entries have been extracted and stored, it also loops through each set of configurations to ensure all required values are present (as an aid to operations personnel). The logic looks like this:


//----------------------------------------------------------------------------------------
// initializeEndpoints()- fetches "com.mdhlabs.endpoint.*" property keys needed
// identify remote endpoint URL and connection timeout limits desired for
// connections to the endpoint and the Authorization Server providing token access.
// Generalized to allow multiple endpoints with multiple Auth Servers to be
// configured
//----------------------------------------------------------------------------------------
// @PostConstruct annotation instructs Spring to call this AFTER completing all other
// startup initiation
//---------------------------------------------------------------------------------------

@PostConstruct
public void initializeEndpoints() {
thisLog.info("initializeEndpoints() -- fetching properties for remote web service and Authorization Server");
Boolean allpresent = true;


try {
   InputStream propertiesInput = this.getClass().getClassLoader().getResourceAsStream("application.properties");
   Properties serviceProperties = new Properties();
   serviceProperties.load(propertiesInput);

   Enumeration<String> propertyKeyEnum = (Enumeration<String>) serviceProperties.propertyNames();
   while (propertyKeyEnum.hasMoreElements() ) {
      String key = (String) propertyKeyEnum.nextElement();
      String value = (String) serviceProperties.getProperty(key);

   // here's what is being parsed:
//# define endpoint parameters for a remote OAuth protected service ("a Resource Server")
//com.mdhlabs.endpoint.targetaclient.baseuri=http://192.168.99.10:8080/depends/api
//com.mdhlabs.endpoint.targetaclient.serviceconnecttimeoutmilliseconds=3000
//com.mdhlabs.endpoint.targetaclient.serviceresponsetimeoutmilliseconds=7000
//com.mdhlabs.endpoint.targetaclient.oauth2connecttimeoutmilliseconds=5000
//com.mdhlabs.endpoint.targetaclient.oauth2responsetimeoutmilliseconds=9000
//
//# define endpoint parameters for a SECOND remote OAuth protected service ("a Resource Server")
//com.mdhlabs.endpoint.targetbclient.baseuri=http://192.168.99.10:8080/depends/api
//com.mdhlabs.endpoint.targetbclient.serviceconnecttimeoutmilliseconds=3000
//com.mdhlabs.endpoint.targetbclient.serviceresponsetimeoutmilliseconds=7000
//com.mdhlabs.endpoint.targetbclient.oauth2connecttimeoutmilliseconds=5000
//com.mdhlabs.endpoint.targetbclient.oauth2responsetimeoutmilliseconds=9000

      if (key.startsWith("com.mdhlabs.endpoint.")) {
          String registrationparm = key.replaceAll("com.mdhlabs.endpoint.", "");
          String[] regparmarray = registrationparm.split("\\.");
          String registration = regparmarray[0];
          String parameter    = regparmarray[1];
          HashMap<String, String> configmap;
          configmap = registration2clientconfig.get(registration);
          if (configmap == null) {   // first time seeing this registration, create a new HashMap for it
             configmap = new HashMap<String,String>();
             registration2clientconfig.put(registration, configmap);
             }
          configmap.put(parameter, value);
//        thisLog.debug("createClientRegistrationRepository2() -- added registration=" + registration
//           + ": " + parameter + "=" + value);
          }
      }

   // after scanning all properties for com.mdhlabs.endpoint.* settings,
   // we have a HashMap keyed by registration pointing to specific
   // config settings to use in creating a WebClient

   // for each configmap in registration2clientconfig, make sure it has
   // all required configuration settings

   for (String registration : registration2clientconfig.keySet()) {
      HashMap<String, String> configmap = registration2clientconfig.get(registration);
      thisLog.debug("() - validating required parameters for registration=" + registration);
      thisLog.debug("() - map from application.properties -->" + configmap.toString());
      for (String requiredkey : requiredPropertyKeys) {
         String testvalue = configmap.get(requiredkey);
         if ((testvalue == null) || (testvalue.equals("")) ) {
            allpresent = false;
            thisLog.error("() - missing com.mdhlabs.endpoint." + registration + "."
               + requiredkey + " entry in application.properties");
            }
         }
      }
   }
catch (Exception theE) {
   thisLog.error("initializeEndpoints() -- failure loading properties -- Exception=" + theE);
   allpresent = false;
   }
thisLog.info("initializeEndpoints() -- completed -- allpresent=" + allpresent);

}

The createClientRegistrationRepository() method is similar in function to initializeEndpoints() and has similar logic. It loads the application.properties file then extracts any entries for spring.security.oauth2.client.registration.* or spring.security.oauth2.client.provider.* , maps them to temporary HashMaps then loops through those HashMaps to create a ClientRegistration for each distinct registration ID found. It then adds those ClientRegistration objects to a new InMemoryClientRegistrationRepository for ongoing use by the application.

Caution Wait a minute! Doesn't Spring Security or Spring Boot automatically discover such configuration entries and load them into the appropriate repository objects as part of auto-configuration? From initial efforts, it appears such auto-configuration WILL take place -- IF only one registration is configured. If MULTIPLE registrations are configured for MULTIPLE Authorization Servers or credentials, it appears autoconfiguration will only result in the first registration parsed being used.


//------------------------------------------------------------------------
// createClientRegistrationRepository() -- provides a generalized solution
// for loading multiple oauth2 client entries from application.properties
// and creating ClientRegistration objects for each and adding them to
// a new InMemoryClientRegistrationRepository for ongoing use by the app.
//------------------------------------------------------------------------------------------

@Bean("clientregistrationrepository")
public ClientRegistrationRepository createClientRegistrationRepository() {

thisLog.info("createClientRegistrationRepository() - returns NON-REACTIVE InMemoryClientRegistrationRepository");

HashMap<String, HashMap<String,String>> registration2clientconfig = new HashMap<>();
HashMap<String, HashMap<String,String>> provider2config = new HashMap<>();

try {
   InputStream propertiesInput = this.getClass().getClassLoader().getResourceAsStream("application.properties");
   Properties appProperties = new Properties();
   appProperties.load(propertiesInput);

   @SuppressWarnings("unchecked")
   Enumeration propertyKeyEnum = (Enumeration) appProperties.propertyNames();
   while (propertyKeyEnum.hasMoreElements() ) {
      String key = (String) propertyKeyEnum.nextElement();
      String value = (String) appProperties.getProperty(key);

//===here's what is being parsed for each possible client =============
//spring.security.oauth2.client.registration.dependsclient.client-id=DependsClientBasic
//spring.security.oauth2.client.registration.dependsclient.client-secret=WePONVSOYbnWDRbg6Uexrgpmc6cYKEHV
//spring.security.oauth2.client.registration.dependsclient.provider=mdhlabs
//spring.security.oauth2.client.registration.dependsclient.authorization-grant-type=client_credentials
//
//spring.security.oauth2.client.provider.mdhlabs.token-uri=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token
//====================================================================

      if (key.startsWith("spring.security.oauth2.client.registration.")) {
          String registrationparm = key.replaceAll("spring.security.oauth2.client.registration.","");
          String[] regparmarray = registrationparm.split("\\.");
          String registration = regparmarray[0];
          String parameter    = regparmarray[1];
          HashMap<String,String> configmap;
          configmap = registration2clientconfig.get(registration);
          if (configmap == null) {   // first time seeing this registration, create a new HashMap for it
             configmap = new HashMap<String,String>();
             registration2clientconfig.put(registration,configmap);
             }

          configmap.put(parameter,value);
//        thisLog.debug("createClientRegistrationRepository() -- added registration=" + registration
//           + ": " + parameter + "=" + value);
          }

      if (key.startsWith("spring.security.oauth2.client.provider.")) {
          String providerparm = key.replaceAll("spring.security.oauth2.client.provider.", "");
          String[] providerparmarray = providerparm.split("\\.");
          String provider  = providerparmarray[0];
          String parameter = providerparmarray[1];
          HashMap<String,String> providermap;
          providermap = provider2config.get(provider);
          if (providermap == null) {   // first time seeing this registration, create a new HashMap for it
             providermap = new HashMap<String, String>();
             provider2config.put(provider, providermap);
             }
          providermap.put(parameter,value);
//        thisLog.debug("createClientRegistrationRepository() -- added provider=" + provider +
//            ": " + parameter + "=" + value);

          }
      }
   }
catch (Exception theE) {
   thisLog.error("createClientRegistrationRepository() -- failure loading properties -- Exception=" + theE);
   return null;
   }

// now we need to return an InMemoryClientRegistrationRepository which
// is physically a HashMap of (registrationid --> ClientRegistration)
// and ClientRegistrations are essentially the HashMaps we just parsed
// by registration above.  The instantiator can create a repo with just
// a single ClientRegistration or it can accept a ArrayList<ClientRegistration>

ArrayList<ClientRegistration> registrationList = new ArrayList<ClientRegistration>();
for (String registration : registration2clientconfig.keySet()) {
   thisLog.debug("createClientRegistrationRepository() - creating ClientRegistration for registration=" + registration);
   HashMap<String,String> config = registration2clientconfig.get(registration);
   thisLog.debug("createClientRegistrationRepository() - config = " + config.toString());

   // the ClientRegistration builder doesn't accept string values for authorization-grant-type
   // so we have to strongly type them here
   AuthorizationGrantType agt =  AuthorizationGrantType.CLIENT_CREDENTIALS;
   String granttypestring = config.get("authorization-grant-type");
   if (granttypestring.equals("password"))           { agt = AuthorizationGrantType.PASSWORD; }
   if (granttypestring.equals("authorization_code")) { agt = AuthorizationGrantType.AUTHORIZATION_CODE; }

   // map provider of registration to tokenuri of provider configuration
   String providername = config.get("provider");
// thisLog.debug("fetching provider config for provider=" + providername);
   HashMap<String,String> providerconfig = provider2config.get(providername);
   String tokenuri = providerconfig.get("token-uri");

   ClientRegistration thisClient = ClientRegistration
     .withRegistrationId(registration)
     .tokenUri(tokenuri)
     .clientId(config.get("client-id"))
     .clientSecret(config.get("client-secret"))
     .authorizationGrantType(agt)
     .build()
     ;
   thisLog.debug("createClientRegistrationRepository() - new ClientRegistration=" + thisClient.toString());
   registrationList.add(thisClient);
   }

// now we have a List of all ClientRegistrations parsed from application.properties
// pass it as a parameter when getting the new repo

thisLog.info("createClientRegistrationRepository() - added registration=" + registrationList.toString());
return new InMemoryClientRegistrationRepository(registrationList);
}

The beans returning AuthorizedClientService and AuthorizedClientRepository objects invoke library methods to return objects which require no customization to function as desired. The code for these is very simple.


//------------------------------------------------------------------------
// LEGACY   APPROACH - createAuthorizedClientService() - returns
// NON-REACTIVE InMemoryOAuth2AuthorizedClientService
// ____ClientRegistrationRepository for housing endpoint parameters of all
// OAuth2 configured services to be used by this application
//------------------------------------------------------------------------

@Bean("authorizedclientservice")
public OAuth2AuthorizedClientService createAuthorizedClientService(
      ClientRegistrationRepository clientRegistrationRepository) {

thisLog.info("createAuthorizedClientService() - returns NON-REACTIVE InMemoryOAuth2AuthorizedClientService");

return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository );
}


//------------------------------------------------------------------------
// LEGACY   APPROACH - createAuthorizedClientRepository() - returns
// NON-REACTIVE AuthenticatedPrincipalOAuth2AuthorizedClientRepository
// ____AUthorizedClientRepository for use in outbound request processing
//------------------------------------------------------------------------

@Bean("authorizedclientrepository")
public AuthenticatedPrincipalOAuth2AuthorizedClientRepository createAuthorizedClientRepository(
          OAuth2AuthorizedClientService myACS) {

thisLog.info("createAuthorizedClientRepository() - returns NON-REACTIVE AuthenticatedPrincipalOauth2AuthorizedClientRepository");

AuthenticatedPrincipalOAuth2AuthorizedClientRepository myACR =
    new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(myACS);

return myACR;
}

The createAuthorizedClientManager() method within OAuthClientConfiguration merits extra design discussion. Generically speaking, the authorized client manager decides when interaction with an Authorization Server will be triggered as requests are processed. The generic OAuth2AuthorizedClientManager class has two distinct implementation classes intended for DIFFERENT scenarios:

AuthorizedClientServiceOAuth2AuthorizedClientManager
DefaultOauth2AuthorizedClientManager

The AuthorizedClientServiceOAuth2AuthorizedClientManager implementation assumes the credential to use for the remote endpoint does NOT need to change to reflect any unique user / authorization of the external user invoking the local web service. The DefaultOauth2AuthorizedClientManager implementation assumes a new token is required for each incoming request and will NOT re-use tokens. A new call to the Authorization Server will be performed for each incoming request. This is NOT the behavior desired for most high volume proxy web services.

//-----------------------------------------------------------------------
// LEGACY   APPROACH - createAuthorizedClientManager() - returns
// NON-REACTIVE AuthorizedClientServiceOAuth2AuthorizedClientManager
// createAuthorizedClientManager() - defined as a bean to be automatically linked
//  by Spring at startup to larger OAuth2 client library functions
// NOTE: This returns a AuthorizedClientServiceOAuth2AuthorizedClientManager
// so outbound requests using an INTERNAL credential rather than something
// derived from a principalName derived from an external incoming query
// will properly cache access tokens -- if instead the class
// DefaultOAuth2AuthorizedClientManager is used, each new incoming query
// will trigger another token request to the Authorization Server
//-----------------------------------------------------------------------

@Bean
public OAuth2AuthorizedClientManager createAuthorizedClientManager(
          ClientRegistrationRepository clientRegistrationRepository,
          OAuth2AuthorizedClientService oAuth2AuthorizedClientService,
          OAuth2AuthorizedClientRepository authorizedClientRepository) {

thisLog.info("createAuthorizedClientManager() - returns NON-REACTIVE AuthorizedClientServiceOAuth2AuthorizedClientManager");
OAuth2AuthorizedClientProvider authorizedClientProvider =
    OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build();

AuthorizedClientServiceOAuth2AuthorizedClientManager myAuthorizedClientManager =
    new AuthorizedClientServiceOAuth2AuthorizedClientManager(
       clientRegistrationRepository,
        oAuth2AuthorizedClientService);

myAuthorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

return myAuthorizedClientManager;
}

As stated earlier, WebClient objects do the heavy lifting of invoking the remote endpoint but -- when properly configured -- also drive the interaction with the Authorization Server any time a new token is required. To isolate all of the OAuth related quirks of configuring a WebClient for OAuth integration, a method returning a configured WebClient is declared as a @Bean within OAuthClientConfiguration so controller classes using the WebClient can avoid adding OAuth specific imports and logic.

An example of creating a WebClient and adding OAuth functionality to its flow is shown below. This example configures the WebClient to use dependsclient as the registrationId any time it needs to fetch Authorization Server credentials from the ClientRegistrationRepository (when fetching a new token) or when it needs to fetch an existing token from the AuthorizedClientRepository. This also uses timeout configuration limits read from application.properties to allow tuning of that behavior by operations personnel. Those limits are implemented by creating a custom HttpClient with those timeout limits and configuring the WebClient to use that specific HttpClient.

//---------------------------------------------------------
// createDependsWebClient() -- returns a WebClient object configured
// to use a filter function which uses our ClientRegistration repo
// and AuthorizedClient repo as collections of Auth Server configurations
// and current tokens then sets the filter to look for the config
// with registrationId=dependsclient.
//--------------------------------------------------------------------------
@Bean("dependsclientbean") //bean qualifier
public WebClient createDependsWebClient(
    ClientRegistrationRepository clientRegistrations,
    AuthenticatedPrincipalOAuth2AuthorizedClientRepository authorizedClients) {

thisLog.info("createDependsWebClient() -- creating WebClient bean for use in invoking remote web service with OAuth2");

ServletOAuth2AuthorizedClientExchangeFilterFunction oauth = new ServletOAuth2AuthorizedClientExchangeFilterFunction(
   clientRegistrations,
   authorizedClients);

oauth.setDefaultClientRegistrationId("dependsclient");

thisLog.info("createDependsWebClient() -- registration2clientconfig = " + registration2clientconfig.toString());

// use the properties to specify connection limits on underlying Netty HttpClient
// that will handle connections to the Oauth2 Authorization server and the
// actual service endpoint


HashMap<String, String> configmap = registration2clientconfig.get("targetaclient");

//com.mdhlabs.endpoint.targetaclient.baseuri=http://192.168.99.10:8080/depends/api
//com.mdhlabs.endpoint.targetaclient.serviceconnecttimeoutmilliseconds=3000
//com.mdhlabs.endpoint.targetaclient.serviceresponsetimeoutmilliseconds=7000
//com.mdhlabs.endpoint.targetaclient.oauth2connecttimeoutmilliseconds=5000
//com.mdhlabs.endpoint.targetaclient.oauth2responsetimeoutmilliseconds=9000

Integer oauth2connecttimeout  = Integer.valueOf(configmap.get("oauth2connecttimeoutmilliseconds"));
long oauth2responsetimeout = Long.valueOf(configmap.get("oauth2responsetimeoutmilliseconds"));
Integer serviceconnecttimeout  = Integer.valueOf(configmap.get("serviceconnecttimeoutmilliseconds"));
long serviceresponsetimeout = Long.valueOf(configmap.get("serviceresponsetimeoutmilliseconds"));

HttpClient serviceHttpClient = HttpClient.create()
   .option(ChannelOption.CONNECT_TIMEOUT_MILLIS,serviceconnecttimeout)
   .responseTimeout(Duration.ofMillis(serviceresponsetimeout))
   .doOnConnected( connection -> connection
     .addHandlerLast(new ReadTimeoutHandler(serviceresponsetimeout, TimeUnit.MILLISECONDS))
     .addHandlerLast(new WriteTimeoutHandler(serviceresponsetimeout, TimeUnit.MILLISECONDS))
     );

ReactorClientHttpConnector connector = new ReactorClientHttpConnector(serviceHttpClient);

return WebClient.builder()
   .filter(oauth)
   .clientConnector(connector)
   .build();
}

Creating the SecurityConfiguration Class

Security protections of the local web service endpoints are defined using the SecurityFilterChain approach in a class called SecurityConfiguration. (The actual class name is immaterial to Spring and Spring Boot as long as the proper annotations are present at the class and method levels to drive discovery at startup.) For an application invoking remote endpoints using OAuth credentials, the SecurityConfiguration class provides references to beans defined in OAuthClientConfiguration that

  • house endpoint configurations of OAuth Authorization Servers and credentials
  • house current live tokens negotatied from any defined Authorization Servers
  • define a client service that will make calls to the Authorization Server as tokens are needed

then links them into the overall application SecurityFilterChain.

For this demonstration, INBOUND security will be left unrestricted. This makes the logic in the SecurityConiguration class very simple, as shown below.


package com.mdhlabs.dependsclient;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;

//  NON-REACTIVE IMPORTS
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;


//------------------------------------------------------------------------
// Defines a that exposes a SecurityFilterChain method for handling security
// filtering of the web services of this project
//------------------------------------------------------------------------

@Configuration
public class SecurityConfiguration {

private final Logger thisLog = LoggerFactory.getLogger(this.getClass().getCanonicalName());



/* ---- NON-REACTIVE VERSION ---- */
@Autowired
@Qualifier("clientregistrationrepository2")
public InMemoryClientRegistrationRepository clientRegistrationRepository;

@Autowired
@Qualifier("authorizedclientrepository")
public AuthenticatedPrincipalOAuth2AuthorizedClientRepository authorizedClientRepository;

@Autowired
@Qualifier("authorizedclientservice")
public OAuth2AuthorizedClientService authorizedClientService;



//-----------------------------------------------------------------------------------
// filterChain() -- returns NON-REACTIVE SecurityFilterChain to filter incoming requests
//   and calls submethods to customize this application's inbound security restrictions
//-----------------------------------------------------------------------------------
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

thisLog.info("filterChain(HttpSecurity) - enabling OAuth2 client hooks into default SecurityFIlterChain");

http
    .httpBasic().disable()
    .formLogin(AbstractHttpConfigurer::disable)
    .csrf(AbstractHttpConfigurer::disable)
    .authorizeHttpRequests()
      .mvcMatchers("/**").permitAll()
    ;

// now configure Oauth2 client parameters for outbound requests
http
   .oauth2Client(oauth2Client -> oauth2Client
      .clientRegistrationRepository(this.clientRegistrationRepository)
      .authorizedClientRepository(this.authorizedClientRepository)
      .authorizedClientService(this.authorizedClientService)
      );

return http.build();
}

} // end of SecurityConfig class


Creating the Controller Class (DependsProxyController)

The Controller class will be processing inputs and outputs involving the Project model so the Project.java source file should be copied from the Resource Server project and placed at src/main/java/com/mdhlabs/depends/models/Project.java

The remote web service being invoked supports these two endpoints that are being used for illustration:

EndpointFunction
/depends/api/project/nreturns data for a single project with id=n
/depends/api/projectsreturns a list of all projects

In this example, the proxy client will not only invoke these two endpoints using an OAuth credential, it will also expose a third endpoint that demonstrates how more than one OAuth credential can be used within a single web service to show how a service needing to invoke more than one endpoint protected by DIFFERENT credentials can manage multiple tokens. Our set of endpoints built in DependsProxyController will thus be

EndpointFunction
/depends/proxyapi/project/nreturns data for a single project with id=n using the token obtained via registrationId=dependsclient
/depends/proxyapi/projectsreturns a list of all projects using the token obtained via registrationId=dependsclient
/depends/proxyapi/xproject/nreturns data for a single project with id=n using the token obtained via registrationId=otherclient

The Controller class to implement these proxy endpoints starts with the definition of local references to the WebClient beans defined in OAuthClientConfiguration. These are very simple.

// declare a local WebClient variable instantiated as a @Bean in OAuthConfigurationClass
@Autowired
@Qualifier("dependsclientbean")
private WebClient targetWebClient;

// a second client needing a separate Authorization Server can also be defined
@Autowired
@Qualifier("otherclientbean")
private WebClient targetbWebClient;

The method to support the /project endpoint using the tailored WebClient object is shown below.

//----------------------------------------------------------------------------------------
// projectRetrieve - returns the specified Project object
// URI = /context/servletPath/project/{project_id}
// METHOD = GET
//----------------------------------------------------------------------------------------
@RequestMapping(value = "/project/{project_id}", method = RequestMethod.GET, produces = javax.ws.rs.core.MediaType.APPLICATION_JSON)
public @ResponseBody Project projectRetrieve(@PathVariable("project_id") int project_id,
        HttpServletRequest theQuery,HttpServletResponse theReply)  throws SQLException, Exception {

thisLog.info("QUERY action=projectRetrieve method=get project_id=" + project_id );

// perform call to core web service here
HashMap<String,String> clientconfig = OAuthClientConfiguration.registration2clientconfig.get("targetaclient");
thisLog.debug("projectLIst() -- clientconfig=" + clientconfig.toString());

String remoteendpoint = clientconfig.get("baseuri");
remoteendpoint = remoteendpoint.concat("/project/" + String.valueOf(project_id));
thisLog.info("projectRetrieve() - proxying to remoteendpoint=" + remoteendpoint);

Mono<Project> response = targetWebClient
   .get()
   .uri(remoteendpoint)
   .retrieve()
   .bodyToMono(new ParameterizedTypeReference<Project>() {})
   ;

// normally, using block() is NOT GOOD for reactive services -- this service is only
// using WebClient for the build-in OAuth support but is not expected to be non-blocking
thisLog.debug("retrieveProject() - invoking response.block() and trapping for 401/403 errors");

try {
   Project responseProject = response.block();
   thisLog.info("REPLY action=projectRetrieve method=get project_id=" + project_id );
   return responseProject;
   }
catch (WebClientRequestException theE) {
   thisLog.error("ERROR action=projectRetrieve method=get Exception=" + theE.toString());
   throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,theE.toString());
   }
catch (WebClientResponseException theE) {
   thisLog.error("ERROR action=projectRetrieve method=get Exception=" + theE.toString());
   throw new ResponseStatusException(HttpStatus.REQUEST_TIMEOUT,theE.toString());
   }
catch (Exception theE) {
   thisLog.error("ERROR action=projectRetrieve method=get Exception=" + theE.toString());
   throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,theE.toString());
   }
}

Besides preconfiguring a default registrationId for the filter function used by the WebClient, it is also possible to define a controller method with an additional input variable that selects the desired authorized client from the AuthorizedClientRepository. That method signature would look like this

@RequestMapping(value = "/project/{project_id}", method = RequestMethod.GET, produces = javax.ws.rs.core.MediaType.APPLICATION_JSON)
public @ResponseBody Project projectRetrieve(@PathVariable("project_id") int project_id,
        HttpServletRequest theQuery,HttpServletResponse theReply,
        @RegisteredOAuth2AuthorizedClient("dependsclient") OAuth2AuthorizedClient authorizedClient)  throws SQLException, Exception {

and would require an additional parameter setting in the WebClient call as shown here:

Mono> response = targetWebClient
   .get()
   .uri(remoteendpoint)
   .attributes(ServletOAuth2AuthorizedClientExchangeFilterFunction.oauth2AuthorizedClient(authorizedClient))
   .accept(org.springframework.http.MediaType.APPLICATION_JSON)
   .retrieve()
   .bodyToMono(new ParameterizedTypeReference>() {})
   ;

In general, this alternate approach results in long, complicated, hard-to-read method signatures and isn't the best way to select alternate OAuth tokens for authentication.

Command Line Testing Of Machine Client Functionality

With the dependsclient.jar built, end to end testing of the client should review logs at all of the following layers:

  1. direct verification of credentials in the Authorization Server via command line - this will ensure the Keycloak server is running and the desired credentials are active and returning expected role / preferred_username parameters in tokens
  2. command line invocation of the outer proxyapi endpoint via curl
  3. review of Authorization Server logs for access token generation
  4. review of Resource Server logs for the inbound request from the proxyapi client
  5. handling of missing required configuration parameters at startup
  6. handling of required configuration parameters with incorrect values

Since dependsclient.jar invokes dependsresource.jar which depends upon MariaDB and Keycloak to be up and running, all of the commands below should be entered before attempting tests of dependsclient.


root@fedora1:~ # mysqld_safe&
[1] 2440
root@fedora1:~ # 220826 12:48:34 mysqld_safe Adding '/usr/lib64/libjemalloc.so.2' to LD_PRELOAD for mysqld
220826 12:48:34 mysqld_safe Logging to '/logs/mariadb/fedora1.mariadb1059.log'.
220826 12:48:34 mysqld_safe Starting mariadbd daemon with databases from /data

root@fedora1:~ # su keycloak
keycloak@fedora1:/root $ /opt/keycloak1901/bin/kc.sh start-dev --log-level=INFO,org.keycloak.authentication:DEBUG,org.keycloak.services:DEBUG --log=file --log-file=/logsmdh/springboot/keycloaklog.txt --http-port=8011&
[1] 2600
keycloak@fedora1:/root $ JAVA_OPTS already set in environment; overriding default settings with values: -Duser.language=en

keycloak@fedora1:/root $ exit
exit
root@fedora1:~ # su mdh
mdh@fedora1:/root $ cd /home/mdh/gitwork/oauthdependsresource
mdh@fedora1:~/gitwork/oauthdependsresource $ . mariadbenv.sh
mdh@fedora1:~/gitwork/oauthdependsresource $ printenv | grep DOCK
DOCKENV_MARIADB_USERID=dependsapp
DOCKENV_MARIADB_PORT=3306
DOCKENV_MARIADB_PASSWORD=badpassword
DOCKENV_MARIADB_HOST=192.168.99.10
mdh@fedora1:~/gitwork/oauthdependsresource $ java -jar ./target/dependsresource.jar

(bunch of Spring Boot console output rolls here...)

In a second console window, start up the dependsclient JAR as shown below.

mdh@fedora1:~ $ cd gitwork/oauthdependsclient
mdh@fedora1:~/gitwork/oauthdependsclient $ java -jar ./target/dependsclient.jar

(bunch of Spring Boot console output rolls here...)

With all of the components started, command line testing from a third console window can proceed as summarized in the following sections.


Verifying Authorization Server Token Content

Before testing from the outside into dependsclient into dependsresource, fetching access tokens directly from the command line is recommended to ensure client-id and client-secret strings are typed EXACTLY correctly in configuration files.

A fetch using the DependsClientFull client is shown below.


mdh@fedora1:~/gitwork/oauthdependsclient $ REPLY=$( curl -d "client_id=DependsClientFull" \
-d "grant_type=client_credentials" -d "client_secret=ediQAgqkh4fwaOP5t5vaHLElj29fskSz" \
"http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token"); \
ACCESS_TOKEN=$(echo $REPLY | jq -r '.access_token');\
REFRESH_TOKEN=$(echo $REPLY | jq -r '.refresh_token')
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1612  100  1508  100   104   101k   7157 --:--:-- --:--:-- --:--:--  112k
mdh@fedora1:~/gitwork/oauthdependsresource $
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1612  100  1508  100   104  24675   1701 --:--:-- --:--:-- --:--:-- 26866
mdh@fedora1:~/gitwork/oauthdependsclient $ echo $ACCESS_TOKEN
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDVGhXMmZfS283V0tBZzZDazJsQ0QyaGh0TzlSZm8yV2YxdE
5yZHR6RGlBIn0.eyJleHAiOjE2NjEzNTY5MjYsImlhdCI6MTY2MTM1NjgwNiwianRpIjoiNjdkZjU3YTgtNGI5My00YjQ0LWEw
YzMtY2U0ZDgwZDZmMTlhIiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguOTkuMTA6ODAxMS9yZWFsbXMvbWRobGFicyIsImF1ZCI6Im
FjY291bnQiLCJzdWIiOiI5MjNjM2UxNi01MDMyLTRkZjctYTg1MS1jNjBmYmQ1ZmZjODIiLCJ0eXAiOiJCZWFyZXIiLCJhenAi
OiJEZXBlbmRzQ2xpZW50RnVsbCIsImFjciI6IjEiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW
5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9m
aWxlIiwiY2xpZW50SWQiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImNsaWVudEhvc3QiOiIxOTIuMTY4Ljk5LjEwIiwiZW1haWxfdm
VyaWZpZWQiOmZhbHNlLCJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xl
cy1tZGhsYWJzIiwibWRobGFic2Z1bGwiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2
xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiXSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LWRlcGVuZHNj
bGllbnRmdWxsIiwiY2xpZW50QWRkcmVzcyI6IjE5Mi4xNjguOTkuMTAifQ.lHsw78TKzBsFc1SrxOB_cXvf607pzICGJCtcnu_
nDeSSy1aTBlfCl9Fq4pRunUVv8HaS5ucHz2_7WUh6gcdeDYEH4xIs33BJbpQ3GDxcjjW3UF3L0_tYvNe976dMgWjv3ZAob0LTX
WnLS2lnxNK7303EuJ6l9ok_DUdKi3VDmsd7M_LDPd_OAf8YgXhwjQHqJQwXbxFLskTj7XBKmyN7yijYFwkVf8pGIb-5ZAFfYCa3MSKi0Vd1eVvMyGoGQBQ9FnuKlPDc9EsZhsb9xAircJ8R8VJ0a524FEGxInJU7GFB4bHWaHwV5FEynB8q6QWjUUF0G
RIJtAIknqNn4r_3pQ
mdh@fedora1:~/gitwork/oauthdependsclient $
mdh@fedora1:~/gitwork/oauthdependsclient $

If the token is pasted into the Jwt Analyzer online utility at https://jwt.io/ the token content appears as follows:

{
  "exp": 1661356926,
  "iat": 1661356806,
  "jti": "67df57a8-4b93-4b44-a0c3-ce4d80d6f19a",
  "iss": "http://192.168.99.10:8011/realms/mdhlabs",
  "aud": "account",
  "sub": "923c3e16-5032-4df7-a851-c60fbd5ffc82",
  "typ": "Bearer",
  "azp": "DependsClientFull",
  "acr": "1",
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "email profile",
  "clientId": "DependsClientFull",
  "clientHost": "192.168.99.10",
  "email_verified": false,
  "roles": [
    "offline_access",
    "uma_authorization",
    "default-roles-mdhlabs",
    "mdhlabsfull",
    "offline_access",
    "uma_authorization",
    "default-roles-mdhlabs",
    "mdhlabsfull"
  ],
  "preferred_username": "service-account-dependsclientfull",
  "clientAddress": "192.168.99.10"
}

The key items to check in the response is that roles includes mdhlabsfull and that the sub parameter is also returned as preferred_username in the response.

Verifying OAuth2 Token Negotation

If the OAuth configuration parameters are all synced properly, executing a request to the dependsproxy api will initiate a WebClient call to the remote web service endpoint of http://192.168.99.10:8080/depends/proxyapi . But as the WebClient object begins that process, its filter will realize it has not obtained an OAuth2 access token and it will look up the configured registration ("dependsclient"), fetch those client-id and client-secret parameters then look up the associated provider ("mdhlabs") to find the token endpoint http://192.168.99.10:8011/realms/mdhlabs/token and execute a grant_type=client_password request as configured to obtain a token.

Looking in the logs of the Keycloak server will show logs looking like this confirming that interaction.

2022-06-23 19:37:07,503 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-0) AUTHENTICATE CLIENT
2022-06-23 19:37:07,503 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-0) client authenticator: client-secret
2022-06-23 19:37:07,503 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-0) client authenticator SUCCESS: client-secret
2022-06-23 19:37:07,503 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-0) Client DependsClientFull authenticated by client-secret
2022-06-23 19:37:07,506 DEBUG [org.keycloak.services.managers.AuthenticationSessionManager] (executor-thread-0) Removing authSession '79820ee1-f706-4faa-bf7e-6aa0b560f21d'. Expire restart cookie: true

If the dependsclient fails to obtain an access token from the Authorization Server, the error below will appear in the logs. This could be due to a typo in the client-id or client-secret string as entered in application.properties.

2022-08-24 11:13:22,565 ERROR org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/depends/proxyapi].[dispatcherServlet] - 
Servlet.service() for servlet [dispatcherServlet] in context with path [/depends/proxyapi] threw exception [Request processing failed; 
nested exception is org.springframework.security.oauth2.client.ClientAuthorizationException: [invalid_token_response] An error occurred
while attempting to retrieve the OAuth 2.0 Access Token Response: 401 Unauthorized: [no body]] with root cause
org.springframework.web.client.HttpClientErrorException$Unauthorized: 401 Unauthorized: [no body]

Verifying OAuth Refresh Functionality

As requests continue to arrive at dependsclient for execution, each invocation of dependsresource will cause the current token to be fetched from the client repository for re-use. Each time the current token is used, the expiration on the token will be checked and the Spring library will attempt to refresh the token 60 seconds again of the official expiration time of the current token. With the session limit set to 2 minutes in Keycloak and running continuous requests into dependsclient, the following logs are generated in Keycloak exactly ONE minute apart.

2022-08-24 11:27:54,284 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-3) AUTHENTICATE CLIENT 2022-08-24 11:28:54,550 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-3) AUTHENTICATE CLIENT 2022-08-24 11:29:54,911 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-3) AUTHENTICATE CLIENT

If Keycloak is altered to set the session limit to 4 minutes and the same test repeated, the Keycloak logs show new AUTHENTICATE CLIENT logs every THREE minutes apart.

2022-08-24 11:35:15,554 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-7) AUTHENTICATE CLIENT 2022-08-24 11:38:15,824 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-7) AUTHENTICATE CLIENT 2022-08-24 11:41:16,400 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-7) AUTHENTICATE CLIENT 2022-08-24 11:45:24,054 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-7) AUTHENTICATE CLIENT

Caution The "lookahead refresh" is only performed if incoming requests are arriving. In the above example, new requests stopped at 11:41:7 and the token was not refreshed at the 3 minute interval or even when it actually expired at 11:45:16. Only when a request was sent at 11:45:24 did a new token get requested.

The value of this lookahead refresh isn't really clear. Processing of new tokens versus refreshing an existing token likely take equal processing time on the Authorization Server. SOME transaction will have to wait for that extra round-trip delay -- the request arriving at early refresh time or the request arriving at/after expiration time so any improvement on average processing time would be expected to be zero.


Verifying Distinct WebClient / Token Mapping

To demonstrate a single web service client could connect invoke different remote web services using more than one OAuth token, the /depends/proxyap/project endpoint was duplicated at /depends/proxyapi/xproject and coded to use a distinct WebClient configured to use a different OAuth registration ID as its default. Use of the alternate OAuth token for the x endpoint can be verified by these command line tests.


mdh@fedora1:~/gitwork/oauthdependsclient $ curl http://192.168.99.10:8033/depends/proxyapi/project/36
{"project_id":36,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2, "factorybusunit_id":6,
"factorybusdept_id":7,"projectname":"Subpoena Automation (LERT)","shortdescription":"Unification to single system",
"longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y", "capitalledger":"","expenseledger":"",
"clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"N","createdatetime":"2018-05-20 20:51:22",
"updatedatetime":"0000-00-00 00:00:00"}
mdh@fedora1:~/gitwork/oauthdependsclient $
mdh@fedora1:~/gitwork/oauthdependsclient $ curl http://192.168.99.10:8033/depends/proxyapi/xproject/34
{"project_id":34,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2,"factorybusunit_id":11,
"factorybusdept_id":23,"projectname":"Ent Portal Unification","shortdescription":"Merge enterprise portals into
spectrum.net","longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y", "capitalledger":"",
"expenseledger":"","clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"N",
"createdatetime":"2018-05-20 20:51:22","updatedatetime":"0000-00-00 00:00:00"}
mdh@fedora1:~/gitwork/oauthdependsclient $

After returning results on the above commands, review of the Keycloak logs confirm that two DIFFERENT authentications were performed reflecting the two different client configurations.


2022-08-26 20:35:24,539 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-15) AUTHENTICATE CLIENT
2022-08-26 20:35:24,539 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-15) client authenticator: client-secret
2022-08-26 20:35:24,539 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-15) client authenticator SUCCESS: client-secret
2022-08-26 20:35:24,539 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-15) Client DependsClientBasic authenticated by client-secret
2022-08-26 20:35:24,541 DEBUG [org.keycloak.services.managers.AuthenticationSessionManager] (executor-thread-15) Removing authSession '36b3e84b-6522-43e2-aa26-30d6afdf87c7'. Expire restart cookie: true
2022-08-26 20:35:32,730 DEBUG [org.keycloak.authentication.AuthenticationProcessor] (executor-thread-15) AUTHENTICATE CLIENT
2022-08-26 20:35:32,731 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-15) client authenticator: client-secret
2022-08-26 20:35:32,731 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-15) client authenticator SUCCESS: client-secret
2022-08-26 20:35:32,731 DEBUG [org.keycloak.authentication.ClientAuthenticationFlow] (executor-thread-15) Client ProviderXFullClient authenticated by client-secret
2022-08-26 20:35:32,733 DEBUG [org.keycloak.services.managers.AuthenticationSessionManager] (executor-thread-15) Removing authSession '2ab07c03-5ae7-4b2c-beb2-c82fcadc467e'. Expire restart cookie: true

Saturday, August 13, 2022

OAuth Integration - Resource Server

What seemed like a simple endeavor -- learn how to implement OAuth to protect web services and portal applications using Java and Spring -- turned out to be much more complicated than expected. The posts in this series are targeting two groups -- developers with little background in the goals and mechanics of OAuth and more experienced developers who just need help avoiding pitfalls resulting from inconsistencies in implementations of OAuth. The content is divided into multiple posts:

OAuth Integration - Overview
OAuth Integration - Challenges
OAuth Integration - Authorization Server
OAuth Integration - Command Line Validation
OAuth Integration - Resource Server<---- YOU ARE HERE
OAuth Integration - Service Client
OAuth Integration - User Portal Application


Task List for Resource Server Implementation

Components acting as Resource Servers are responsible for accepting a JWT token with incoming requests, validating the integrity of the token (structure, unaltered content and expiration) then mapping identity information derived from the token to internal role information need to authorize or block execution of the requested endpoint. Resource Server components are NOT responsible for renewing expired tokens -- logic for that is implemented by clients and will be discussed in subsequent sections.

Developers implementing Resource Server protections for components will tackle the following tasks, shown in some semblance of build / dependency order.

  1. Add the dependencies required for OAuth2, JSON and JWT functions to the pom.xml file for the project.
  2. Devise a scheme for providing configuration information needed for token validation and updating application.properties with new entries. NOTE -- this solution will use a mix of "factory" settings used by Spring libraries with additional attributes that proved to be easier to use in implementing consistent functionality for multiple Authorization Server implementions.
  3. Creating a class to house logic performing signature validation and expiration validation on incoming tokens.
  4. Creating a class to house logic to transform external userid / role information to internal Java Authentication objects used by core Spring libraries and map external non-standard role information to a uniform internal model.
  5. Create a SecurityConfiguration class that will house various @Bean methods that hook methods in the above validation and authorization classes into appropriate extension points in the Spring Filter Chain security flow.
  6. Within the SecurityConfiguration class, code a method returning a reference to a NimbusJwtDecoder objected configured to use security certificates from the desired Authorization Server endpoint.
  7. Within the SecurityConfiguration class, code a filterChain(HttpSecurity) method to configure various aspects of the Spring Security FilterChain and insert the custom mdhlabsJwtFilterBean component.
  8. Use @PreAuthorize and @PostAuthorize annotations as needed in the application Controller classes to apply desired authorization restrictions to the underlying methods implementing each incoming URI call.

Before beginning or resuming any development involving OAuth functionality in Java, take time to review the precautions summarized in section 2.4 of this document regarding deprecated classes and use of legacy ServletContext versus newer WebServletContext classes.

The process of protecting a Spring Boot web service via Oauth JWT tokens will be described in the sections below using a typical REST type Spring Boot web service and Keycloak as the OAuth Authorization Server component. The existing source code structure of the Depends service is recapped below with directory-only rows deleted for brevity.


mdh@fedora1 ~/gitwork/oauthdependsresource]$ find src/main
src/main/java/com/mdhlabs/depends/dao/ProjectsDAO.java
src/main/java/com/mdhlabs/depends/services/ProjectController.java
src/main/java/com/mdhlabs/depends/models/Project.java
src/main/java/com/mdhlabs/depends/DependsConfiguration.java
src/main/java/com/mdhlabs/depends/SecurityConfig.java
mdh@fedora1:~/gitwork/oauthdependsresource $ 


5.2 Code Modularization Strategy

The solution design reflected in the example being built here is influenced by the following goals:

  1. providing an intuitive configuration model that is consistent for any arbitrary Authorization Server
  2. minimizing or avoiding entirely any changes to "core code" of a typical web service
  3. use standard extension points in existing Spring classes where doing so retains the ability to support multiple providers with a single processing model
  4. isolation of JWT validation / transformation to classes outside the core application to allow packaging as a standalone module to allow plug-in re-use in multiple services and applications

The solution design also reflects one CRUCIAL choice a developer must make for any implementation based on Java and Spring Security. As referenced in the Spring Evolution section of the Overview, the Security Filter Chain became the directional design beginning in Spring Security 5.4. That approach passes incoming requests through a LARGE number of sequential filters, each looking at the same request / response variables but optimized for specific lifecycle tasks of authentication / authorization. Spring Security includes a filter called BearerTokenAuthenticationFilter which includes configurations that support custom classes to perform validations and transformatiions. Developers can extend BearerTokenAuthenticationFilter OR they can create their own custom filter class extending GenericFilterBean and insert that into the filter chain to perform the same validations and transformations with whatever class signatures they desire.

Having tried both approaches, the custom filter approach does pose one possible challenge. Configuring centralized Http layer security restrictions seems to rely upon an Authentication object be provided by BearerTokenAuthenticationFilter and will not see the Authentication object set into the SecurityContext by a custom filter, even if it is inserted prior to BearerTokenAuthenticationFilter in the chain. If you only plan on use method-level security, this won't be a problem. If you wish to use centralized Http security, more experimentation may be required to use a custom filter.

The example built in this section will use defined extension points for BearerTokenAuthenticationFilter, which dictates the modularization and class signatures of some of the components below.

Class Purpose / Contents
mdhabsAuthenticationFailureHandlerImplements AuthenticationEntryPoint to allow customization of outgoing error messages when client credentials fail basic validation.
mdhlabsAuthorizationFailureHandlerImplements AccessDeniedHandler to allow customization of outgoing error messages when client credentials lack authorization to execute requested functions.
mdhlabsBearerTokenResolverImplements BearerTokenResolver to provide a method for extracting bearer tokens from incoming requests
mdhlabsJwtAuthenticationConverterImplements a generic Converter<Sourcetype, Targettype> class to convert an AbstractAuthenticationToken (an external token) to a Jwt with user / authorization information standardized for internal use.
JwtValidationUtilitiesImplements a collection of methods for validating the signature / expiration of a JWT, signature generation, signature validation and methods for creating keypairs and public keys from a variety of specification formats.

Failure Handlers and BearerTokenResolver Logic

The AuthenticationFailureHandler, AuthorizationFailureHandler and BearerTokenResolver classes are the most straightforward tasks in the entire effort of adopting Resource Manager functionality. Each class implements a generic class used by Spring Security to allow custom logic to be plugged into the larger BearerTokenAuthenticationFilter process provided by generic Spring Security. Each class is very brief and nearly self explanatory.

The AuthenticationFailureHandler class extends AuthenticationEntryPoint which implies it should handle logic invoked at the BEGINNING of any authentication attempt via user interactions or tokens. However, in reality, the implementation point is AFTER the point where earlier processes determine that authentication has failed. Confusing class name being implemented but the functionalty is simple.

The two FailureHandler classes are nearly identical. Here is the source for the mdhlabsAuthenticationFailureHandler class.


package com.mdhlabs.oauthsecurity;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.core.AuthenticationException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.HashMap;
import java.util.Calendar;
import java.io.IOException;
import com.fasterxml.jackson.databind.ObjectMapper;



@Component
public final class mdhlabsAuthenticationFailureHandler implements AuthenticationEntryPoint {

private final Logger thisLog = LoggerFactory.getLogger(this.getClass().getCanonicalName());



@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {

ObjectMapper objectMapper = new ObjectMapper();

response.setStatus(HttpStatus.UNAUTHORIZED.value());
Map data = new HashMap<>();
data.put("timestamp", Calendar.getInstance().getTime());
data.put("exception", exception.getClass());
data.put("message", exception.getMessage());

response.getOutputStream().println(objectMapper.writeValueAsString(data));
thisLog.error("Exception=" + exception.getClass() + " message=" + exception.getMessage());
}

}

Here is the source for the mdhlabsAuthorizationFailureHandler class.

package com.mdhlabs.oauthsecurity;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.stereotype.Component;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.authentication.*;
import org.springframework.security.core.*;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.Calendar;
import java.util.HashMap;
import java.io.IOException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.access.AccessDeniedException;


@Component
public final class mdhlabsAuthorizationFailureHandler implements AccessDeniedHandler {

private final Logger thisLog = LoggerFactory.getLogger(this.getClass().getCanonicalName());

private ObjectMapper objectMapper = new ObjectMapper();



@Override
public void handle(
      HttpServletRequest request,
      HttpServletResponse response,
      AccessDeniedException exception)
      throws IOException, ServletException {

response.setStatus(HttpStatus.UNAUTHORIZED.value());
Map<String, Object> data = new HashMap<>();
data.put("timestamp", Calendar.getInstance().getTime());
data.put("exception", exception.getClass());
data.put("message", exception.getMessage());

response.getOutputStream().println(objectMapper.writeValueAsString(data));
thisLog.error("Exception=" + exception.getClass() + " message=" + exception.getMessage());
}

}

The mdhlabsBearerTokenResolver class implements a somewhat confusingly named parent class called BearerTokenResolver. In typical Java development frameworks, a "Resolver" chooses AMONG subtending classes to perform work for a specific object based upon some characteristic within the object. Here, the "Resolver" just does the work. For tokens, this implementation class just attempts to find a token in an Authorization header of the request or as a cookie object named access_token. It isn't responsible for validating it or transforming it, just plucking it out of the HttpServletRequest and returning it up the chain.


package com.mdhlabs.oauthsecurity;

import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.Cookie;
import org.springframework.util.StringUtils;


@Component
public class mdhlabsBearerTokenResolver implements BearerTokenResolver {

private final Logger thisLog = LoggerFactory.getLogger(this.getClass().getCanonicalName());


public String resolve(HttpServletRequest request) {

thisLog.debug("resolve() - extracting base64jwt from HttpServletRequest");

//-------------------------------------------------------------------------------------------
// for maximum re-usability, check for token sent as header "Authorization: Bearer $token"...
//-------------------------------------------------------------------------------------------
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
    String jwt = bearerToken.substring(7, bearerToken.length());
//  thisLog.debug("resolve() - found in Authorization: header - jwt=" + jwt);
    thisLog.debug("resolve() - found in Authorization: header - jwt=NOTSHOWINGFULLSTRING" );
    return jwt;
    }

//-----------------------------------------------------------------------------------------
// ...or see if it was sent in an access_token cookie from a browser / servlet application
//------------------------------------------------------------------------------------------
//thisLog.debug("resolveToken() - attempting to extract token from access_token cookie");
String token = "";

// get token from a Cookie
Cookie[] cookies = request.getCookies();

if (cookies == null || cookies.length < 1) {
   thisLog.debug("resolve() -- no cookies submitted with request - returning null");
   return null;
   }

Cookie sessionCookie = null;
for (Cookie cookie : cookies) {
// thisLog.debug("attemptAuthentication() -- saw cookie name=" + cookie.getName());
   if(("access_token").equals(cookie.getName())) {
      thisLog.debug("resolve() -- found access_token=" + cookie.getValue());
//    thisLog.debug("resolve() -- found access_token=NOTSHOWINGFULLSTRING");
      return cookie.getValue();
      }
   }

// if here, we didn\\'t find a cookie named access_token so return null
return null;

}

}


Configuration via application.properties Entries

Validating external tokens and transforming them into internal Authentication objects across possibly multiple external providers requires a significant number of "this 2 that" mappings between provider names, parameter names and external / internal values for parameters. The validations and mappings described in the Overview section can be accommodated by a collection of configuration parameters that can be parsed and added to a series of HashMaps to allow transformations to be performed by simple "get" operations against these HashMaps.

A custom scheme will be devised for most of the validation and role mapping tasks and some standard configuration values expected by Spring libraries are described in the sections below.


Configuration Design for Unique mdhlabs Implementation Properties

The public key information comes from each Authorization Server but that is NOT information that should be fetched for each request since it should not change often. It makes sense to query that from each server when detected in configuration entries at startup then house in local cache for rapid use. A careful read of all of the task bullets above directly suggests a design for handling these parameter transformations that is easy to configure for administrators, easy to grasp for developers and highly performant.

  • create a HashMap mapping issuer to provider (issuer2provider)
  • create a HashMap mapping provider to jwkseturi (provider2jwkseturi)
  • create a HashMap mapping provider to jwkset (provider2jwkset)
  • create a HashMap mapping provider to username (provider2username)
  • create a HashMap mapping provider to a role mapping (provider2mapping)
  • create a HashMap mapping kid (key id) to public key (kid2publickey)
  • create a method that reads in application.properties entries, parses the key and puts a (key,value) entry into the appropriate this2that HashMap
  • create a method that loops through the provider2jwkseturi entries and fetches content from the specified URI and puts the (provider, jwkset) entry into provider2jwkset then generates the public key for each key set entry and puts a (kid,publickey) entry in kid2publickey

Here is what the custom configuration properties would look like for two Authorization Server providers, one named mdhlabs and one named providerx.


# these define role mappings from various OAuth Authorization Server providers above
# and where to locate the provider roles in their token response payloads
com.mdhlabs.oauth.mapping.mdhlabs=mdhlabsgetonly:customer,mdhlabsfull:engineer
com.mdhlabs.oauth.rolepath.mdhlabs=roles
com.mdhlabs.oauth.issuer.mdhlabs=http://192.168.99.10:8011/realms/mdhlabs
com.mdhlabs.oauth.username.mdhlabs=preferred_username
com.mdhlabs.oauth.jwkseturi.mdhlabs=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/certs

# adding these to test unique solution logic that may support multiple Authorization Servers
# these define role mappings from various OAuth Authorization Server providers above
# and where to locate the provider roles in their token response payloads
com.mdhlabs.oauth.mapping.providerx=providerxbasic:customer,providerxadvanced:admin,mdhlabsuser:customer
com.mdhlabs.oauth.rolepath.providerx=roles
com.mdhlabs.oauth.issuer.providerx=http://192.168.99.10:8011/realms/providerx
com.mdhlabs.oauth.username.providerx=preferred_username
com.mdhlabs.oauth.jwkseturi.providerx=http://192.168.99.10:8011/realms/providerx/protocol/openid-connect/certs

The HashMap variables to house these mappings should be defined as gobal public variables in a class as shown here.


public static HashMap issuer2provider     = new HashMap<>();
// yup, we need this mapping in both directions -- not all providers recap their issuer
// in their access_tokens
public static HashMap provider2issuer    = new HashMap<>();
public static HashMap provider2username  = new HashMap<>();
public static HashMap> provider2role = new HashMap<>();
public static HashMap provider2path      = new HashMap<>();
public static HashMap provider2scopes    = new HashMap<>();
public static HashMap algorithm2bytes    = new HashMap<>();
public static HashMap> provider2claimmap = new HashMap<>();
public static HashMap provider2jwkseturi = new HashMap<>();
public static HashMap provider2jwkset    = new HashMap<>();
public static HashMap kid2publickey   = new HashMap<>();

Here is what each configuration element defines:

provider internal shorthand name for each unique Authorization Server
issuer primary URL of the Authorization Server issuing an access token
jwkseturi the URL of service on the Authorization Server that will return a JSON encoded list of all public keys in service on the Authorization server, each identified by a kid ("key id") value
username identifies the name of the claim in the JWT body that will be used as the username in the internal Authentication objec
rolepath identifies the name of the claim in the JWT body that will be used as the external role information of the user that will be transformed into internal role string
mapping provides the externalrole:internalrole mappings to use for an Authorization Server provider

A typical code block for reading and interating through all configuration entires in application.properties looks like this.


try {
   InputStream propertiesInput = this.getClass().getClassLoader().getResourceAsStream("application.properties");
   Properties appProperties = new Properties();
   appProperties.load(propertiesInput);

   @SuppressWarnings("unchecked")
   Enumeration propertyKeyEnum = (Enumeration) appProperties.propertyNames();
   while (propertyKeyEnum.hasMoreElements() ) {
      String key = propertyKeyEnum.nextElement();
      String value = appProperties.getProperty(key);
      //=============================================
      // do something magic here with each (key,value) 
      //=============================================
      }
   }
catch (Exception theE) {
   thisLog.error("initializeClient() -- failure loading properties -- Exception=" + theE );
   allpresent=false;
   }

Within that main loop, each (key,value) combination must be matched against the appropriate key prefix to identify which this2that HashMap needs to be updated. The logic for two of the maps is shown below.


      if (key.startsWith("com.mdhlabs.oauth.jwkseturi.")) {
          String provider = key.replaceAll("com.mdhlabs.oauth.jwkseturi.","");
          provider2jwkseturi.put(provider, value);
          }

      if (key.startsWith("com.mdhlabs.oauth.issuer.")) {
          String provider = key.replaceAll("com.mdhlabs.oauth.issuer.","");
          // when used to decode tokens, we DO want this keyed by issuer_uri
          issuer2provider.put(value, provider);
          provider2issuer.put(provider, value);
          }

Since these transformation lookups are reqired for each incoming request token, these methods and the HashMap objects they populate will be implanted in the JwtAuthenticationConverter class which will be covered in more detail below. The constructor method of the class will perform the loading of application.properties entries and trigger the real-time fetch of public keys for each provider's defined jwkseturi endpoint.

Configuration for Spring Library OAuth Properties

For Resource Server implementations, Spring libraries assume only a single Authorization Server will be used for validation of JWT signatures. The property name to use to supply that parameter depends upon whether the application being built is being coded as a standalone servlet or for running within Spring Boot. The property entries for both are illustrated below and both can be included in a properties file but only one will be used based upon whether Spring Boot is in use.


#----------------------------------------------------------------------------------------------
# if this service only uses OAuth2 to protect itself using an OpenID Connect compliant
# Authorization Server (such as KeyCloak), this is the only endpoint configuration needed
#-----------------------------------------------------------------------------------------------
spring.security.oauth2.resourceserver.jwt.issuer-uri=http://192.168.99.10:8011/realms/mdhlabs
# the jwk-set-uri can be optionally provided to allow this service to start even if
# the Authorization Server is down at the time of startup
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/certs

# this parameter IS required for proper token validation within Spring Boot
security.oauth2.resource.jwk.key-set-uri=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/certs
Note! If building the Resource Server module WITHOUT Spring Boot, only the spring.security.oauth2.resourceserver.jwt entry is used for jwk-set-uri. If USING Spring Boot, only the security.oauth2.resource.jwk prefix entry is used for key-set-uri. Note the variation in parameter names.

The above parameters allow token handling functions to recognize an Authorization Server referenced in an incoming token and where to get public key information from that Authorization Server to validate the integrity of incoming tokens to guard against tampering, expired tokens, etc. However, logic required to understand user authorizations requires information from Authorization Servers that has enormous variability in implementations making the out-of-the-box Spring implementation lacking.

In these examples, the required properties will be specified in the application.properties file in the build. These settings would typically differ between development, test and production environments and thus would normally be something kept outside the final service binary archive. That isolation of configuration to external files will be left as a an exercise for the diligent student.

The Spring Security Libraries do not consistently scan properties and specifically identify required properties that are missing. Some parameter errors will crash the application at startup. Other parameter errors will result in null strings being passed at the time of invocation causing exceptions then. If such errors are encountered, CHECK VERSION NUMBERS of all dependencies in pom.xml and CHECK FOR TYPOS in the application.properties file.

Introspect Configuration for Opaque Token Processing

As summarized in section 2.2.7, authorization information can be conveyed in a standard JWK token in which most / all of the parameters needed about the user and roles are supplied as "key":"value" claim pairs in the body of the JWT token. Some scenarios want or desire to limit exposure of user information as much as possible and instead provide an "opaque" token which provides a barebones token with signature / expiration parameters but instead of user details, the Authorization Server requires the access token and the client-id / client-secret to be submitted to a second endpoint to fetch the user details. If an Authorization Server uses opaque tokens, additional Spring configuration parameters must be provided as shown below to allow that $introspect endpoint to be invoked.


# these extra parms are required if using opaque access tokens - no harm
# if configured but not used
spring.security.oauth2.resourceserver.opaque.introspection-uri= http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token/introspect
spring.security.oauth2.resourceserver.opaque.client-id=KuberDependsOauth
spring.security.oauth2.resourceserver.opaque.client-secret=9eL1t5XalxDL9GFrYBfoYrWAo0Jo1baH
This example will not delve into handling opaque token introspection. The flow required in the Authorization server will be very similar to the userinfo flow implemented for the GUI client example later in this series.

JWT Utilities / Validation Logic Development

Validation of incoming JWT payloads requires a wrapper method to combine the following steps.

  1. split the full token into header64url, body64url and signature64url
  2. convert header64url to headerjson
  3. convert body64url to bodyjson
  4. extract "kid" key id from headerjson and get the associated RSAPublicKey from kid2publickey HashMap
  5. concatenate header64url + . + body64url
  6. pass that combined string and the publickey to verifySignature(message, publickey) -- throw Exception on failure
  7. test for expiration by extracting "iat" and "exp" from bodyjson and comparing against Instant.now()

This logic will be implemented as validBase64token() which accepts a String value for the token requiring validation in its raw form directly out of the HttpServletRequest after extraction by mdhlabsBearerTokenResolver.


//---------------------------------------------------------------------------------
// validBase64token() - accepts a full (head/body/sig) base64 token and
//  1) splits into head/body/sig
//  2) converts head to json and extracts kid (key id)
//  3) converts body to json and extracts iat and exp (issued at / expires at)
//  4) gets key for kid from kid2publickey hashmap
//  5) validates sent signature against recalculated signature - REJECT if invalid
//  6) checks for expiration
//---------------------------------------------------------------------------------
public static String validBase64token(String base64token) {

thisLog.debug("validBase64token() =====================================================");
String result = "valid";

String tokenparts[] = base64token.split("\\.");
if (tokenparts.length != 3) {
    thisLog.error("validBase64token() -- result=invalidHeaderBodySig");
    return "invalidHeaderBodySig";
    }

String sentmessage = tokenparts[0] + "." + tokenparts[1];
String sentsignature = tokenparts[2];

String kid        = "";
String headerJson = "";
String bodyJson   = "";

try {
   headerJson = new String(Base64.decodeBase64(tokenparts[0]), "UTF-8");
   bodyJson   = new String(Base64.decodeBase64(tokenparts[1]), "UTF-8");

   ObjectMapper mapper = new ObjectMapper();
   JsonNode headernode = mapper.readTree(headerJson);
   kid = headernode.path("kid").asText();
   if (kid.equals("")) {
      thisLog.error("validBase64token() -- result=invalidMissingkid");
      return "invalidHeaderBodySig";
      }
   thisLog.debug("validBase64token() -- kid=" + kid);

   RSAPublicKey publickey = mdhlabsJwtAuthenticationConverter.kid2publickey.get(kid);
   if (publickey == null) {
      thisLog.error("validBase64token() -- result=invalidMissingkid");
      return "invalidWrongkid";
      }
// thisLog.debug("validBase64token() -- publickey=" + publickey.toString());

   byte[] publickeybytes = publickey.getEncoded();
// String publickey64 = Base64.encodeBase64String(publickeybytes);
   String publickey64url = Base64.encodeBase64URLSafeString(publickeybytes);
// thisLog.debug("validBase64token() -- publickey64    =" + publickey64);
   thisLog.debug("validBase64token() -- publickey64url =" + publickey64url);

// String sentsignature64 = sentsignature.replace('-','+');
// sentsignature64 = sentsignature64.replace('_','/');

   thisLog.debug("validBase64token() -- sentsignature  =" + sentsignature);
// thisLog.debug("validBase64token() -- sentsignature64=" + sentsignature64);
   boolean validSignature = verify(sentmessage,sentsignature,publickey);
// boolean validSignature64 = verify(sentmessage,sentsignature64,publickey);
   thisLog.debug("validBase64token() -- validSignature=" + validSignature);
   if (validSignature == false) {
      thisLog.error("validBase64token() -- result=invalidSignatureMismatch");
      return "invalidSignatureMismatch";
      }

   // 6) check for expiration
   JsonNode bodynode   = mapper.readTree(bodyJson);
   Instant issuedat  = Instant.ofEpochSecond(bodynode.path("iat").asLong());
   Instant expiresat = Instant.ofEpochSecond(bodynode.path("exp").asLong());
   Instant now = Instant.now();
   if (expiresat.compareTo(Instant.now()) < 0) {
      thisLog.debug("validBase64token()  -- result=invalidExpired expiresat=" + expiresat.toString() + " now="
         + now.toString());
      return "invalidExpired";
      }
   }
catch (Exception theE) {
   thisLog.error("validBase64token() - other error encountered -- Exception=" + theE);
   return "invalidException";
   }
thisLog.debug("validBase64token() - result=" + result);
return result;

}

Within those steps are references to other simple utilty functions also required for the lower level security key recovery, generation and verification. Since these methods are relatively straightforward, additional methods to create key pairs from external key specifications, generate signatures and enrypt/de-encrypt data will also be provided that will be useful for the GUI application example.


public static String sign(String plainText, PrivateKey privateKey) throws Exception {

Signature privateSignature = Signature.getInstance("SHA256withRSA");
privateSignature.initSign(privateKey);
privateSignature.update(plainText.getBytes(StandardCharsets.UTF_8));

byte[] signature = privateSignature.sign();

return Base64.encodeBase64URLSafeString(signature);
}




public static boolean verify(String plainText, String signature, PublicKey publicKey) throws Exception {

//thisLog.debug("verify() -- plaintText=" + plainText);
Signature publicSignature = Signature.getInstance("SHA256withRSA");
publicSignature.initVerify(publicKey);
publicSignature.update(plainText.getBytes(StandardCharsets.UTF_8));

byte[] signatureBytes = Base64.decodeBase64(signature);

return publicSignature.verify(signatureBytes);
}



public static String encrypt(String plainText, PublicKey publicKey) throws Exception {

Cipher encryptCipher = Cipher.getInstance("RSA");
encryptCipher.init(Cipher.ENCRYPT_MODE, publicKey);

byte[] cipherText = encryptCipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));

return Base64.encodeBase64URLSafeString(cipherText);
}


public static String decrypt(String cipherText, PrivateKey privateKey) throws Exception {

byte[] bytes = Base64.decodeBase64(cipherText);

Cipher decriptCipher = Cipher.getInstance("RSA");
decriptCipher.init(Cipher.DECRYPT_MODE, privateKey);

return new String(decriptCipher.doFinal(bytes), StandardCharsets.UTF_8);
}


public static KeyPair generateKeyPair() throws Exception {
    KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
    generator.initialize(2048, new SecureRandom());
    KeyPair pair = generator.generateKeyPair();

    return pair;
}


public static PublicKey createPublicKeyFromJWKS(String jwksJson) {

try {
   ObjectMapper mapper = new ObjectMapper();
   JsonNode rootNode = mapper.readTree(jwksJson);
   String exponent64url = rootNode.path("e").asText();
   String nmodulus64url = rootNode.path("n").asText();
   byte[] exponentBytes = Base64.decodeBase64(exponent64url);
   byte[] nmodulusBytes = Base64.decodeBase64(nmodulus64url);
   KeyFactory kf = KeyFactory.getInstance("RSA");
   return kf.generatePublic(new RSAPublicKeySpec(new BigInteger(nmodulusBytes),new BigInteger(exponentBytes)));
   }
catch (Exception theE) {
   thisLog.error("createPublicKeyFromPublicCertString() - Exception=" + theE);
   return null;
   }
}


public static PublicKey createPublicKeyFromNEStrings(String n64url, String e64url) {

try {
   byte[] exponentBytes = Base64.decodeBase64(e64url);
   byte[] nmodulusBytes = Base64.decodeBase64(n64url);
   KeyFactory kf = KeyFactory.getInstance("RSA");
   // NOTE: the JWK spec returns n and e as negative numbers so BigInteger() must
   // be called to set signmagnitude=1 (negative)
   BigInteger nmodulusBigInteger = new BigInteger(1, nmodulusBytes);
   BigInteger exponentBigInteger = new BigInteger(1, exponentBytes);

   thisLog.debug("createPublicKeyFromNEStrings() - nmodulusBigInteger=" + nmodulusBigInteger
     + " exponentBigInteger=" + exponentBigInteger);
   return kf.generatePublic(new RSAPublicKeySpec(nmodulusBigInteger, exponentBigInteger));
   }
catch (Exception theE) {
   thisLog.error("createPublicKeyFromNEStrings() - Exception=" + theE);
   return null;
   }
}


public static PublicKey createPublicKeyFromModExp(BigInteger modulus, BigInteger exponent) {

try {
   KeyFactory kf = KeyFactory.getInstance("RSA");
   return kf.generatePublic(new RSAPublicKeySpec(modulus,exponent));
   }
catch (Exception theE) {
   thisLog.error("createPublicKeyFromPublicCertString() - Exception=" + theE);
   return null;
   }
}


public static PublicKey createPublicKeyFromPublicCertString(String certstring, String algorithm) {

try {
   byte[] keybytes = Base64.decodeBase64(certstring.getBytes());
   X509EncodedKeySpec xkey = new X509EncodedKeySpec(keybytes);
   KeyFactory kf = KeyFactory.getInstance(algorithm);
   return kf.generatePublic(xkey);
   }
catch (Exception theE) {
   thisLog.error("createPublicKeyFromPublicCertString() - Exception=" + theE);
   return null;
   }
}


public static RSAPublicKey getPublicKeyFromString(String publicKey, String algorithm) throws NoSuchAlgorithmException, InvalidKeySpecException {

byte[] bytes = Base64.decodeBase64(publicKey.getBytes());   // should this be decodeBase64URL ?
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
return (RSAPublicKey) keyFactory.generatePublic(new X509EncodedKeySpec(bytes));
}


public static RSAPublicKey getPublicKeyFromx5c(String x5c) throws CertificateException {

try {
   byte[] keyBytes = Base64.decodeBase64(x5c);  // should this be decodeBase64URL ?
   CertificateFactory fact = CertificateFactory.getInstance("X.509");
   Certificate cer = fact.generateCertificate(new ByteArrayInputStream(keyBytes));
   return (RSAPublicKey) cer.getPublicKey();
   }
catch (Exception theE) {
   thisLog.error("getPublicKeyFromx5c() - Exception=" + theE.toString());
   return null;
   }
}

Transforming Authorization Attributes (mdhlabsJwtAuthenticationConverter)

After logic in the prior section validates the integrity of an incoming JWT token, information about the identity and its external role information needs to be transformed into an internal Authentication object which itself is a collection of lower level objects. A visual summary of that desired final construct is shown below.

It isn't clear why these object structures duplicate the authorities structure as both a standalone top level object in the BearerAuthenticationToken and as a sub-object within the principal object.

A crucial point reflected in this diagram is that the external bearer token submitted by a client is NOT the object passed between internal modules of an application used for authorization of the request. The userid and authorities reflected in the incoming bearer token must be converted into objects used by Spring Security and the underlying application being protected.

It helps to think of Authentication as a noun rather than a physical process / verb. The user is not re-authenticating each time this Authentication object is created with each incoming request.

As noted in the prior two sections, earlier incarnations of Spring Security and OAuth2 functionality provided PrincipalExtractor and AuthoritiesExtractor as extensible classes to extract an identity (typically username or sub or preferred_username) and claims from an external OAuth2 access token and convert them into internal elements depicted in the Authentication object above. However, these older methodologies were deprecated as of Spring 5.4 and should NOT be used.

Instead, a custom class named mdhlabsJwtAuthenticationConverter will be created that implements Converter<FromClass,ToClass> which can be provided as a @Bean reference to be invoked by the BearerTokenAuthenticationFilter in the main security chain. This custom class will perform multiple functions:

  • at application startup, populate a collection of HashMaps that aid in simplifying lookups and mappings needed for the external to internal transformations
  • at application startup, fetch the key sets from all defined Authorization Servers seen in the prior configuration load and put them as entries in the kid2publickey HashMap
  • implement an convert(Jwt thejwt) method with @Override annotation to provide the standard interface for incoming token conversions that returns a populated AbstractAuthenticationToken object
  • implement a helper method extractUserRoles() that performs the configured mapping of external role strings to internal role strings
  • implement a helper method extractAuthenticationFromMap() that converts claims from the external JWT to a Collection of GrantedAuthority objects, creates the Principal and Credential objects then returns all of them as an Authentication object returned through convert().

Configuring the Spring SecurityFilterChain

Some of the OAuth2 functionality required of a process acting as a Resource Server will be discovered and configured into processing flows via the various @Bean and @Component annotations used in various classes. However, unique scenarios will inevitiably require some behaviors to be explicitly configured, either via application.properties files or calls made by an application at startup as specified in a configuration class. The recommended approach for creating this configuration class has changed significantly as of Spring Security 5.4.

Deprecated Approach Creating a SecurityConfiguration class extending WebSecurityConfigurerAdapter that includes a configure(HttpSecurity http) method with @Override that calls submethods of that HttpSecurity object to configure login protections, oauth2resourceServer behavior, etc.
Directional Approach Create a generic SecurityConfiguration class with @Configuration and @EnableWebSecurity that defines a filterChain(HttpSecurity http) method virtually identical to what would have been configured in the configure(HttpSecurity http) method in the old style.

These two approaches are mutually incompatible with one another. If any lower level custom classes or methods used to build either one of these configuration classes uses classes from the other, VERY CONFUSING compile-time or run-time errors will occur. Specifically, if using the SecurityChainFilter approach, any use of classes present in spring-security-oauth2 libraries will pull in other "Spring Security OAuth2" functionality that is DEPRECATED and will conflict with SecurityChainFilter functionality. In particular, at startup, the exception Found WebSecurityConfigurerAdapter as well as SecurityFilterChain. Please select just one.. may appear. To get rid of it, ensure no classes are loading classes found in spring-security-oauth2 package and remove that package from the pom.xml project file.

The security flow that will be created when this SecurityConfigurationSFC class executes is illustrated below. There are direct correlations between many of the flow arrows and configuration statements within the class.

Sample code for using the SecurityFilterChain approach is shown below in a class named SecurityConfigurationSFC (the SFC being a reminder of the technique used).

package com.mdhlabs.depends;

import com.mdhlabs.oauthsecurity.*;
import com.mdhlabs.oauthsecurity.JwtValidationUtilities;
import com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter;
import java.util.*;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.AuthenticationFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;


//------------------------------------------------------------------------
// CLASS SecurityConfigureSFC -  uses SecurityFilterChain configuration approach
// defines helper beans used by filters in the security chain to analyze tokens
// of incoming reqests to validate token integrity and perform any required
// security blocks before forwarding the request to the servlet controller
//------------------------------------------------------------------------

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@ComponentScan("com.mdhlabs")
public class SecurityConfigurationSFC {

private final Logger thisLog = LoggerFactory.getLogger(this.getClass().getCanonicalName());


// -------------------------------------------------------------------------------
// getmdhlabsAuthenticationFailureHandler() - returns an instance of a custom
// AuthenticationFailureHandler named mdhlabsAuthenticationFailureHandler
// that provides some control of how much detail is returned to clients
// encountering authentication failures
// -------------------------------------------------------------------------------
@Bean
public AuthenticationEntryPoint getAuthenticationFailureHandler() {

thisLog.info("getAuthenticationFailureHandler() - instantiating mdhlabsAuthenticationFailureHandler");
return (AuthenticationEntryPoint) new mdhlabsAuthenticationFailureHandler();
}



//---------------------------------------------------------------------------
// getAuthorizationFailureHandler() - stub method to return reference
// to custom mdhlabsAuthorizationFailureHandler -- for insufficient permission
// errors
//----------------------------------------------------------------------------
@Bean
public AccessDeniedHandler getAuthorizationFailureHandler() {

thisLog.debug("getAuthorizationFailureHandler() - instantiating mdhlabsAuthorizationFailureHandler");
return new mdhlabsAuthorizationFailureHandler();
}


//---------------------------------------------------------------------------
// getmdhlabsBearerTokenResolver() - stub method to return reference
// to custom mdhlabsBeaerTokenResolver() which extracts raw base64 token from
// an HttpServetRequest object
//----------------------------------------------------------------------------
@Bean
public BearerTokenResolver getBearerTokenResolver() {

thisLog.debug("getBearerTokenResolver() - instantiating mdhlabsBearerTokenResolver");
return new mdhlabsBearerTokenResolver();
}


//--------------------------------------------------------------------------
// validMdhlabsAuthentication() - accepts an Authentication which houses a
// Principal which will contain a base64token previously extracted by our
// mdhlabsBearerTokenResolver class (resolve() method).  We can inspect
// that here and confirm if it is a token type we can process.  For now,
// we will assume TRUE as long as it isn't null.
//--------------------------------------------------------------------------
private String validMdhlabsAuthentication(Authentication authentication) {

//thisLog.debug("validMdhlabsAuthentication() - testing Authentication=" + authentication.toString());
thisLog.debug("validMdhlabsAuthentication() - testing Authentication=MASKEDFORBREVITY");
String base64jwt = (String) authentication.getPrincipal();
//thisLog.debug("validMdhlabsAuthentication() - base64jwt=" + base64jwt);

String accessStatus = JwtValidationUtilities.validBase64token(base64jwt);

thisLog.debug("validMdhlabsAuthentication() - result=" + accessStatus);
return accessStatus;

}



//--------------------------------------------------------------------------
// mdhlabsAuthenticationManager() - returns a UsernamePasswordAuthenticationToken
//  object if the implicitly supplied Authentication matches our criteria
//---------------------------------------------------------------------------
private AuthenticationManager mdhlabsAuthenticationManager() {

thisLog.debug("mdhlabsAuthenticationManager() - testing incoming Authentication object");
return authentication -> {
   String authState = validMdhlabsAuthentication(authentication);
   if (authState.equals("valid"))  {
      String base64jwt = (String) authentication.getPrincipal();
      UsernamePasswordAuthenticationToken internalAuthentication =
          mdhlabsJwtAuthenticationConverter.extractAuthenticationFromJwtBase64(base64jwt);
      return internalAuthentication;
      }
   throw new BadCredentialsException(authState);
   };
}



//-------------------------------------------------------------------------
// getJwtAuthenticationConverter -- simple @Bean returning a reference to
// a custom Converter used to convert external non-standard JWTs into
// a consistent internal Authentication object for use by core Spring
// Security handlers
//-------------------------------------------------------------------------
@Bean
public Converter getJwtAuthenticationConverter() {

thisLog.debug("getAuthenticationConverter() - instantiating mdhlabsAuthenticationConverter object");
return new mdhlabsJwtAuthenticationConverter();
}



//-------------------------------------------------------------------------
// mdhlabsAuthenticationManagerResolver() - simple stub that returns the
// reference to mdhlabsAuthenticationManager() for handling incoming tokens
// In general, this can be used to select BETWEEN different AuthenticationManager
// classes if the distinction is based upon the request URI or something that
// can be parsed from the request - we might extend this to parse the token
// then use the issuer ("iss") to select which AuthenticationManager to use.
//-------------------------------------------------------------------------
public AuthenticationManagerResolver mdhlabsAuthenticationManagerResolver() {

thisLog.debug("mdhlabsAuthenticationResolver() - returning reference to mdhlabsAuthenticationManager for token processing");
return request -> {
//   if (request.getPathInfo().startsWith("/employee")) {
//       return employeesAuthenticationManager();
//       }
//   return customersAuthenticationManager();
   return mdhlabsAuthenticationManager();
   };
}



//-------------------------------------------------------------------------
// getmdhlabsAuthenticationFilter() - returns an AuthentictionFilter referencing
// custom AuthenticationManagerResolver and AuthenticationConverter objects
//--------------------------------------------------------------------------
private AuthenticationFilter getmdhlabsAuthenticationFilter() {

thisLog.debug("getAuthenticationFilter() - instantiating new mdhlabsAuthenticationResolver / mdhlabsAuthenticationConverter");
AuthenticationFilter filter = new AuthenticationFilter(
    (AuthenticationManagerResolver) mdhlabsAuthenticationManagerResolver(),
    (AuthenticationConverter) getJwtAuthenticationConverter()
    );
filter.setSuccessHandler((request, response, auth) -> { });
return filter;
}



//-------------------------------------------------------------------------------
// encoder() - returns an instance of library function BCryptPasswordEncoder()
//  for use in creating User objects -- not sure this is needed for our scenerio
//  where we aren't authenticating human passwords but is temprorarily used
//  in another method call
//-------------------------------------------------------------------------------
@Bean
public PasswordEncoder encoder() {

thisLog.info("encoder() -- instantiation for BCryptPasswordEncoder ");
return new BCryptPasswordEncoder();
}




//-----------------------------------------------------------------------------------
// getmdhlabsJwtFilterBean() - returns new object instance of mdhlabsJwtFilter to
//   be inserted in the SecurityFilter chain via addFilterAfter() below.
//-----------------------------------------------------------------------------------
/* DISABLE FOR NOW
@Bean
public mdhlabsJwtFilter getmdhlabsJwtFilter() {

return new mdhlabsJwtFilter();
}
*/



//-----------------------------------------------------------------------------
// jwtDecoder() - instantiates a JWT decoder using the JWKsetUri of an OAuth
//   provider to fetch strings to decode / unencrypt a token
// NOTE -- not clear how this approach works when a single app can use
//   multiple OAuth providers for google, facebook, github, keycloak, etc
//   For now, this is hardcoding the JwkSetUri for a local keycloak instance.
//-----------------------------------------------------------------------------

@Bean
public JwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties) {

thisLog.debug("jwtDecoder() - instantiating - incoming OAuth2ResourceServerProperties=" + properties.toString());
thisLog.info("jwtDecoder() - instantiating NimbusJwtDecoder pointing to Keycloak jwk-set-uri=" + properties.getJwt().getJwkSetUri());
NimbusJwtDecoder thisDecoder = NimbusJwtDecoder.withJwkSetUri(properties.getJwt().getJwkSetUri()).build();

return thisDecoder;
}



//-----------------------------------------------------------------------------------
// filterChain(HttpSecurity) - the primary method in the SecurityFilterChain based approach
//   for defining security rules -- this is the new equivalent of the older
//   configure(HttpSecurity) method when using the WebSecurityConfigurerAdapter model
//-----------------------------------------------------------------------------------
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

//------------------------------------------------------------------------------------------------
// NOTE: These patterns are APPENDED to the servlet context /depends/gui in application.properties
//------------------------------------------------------------------------------------------------

thisLog.info("configure(HttpSecurity) - defining access filters for application URI patterns");

http
    .httpBasic().disable()
    .formLogin(AbstractHttpConfigurer::disable)
    .csrf(AbstractHttpConfigurer::disable)
    // NOTE: use newer .authorizeHttpRequests instead of older .authorizeRequests
    .authorizeHttpRequests(authorize -> authorize
       // NOTE: use .mvcMatchers instead of .antMatchers
       .mvcMatchers(HttpMethod.GET,  "/project/*").hasAnyAuthority("customer", "engineer")
//     .mvcMatchers(HttpMethod.GET,  "/project/*").denyAll()
       .mvcMatchers(HttpMethod.GET,  "/projects*").hasAuthority("engineer")
//     .mvcMatchers(HttpMethod.POST, "/project").hasAuthority("engineer")
//     .mvcMatchers(HttpMethod.PUT,  "/project/**").hasAuthority("engineer")
       .anyRequest().authenticated()
    );

// when configuring .oauth2ResourceServer(), you must either enable clear-text JTW
// processing via .jwt() or opaque token processing via .opaqueToken()

// if you configure .authenticationManagerResolver() under oauth2ResourceServer(), then
// the .jwt() level configurations cannot be used

http.oauth2ResourceServer()
   .authenticationEntryPoint(getAuthenticationFailureHandler())
   .accessDeniedHandler(getAuthorizationFailureHandler())
   .authenticationManagerResolver(mdhlabsAuthenticationManagerResolver())
   .bearerTokenResolver(this.getBearerTokenResolver() )
// .jwt()
//     .authenticationManager(AuthenticationManager mdhlabsAuthenticationManager
//     .jwtAuthenticationConverter(getmdhlabsJwtAuthenticationConverter() )
//     .decoder(myJwtDecoder() )
        ;


//---------------------------------------------------------------------------
// here is a full illustration of all oauth2ResourceServer() configurations
//---------------------------------------------------------------------------
//http.oauth2ResourceServer()
//   .accessDeniedHandler(AccessDeniedHandler theADH)
//   .authenticationEntryPoint(AuthenticationEntryPoint theAEP)
//   .authenticationManagerResolver(AuthenticationManagerResolver theARH)
//   .bearerTokenResolver(BearerTokenResolver theBTR)
//   .jwt()
//       .authenticationManager(AuthenticationManager theAM)
//       .decoder(JwtDecorer theDecoder)
//       .jwkSetUri(String uri)
//       .jwtAuthenticationConverter(Converter theJTC)
//       .and()
//   .opaqueToken()
//       .authenticationManager(AuthenticationManager mgr)
//       .introspectionClientCredentials(String clientid, String clientsecret)
//       .introspectionUri(String uri)
//       .introspector(OpaqueTokenIntrospector inspector)

http.sessionManagement(sessionManagement ->
       sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
       );

return http.build();
}


} // end of SecurityConfig class

Defining Authorization Restrictions for Services

So far, the web service has been altered to require client AUTHENTICATION via an access token obtained from the Authorization Server (Keycloak in this case) mutually trusted by the service and the client. To properly restrict an authenticated user to only those functions they require, AUTHORIZATION must also be performed against each incoming request reaching the service. The OAuth implementation in Spring allows authorization rules to be defined at two different planes of service logic:

Centralized Security Configuration via URI -- Using the SecurityConfigurationSFC class built previously, expressions can be applied to the OAuth configuration passed to the Spring Security FilterChain to identify roles / GrantedAuthorities required to execute specific URI endpoints.

Individual Controller Class Methods -- @PreAuthorize and PostAuthorize annotations can be added to method definitions within Controller classes providing the same matching rules for roles / GrantedAuthorities, etc.

Details on both approaches are provided below.

Why bother supporting TWO layers of configuration for authorization restrictions? Isn't the Spring model purposely biased towards centralizing and simplifying configuration in fewer rather than more locations? If applied at the Spring Security layer, wouldn't that keep more unauthorized traffic out of the system earlier in the flow? GOOD QUESTIONS.

The framework developers actually recommend using annotations in Controller classes at the method level. The rationale is that the filtering performed via a SecurityConfiguration class and antMatcher is literally only matching on an incoming request URI. It has no context for the actual SERVICE method being invoked later when the request is routed through the servlet dispatcher. As a result, if a @RequestMapping annotation in a controller class is altered to remap a service from /myapp/api/group1/thisendpoint to /myapp/api/group2/thatendpoint and the corresponding antMatcher in SecurityConfiguration is not adjusted to match, it is POSSIBLE access will suddenly be provided to the service on the new URI if /myapp/api/group2 matches some other existing configuration that permitted traffic.

Perhaps the best reason for preferring use of @PreAuthorize annotations in controllers instead of antMatchers or mvcMatchers in a SecurityConfiguration class is that the filtering of requests through matcher patterns is SEQUENTIAL. The first condition that matches the URI to a permission will BYPASS any other antMatcher patterns. Accidentally adding a wildcard pattern PRIOR to more restrictive patters will SKIP those later patterns for any requests that match.

Centralized Configuration of Authorization Restrictions

The general class built as SecurityConfigurationSFC in these examples had a configure(HttpSecurity) method that looked like this:


//-----------------------------------------------------------------------------------
// configure(HttpSecurity) - the primary method in the SecurityFilterChain based approach
//   for defining security rules -- this is the new equivalent of the older
//   configure(HttpSecurity) method when using the WebSecurityConfigurerAdapter model
//-----------------------------------------------------------------------------------
@Override
public void configure(HttpSecurity http) throws Exception {

thisLog.info("filterChain(HttpSecurity) - defining access filters for application URI patterns");

http
    .httpBasic().disable()
    .formLogin(AbstractHttpConfigurer::disable)
    .csrf(AbstractHttpConfigurer::disable)
    // NOTE: use newer .authorizeHttpRequests instead of older .authorizeRequests
    .authorizeHttpRequests(authorize -> authorize
       // NOTE: use .mvcMatchers instead of .antMatchers
       .mvcMatchers(HttpMethod.GET,  "/project/*").hasAnyAuthority("customer", "engineer")
//     .mvcMatchers(HttpMethod.GET,  "/project/*").denyAll()
       .mvcMatchers(HttpMethod.GET,  "/projects*").hasAuthority("engineer")
//     .mvcMatchers(HttpMethod.POST, "/project").hasAuthority("engineer")
//     .mvcMatchers(HttpMethod.PUT,  "/project/**").hasAuthority("engineer")
       .anyRequest().authenticated()
    );

// when configuring .oauth2ResourceServer(), you must either enable clear-text JTW
// processing via .jwt() or opaque token processing via .opaqueToken()

// if you configure .authenticationManagerResolver() under oauth2ResourceServer(), then
// the .jwt() level configurations cannot be used

http.oauth2ResourceServer()
   .authenticationEntryPoint(getAuthenticationFailureHandler())
   .accessDeniedHandler(getAuthorizationFailureHandler())
   .authenticationManagerResolver(mdhlabsAuthenticationManagerResolver())
   .bearerTokenResolver(this.getBearerTokenResolver() )
// .jwt()
//     .authenticationManager(AuthenticationManager mdhlabsAuthenticationManager
//     .jwtAuthenticationConverter(getmdhlabsJwtAuthenticationConverter() )
//     .decoder(myJwtDecoder() )
        ;

http.sessionManagement(sessionManagement ->
       sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
       );

return http.build();
}

This code segment uses a variety of patterns that merit additional explanation and clarification.

PATTERN ONE -- Concatenated versus Distinct Calls. The HttpSecurity object is designed so that most of its methods return a reference to the object itself. Since Java's core syntax allows chaining object calls together such as

      myString.trim().equals("NONPADDEDSTRING")

the methods for HttpSecurity allow the different areas of subfunctionality to be configured with calls that appear to add onto one another. These two blocks of code are functionally identical:

http
    .httpBasic().disable()
    .formLogin(AbstractHttpConfigurer::disable)
    .csrf(AbstractHttpConfigurer::disable)
    ;

http.httpBasic().disable();
http.formLogin(AbstractHttpConfigurer::disable);
http.csrf(AbstractHttpConfigurer::disable);

The resulting source code looks a bit cleaner but will function as if each call was a separate statement. In the hierarchy of methods / objects and submethods / subobjects, most are built to return a reference to the SAME object, allowing another sub-method to be called. Each object also has an and() method which will instead return a reference to the parent object. In source code, this allows the and() to essentially conclude a sub-level and return to the parent where a different sub-component can be configured. Whether this aids source readability will be left as a religious debate.

PATTERN TWO -- Use of Lambda Function Syntax Some of the methods of HttpSecurity accept a reference to another class used to implement a custom function. Others implement their own submethods that accept method references as an argument. Many of the examples online for methods like authorizeHttpRequest() or xxxxxxx() provide methods as arguments using lambda syntax which can be VERY confusing. In general, use of lambda syntax is helpful for defining static logic to perform a required transformation without having to declare a standalone class / method and bother with naming conventions and conflicts. However, it is not REQUIRED and there is always a "non-lamba" way to achieve the same result if the lambda syntax is too confusing.

As an example, these two code blocks are functionally identical:

Lambda Syntax:

http
    .httpBasic().disable()
    .formLogin(AbstractHttpConfigurer::disable)
    .csrf(AbstractHttpConfigurer::disable)
    // NOTE: use newer .authorizeHttpRequests instead of older .authorizeRequests
    .authorizeHttpRequests(authorize -> authorize
       // NOTE: use .mvcMatchers instead of .antMatchers
       .mvcMatchers(HttpMethod.GET,  "/project/*").hasAnyAuthority("customer", "engineer")
       .mvcMatchers(HttpMethod.GET,  "/projects*").hasAuthority("engineer")
       .mvcMatchers(HttpMethod.POST, "/project").hasAuthority("engineer")
       .mvcMatchers(HttpMethod.PUT,  "/project/**").hasAuthority("engineer")
       .anyRequest().authenticated()
    );

Traditional Syntax:

http
    .httpBasic().disable()
    .formLogin(AbstractHttpConfigurer::disable)
    .csrf(AbstractHttpConfigurer::disable)
    .authorizeHttpRequests()
       .mvcMatchers(HttpMethod.GET,  "/project/*").hasAnyAuthority("customer", "engineer")
       .mvcMatchers(HttpMethod.GET,  "/projects*").hasAuthority("engineer")
       .mvcMatchers(HttpMethod.POST, "/project").hasAuthority("engineer")
       .mvcMatchers(HttpMethod.PUT,  "/project/**").hasAuthority("engineer")
       .anyRequest().authenticated()
    ;

PATTERN THREE -- antMatcher versus mvcMatcher Spring Security includes two classes -- AntMatcher (older) MvcMatcher (newer) -- for specifiying patterns of URI strings when matching incoming requests against desired permission rules. The MvcMatcher functionality is viewed by most of the Spring community as being superior because its matching logic is identical to that used by @RequestMapping functionality in servlet controller functions. Specifically, antMatcher("/this/path/method") would NOT match on /this/path/methodx or /this/path/method/submethod. An invocation of mvcMatcher("/this/path/method") WOULD match on those variants. In essence, mvcMatcher casts a wider net for matches even without trailing wildcards. To avoid condition with matching most developers are already familiar with for @RequestMapping, it is better to use mvcMatcher. HOWEVER, example confiugrations on web sites abound with references to antMatcher(). In general, any reference to antMatcher() can be (should be) replaced with mvcMatcher().

PATTERN FOUR -- Matcher Rules on Roles and Authorities Both antMatcher() and mvcMatcher() provide identical submethods which can be concatenated to the match to further narrow an authorization.

   hasRole()
   hasAnyRole()
   hasAuthority()
   hasAnyAuthority()

The functionality of these methods creates one area of potential confusion. Internally, as authority strings are converted from an external authentication token, Spring Security defaults to prefixing each string with ROLE_ so an external role mdhlabsgetonly mapped to internal role customer is actually saved as ROLE_customer, etc. HOWEVER, logic in the hasRole() and hasAnyRole() methods will still see hasRole("customer") as a match even when the underlying external authority was converted to ROLE_customer. The hasAuthority() and hasAnyAuthority() methods do NOT add this equivalence. An exact match is required to satisfy the condition.

PATTERN FIVE -- authorizeRequests() versus authorizeHttpRequests() There are TWO functions in the Spring Security library for configuring authorizations at the URI level with overlapping, confusing names --- authorizeRequests() and authorizeHttpRequests(). The authorizeRequests() incarnation is older and uses older concepts for making authorization decisions if more than one authorization provider must be supported, etc. The authorizeHttpRequests() incarnation is new as of Spring Security 5.4 and its implementation is intended to use a streamlined AuthenticationManager based process for customization required for multiple providers. The documentation also says using authorizeHttpRequests() will use AuthorizationFilter in the security filter chain rather than the older FilterSecurityInterceptor. Searches for guidance on OAuth security with Spring will INEVITABLY land on pages referencing the older authorizeRequests() methods which will drag in other, older deprecated coding and configuration practices which will complicate development and code maintenance going forward. Make sure authorizeHttpRequests() is used.


Controller-Level Configuration of Authorization Restrictions

Instead of defining AUTHORIZATION restrictions in the central SecurityConfiguration class based on URI paths, it is possible to define within individual Controller classes implementing the methods tied to URI endpoints. This is actually the preferred approach for the reasons explained above in section 5.8.

A typical controller class is shown below using these two annotations atop two specific endpoints:

    @PreAuthorize("hasRole('engineer')")

    @PreAuthorize("hasAnyRole('customer','engineer')")

These are two very simple examples showing how a given method can be restricted to one or multiple role strings mapped from the external access token to the internal Authentication object. The @PreAuthorize annotation applies the rule BEFORE the method is invoked making it the obvious tool for BLOCKING access. However, if blocking critiera can only be tested AFTER a method is executed (but before a response is returned), the @PostAuthorize annotation can be used in the exact same way.

The @PreAuthorize and @PostAuthorize functions are quite powerful and complex and can trigger off any content in the request or response object drafted to return to the client. Full documentation is available at:

https://docs.spring.io/spring-security/reference/servlet/authorization/expression-based.html

package com.mdhlabs.depends.services;

import java.util.List;
import java.sql.SQLException;
import javax.ws.rs.core.MediaType;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;


import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
//import org.springframework.security.oauth2.server.resource.authentication.Jwt;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.access.prepost.PreAuthorize;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.mdhlabs.depends.dao.ProjectsDAO;
import com.mdhlabs.depends.models.Project;
import com.mdhlabs.oauthsecurity.*;
import java.lang.Exception;

import org.springframework.ui.Model;


@RestController
@EnableResourceServer
public class ProjectController {

    @Autowired
    private ProjectsDAO projectsDAO;

    private final Logger thisLog = LoggerFactory.getLogger(this.getClass().getCanonicalName());

    //----------------------------------------------------------------------------------------
    // projectList - returns an array of all project objects in the database
    // URI = /context/servletPath/projects/
    // METHOD = GET
    //----------------------------------------------------------------------------------------
    @PreAuthorize("hasRole('ROLE_mdhlabsfull')")
    @RequestMapping(value="/projects", method=RequestMethod.GET, produces=MediaType.APPLICATION_JSON)
    public @ResponseBody List projectList() throws SQLException, Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = mdhlabsSecurityUtilities.getUsernameFromAuthentication(authentication);
        thisLog.info("QUERY action=projectList method=get username=" + username);
        List listOfProjects = projectsDAO.retrieveProjects();
        thisLog.info("REPLY action=projectList method=get username=" + username);
        return listOfProjects;
    }

    //----------------------------------------------------------------------------------------
    // projectRetrieve - returns the specified Project object
    // URI = /context/servletPath/project/{project_id}
    // METHOD = GET
    //---------------------------------------------------------------------------------------------------
    // to access values in the request objects, add HttpServletRequest as a pararameter of the controller
    // method then use .getHeader("xxxx")-- if you want to manually set response headers, add
    // HttpServletRespose as a parameter of the controller method then use .setHeader("xxxxx",value)
    //--------------------------------------------------------------------------------------------------
    @PreAuthorize("hasAnyRole('ROLE_mdhlabsgetonly','ROLE_mdhlabsfull')")
    @RequestMapping(value="/project/{project_id}", method=RequestMethod.GET, produces=MediaType.APPLICATION_JSON)
    public @ResponseBody Project projectRetrieve(@PathVariable("project_id") int project_id,
        HttpServletRequest theQuery,HttpServletResponse theReply)  throws SQLException, Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = mdhlabsSecurityUtilities.getUsernameFromAuthentication(authentication);
        thisLog.info("QUERY action=projectRetrieve method=get project_id=" + project_id + " username=" + username);
        String headerDatetimegmt = theQuery.getHeader("datetimegmt");
        String headerTxRoot = theQuery.getHeader("txroot");
        String headerClient = theQuery.getHeader("client");
        thisLog.info("QUERY HTTPHEADERS datetimegmt=" + headerDatetimegmt + " txroot=" + headerTxRoot + " client=" + headerClient);
        Project thisProject = projectsDAO.retrieveProject(project_id);
        thisLog.info("REPLY action=projectRetrieve method=get project_id=" + project_id + " username=" + username);
        return thisProject;
    }

//other methods omitted here for brevity

} // end of class ProjectController

When using @PreAuthorize or @PostAuthorize protections, log messages will be generated at startup of the component reflecting any filter configured against methods in the controller class. The logs look like this:

2022-06-27 17:19:58,361 DEBUG org.springframework.security.access.prepost.PrePostAnnotationSecurityMetadataSource - @org.springframework.security.access.prepost.PreAuthorize("hasRole(\'ROLE_mdhlabsfull\')") found on specific method: public java.util.List com.mdhlabs.depends.services.ProjectController.projectList() throws java.sql.SQLException,java.lang.Exception 2022-06-27 17:19:58,375 DEBUG org.springframework.security.access.method.DelegatingMethodSecurityMetadataSource - Caching method [CacheKey[com.mdhlabs.depends.services.ProjectController; public java.util.List com.mdhlabs.depends.services.ProjectController.projectList() throws java.sql.SQLException,java.lang.Exception]] with attributes [[authorize: '#oauth2.throwOnError(hasRole('ROLE_mdhlabsfull'))', filter: 'null', filterTarget: 'null']] 2022-06-27 17:19:58,387 DEBUG org.springframework.security.access.prepost.PrePostAnnotationSecurityMetadataSource - @org.springframework.security.access.prepost.PreAuthorize("hasAnyRole(\'ROLE_mdhlabsgetonly\',\'ROLE_mdhlabsfull\')") found on specific method: public com.mdhlabs.depends.models.Project com.mdhlabs.depends.services.ProjectController.projectRetrieve(int,javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse) throws java.sql.SQLException,java.lang.Exception 2022-06-27 17:19:58,388 DEBUG org.springframework.security.access.method.DelegatingMethodSecurityMetadataSource - Caching method [CacheKey[com.mdhlabs.depends.services.ProjectController; public com.mdhlabs.depends.models.Project com.mdhlabs.depends.services.ProjectController.projectRetrieve(int,javax.servlet.http.HttpServletRequest,javax.servlet.http.HttpServletResponse) throws java.sql.SQLException,java.lang.Exception]] with attributes [[authorize: '#oauth2.throwOnError(hasAnyRole('ROLE_mdhlabsgetonly','ROLE_mdhlabsfull'))', filter: 'null', filterTarget: 'null']]

When requests are BLOCKED because of permissions enforced at the method layer, error logs will look like this:

2022-08-14 16:17:50,756 DEBUG org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor - Failed to authorize 2022-08-23 14:14:24,082 DEBUG org.springframework.security.access.intercept.aopalliance.MethodSecurityInterceptor - Failed to authorize ReflectiveMethodInvocation: public java.util.List com.mdhlabs.depends.services.ProjectController.projectList() throws java.sql.SQLException,java.lang.Exception; target is of class [com.mdhlabs.depends.services.ProjectController] with attributes [[authorize: '#oauth2.throwOnError(hasAuthority('engineer'))', filter: 'null', filterTarget: 'null']]
2022-08-23 14:14:24,117 ERROR com.mdhlabs.oauthsecurity.mdhlabsAuthorizationFailureHandler - Exception=class org.springframework.security.access.AccessDeniedException message=Access is denied

Accessing User Details Within Service Logic

It is likely that application logic may need to reference information about the authenticated user in downstream transactions or log events triggered by a request. Information about the current user will be housed as an Authentication object inside the SecurityContext of the request. A utility class named mdhlabsSecurityUtilities can be provided to house a method for extracting the username from any Authentication subclass and return it for use in service logic and logging.

package com.mdhlabs.oauthsecurity;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.security.core.context.SecurityContextHolder;
//import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.core.DefaultOAuth2AuthenticatedPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.jwt.Jwt;


public class mdhlabsSecurityUtilities {

private static final Logger thisLog = LoggerFactory.getLogger("com.mdhlabs.oauthsecurity.mdhlabsSecurityUtilities");


//------------------------------------------------------------------------
// getUsernameFromAuthentication() -- extracts what should be the username
// parameter from various types of incoming Authentication-derived objects
//------------------------------------------------------------------------
public static String getUsernameFromAuthentication(Authentication auth) {

Object currentPrincipal = auth.getPrincipal();
thisLog.debug("getUsernameFromAuthentication()-- " + currentPrincipal.toString());
String username;
if (currentPrincipal instanceof UserDetails) {
   username = ((UserDetails) currentPrincipal).getUsername();
   }
else if (currentPrincipal instanceof OidcUser) {
   OidcUserInfo thisOidcUserInfo = ((OidcUser) currentPrincipal).getUserInfo();
   thisLog.debug("DEBUG getUsernameFromAuthentication() - OidcUserInfo = " + thisOidcUserInfo.toString());
   username = thisOidcUserInfo.getPreferredUsername();
   }
else if (currentPrincipal instanceof Jwt) {
   JwtAuthenticationToken authenticationToken = (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
   Jwt jwt = (Jwt) authenticationToken.getCredentials();
   String email = (String) jwt.getClaims().get("email");
   username = (String) jwt.getClaims().get("preferred_username");
   thisLog.debug("DEBUG getUsernameFromAuthentication() - Jwt = email=" + email + " username=" + username);
   }
else if (currentPrincipal instanceof DefaultOAuth2AuthenticatedPrincipal) {
   DefaultOAuth2AuthenticatedPrincipal thisPrincipal = (DefaultOAuth2AuthenticatedPrincipal) currentPrincipal;
   thisLog.debug("DEBUG getUsernameFromAuthentication() - DefaultOAuth2AuthenticatedPrincipal = " + thisPrincipal.toString());
   username = thisPrincipal.getName();
   thisLog.debug("getUsernameFromAuthentication() - username="+username);
   }
else {
   thisLog.error("getUsernameFromAuthentication() -- other or null object type from getPrincipal()");
   username = "NOTEXTRACTED";
   }

return username;
}


} // end of class

With that class available, a controller method for an endpoint can fetch the username from the request as shown below.

    //----------------------------------------------------------------------------------------
    // projectList - returns an array of all project objects in the database
    // URI = /context/servletPath/projects/
    // METHOD = GET
    //----------------------------------------------------------------------------------------
    @PreAuthorize("hasRole('ROLE_mdhlabsfull')")
    @RequestMapping(value="/projects", method=RequestMethod.GET, produces=MediaType.APPLICATION_JSON)
    public @ResponseBody List projectList() throws SQLException, Exception {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String username = mdhlabsSecurityUtilities.getUsernameFromAuthentication(authentication);
        thisLog.info("QUERY action=projectList method=get username=" + username);
        List listOfProjects = projectsDAO.retrieveProjects();
        thisLog.info("REPLY action=projectList method=get username=" + username);
        return listOfProjects;
    }

Validating Resource Server Startup

After altering the original web service build with new dependencies in pom.xml, new configuration in application.properties and adding dedicated classes related to token validation / transformation, the service is ready for testing. The original web service core still requires MySQL / MariaDB related configuration to be passed via environment variables set with these commands.

export DOCKENV_MYSQL_HOST='192.168.99.10'
export DOCKENV_MYSQL_PORT='3306'
export DOCKENV_MYSQL_USERID='dependsapp'
export DOCKENV_MYSQL_PASSWORD='weakpassword'

After compiling and packaging the service via mvn package, the service can be started by entering

java -jar ./target/dependsresource.jar

If everything is running and configured correctly, the screen logs will stop with the following message until actual test traffic is sent using commands in the floowing section.

2022-08-23 16:27:47,225 INFO com.mdhlabs.depends.DependsConfiguration - Started DependsConfiguration in 4.293 seconds (JVM running for 4.935)


A few milliseconds prior to that, a message similar to this will confirm the configured SecurityChainFilter and reference BearerTokenAuthenticationFilter as shown.


2022-08-23 16:27:46,851 INFO org.springframework.security.web.DefaultSecurityFilterChain - Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@4c27d39d, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@40ee0a22, org.springframework.security.web.context.SecurityContextPersistenceFilter@1bcf67e8, org.springframework.security.web.header.HeaderWriterFilter@7f02251, org.springframework.security.web.authentication.logout.LogoutFilter@878537d, org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter@677b8e13, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@53692008, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@7b2a3ff8, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7bde1f3a, org.springframework.security.web.session.SessionManagementFilter@4d8126f, org.springframework.security.web.access.ExceptionTranslationFilter@4c98a6d5, org.springframework.security.web.access.intercept.AuthorizationFilter@30331109]


Prior to that, a series of log messages generated by the custom classes will confirm the loading of configuration settings required for token validation and transformation from external to internal values.


2022-08-23 16:27:46,534 DEBUG com.mdhlabs.depends.SecurityConfigurationSFC$$EnhancerBySpringCGLIB$$728df514 - getAuthenticationConverter() - instantiating mdhlabsAuthenticationConverter object
2022-08-23 16:27:46,536 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsJwtAuthenticationConverter() -- INITIALIZATION - reading config properties from classpath application.properties
2022-08-23 16:27:46,537 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - provider2role     = {mdhlabs={mdhlabsgetonly=customer, mdhlabsfull=engineer}, providerx={providerxbasic=customer, providerxadvanced=admin, mdhlabsuser=customer}}
2022-08-23 16:27:46,537 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - provider2path     = {mdhlabs=roles, providerx=roles}
2022-08-23 16:27:46,537 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - issuer2provider   = {http://192.168.99.10:8011/realms/mdhlabs=mdhlabs, http://192.168.99.10:8011/realms/providerx=providerx}
2022-08-23 16:27:46,537 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - provider2username = {mdhlabs=preferred_username, providerx=preferred_username}
2022-08-23 16:27:46,537 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - provider2claimmap = {}
2022-08-23 16:27:46,537 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - provider2jwkseturi= {mdhlabs=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/certs, providerx=http://192.168.99.10:8011/realms/providerx/protocol/openid-connect/certs}
2022-08-23 16:27:46,539 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - fetching provider=mdhlabs jwkseturi=http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/certs
2022-08-23 16:27:46,570 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - loadProviderJWKSEndpoints() - provider=mdhlabs jwkset=com.auth0.jwk.UrlJwkProvider@4e558728
2022-08-23 16:27:46,606 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - loadProviderJWKSEndpoints() - stored publickey for kid=7UEx1Zb-WAFET4Z4axXo-ZJi2xJ7rV-SQLLEDitt3to in kid2publickey
2022-08-23 16:27:46,606 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - loadProviderJWKSEndpoints() - stored publickey for kid=CThW2f_Ko7WKAg6Ck2lCD2hhtO9Rfo2Wf1tNrdtzDiA in kid2publickey
2022-08-23 16:27:46,606 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - fetching provider=providerx jwkseturi=http://192.168.99.10:8011/realms/providerx/protocol/openid-connect/certs
2022-08-23 16:27:46,607 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - loadProviderJWKSEndpoints() - provider=providerx jwkset=com.auth0.jwk.UrlJwkProvider@3ff57625
2022-08-23 16:27:46,613 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - loadProviderJWKSEndpoints() - stored publickey for kid=sSZjvijnbeafQYeII1E5qME8r_DrWSh1liZC8vukeUU in kid2publickey
2022-08-23 16:27:46,613 DEBUG com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - loadProviderJWKSEndpoints() - stored publickey for kid=QE6-c4NkvcLGKTlSTU11JiC5Ap36SDmzPVC0PvDvsDY in kid2publickey
2022-08-23 16:27:46,613 INFO com.mdhlabs.oauthsecurity.mdhlabsJwtAuthenticationConverter - mdhlabsAuthenticationConverter() - mdhlabsAuthenticationConverter() -- initialization completed
2022-08-23 16:27:46,826 INFO com.mdhlabs.depends.SecurityConfigurationSFC$$EnhancerBySpringCGLIB$$728df514 - getAuthenticationFailureHandler() - instantiating mdhlabsAuthenticationFailureHandler
2022-08-23 16:27:46,826 DEBUG com.mdhlabs.depends.SecurityConfigurationSFC$$EnhancerBySpringCGLIB$$728df514 - getAuthorizationFailureHandler() - instantiating mdhlabsAuthorizationFailureHandler
2022-08-23 16:27:46,827 DEBUG com.mdhlabs.depends.SecurityConfigurationSFC$$EnhancerBySpringCGLIB$$728df514 - mdhlabsAuthenticationResolver() - returning reference to mdhlabsAuthenticationManager for token processing
2022-08-23 16:27:46,828 DEBUG com.mdhlabs.depends.SecurityConfigurationSFC$$EnhancerBySpringCGLIB$$728df514 - getBearerTokenResolver() - instantiating mdhlabsBearerTokenResolver

Earlier in the startup logs, the following entries are generated by the DAO built to perform the underlying database queries to confirm the required JDBC configuration values were parsed and used to connect to the database.


2022-08-23 16:27:46,336 INFO com.mdhlabs.depends.dao.ProjectsDAO - ProjectsDAO constructor 2022-08-23 16:27:46,342 INFO com.mdhlabs.depends.dao.ProjectsDAO - DOCKENV_MARIADB_HOST=192.168.99.10 DOCKENV_MARIADB_PORT=3306 DOCKENV_MARIADB_USERID=dependsapp DOCKENV_MARIADB_PASSWORD=badpassword

Command Line Testing Resource Server Functionality

Before diving into details, it is wise to summarize the different tests that should be executed against any implementation of a Resource Server. It is easier to find and correct any flaws from the command line during development than after a service has been exposed to upstream developers creating clients of the Resource Server.

R
Test ID Scenario Expected Behavior
1no token submittedReturn response with status=401 and "WWW-Authenticate: Bearer" header
2Valid token, correct role submittedJSON payload of response data
3Valid token, insufficient roleReturn response with status=403 and "WWW-Authenticate: Bearer" header
4Expired token submittedReturn response with status=401 and "WWW-Authenticate: Bearer" header
5Corrupt / invalid token submittedeturn response with status=401 and "WWW-Authenticate: Bearer" header

Test - No Token Submitted

With the dependsresource.jar running in one window, executing the commands below which do not submit any access token header should return 401 responses that include "WWW-Authenticate: Bearer" as a response header as a trigger to a client they need to initiate creation of a token.

[mdh@fedora1 ~/gitwork/oauthdependsresource]$ curl --verbose -H "Content-type: application/json" -X GET http://127.0.0.1:8080/depends/api/project/34
{"timestamp":"2021-03-21T23:41:18.574+0000","status":401,"error":"Unauthorized","message":"Unauthorized","path":"/depends/api/project/34"}
[mdh@fedora1 ~/gitwork/oauthdependsresource]$
[mdh@fedora1 ~/gitwork/oauthdependsresource]$
[mdh@fedora1 ~/gitwork/oauthdependsresource]$
[mdh@fedora1 ~/gitwork/oauthdependsresource]$ curl --verbose -H "Content-type: application/json" -X GET http://127.0.0.1:8080/depends/api/projects
{"timestamp":"2021-03-21T23:41:29.157+0000","status":401,"error":"Unauthorized","message":"Unauthorized","path":"/depends/api/projects"}
[mdh@fedora1 ~/gitwork/oauthdependsresource]$
[mdh@fedora1 ~/gitwork/oauthdependsresource]$
[mdh@fedora1 ~/gitwork/oauthdependsresource]$

Test - Fetching Valid Token from Authorization Server

Recapping tests performed in the Authorization Server section, we first need to obtain a valid access token from the Authorization Server. For these test scenarios, our "client" will use the client credentials flow with Keycloak to obtain a token which will then be submitted in requests to our Resource Server web service. The first command below chains together multiple shell commmands that:

  • submits a client-credentials request to Keycloak for a token and stuffs the result into a variable $REPLY
  • parses $REPLY once to extract the access_token string out of the response and save it as variable $ACCESS_TOKEN
  • parses $REPLY a second time to extract any refresh_token string out of the response and save it as $REFRESH_TOKEN

The $ACCESS_TOKEN variable can be displayed and / or included in subsequent commands when the token is submitted in a request header as shown in the subsequent examples.

Test - Valid Token, Correct Role Submitted

For the DependsClientBasic, a call to /depends/api/project/35 should return a project JSON object since it is entitled to run that endpoint.

mdh@fedora1:~/gitwork $ REPLY=$( curl -d "client_id=DependsClientBasic" -d "grant_type=client_credentials" -d "client_secret=WePONVSOYbnWDRbg6Uexrgpmc6cYKEHV" "http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token"); ACCESS_TOKEN=$(echo $REPLY | jq -r '.access_token');REFRESH_TOKEN=$(echo $REPLY | jq -r '.refresh_token')
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1775  100  1670  100   105  59066   3713 --:--:-- --:--:-- --:--:-- 63392
mdh@fedora1:~/gitwork $
mdh@fedora1:~/gitwork $ curl -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" -X GET http://192.168.99.10:8080/depends/api/project/35
{"project_id":35,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2,"factorybusunit_id":6,"factorybusdept_id":7,"projectname":"Abuse Unification to CATS","shortdescription":"Unified integration to WFX","longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y","capitalledger":"","expenseledger":"","clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"N","createdatetime":"2018-05-20 20:51:22","updatedatetime":"0000-00-00 00:00:00"}mdh@fedora1:~/gitwork $
mdh@fedora1:~/gitwork $

Test - Valid Token, Insufficient Role Submitted

The first command below chains together shell commands to capture the response from the Authorization Server for a client_credentials request which will return an access_token which is then extracted into $ACCESS_TOKEN as a shell variable. At that point, the second command sends a GET injecting the token as an Authorization header to the Resource Server endpoint.

For the DependsClientBasic, a call to /depends/api/projects should return a 403 error since that endpoint requires the engineer role and the external mdhlabsgetonly role for DependsClientBasic only maps to the internal role customer.

mdh@fedora1:~/gitwork/oauthdependsresource $ REPLY=$( curl -d "client_id=DependsClientBasic" -d "grant_type=client_credentials" -d "client_secret=WePONVSOYbnWDRbg6Uexrgpmc6cYKEHV" "http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token"); ACCESS_TOKEN=$(echo $REPLY | jq -r '.access_token');REFRESH_TOKEN=$(echo $REPLY | jq -r '.refresh_token')
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1521  100  1416  100   105  96906   7185 --:--:-- --:--:-- --:--:--  106k
mdh@fedora1:~/gitwork/oauthdependsresource $
mdh@fedora1:~/gitwork/oauthdependsresource $
mdh@fedora1:~/gitwork/oauthdependsresource $ curl -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" -X GET http://192.168.99.10:8080/depends/api/projects
{"exception":"org.springframework.security.access.AccessDeniedException","message":"Access is denied","timestamp":1660504041558}
mdh@fedora1:~/gitwork/oauthdependsresource $

For the DependsClientFull client, a call to /depends/api/projects should succeed and return a list of all projects since it has the external role mdhlabsfull in Keycloak which maps to engineer within the web service.

mdh@fedora1:~/gitwork/oauthdependsresource $ REPLY=$( curl -d "client_id=DependsClientFull" -d "grant_type=client_credentials" -d "client_secret=ediQAgqkh4fwaOP5t5vaHLElj29fskSz" "http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token"); ACCESS_TOKEN=$(echo $REPLY | jq -r '.access_token');REFRESH_TOKEN=$(echo $REPLY | jq -r '.refresh_token')
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1521  100  1416  100   105  96906   7185 --:--:-- --:--:-- --:--:--  106k
mdh@fedora1:~/gitwork/oauthdependsresource $
mdh@fedora1:~/gitwork/oauthdependsresource $
mdh@fedora1:~/gitwork/oauthdependsresource $ curl -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" -X GET http://192.168.99.10:8080/depends/api/projects
mdh@fedora1:~/gitwork/oauthdependsresource $ curl -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" -X GET http://192.168.99.10:8080/depends/api/projects 
[{"project_id":1,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2,"factorybusunit_id":6,"factorybusdept_id":7,"projectname":"SoloCustomers","shortdescription":"Build services for CUSTOMER object","longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y","capitalledger":"","expenseledger":"","clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"N","createdatetime":"2018-05-20 20:51:22","updatedatetime":"0000-00-00 00:00:00"},
{"project_id":2,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2,"factorybusunit_id":6,"factorybusdept_id":7,"projectname":"SoloAccounts","shortdescription":"Build model for ACCOUNT object","longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y","capitalledger":"","expenseledger":"","clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"N","createdatetime":"2018-05-20 20:51:22","updatedatetime":"0000-00-00 00:00:00"},
{"project_id":3,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2,"factorybusunit_id":6,"factorybusdept_id":7,"projectname":"SoloWallets","shortdescription":"Build model for WALLET object","longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y","capitalledger":"","expenseledger":"","clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"N","createdatetime":"2018-05-20 20:51:22","updatedatetime":"0000-00-00 00:00:00"},
NUMEROUS JSON ENTRIES OMITTED FOR BREVITY....
{"project_id":38,"projectstatus_id":0,"clientbusunit_id":2,"clientbusdept_id":2,"factorybusunit_id":6,"factorybusdept_id":7,"projectname":"Members Only Test Project","shortdescription":"Only show to members of project","longdescription":"Longer desc here","hascapitalspend":"Y","hasexpensespend":"Y","capitalledger":"","expenseledger":"","clientpriority":1,"deliverypriority":1,"restricttodept":"N","restricttomembers":"Y","createdatetime":"2018-05-20 20:51:22","updatedatetime":"0000-00-00 00:00:00"}]mdh@fedora1:~/gitwork/oauthdependsresource $

mdh@fedora1:~/gitwork/oauthdependsresource $
mdh@fedora1:~/gitwork/oauthdependsresource $

Test - Expired Token Submitted

Using the same token previously generated after waiting 300 seconds for the token to expire, submitted a request again should return a 401 error that also includes WWW-Authenticate: Bearer as a header to trigger a client to request a new token.

mdh@fedora1:~/gitwork $ curl --verbose -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" -X GET http://192.168.99.10:8080/depends/api/project/35
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 192.168.99.10:8080...
* Connected to 192.168.99.10 (192.168.99.10) port 8080 (#0)
> GET /depends/api/projects HTTP/1.1
> Host: 192.168.99.10:8080
> User-Agent: curl/7.82.0
> Accept: */*
> Content-type: application/json
> Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDVGhXMmZfS283V0tBZzZDazJsQ0QyaGh0TzlSZm8yV2YxdE5yZHR6RGlBIn0.eyJleHAiOjE2NjEzMDA0OTIsImlhdCI6MTY2MTMwMDM3MiwianRpIjoiZGU3MTRlMTMtOGE5ZC00MGM5LWI4NTEtMTMyMzI2NGY4Yzc2IiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguOTkuMTA6ODAxMS9yZWFsbXMvbWRobGFicyIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI5MjNjM2UxNi01MDMyLTRkZjctYTg1MS1jNjBmYmQ1ZmZjODIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImFjciI6IjEiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiY2xpZW50SWQiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImNsaWVudEhvc3QiOiIxOTIuMTY4Ljk5LjEwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiXSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LWRlcGVuZHNjbGllbnRmdWxsIiwiY2xpZW50QWRkcmVzcyI6IjE5Mi4xNjguOTkuMTAifQ.BRGyixoxhzu1N4M9VBQH7bghn-sBjYk-YvKUmzCQ55NK9FP-tnvEn6NQaNs8q02ZWJJTQrd_6DasV8EfWDDsHoz5UmQrU0GoNgpgN_z-blt_oDqPX7hHXsW7gqpLLhcQneeC1fKm187Y55VoBnwjC8SfEAh6aZBNA_nQ9mCZeL1JtrIAZxghlZCywnKDGR86XA07mkxH9oT3b8l6UkKJ5M37MQHZ6V59ooUN-lDNABRoohUdKZXQ5dFz1jjhDPJ7WzabJ-J5dVo5W_uVPskTUuIGbGU0or7bGEI_8yuM49jfJ3S-AKXXn0VXKm8uYej4nReXH-n7D8oZTj8bQNsrWw
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 401
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Length: 138
< Date: Wed, 24 Aug 2022 00:27:13 GMT
<
{"exception":"org.springframework.security.authentication.BadCredentialsException","message":"invalidExpired","timestamp":1661300833291}
* Connection #0 to host 192.168.99.10 left intact
mdh@fedora1:~/gitwork $

It is useful to conduct an additional test to send the same request repeatedly and validate how tightly token expirations are enforced. In a prior incarnation of this service which relied on Spring libraries for some of the token validation, testing confirmed tokens were honored up to 60 seconds past their nominal expiration time. A token returned to be valid for 120 seconds was accepted up to 180 seconds. A token generated with a 180 second interval began encountering rejections at 240 seconds.

As crafted with this custom JwtValidationUtilities class, token expirations are STRICTLY enforced. Any request arriving after the assigned expiration parameter is rejected with the above exception returned to the client in a 401 response.

Test - Corrupt / Invalid Token Submitted

To test handling of a corrupt or invalid token, the $ACCESS_TOKEN value can be echo'ed to the screen then altered (a single character difference will invalidate it) then set back to the $ACCESS_TOKEN variable. At that point, re-using the same curl command now using the altered $ACCESS_TOKEN will test handling of a corrupt token. In the example below, a previously valid token is altered to set the last three characters to xxx, thus invalidating its cryptographic signature. The Resource Server should return a 401 error and also include WWW-Authenticate: Bearer as a header to trigger a client to request a new token.

mdh@fedora1:~/gitwork/oauthdependsresource $ REPLY=$( curl -d "client_id=DependsClientFull" -d "grant_type=client_credentials" -d "client_secret=ediQAgqkh4fwaOP5t5vaHLElj29fskSz" "http://192.168.99.10:8011/realms/mdhlabs/protocol/openid-connect/token"); ACCESS_TOKEN=$(echo $REPLY | jq -r '.access_token');REFRESH_TOKEN=$(echo $REPLY | jq -r '.refresh_token')
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1612  100  1508  100   104   101k   7157 --:--:-- --:--:-- --:--:--  112k
mdh@fedora1:~/gitwork/oauthdependsresource $ echo $ACCESS_TOKEN
eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDVGhXMmZfS283V0tBZzZDazJsQ0QyaGh0TzlSZm8yV2YxdE5yZHR6RGlBIn0.eyJleHAiOjE2NjEzMDIwMjAsImlhdCI6MTY2MTMwMTkwMCwianRpIjoiZDViYWNjNDgtYzNhOC00MGY5LTkxMzUtODczZWZiYzRhNDkwIiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguOTkuMTA6ODAxMS9yZWFsbXMvbWRobGFicyIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI5MjNjM2UxNi01MDMyLTRkZjctYTg1MS1jNjBmYmQ1ZmZjODIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImFjciI6IjEiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiY2xpZW50SWQiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImNsaWVudEhvc3QiOiIxOTIuMTY4Ljk5LjEwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiXSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LWRlcGVuZHNjbGllbnRmdWxsIiwiY2xpZW50QWRkcmVzcyI6IjE5Mi4xNjguOTkuMTAifQ.iD90BPphbP8cYXfnNQLgZ2BVhXkcMAr5cv01_-DBqceBJm5JZ54BG7xHFKGKhb4Q3YKe_eS9bd6mdTZY2SXGvthYKy-CNnk92O4siLBIq3W5cVJO2AfHEGOmdyIxE55D49U9qwKCipKGpHVIzJdcP5r5n36PC60HR_aySRW2W-49RzkfAO0mbwAFw_s4fTWT6aE7-oxWp7eG4ToJuJjzlAuRzbdeMIXFIpifJhfyS_UkMGmrhKXP0ScbmTl8uDJQe3Ks5-EvwoQRRaXAUPkqLYWKGBbOyDbhDq_aIJo9GE98zwm6WHaRwVjrKEdyyyD2HoNr5i8_xBhSl1noI4drSQ
mdh@fedora1:~/gitwork/oauthdependsresource $ export ACCESS_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDVGhXMmZfS283V0tBZzZDazJsQ0QyaGh0TzlSZm8yV2YxdE5yZHR6RGlBIn0.eyJleHAiOjE2NjEzMDIwMjAsImlhdCI6MTY2MTMwMTkwMCwianRpIjoiZDViYWNjNDgtYzNhOC00MGY5LTkxMzUtODczZWZiYzRhNDkwIiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguOTkuMTA6ODAxMS9yZWFsbXMvbWRobGFicyIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI5MjNjM2UxNi01MDMyLTRkZjctYTg1MS1jNjBmYmQ1ZmZjODIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImFjciI6IjEiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiY2xpZW50SWQiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImNsaWVudEhvc3QiOiIxOTIuMTY4Ljk5LjEwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiXSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LWRlcGVuZHNjbGllbnRmdWxsIiwiY2xpZW50QWRkcmVzcyI6IjE5Mi4xNjguOTkuMTAifQ.iD90BPphbP8cYXfnNQLgZ2BVhXkcMAr5cv01_-DBqceBJm5JZ54BG7xHFKGKhb4Q3YKe_eS9bd6mdTZY2SXGvthYKy-CNnk92O4siLBIq3W5cVJO2AfHEGOmdyIxE55D49U9qwKCipKGpHVIzJdcP5r5n36PC60HR_aySRW2W-49RzkfAO0mbwAFw_s4fTWT6aE7-oxWp7eG4ToJuJjzlAuRzbdeMIXFIpifJhfyS_UkMGmrhKXP0ScbmTl8uDJQe3Ks5-EvwoQRRaXAUPkqLYWKGBbOyDbhDq_aIJo9GE98zwm6WHaRwVjrKEdyyyD2HoNr5i8_xBhSl1noI4drSz
mdh@fedora1:~/gitwork/oauthdependsresource $
mdh@fedora1:~/gitwork/oauthdependsresource $ curl --verbose -H "Content-type: application/json" -H "Authorization: Bearer $ACCESS_TOKEN" -X GET http://192.168.99.10:8080/depends/api/project/32
Note: Unnecessary use of -X or --request, GET is already inferred.
*   Trying 192.168.99.10:8080...
* Connected to 192.168.99.10 (192.168.99.10) port 8080 (#0)
> GET /depends/api/project/32 HTTP/1.1
> Host: 192.168.99.10:8080
> User-Agent: curl/7.82.0
> Accept: */*
> Content-type: application/json
> Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDVGhXMmZfS283V0tBZzZDazJsQ0QyaGh0TzlSZm8yV2YxdE5yZHR6RGlBIn0.eyJleHAiOjE2NjEzMDIwMjAsImlhdCI6MTY2MTMwMTkwMCwianRpIjoiZDViYWNjNDgtYzNhOC00MGY5LTkxMzUtODczZWZiYzRhNDkwIiwiaXNzIjoiaHR0cDovLzE5Mi4xNjguOTkuMTA6ODAxMS9yZWFsbXMvbWRobGFicyIsImF1ZCI6ImFjY291bnQiLCJzdWIiOiI5MjNjM2UxNi01MDMyLTRkZjctYTg1MS1jNjBmYmQ1ZmZjODIiLCJ0eXAiOiJCZWFyZXIiLCJhenAiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImFjciI6IjEiLCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwiY2xpZW50SWQiOiJEZXBlbmRzQ2xpZW50RnVsbCIsImNsaWVudEhvc3QiOiIxOTIuMTY4Ljk5LjEwIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJyb2xlcyI6WyJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1tZGhsYWJzIiwibWRobGFic2Z1bGwiXSwicHJlZmVycmVkX3VzZXJuYW1lIjoic2VydmljZS1hY2NvdW50LWRlcGVuZHNjbGllbnRmdWxsIiwiY2xpZW50QWRkcmVzcyI6IjE5Mi4xNjguOTkuMTAifQ.iD90BPphbP8cYXfnNQLgZ2BVhXkcMAr5cv01_-DBqceBJm5JZ54BG7xHFKGKhb4Q3YKe_eS9bd6mdTZY2SXGvthYKy-CNnk92O4siLBIq3W5cVJO2AfHEGOmdyIxE55D49U9qwKCipKGpHVIzJdcP5r5n36PC60HR_aySRW2W-49RzkfAO0mbwAFw_s4fTWT6aE7-oxWp7eG4ToJuJjzlAuRzbdeMIXFIpifJhfyS_UkMGmrhKXP0ScbmTl8uDJQe3Ks5-EvwoQRRaXAUPkqLYWKGBbOyDbhDq_aIJo9GE98zwm6WHaRwVjrKEdyyyD2HoNr5i8_xBhSl1noI4drSz
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 401
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Length: 148
< Date: Wed, 24 Aug 2022 00:47:14 GMT
<
{"exception":"org.springframework.security.authentication.BadCredentialsException","message":"invalidSignatureMismatch","timestamp":1661302034916}
* Connection #0 to host 192.168.99.10 left intact
mdh@fedora1:~/gitwork/oauthdependsresource $