In this lab we will build an OAuth2/OIDC compliant resource server.
We will use Keycloak as identity provider.
Please again make sure you have set up keycloak as described in Setup Keycloak
In lab 1 you will learn how to:
Implement a basic resource server requiring bearer token authentication using JSON web tokens (JWT)
Customize the resource server with user & authorities mapping
Implement additional recommended validation of the audience claim of the access token
Folder Contents
In the lab 1 folder you find 2 applications:
library-server-initial: This is the application we will use as starting point for this lab
library-server-complete: This application is the completed reference for this lab
Start the Lab
Now, let's start with this lab.
Explore the initial application
Please navigate your Java IDE to the lab1/library-server-initial project and at first explore this project a bit.
Then start the application by running the class com.example.library.server.Lab1InitialLibraryServerApplication inside your IDE or by issuing a gradlew bootRun command.
As already described in the application architecture section the initial application is secured using basic authentication.
There are three target user roles for this application:
LIBRARY_USER: Standard library user who can list, borrow and return his currently borrowed books
LIBRARY_CURATOR: A curator user who can add, edit or delete books
LIBRARY_ADMIN: An administrator user who can list, add or remove users
Important: To log into the application using basic authentication you have to use the email as username.
To test if the application works as expected, either
To answer this question have a look again at the user roles and what are the permissions associated with these roles. You might try again to get the list of users this way (with Clark Kent):
Note: If you still get compilation errors after replacing dependencies please trigger a gradle update (check how this is done in your IDE, e.g. in Eclipse there is an option in project context menu, in IntelliJ click the refresh toolbar button in the gradle tool window).
Configure The Resource Server
Spring security 5 uses the OpenID Connect Discovery specification to completely configure the resource server to use our keycloak instance.
Make sure keycloak has been started as described in the setup section.
For configuring a resource server the important entries are issuer and jwk-set_uri. For a resource server only the correct validation of a JWT token is important, so it only needs to know where to load the public key from to validate the token signature.
Spring Security 5 automatically configures a resource server by specifying the jwk-set uri value as part of the predefined spring property spring.security.oauth2.resourceserver.jwt.set-uri
To perform this step, open application.yml_ and add the jwk set uri property. After adding this it should look like this:
Hint: An error you get very often with files in yaml format is that the indents are not correct. This can lead to unexpected errors later when you try to run all this stuff.
With this configuration in place we have already a working resource server that can handle JWT access tokens transmitted via http bearer token header. Spring Security then validates by default:
the JWT signature against the queried public key(s) from specified jwks_url
the issuer claim of the JWT
that the JWT is not expired
Usually this configuration would be sufficient to configure a resource server (by auto-configuring all settings using spring boot). As there is already a security configuration for basic authentication in place (com.example.library.server.config.WebSecurityConfiguration), this disables the spring boot auto configuration. Starting with Spring Boot 2 you always have to configure Spring Security yourself as soon as you introduce a class which extends WebSecurityConfigurerAdapter.
So we have to change the existing security configuration to enable token based authentication instead of basic authentication. We also want to make sure, our resource server is working with stateless token authentication, so we have to configure stateless sessions (i.e. prevent JSESSION cookies).
Open the class com.example.library.server.config.WebSecurityConfiguration and change the existing configuration like this:
configures stateless sessions (i.e. no JSESSION cookies anymore)
disables CSRF protection (with stateless sessions, i.e. without session cookies we do not need this anymore)
(which also enables us to even make post requests on the command line)
protects any request (i.e. requires authentication for any endpoint)
enables this application as a resource server with expecting access tokens in JWT format (as of spring security 5.2 you may also
configure this to use opaque tokens instead)
PasswordEncoder is not required anymore as we now stopped storing passwords in our resource server, but for time reasons we won't delete it. Otherwise, we would need plenty of time just for removing all password related stuff from other source code locations.
The .cors(withDefaults()) expression configures Cross-Origin Resource Sharing (CORS). This configuration is done in conjunction with the corsConfigurationSource() bean definition.
For now, we can ignore this setting as this is only important when making AJAX request to this application from a javascript client.
Step 2: Run and test basic resource server
Now it should be possible to re-start the reconfigured application com.example.library.server.Lab1InitialLibraryServerApplication. Or just use the gradlew bootRun command.
Now, the requests you have tried when starting this lab using basic authentication won't work anymore as we now require bearer tokens in JWT format to authenticate at our resource server.
With basic authentication when omitting the credentials you got this response:
So what is needed here is a JSON Web Token (JWT). First we need to get such token, and then we can try to call this API again.
To do this we will use the resource owner password grant to directly obtain an access token from keycloak by specifying our credentials as part of the request.
You may argue now: "This is just like doing basic authentication??"
Yes, you're right. You should ONLY use this grant flow for testing purposes as it completely bypasses the base concepts of OAuth 2. Especially when using the command line this is the only possible flow to use if you want to authenticate a user. If no user is involved, then you can also use the client credentials grant.
By using Postman you can also use the authorization code grant.
This is how this password grant request looks like:
To make the same request for a list of books (like in the beginning of this lab) we have to specify the access token as part of a Authorization header of type Bearer like this:
You have to replace [access_token] with the one you have obtained in previous request.
Now the user authenticates by the given token, but even with using the correct user Clark Kent you get a "403" response (Forbidden).
This is due to the fact that Spring Security 5 automatically maps all scopes that are part of the JWT token to the corresponding authorities.
Navigate your web browser to jwt.io and paste your access token into the Encoded text field.
If you scroll down a bit on the right hand side then you will see the following block:
As you can see our user has the scopes library_admin, email and profile. Spring Security maps these scopes to the Spring Security authorities SCOPE_library_admin, SCOPE_email and SCOPE_profile by default.
If you have a look inside the com.example.library.server.business.UserService class you will notice that the corresponding method has the following authorization check:
The required authority ROLE_LIBRARY_ADMIN does not match the mapped authority SCOPE_library_admin. To solve this we would have to add the SCOPE_xxx authorities to the existing ones like this:
You can imagine what effort this would be especially for big applications using lots of authorizations. So we won't add these additional authority checks, we rather want to implement our customized JWT to Spring Security authorities mapping. So let's continue with this in the next step.
Step 3: Custom JWT converter
To add our custom mapping for a JWT access token Spring Security requires us to implement the interface Converter.
packageorg.springframework.core.convert.converter;importorg.springframework.lang.Nullable;publicinterfaceConverter<S,T> { /** * Convert the source object of type {@code S} to target type {@code T}. * @param source the source object to convert, which must be an instance of {@code S} (never {@code null}) * @return the converted object, which must be an instance of {@code T} (potentially {@code null}) * @throwsIllegalArgumentException if the source cannot be converted to the desired target type */ @NullableTconvert(S source);}
In general, you have two choices here:
Map the corresponding LibraryUser to the JWT token user data and read the
authorization data from the token and map it to Spring Security authorities
Map the corresponding LibraryUser to the JWT token user data but map locally
stored roles of the LibraryUser to Spring Security authorities.
In this workshop we will use the first approach and...
...read the authorization data from the scope claim inside the JWT token
...map to our local LibraryUser by reusing the LibraryUserDetailsService to search
for a user having the same email as the email claim inside the JWT token
To achieve this please go ahead and create a new class LibraryUserJwtAuthenticationConverter in package com.example.library.server.security with the following contents:
This converter maps the JWT token information to a LibraryUser by associating these via the email claim. It reads the authorities from groups claim in the JWT token and maps these to the corresponding authorities.
This way we can map these groups again to our original authorities, e.g. ROLE_LIBRARY_ADMIN.
No open again the class com.example.library.server.config.WebSecurityConfiguration and add this new JWT converter to the JWT configuration:
Note:: The other approach can be seen in class LibraryUserRolesJwtAuthenticationConverter in completed application in project library-server-complete-custom.
Step 4: JWT validation for the 'audience' claim
Implementing an additional token validator is quite easy, you just have to implement the provided interface OAuth2TokenValidator.
Audience(s) that this ID Token is intended for. It MUST contain the OAuth 2.0 client_id of the Relying Party as an audience value. It MAY also contain identifiers for other audiences.
Despite the fact that the audience claim is not specified or mandatory for access tokens specifying and validating the audience claim of access tokens is strongly recommended avoiding misusing access tokens for other resource servers.
There is also a new draft specification on the way to provide a standardized and interoperable profile as an alternative to the proprietary JWT access token layouts.
So we should also validate that our resource server only successfully authenticates those requests bearing access tokens containing the expected value of "library-service" in the audience claim.
So let's create a new class AudienceValidator in package com.example.library.server.security with the following contents:
packagecom.example.library.server.security;importorg.springframework.security.oauth2.core.OAuth2Error;importorg.springframework.security.oauth2.core.OAuth2TokenValidator;importorg.springframework.security.oauth2.core.OAuth2TokenValidatorResult;importorg.springframework.security.oauth2.jwt.Jwt;/** Validator for expected audience in access tokens. */publicclassAudienceValidatorimplementsOAuth2TokenValidator<Jwt> {privateOAuth2Error error =newOAuth2Error("invalid_token","The required audience 'library-service' is missing",null);publicOAuth2TokenValidatorResultvalidate(Jwt jwt) {if (jwt.getAudience().contains("library-service")) {returnOAuth2TokenValidatorResult.success(); } else {returnOAuth2TokenValidatorResult.failure(error); } }}
Adding such validator is a bit more effort as we have to replace the auto-configured JwtDecoder with our own bean definition. An additional validator can only be added this way.
To achieve this open again the class com.example.library.server.config.WebSecurityConfiguration one more time and add our customized JwtDecoder.
As the JwtValidators creator depends on the full issuer URI pointing to the OpendID Connect configuration of Keycloak we need to add the issuer-uri in addition to jwk-set-uri . So basically this now should look like this in the application.yaml file:
Now we can re-start the application and test again the same request we had retrieved an '403' error before. For this just use the gradlew bootRun command.