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 - OverviewOAuth 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:
- sending a grant_type=client_credential request to the token endpoint of the authorization server
- retaining the access token returned then embedding it within each outbound request to the resource server
- 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 ORB) 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.
- scan application.properties for references to client and provider details
- load client endpoint parameters as ClientRegistration objects in a ClientRegistrationRepository
- create an OAuth2AuthorizedClientService object with a reference to the ClientRegistrationRepositor
- create an AuthorizedClientRepository object
- create an AuthorizedClientProvider object
- create an AuthorizedClientManager object pointing to the ClientRegistrationRepository and AuthorizedClientRepository objects
- further configure the AuthorizedClientManager to point to the AuthorizedClientProvider object
- create an ExchangeFilterFunction object that points to the ClientConfigurationRepository and AuthorizedClientRepository objects
- 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. |
ServerHttpSecurity | The 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 Question | Design 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:
- InMemory or Jdbc? --- This example is not attempting to illustrate something that will run with dozens of parallel processes so InMemory____ is appropriate.
- Reactive or Legacy? --- This example is not attempting to implement a Reactive non-blocking service so a traditional Servlet based controller will be implemented.
- 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.
- 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)
- Use the Spring Initializr at https://start.spring.io to create an empty source tree with the core dependencies required
- 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. - create a
OAuth2ClientConfiguration
class with@Configuration
annotation -- this class doesn't extend any standard class - 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 - create a
SecurityConfiguration
class with@Component
and@EnableWebFluxSecurity
annotations -- this class should not use older paradigms extendingWebSecurityConfigurerAdapter
- create an initial Controller class defining the endpoint for this source web service with whatever authentication / authorization controls it requires
- 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.
- Access the Keycload admin console at http://192.168.99.10:8011 and login as the admin user
- Select the mdhLabs realm if it is not already the current realm for the admin user
- Click on Clients in the left navigation, then click on the Create client button.
- 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 - After the first save confirmation, fill in the following additional parameter values:
Name = DependsClientFull
Description = machine-to-machine with mdhlabsfull role - 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.
- Click on the Service account roles tab, then click the Assign role button then select the mdhlabsfull role and click the Assign button.
- Click on the Cllent scopes tab of the new client, then verify profile and roles are listed as included by Default.
- 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:
The structure of the application.properties
content supporting this topology would look like this:
For machine-client scenarios, configurations can be simplified for the following reasons:
- Because this is a machine-to-machine integration, the
client-name
andclient-authentication-method
parameters do not affect token request flows and do not need to be provided. - 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. - Because the client will not use the
authorization-code
flow to obtain a token, theauthorization-uri
parameter isn't used and does not have to be provided. - 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. - 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
andjwk-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.
![]() |
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.
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:
Endpoint | Function |
/depends/api/project/n | returns data for a single project with id=n |
/depends/api/projects | returns 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
Endpoint | Function |
/depends/proxyapi/project/n | returns data for a single project with id=n using the token obtained via registrationId=dependsclient |
/depends/proxyapi/projects | returns a list of all projects using the token obtained via registrationId=dependsclient |
/depends/proxyapi/xproject/n | returns 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:
- 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
- command line invocation of the outer proxyapi endpoint via curl
- review of Authorization Server logs for access token generation
- review of Resource Server logs for the inbound request from the proxyapi client
- handling of missing required configuration parameters at startup
- 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
![]() |
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