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