Spring Security Oauth2- JWT Authentication in a resource server
Oauth2 is an industry-standard protocol for authorization.
As per Oauth2 specification(RFC-6749) —
The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowing the third-party application to obtain access on its own behalf.
The following diagram illustrates the working of an Oauth2 authentication request with an Authorization Code Grant flow.
There are four parties involved —
- Client is a third party Application that wants access to the protected resource from a resource server.
- Authentication Server is the server that helps to authenticate a Resource Owner.
- Resource Owner is the user that owns the protected resource.
- Resource Server is the server that serves the protected resources owned by Resource Owner.
- First of all, the client sends an authorization request to Resource Owner so that on behalf of the Resource Owner, it can access the protected resource(s).
- If the Authorization-code-grant is used, the Authorization code is returned to the client. It means the Resource owner has given access to the client for protected resources.
- The client sends this Authorization code to the Authentication Server, which in return provides an Authentication token — typically a JWT token.
- Once the client has the authentication token, It use it to access the protected resources from a resource server. The token expires after a set timeout.
In this post, we will focus on the 4th step i.e. How a Resource Server validates a JWT token provided by any third party client.
First let us understand, what is JWT and what API’s are provided by spring security to implement Jwt Authentication.
What is JWT?
👉🏼 Checkout the complete introduction at jwt.io 😜
Spring Security API for JWT Authentication
The below diagram provides a thorough overview of Spring security API Specs for JWT Authentication.
When a client submits a request along with bearer token. It is passed through the security filter chain. The
BearerTokenAuthenticationFilter
creates aBearerTokenAuthenticationToken
of the typeAuthentication
.Next, The
AuthenticationManagerResolver
resolves theAuthenticationManager
which in turn selects the specificAuthenticationProvider
.The
BearerTokenAuthenticationToken
is passed toAuthenticationProvider
byProviderManager
( the default Implementation ofAuthenticationManager
)For JWT authentication,
JwtAuthenticationProvider
is selected. It decodes, verifies and validates theJwt
usingJwtDecoder
.If the authentication succeeds, the Authentication is set on the
SecurityContextHolder
.If the Authentication fails,
SecurityContextHolder
is cleared.
Finally, Let move ahead with implementing the JWT Authentication.
JWT Authentication with Spring Security
In order to implement it, we would require the following components —
- Authentication server - we will use Keycloak. It supports Oauth2.0.
- Resource Server - We will create one using a spring-boot application.
- Client - We can use Postman API client as the client.
- User - we will setup one user in Keycloak server.
Authentication server via Keycloak
- Follow this link to quickly setup a Keycloak server via Docker.
- Follow this tutorial to setup Keycloak Authorization code grant.
While you are at it, here are few things, you would require once the Keycloak server is setup.
- issuer uri — http://authserver.com/auth/realms/{realm}
- jwks-uri — http://authserver.com/auth/realms/{realm}/protocol/openid-connect/certs
- Metadata endpoint —To use the
issuer-uri
property, the Authorization server should support either one of the below URLs as the metadata endpoint :
Make sure to replace authserver.com with a valid domain. Also, make sure to provide the value for realm
Once you have the Keycloak
server ready — Let’s go ahead and create a resource server.
Resource Server
The resource server will be the simplest one and will contain only one secure rest API.
Dependencies:
spring-security-oauth2-resource-server
**— Most of the resource server support is collected here.spring-security-oauth2-jose
— provides support for decoding and verifying JWT.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
API Endpoint
GET /api/v1/users
@RestController
@RequestMapping("/api/v1")
public class UserController {
@GetMapping("/users")
public List<User> getUsers(){
return Arrays.asList(
new User("john doe", 100),
new User("jane doe",300)
);
}
}
Since we have Spring security in the class path, every route will be private.
Setup JWT issuer URL
This is minimal setup required to implement the JWT authentication. The issuer-url
provided is used by Resource Server to discover public keys of the authorization server and validate the token. It is also the same URL present in iss
claim.
Spring:
security:
oauth2:
resourceserver:
jwt:
issuer-url: http://localhost:8080/auth/realms/{realm}
When the Resource server is starts up, it will automatically configure itself to validate JWT encoded Bearer Token. It achieves this by querying the Authorization Server metadata endpoint for jwks_url
property. This provides access to to the supported algorithm and its valid public keys.
The Only drawback to this setup is that it would fail if the Authentication server is not already up. To void startup failure, we would need to add the jwk-set-uri
Spring:
security:
oauth2:
resourceserver:
jwt:
issuer-url: http://localhost:8080/auth/realms/dev
jwk-set-uri: http://localhost:8080/auth/realms/{realm}/protocol/openid-connect/certs
Now, the Resource Server will not ping the authorization server at startup. However, issuer-uri
is still kept to validate the JWT iss
claim on the incoming token.
Spring Security provides extension points to override or customize the default behavior of the implementation. We will look into customizing some of the default features.
…But before that, Let’s test the default implementation.
Time to Test the Implementation 💎
If you recall, the resource server contains one endpoint with a path /api/v1/users
. If we call it without providing an authentication token, it will return 401 - Unauthorized
status. That is due to the absence of an authorization token.
Let’s see how we can use an authorization code grant to fetch a token from the Keycloak
server and use it to access the API
provided by the resource server.
Step - 1: Request OAuth Authorization Code
At this point, we would need a client to request the Authorization code. However, to make it easier to test, we can run the following URL in the browser. It should redirect you to the login page and you will have to provide the credentials of the user.
http://authserver.com/auth/realms/{realm}/protocol/openid-connect/auth
?client_id=your-client-id&response_type=code&state=app-state
After the successful completion, it will redirect you to the redirect-url
with the values similar to below.
http://{redirect-url}/?state=appstate
&session_state=d7c5d4de-c883-494a-a2a2-e5108062830c
&code=f5935f66-88e0-4085-80aa-000b2a6b2b51.d7c5d4de-c883-494a-a2a2-e5108062830c.bf89a5ff-5703-42d6-9534-ca59f667f81f
We would require code
to fetch the actual token.
Step - 2: Fetch the Authentication Token
curl -L -X POST "http://localhost:8080/auth/realms/dev/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Cookie: JSESSIONID=E8D36F0DBCBF7E33130B9125F8795CAC.9b51ecd0cc5c; JSESSIONID=8E34666DECDB395B1754FD08C5B385F2" \
--data-urlencode "client_id=client-id-value" \
--data-urlencode "client_secret=client-secret-uuid" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=92236b97-c48f-4827-ae80-80a46e39a0f2.d7c5d4de-c883-494a-a2a2-e5108062830c.bf89a5ff-5703-42d6-9534-ca59f667f81f" \
--data-urlencode "redirect-uri=http://localhost:8085"
client-id
,client-secret
can be fetched from the client credentials inKeycloak
server.grant_type = authorization_code
describes the grant type used.code
fetched in the Step-1.redirect-url
should be the same as configured for the client inKeycloak
server.
The response returned would look similar to the below example:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0S1ZQNGVhRVdjdno4ZjRndXlPd05XTE9GWFEzYWo0b1I0eWx0dkFSZldFIn0.eyJleHAiOjE2MzI5MzMzOTksImlhdCI6MTYzMjkzMzA5OSwiYXV0aF90aW1lIjoxNjMyOTMzMDgyLCJqdGkiOiIzZTk1NGRkMi0zMjhhLTQ3NzItYWQ2NS0xOWQ3NGM2MGZjZGEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZGV2IiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImMzNjFkMGVjLWM1MWItNGRmNy05MTA1LTVhOWUyZGViMmRjOCIsInR5cCI6IkJlYXJlciIsImF6cCI6InJlc291cmNlc2VydmVyIiwic2Vzc2lvbl9zdGF0ZSI6ImMyYWRiODIyLWQwY2ItNDY3MC1iYmNjLWU3NWJlOTkyYzg5OSIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1kZXYiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVzb3VyY2VzZXJ2ZXIiOnsicm9sZXMiOlsiVVNFUiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiYzJhZGI4MjItZDBjYi00NjcwLWJiY2MtZTc1YmU5OTJjODk5IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJ1c2VyX25hbWUiOiJ1c2VyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidXNlciJ9.A66uqbRwsUL36GSGozZ7FC3x-M4SCYYLaABMdps-XneseP1saIjsTbHO2QrYq2HbD9jl6nKTYxJHjMdbsRJyY3VtM2mf1D8W24-u8y8qmGf1YNbtFfSTZyrUmwiACEv17onAT8wKgR0C4sdbVFETpRY12f2qQb0mM4ZkT9QQ5DYPBu6dnwyBVXLYJzn8kfmp7JB0OR6LsBTTtyh03t_xiRwb1nSALbUmwq7iUk9lTFEUuUZ182p05q3TKxy9b_kxrCh91EYoYWUdBEhRM4yHjrvN99T-MFpRVaCadyn2YibFbCeZHpsqUmgi-ghR3I70U70HGsL22FEAE4N9X5y_pg",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIzZGU1NjBjZi1kMDZlLTRiZmItODY2Yi1mNzJhYjk0YjA0NGMifQ.eyJleHAiOjE2MzI5MzQ4OTksImlhdCI6MTYzMjkzMzA5OSwianRpIjoiODQ5OWNmY2QtZjY1Yy00YzdhLThhNDctMzdhNjg4ZGZjMjU0IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL2F1dGgvcmVhbG1zL2RldiIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9hdXRoL3JlYWxtcy9kZXYiLCJzdWIiOiJjMzYxZDBlYy1jNTFiLTRkZjctOTEwNS01YTllMmRlYjJkYzgiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoicmVzb3VyY2VzZXJ2ZXIiLCJzZXNzaW9uX3N0YXRlIjoiYzJhZGI4MjItZDBjYi00NjcwLWJiY2MtZTc1YmU5OTJjODk5Iiwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiYzJhZGI4MjItZDBjYi00NjcwLWJiY2MtZTc1YmU5OTJjODk5In0.747XKhyNqZCDEzSfLV4K96sgAW0daN1C1ROUr5L_s_E",
"token_type": "Bearer",
"not-before-policy": 0,
"session_state": "c2adb822-d0cb-4670-bbcc-e75be992c899",
"scope": "email profile"
}
Step - 3: Run the API with
We will use the access_token
value from the previous response as the bearer token to run the private API — (api/v1/users
) provided by the resource server.
curl -L -X GET "http://localhost:8090/api/v1/users" \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI0S1ZQNGVhRVdjdno4ZjRndXlPd05XTE9GWFEzYWo0b1I0eWx0dkFSZldFIn0.eyJleHAiOjE2MzI5MzMzOTksImlhdCI6MTYzMjkzMzA5OSwiYXV0aF90aW1lIjoxNjMyOTMzMDgyLCJqdGkiOiIzZTk1NGRkMi0zMjhhLTQ3NzItYWQ2NS0xOWQ3NGM2MGZjZGEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwODAvYXV0aC9yZWFsbXMvZGV2IiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImMzNjFkMGVjLWM1MWItNGRmNy05MTA1LTVhOWUyZGViMmRjOCIsInR5cCI6IkJlYXJlciIsImF6cCI6InJlc291cmNlc2VydmVyIiwic2Vzc2lvbl9zdGF0ZSI6ImMyYWRiODIyLWQwY2ItNDY3MC1iYmNjLWU3NWJlOTkyYzg5OSIsImFjciI6IjEiLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1kZXYiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicmVzb3VyY2VzZXJ2ZXIiOnsicm9sZXMiOlsiVVNFUiJdfSwiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJlbWFpbCBwcm9maWxlIiwic2lkIjoiYzJhZGI4MjItZDBjYi00NjcwLWJiY2MtZTc1YmU5OTJjODk5IiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJ1c2VyX25hbWUiOiJ1c2VyIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidXNlciJ9.A66uqbRwsUL36GSGozZ7FC3x-M4SCYYLaABMdps-XneseP1saIjsTbHO2QrYq2HbD9jl6nKTYxJHjMdbsRJyY3VtM2mf1D8W24-u8y8qmGf1YNbtFfSTZyrUmwiACEv17onAT8wKgR0C4sdbVFETpRY12f2qQb0mM4ZkT9QQ5DYPBu6dnwyBVXLYJzn8kfmp7JB0OR6LsBTTtyh03t_xiRwb1nSALbUmwq7iUk9lTFEUuUZ182p05q3TKxy9b_kxrCh91EYoYWUdBEhRM4yHjrvN99T-MFpRVaCadyn2YibFbCeZHpsqUmgi-ghR3I70U70HGsL22FEAE4N9X5y_pg" \
-H "Cookie: JSESSIONID=8E34666DECDB395B1754FD08C5B385F2"
Response:
[
{
"name": "John Doe",
"age": 100
},
{
"name": "Jane Doe",
"age": 300
}
]
Customize the default Implementation
Provide custom JWT Converter
A JWT Converter is responsible for converting a JWT Bearer token into a valid JwtAuthenticationToken
which is of the type Authentication. In order to provide a customer converter, we will need to override WebSecurityConfigurerAdapter
and supply an instance of the custom converter.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt().jwtAuthenticationConverter(new JwtAuthenticationConverter());
}
}
Change the default JWT Decoder
A JwtDecoder decodes a JWT token into an instance of Jwt
instance. Jwt
instance is the java representation of JSON Web Token. Please refer to Jwt docs for the proper understanding.
The default implementation is NimbusJwtDecoder
. We can either provide a custom JwtDecoder bean
or directly provide the instance of it.
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt().jwtAuthenticationConverter(new JwtAuthenticationConverter());
}
@Bean
public JwtDecoder jwtDecoder(){
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt().decoder(NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build());
}
Configure Authorization
By default, Spring Security uses JWT claims such as scope
or scp
to fetch the scopes present in the JWT and map it to GrantedAuthorities
. While mapping, It will also prepend the scope with SCOPE_
. Here, we will see —
- How to change the default Prefix.
- How to use a different claim name to fetch the authorities.
In order to change the default implementation, we will have to provide a customized instance of JwtGrantedAuthoritiesConverter
. It is responsible for converting the JWT scopes to GrantedAuthorities
.
JwtAuthenticationConverter
which is responsible to convert JWT to a valid Authentication . The below example shows how to change the Authorities claim name and authority prefix.
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
//change the prefix to ROLE_
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
//change the default claim name. default claim is "scope", "scp"
grantedAuthoritiesConverter.setAuthoritiesClaimName("c_scope");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
Assuming the JWT token has claims such as {.... ,"c_scope: 'profile dashboard' "}
.The granted authorities will be mapped as ROLE_profile, ROLE_dashboard
Configure Timeouts
Sometimes the default timeout of 30 seconds for connections and sockets won’t suffice.
JwtDecoder
accepts an instance of RestOperation
. A RestTemplate
is the implementation of RestOperation.
We can set custom values to connectTimeout
and readTimeout
to change the default values.
@Bean
public JwtDecoder jwtDecoder(RestTemplateBuilder restTemplateBuilder){
RestTemplate restOperations = restTemplateBuilder
.setConnectTimeout(Duration.ofSeconds(90))
.setReadTimeout(Duration.ofSeconds(90))
.build();
return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).restOperations(restOperations).build();
}
Configuring Timestamp validations.
JWT is valid between a certain time period. nbf
and exp
claims contain start and end of the valid time period respectively.
However, in distributed environments, It is possible to experience **clock drift**. As a result, JWT might occur invalid in some servers. To overcome this, we can provide a Clock Skew to compensate for the clock drift time.
The DelegatingOAuth2TokenValidator
takes an array of validators which are used for validating the JWT token. We can use JwtTimestampValidator
and supply a time offset value.
@Bean
public JwtDecoder jwtDecoder(RestTemplateBuilder restTemplateBuilder) {
OAuth2TokenValidator<Jwt> clockSkew
= new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(Duration.ofSeconds(60)));
nimbusJwtDecoder.setJwtValidator(clockSkew);
return nimbusJwtDecoder;
}
Set Jwks-uri through DSL
We can set jwks-uri
via DSL. This can be useful to provide different authentication servers for different environments without having to change the configurations.
public class SecurityConfig extends WebSecurityConfigurerAdapter{
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests().anyRequest().authenticated()
.and()
.oauth2ResourceServer()
.jwt().jwkSetUri(jwkSetUri);
}
}
Provide a custom location for public key
If the Oauth2 server doesn’t provide a jwks-uri
and you want to setup a location for the public key. It can be set either through properties or configure JwtDecoder
bean
// VIA spring boot
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:my-key.pub
// use decoder
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.key).build();
}
Source code for the above examples is present here