In this first part we extend an existing Microservice to an OAuth 2.0 and OpenID Connect 1.0 compliant Resource Server.
See for all details on how to build and configure a resource server.
Please check out the for the sample application before starting with the first hands-on lab (especially the server side parts).
Lab Contents
Learning Targets
In this lab we will build an OAuth2/OIDC compliant resource server and look at various mapping options for converting the JWT contents into spring security native objects for authentication and authorization.
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
Have a small look into alternative token type: Opaque tokens
Folder Contents
In the lab 1 folder you find 2 applications:
initial: This is the application we will use as starting point for this lab
final-automatic: This is the completed application for this lab using automatic standard authorization mapping of scopes to JWT principal
final-jwt: This is the completed application for this lab using custom authorization mapping with JWT principal
final-user: This is the completed application for this lab using full custom user mapping with User class as principal
Start the Lab
Now, let's start with this lab.
Explore the initial application
Please navigate your Java IDE to the lab1/initial project and at first explore this project a bit.
Then start the application by running the class com.example.todo.ToDoApplicationLab1Initial inside your IDE or by issuing a gradlew bootRun command.
There are two target user roles for this application:
USER: Standard user who can list and add todo items
ADMIN: An administrator user who can list, add or remove users and can see all todo items (of all users)
Username
Email
Identifier
Password
Role
bwayne
bruce.wayne@example.com
c52bf7db-db55-4f89-ac53-82b40e8c57c2
wayne
USER
ckent
clark.kent@example.com
52a14872-ba6b-488f-aa4d-453b11f9ddce
kent
USER
pparker
peter.parker@example.com
3a73ef49-c671-4d66-b6f2-7725ccde5c2b
parker
ADMIN
To test if the application works as expected, either
or use a command line like curl or httpie or postman (if you like a UI)
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 Peter Parker):
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).
For configuring a resource server the important entries are issuer-uri and jwk-set-uri. For a resource server only the correct validation of a JWT token is significant, so it only needs to know where to load the public key from to validate the token signature. In case of the Spring Authorization Server it is the entry for "jwks_uri": "http://localhost:9000/oauth2/jwks".
If you specify the spring.security.oauth2.resourceserver.jwt.issuer-uri instead then when starting the server application it reads the jwk-set-uri from the provided openid configuration. If you do not want to check this on application start just use the jwk-set-uri property.
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 to the end of the spring entry. 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 autoconfiguring all settings using spring boot). As there is already a security configuration for basic authentication in place (com.example.toto.config.ToDoWebSecurityConfiguration), this disables the spring boot autoconfiguration.
Please note: The security configuration already uses the new approach of configuring beans of type SecurityFilterChain instead of extending WebSecurityConfigurerAdapter class.
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 and CSRF attack surface).
Open the class com.example.todo.config.ToDoWebSecurityConfiguration and change the existing configuration like this (only the security configuration block for the API):
package com.example.todo.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
public class ToDoWebSecurityConfiguration {
// ...
/*
* Security configuration for user and todos Rest API.
*/
@Bean
@Order(4)
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http.mvcMatcher("/api/**")
.authorizeRequests()
.mvcMatchers("/api/users/me").hasAnyAuthority("SCOPE_USER", "SCOPE_ADMIN")
.mvcMatchers("/api/users/**").hasAuthority("SCOPE_ADMIN")
.anyRequest().hasAnyAuthority("SCOPE_USER", "SCOPE_ADMIN")
.and()
// only disable CSRF for demo purposes or when NOT using session cookies for auth
.csrf().disable() // (2)
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 1
.and()
.oauth2ResourceServer().jwt(withDefaults()); // (3)
return http.build();
}
// ...
}
This configuration above:
configures stateless sessions (i.e. no JSESSION cookies anymore) (1)
disables CSRF protection (with stateless sessions, i.e. without session cookies this kind of attack does not work anymore, so we do not need this anymore). As a benefit this also enables us to even make post requests on the command line. (2)
protects any request (i.e. requires authentication for any endpoint)
enables this application to switch authentication to OAuth2/OIDC resource server with expecting access tokens in JWT format (as of spring security 5.2 you may also configure this to use opaque - aka reference - tokens instead) (3)
Also, the PasswordEncoder bean defined in this configuration is not required anymore as we now stopped storing passwords in our resource server, so you can also delete that bean definition. Please make sure that you also remove the PasswordEncoder from the com.example.todo.DataInitializer class, just replace the encoder calls here with the default string "n/a" as the password is not relevant anymore. So instead of passwordEncoder.encode("wayne") just replace it with "n/a" or remove the password attribute completely from the ToDoItemEntity and ToDoItem and remove it as constructor parameter for User in the DataInitializer class.
Step 2: Change the Authenticated Principal
In the following table you can see the corresponding spring security core classes like Authentication and Principle that are used for the different authentication types.
After changing the authentication mechanism to JWT bearer tokens it is also required to replace the existing User principle annotated with @AuthenticationPrincipal in the rest controllers ToDoRestController and UserRestController.
Please replace all references to @AuthenticationPrincipal User authenticatedUser in the ToDoRestController with @AuthenticationPrincipal Jwt authenticatedUser. Then do the same for the other rest controller UserRestController class.
ToDoRestController:
package com.example.todo.api;
import com.example.todo.DataInitializer;
import com.example.todo.service.ToDoItem;
import com.example.todo.service.ToDoService;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/todos")
@Validated
@OpenAPIDefinition(tags = @Tag(name = "todo"), info = @Info(title = "ToDo", description = "API for ToDo Items", version = "1"), security = {@SecurityRequirement(name = "bearer")})
public class ToDoRestController {
private final ToDoService toDoService;
public ToDoRestController(ToDoService toDoService) {
this.toDoService = toDoService;
}
@Operation(tags = "todo", summary = "ToDo API", description = "Finds all ToDo items for given user identifier", parameters = @Parameter(name = "user", example = DataInitializer.WAYNE_ID))
@GetMapping
public List<ToDoItem> findAllForUser(@RequestParam(name = "user") UUID userIdentifier, @AuthenticationPrincipal Jwt authenticatedUser) {
if (authenticatedUser.getClaimAsStringList("roles").contains("ADMIN")) {
return toDoService.findAll();
} else {
return toDoService.findAllForUser(userIdentifier, UUID.fromString(authenticatedUser.getSubject()));
}
}
@Operation(tags = "todo", summary = "ToDo API", description = "Finds one ToDo item for given todo item identifier")
@GetMapping("/{todoItemIdentifier}")
public ResponseEntity<ToDoItem> findOneForUser(
@PathVariable("todoItemIdentifier") UUID todoItemIdentifier,
@AuthenticationPrincipal Jwt authenticatedUser) {
return toDoService.findToDoItemForUser(todoItemIdentifier, UUID.fromString(authenticatedUser.getSubject()))
.map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@Operation(tags = "todo", summary = "ToDo API", description = "Creates a new ToDo item for current user")
@PostMapping
public ToDoItem create(@RequestBody @Valid ToDoItem toDoItem, @AuthenticationPrincipal Jwt authenticatedUser) {
toDoItem.setUserIdentifier(UUID.fromString(authenticatedUser.getSubject()));
return toDoService.create(toDoItem);
}
}
UserRestController:
package com.example.todo.api;
import com.example.todo.service.CreateUser;
import com.example.todo.service.User;
import com.example.todo.service.UserService;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.HttpStatus.CREATED;
@RestController
@RequestMapping("/api/users")
@Validated
@OpenAPIDefinition(tags = @Tag(name = "user"), info = @Info(title = "User", description = "API for Users", version = "1"), security = {@SecurityRequirement(name = "bearer")})
public class UserRestController {
private final UserService userService;
public UserRestController(UserService userService) {
this.userService = userService;
}
@Operation(tags = "user", summary = "User API", description = "Finds all registered users")
@GetMapping
public List<User> allUsers() {
return userService.findAll();
}
@Operation(tags = "user", summary = "User API", description = "Finds user specified by user identifier")
@GetMapping("/{userIdentifier}")
public ResponseEntity<User> findUser(@PathVariable UUID userIdentifier) {
return userService.findOneByIdentifier(userIdentifier).map(
ResponseEntity::ok
).orElse(ResponseEntity.notFound().build());
}
@Operation(tags = "user", summary = "User API", description = "Retrieves the currently authenticated user")
@GetMapping("/me")
public String getAuthenticatedUser(@AuthenticationPrincipal Jwt authenticatedUser) {
return String.format("%s %s", authenticatedUser.getClaimAsString("given_name"), authenticatedUser.getClaimAsString("family_name"));
}
@Operation(tags = "user", summary = "User API", description = "Creates a new user")
@PostMapping
@ResponseStatus(CREATED)
public User createUser(@RequestBody @Valid CreateUser user) {
return userService.create(user);
}
}
Step 3: Run and test basic resource server
Now it should be possible to re-start the reconfigured application com.example.todo.ToDoApplicationLab1Initial. 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.
Just to memorize: With basic authentication when omitting the credentials you got this response:
So what is needed here is a bearer token to authenticate, in our case in fact it is a JSON Web Token (JWT). First we need to get such token, and then we can try to call this API again.
For convenience, in the past we have been able to use the resource owner password grant to directly obtain an access token from Spring Authorization Server via the command line by specifying our credentials as part of the request.
You may argue now: "This is just like doing basic authentication??"
Yes, you're right. This grant flow completely bypasses the base concepts of OAuth 2. This is why in OAuth 2.1 this grant flow is deprecated and will be removed from the standard. And because of this, the Spring Authorization Server does and will not support the password grant flow.
So, how to get a token now? You basically have two options as part of this workshop:
For both options please log in as bwayne/wayne to get a token authorized to perform the request we will execute below.
After you have received a token by either way above you can make the same request for a list of todos (like in the beginning of this lab). This time we have to present the access token as part of the 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 Bruce Wayne 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. For example the scopes "openid profile" will be mapped automatically to the authorities SCOPE_openid and SCOPE_profile. And for sure that does not map to our requirement of authorities to include ROLE_USER and/or ROLE_ADMIN.
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 openid, and USER. Spring Security maps these scopes to the Spring Security authorities SCOPE_openid and SCOPE_USER by default.
If you have a look inside the com.example.todo.service.ToDoService class you will notice that this has the following authorization checks on method security layer:
The required authorities ROLE_ADMIN and ROLE_USER do not match the automatically mapped authorities of SCOPE_xxx. The same problem applies to our web security configuration on the web layer:
package com.example.todo.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
public class ToDoWebSecurityConfiguration {
// ...
/*
* Configure actuator endpoint security.
* Allow access for everyone to health, info and prometheus.
* All other actuator endpoints require ADMIn role.
*/
@Bean
@Order(3)
public SecurityFilterChain actuator(HttpSecurity http) throws Exception {
http.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeRequests(
authorizeRequests ->
authorizeRequests
.requestMatchers(EndpointRequest.to(
HealthEndpoint.class,
InfoEndpoint.class,
PrometheusScrapeEndpoint.class))
.permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ADMIN")
)
.httpBasic(withDefaults()).formLogin(withDefaults());
return http.build();
}
/*
* Security configuration for user and todos Rest API.
*/
@Bean
@Order(4)
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http.mvcMatcher("/api/**")
.authorizeRequests()
.mvcMatchers("/api/users/me").hasAnyRole("USER", "ADMIN")
.mvcMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().hasAnyRole("USER", "ADMIN")
.and()
// only disable CSRF for demo purposes or when NOT using session cookies for auth
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.oauth2ResourceServer().jwt(withDefaults());
return http.build();
}
// ...
}
In summary this leads to authorization errors in the log like these:
Sending JwtAuthenticationToken [Principal=org.springframework.security.oauth2.jwt.Jwt@9dd3cf4a, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=null], Granted Authorities=[SCOPE_openid, SCOPE_profile, SCOPE_email]] to access denied handler since access is denied
To fix this you basically have 3 options:
Adapt our configuration in com.example.todo.config.ToDoWebSecurityConfiguration and the @PreAuthorize annotations with the SCOPE_xx authorities in the service classes. You can imagine what effort this would be especially for big applications using lots of authorizations. So usually only makes sense for small applications with only a few authorities to be replaced.
Implement a simple mapping that reads the authorities from the intended roles claim and not from the scopes and also uses the ROLE_ prefix again instead of SCOPE_.
Implement a full conversion that maps all contents (like firstname and lastname in addition to the roles and authorities) of the JWT to our User object
The following table also shows details for each option:
Option
Approach
Principal Object
Authorities Claim
Authorities
1
Automatic mapping
JWT
scope
SCOPE_USER, SCOPE_ADMIN, ...
2
Custom authorities mapping
JWT
roles
ROLE_USER, ROLE_ADMIN, ...
3
Full JWT conversion
User
roles
ROLE_USER, ROLE_ADMIN, ...
As part of the workshop we will follow along option number 2. Option 3 is an optional step if there still is time left. Before we head to the next step, here you see the adapted security configuration for option 1:
package com.example.todo.config;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
public class ToDoWebSecurityConfiguration {
// ...
/*
* Configure actuator endpoint security.
* Allow access for everyone to health, info and prometheus.
* All other actuator endpoints require ADMIn role.
*/
@Bean
@Order(3)
public SecurityFilterChain actuator(HttpSecurity http) throws Exception {
http.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeRequests(
authorizeRequests ->
authorizeRequests
.requestMatchers(EndpointRequest.to(
HealthEndpoint.class,
InfoEndpoint.class,
PrometheusScrapeEndpoint.class))
.permitAll()
.requestMatchers(EndpointRequest.toAnyEndpoint()).hasAuthority("SCOPE_ADMIN")
)
.httpBasic(withDefaults()).formLogin(withDefaults());
return http.build();
}
/*
* Security configuration for user and todos Rest API.
*/
@Bean
@Order(4)
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http.mvcMatcher("/api/**")
.authorizeRequests()
.mvcMatchers("/api/users/me").hasAnyAuthority("SCOPE_USER", "SCOPE_ADMIN")
.mvcMatchers("/api/users/**").hasAuthority("SCOPE_ADMIN")
.anyRequest().hasAnyAuthority("SCOPE_USER", "SCOPE_ADMIN")
.and()
// only disable CSRF for demo purposes or when NOT using session cookies for auth
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.oauth2ResourceServer().jwt(withDefaults());
return http.build();
}
/...
}
Step 4: Custom JWT authorities mapper
In this step we would like to add a custom mapping for authorizations, so we can get rid of the automatic SCOPE_xx authorities back again to ROLE_xx authorities. To achieve this, simply add the following new bean to our web security configuration class:
reads the spring security authorities from the roles claim of the incoming JWT (1)
maps it to spring security authorities using the ROLE_ prefix (2)
(Optional) Step 5: Custom JWT converter
The third option is the one with the most effort. Here we completely map token claim values int a User object.
To implement a full custom mapping for a JWT access token Spring Security requires us to implement the interface Converter<Jwt, AbstractAuthenticationToken>.
package org.springframework.core.convert.converter;
import org.springframework.lang.Nullable;
public interface Converter<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})
* @throws IllegalArgumentException if the source cannot be converted to the desired target type
*/
@Nullable
T convert(S source);
}
In general, you have again two choices here:
Map the JWT token user data to the corresponding User and read the authorization data from the token and map it to Spring Security authorities
Identify the locally stored User by the subject claim in the token and map the corresponding User from the JWT token user data but map the persistent roles of the User to Spring Security authorities.
In this workshop we will use the second option...
...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 JwtUserAuthenticationConverter in package com.example.todo.security with the following contents:
package com.example.library.server.security;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.stream.Collectors;
/** JWT converter that takes the roles from 'groups' claim of JWT token. */
@SuppressWarnings("unused")
public class JwtUserAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private final UserService userService;
public JwtUserAuthenticationConverter(UserService userService) {
this.userService = userService;
}
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
UUID userIdentifier = UUID.fromString(jwt.getSubject());
return userService.findOneByIdentifier(userIdentifier).map(u ->
new UsernamePasswordAuthenticationToken(u, jwt.getTokenValue(), u.getAuthorities())
).orElse(null);
}
}
This converter maps the JWT token information to a User 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_ADMIN.
No open again the class com.example.todo.config.ToDoWebSecurityConfiguration and add this new JWT converter to the JWT configuration:
package com.example.todo.config;
import com.example.todo.security.JwtUserAuthenticationConverter;
import com.example.todo.service.UserService;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
public class ToDoWebSecurityConfiguration {
//...
/*
* Security configuration for user and todos Rest API.
*/
@Bean
@Order(4)
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http.mvcMatcher("/api/**")
.authorizeRequests()
.mvcMatchers("/api/users/me").hasAnyRole("USER", "ADMIN")
.mvcMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().hasAnyRole("USER", "ADMIN")
.and()
// only disable CSRF for demo purposes or when NOT using session cookies for auth
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.oauth2ResourceServer().jwt().jwtAuthenticationConverter(customJwtUserAuthenticationConverter()); //1
return http.build();
}
// ...
public Converter<Jwt, AbstractAuthenticationToken> customJwtUserAuthenticationConverter() {
return new JwtUserAuthenticationConverter(userService);
}
}
(Optional) Step 6: JWT validation for the 'audience' claim
Implementing an additional custom token validator is quite easy, you just have to implement the provided interface OAuth2TokenValidator and add your custom validator on top of the mandatory validators..
In this step we will also validate the audience (aud) claim of the access token.
The privileges associated with an access token SHOULD be restricted to the minimum required for the particular application or use case. [...] In particular, access tokens SHOULD be restricted to certain resource servers (audience restriction)
So we should also validate that our resource server only successfully authenticates those requests bearing access tokens containing the expected value of http://localhost:9090/api/todos in the audience claim.
So let's create a new class AudienceValidator in package com.example.todo.security with the following contents:
package com.example.todo.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private static final Logger LOGGER = LoggerFactory.getLogger(AudienceValidator.class);
private final OAuth2Error error =
new OAuth2Error("invalid_token", "The required audience 'http://localhost:9090/api/todos' is missing", null);
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience().contains("http://localhost:9090/api/todos")) {
LOGGER.info("Successfully validate audience");
return OAuth2TokenValidatorResult.success();
} else {
LOGGER.warn(error.getDescription());
return OAuth2TokenValidatorResult.failure(error);
}
}
}
Adding such validator is a bit more effort as we have to replace the previously autoconfigured JwtDecoder with our own bean definition.
To achieve this, open again the class com.example.todo.config.ToDoWebSecurityConfiguration one more time and add the customized JwtDecoder with the validator.
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.
Now it is time to test again using the well known request for the ToDo list. Try to change the validator and check if the validator works as expected and leads to an authentication error when validation fails for the expected audience.
Opaque Tokens
Changing the resource server to use opaque tokens for authentication is quite easy.
Just replace the jwt() reference in the ToDoWebSecurityConfiguration by the opaque() reference like this:
package com.example.todo.config;
import com.example.todo.security.AudienceValidator;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint;
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@EnableWebSecurity
public class ToDoWebSecurityConfiguration {
// ...
/*
* Security configuration for user and todos Rest API.
*/
@Bean
@Order(4)
public SecurityFilterChain api(HttpSecurity http) throws Exception {
http.mvcMatcher("/api/**")
.authorizeRequests()
.mvcMatchers("/api/users/me").hasAnyRole("USER", "ADMIN")
.mvcMatchers("/api/users/**").hasRole("ADMIN")
.anyRequest().hasAnyRole("USER", "ADMIN")
.and()
// only disable CSRF for demo purposes or when NOT using session cookies for auth
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.oauth2ResourceServer().opaqueToken(withDefaults());
return http.build();
}
// ...
}
and then replace the jwt entries in the application.yml by the new opaquetoken entries:
And for testing please retrieve an opaque token from the Spring Authorization Server by switching the client_id to demo-client-opaque (client_secret is still secret) or demo-client-opaque-pkce.
Important Note: If you could not finish part 1, then just use the project lab1/final-jwt to start into the next labs or use the lab2/initial project as new starting point.
We will use Spring Authorization Server as identity provider.
Please again make sure you have set up Spring Authorization Server as described in
As already described in the section the initial application is secured using basic authentication.
open a web browser and navigate to and use bwayne and wayne as login credentials
Spring security 5 uses the specification to completely configure the resource server to use our Spring Authorization Server instance.
Make sure Spring Authorization Server has been started as described in the section.
Navigate your web browser to the url .
Then you should see the public discovery information that Spring Authorization Server provides (like the following).
If you have Postman installed (or just install it now from ) you can use the authorization code flow (+ PKCE) as built in functionality (even in the free edition)
You can use the provided test client (see ) to get a token. Just follow instruction in the
Navigate your web browser to and paste your access token into the Encoded text field.
You can find this kind of _automatic' standard solution in the corresponding final reference application located in the folder. In the following sections we will look into the other two mapping options. Let's start with option 2.
You can find this kind of solution in the corresponding final reference solution located in the folder.
According to the current the audience claim should be used to restrict token usage for specific resource servers:
Recently the OAuth working group published the new RFC 9068 standard called that also describes the same audience claim as mandatory for access tokens.
This ends lab 1. In the next we will see how to test for OAuth and JWT authentication.
To continue with testing the OAuth2/OIDC resource server application please head over to .