Spring Security with JWT: OAuth 2 Resource Server

toojannarong

Jannarong Wadthong

Posted on April 15, 2020

Spring Security with JWT: OAuth 2 Resource Server

Since version 5.2, Spring has introduced a new library, OAuth 2.0 Resource Sever, handling JWT so that we no longer need to manually add a Filter to extract claims from JWT token and verify the token.

What is a Resource server?

Resource server provides protected resources. It communicates with its Authorization server to validate a request to access a protected resource. Typically the endpoints of a resource server are protected based on the Oauth2 scopes and user roles.
Please refer to this for more details.

Example Token



eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjQ3NDA1NDczODcsImp0aSI6ImM4YWEyZjc3LTY2NjYtNDdmNy1iNTZlLTQyNGUxYzFlMThjYiIsImlhdCI6MTU4Njk0NzM4N30.PmHnPwf7M1vTPxskfzPgVvJhhJ9azLgoqTA6C4EsTSU


Enter fullscreen mode Exit fullscreen mode

When you decode it from jwt.io, you find that the JWT structure consists of 3 parts: Header, Payload, Signature.

Header

It usually contains two fields:

  1. A type of token, type, JWT
  2. The signing algorithm, alg, HS256


{
  "typ": "JWT",
  "alg": "HS256"
}


Enter fullscreen mode Exit fullscreen mode

Payload

The payload contains a set of claims. e.g. iss (issuer), exp (expiration time), sub (subject)



{
  "iss": "http://my.microservice.com/",
  "sub": "subject",
  "scope": [
    "read"
  ],
  "exp": 4740547387,
  "jti": "c8aa2f77-6666-47f7-b56e-424e1c1e18cb",
  "iat": 1586947387
}


Enter fullscreen mode Exit fullscreen mode

And the claim that is going be used to authorise our endpoints is scope: read.

According to this, Spring OAuth 2 Resource Server, by default, looks for the clam names: scope and scp, as they are well-known claims for authorisation. If you are going use a custom claim name, you can see the example at the end of this post.

Example Project

We're going to use Spring Initializr to generate Spring Boot project from scratch.

Here is the dependencies inside build.gradle file:



plugins {
  id 'org.springframework.boot' version '2.2.6.RELEASE'
  id 'io.spring.dependency-management' version '1.0.9.RELEASE'
  id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
  mavenCentral()
}
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  testImplementation('org.springframework.boot:spring-boot-starter-test') {
    exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
  }
}
test {
  useJUnitPlatform()
}


Enter fullscreen mode Exit fullscreen mode

As you can see, we use Spring Boot version 2.2.6.RELEASE. The spring-boot-starter-oauth2-resource-server includes spring-security-oauth2-jose version 5.2.5.RELEASE containing nimbus-jose-jwt library to support JWT decoding.


Controller

We have created 2 endpoints:

  1. "/" endpoint - accepts HTTP GET method and expects HTTP Header with 'Authorization: Bearer (JWT Token)'
  2. "/message" endpoints accepts 2 HTTP methods: GET and POST


import org.springframework.security.core.annotation.AuthenticationPrincipal;  
import org.springframework.security.oauth2.jwt.Jwt;  
import org.springframework.web.bind.annotation.GetMapping;  
import org.springframework.web.bind.annotation.PostMapping;  
import org.springframework.web.bind.annotation.RequestBody;  
import org.springframework.web.bind.annotation.RestController;  

@RestController  
public class Controller {  

  @GetMapping("/")  
  public String index(@AuthenticationPrincipal Jwt jwt) {  
    return String.format("Hello, %s!", jwt.getSubject());  
  }  

  @GetMapping("/message")  
  public String message() {  
    return "secret message";  
  }  

  @PostMapping("/message")  
  public String createMessage(@RequestBody String message) {  
    return String.format("Message was created. Content: %s", message);  
  }  
}


Enter fullscreen mode Exit fullscreen mode

Configuration

  1. We define the security rules to the /message endpoint. The message endpoint will check if

    • the request has the authority read for GET method
    • the request has the authority write for POST method
  2. We also tell Spring that we are going use OAuth2 Resource Sever with JSON Web Token (JWT).

  3. We disable

    • Session Management – this will prevent the creation of session cookies
    • HTTP Basic Authentication
    • Default Spring login page
    • CSRF .


import org.springframework.http.HttpMethod;  
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.WebSecurityConfigurerAdapter;  
import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer;  


@EnableWebSecurity  
public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter {  

  @Override  
  protected void configure(HttpSecurity http) throws Exception {  
    http  
        .httpBasic().disable()  
        .formLogin(AbstractHttpConfigurer::disable)  
        .csrf(AbstractHttpConfigurer::disable)  
        .authorizeRequests(authorize -> authorize  
            .mvcMatchers(HttpMethod.GET, "/messages/**").hasAuthority("SCOPE_read")  
            .mvcMatchers(HttpMethod.POST, "/messages/**").hasAuthority("SCOPE_write")  
            .anyRequest().authenticated()  
        )  
        .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)  
        .sessionManagement(sessionManagement ->  
            sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
    ;  
  }  
}


Enter fullscreen mode Exit fullscreen mode

Note that this configuration file is expressed as DSL. Unlike the traditional approach with builder chaining, we can use Java 8 lamda to express the configurations.

application.yml



spring:  
  security:  
    oauth2:  
      resourceserver:  
        jwt:  
          jwk-set-uri: https://login.domain.com/xxx/keys # JSON Web Key URI to use to verify the JWT token.


Enter fullscreen mode Exit fullscreen mode

Expected Results

You’ll get HTTP 403 message when you call secured endpoint with an invalid claims. For example, you sent a token with read scope but the endpoints expect write scope.

You’ll get HTTP 401 message when the JWT authorisation fails. For example,

  1. the token is not recognised by the issuer.
  2. the token has expired.
  3. the token is invalid structure.
  4. etc.

Testing

Request message endpoint using HTTP GET. The token contains read scope



GET http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjQ3NDA1NDczODcsImp0aSI6ImM4YWEyZjc3LTY2NjYtNDdmNy1iNTZlLTQyNGUxYzFlMThjYiIsImlhdCI6MTU4Njk0NzM4N30.PmHnPwf7M1vTPxskfzPgVvJhhJ9azLgoqTA6C4EsTSU


Enter fullscreen mode Exit fullscreen mode


HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 33
Date: Wed, 15 Apr 2020 16:12:03 GMT

secret message

Response code: 200; Time: 1261ms; Content length: 337bytes


Enter fullscreen mode Exit fullscreen mode

Request message endpoint using HTTP POST. The token contains read scope. But it expects write scope.



POST http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwic2NvcGUiOlsicmVhZCJdLCJleHAiOjQ3NDA1NDczODcsImp0aSI6ImM4YWEyZjc3LTY2NjYtNDdmNy1iNTZlLTQyNGUxYzFlMThjYiIsImlhdCI6MTU4Njk0NzM4N30.PmHnPwf7M1vTPxskfzPgVvJhhJ9azLgoqTA6C4EsTSU


Enter fullscreen mode Exit fullscreen mode


HTTP/1.1 403 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 15 Apr 2020 20:12:00 GMT

{
  "timestamp": "2019-04-15T12:27:25.020+0000",
  "status": 403,
  "error": "Forbidden",
  "message": "Access Denied",
  "path": "/message"
}

Response code: 403; Time: 28ms; Content length: 125 byte


Enter fullscreen mode Exit fullscreen mode

Custom claim

What if our JWT does not contain the well-known claims(scope, scp) for authorisation?

We'll use claim name: roles as example.

Token with claim: roles



eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwicm9sZXMiOlsic3R1ZGVudCJdLCJleHAiOjQ3NDA1NzAxMjMsImp0aSI6IjM3OWVhNzYxLTNlNTAtNDM2Mi04ZTEyLWQwNzIzNDZhN2JlMSIsImlhdCI6MTU4Njk3MDEyM30.qJCgYrMb17D6Y6MWKrpsaTLBmWKZXvc4wTGsZg2YGGY


Enter fullscreen mode Exit fullscreen mode

Payload



{
  "iss": "http://my.microservice.com/",
  "sub": "subject",
  "roles": [
    "student"
  ],
  "exp": 4740570123,
  "jti": "379ea761-3e50-4362-8e12-d072346a7be1",
  "iat": 1586970123
}


Enter fullscreen mode Exit fullscreen mode

This section is going to illustrate on how to modify a the Default JWT Converter.

We'll modify our existing configuration file as follows:



package com.example.resourcesever;  

import org.springframework.core.convert.converter.Converter;  
import org.springframework.http.HttpMethod;  
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.WebSecurityConfigurerAdapter;  
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;  
import org.springframework.security.config.http.SessionCreationPolicy;  
import org.springframework.security.core.GrantedAuthority;  
import org.springframework.security.oauth2.jwt.Jwt;  
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;  
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;  

import java.util.Collection;  


@EnableWebSecurity  
public class OAuth2ResourceServerSecurityCustomConfiguration extends WebSecurityConfigurerAdapter {  

  private static final String AUTHORITY_PREFIX = "ROLE_";  
 private static final String CLAIM_ROLES = "roles";  

  @Override  
  protected void configure(HttpSecurity http) throws Exception {  
    http  
        .httpBasic().disable()  
        .formLogin(AbstractHttpConfigurer::disable)  
        .csrf(AbstractHttpConfigurer::disable)  
        .authorizeRequests(authorize -> authorize  
            .mvcMatchers(HttpMethod.GET, "/messages/**").hasAuthority("ROLE_student")  
            .mvcMatchers(HttpMethod.POST, "/messages/**").hasAuthority("ROLE_admin")  
            .anyRequest().authenticated()  
        )  
        .oauth2ResourceServer(oauth2ResourceServer ->  
            oauth2ResourceServer  
                .jwt(jwt ->  
                    jwt.jwtAuthenticationConverter(getJwtAuthenticationConverter()))  
        )  
        .sessionManagement(sessionManagement ->  
            sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
    ;  
  }  

  private Converter<Jwt, AbstractAuthenticationToken> getJwtAuthenticationConverter() {  
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();  
  jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(getJwtGrantedAuthoritiesConverter());  
 return jwtAuthenticationConverter;  
  }  

  private Converter<Jwt, Collection<GrantedAuthority>> getJwtGrantedAuthoritiesConverter() {  
    JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();  
  converter.setAuthorityPrefix(AUTHORITY_PREFIX);  
  converter.setAuthoritiesClaimName(CLAIM_ROLES);  
 return converter;  
  }  
}


Enter fullscreen mode Exit fullscreen mode

From above, first, we tell Spring that we want to use claim name roles instead of scope or scp.

  • Only student role will pass the GET method authorisation.
  • Only admin role will pass the POST method authorisation.

Second, we want to set authority prefix with ROLE_ instead of SCOPE_

We can modify it further... We can use hasRole instead of hasAuthority.



package com.example.resourcesever;  

import org.springframework.core.convert.converter.Converter;  
import org.springframework.http.HttpMethod;  
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.WebSecurityConfigurerAdapter;  
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;  
import org.springframework.security.config.http.SessionCreationPolicy;  
import org.springframework.security.core.GrantedAuthority;  
import org.springframework.security.oauth2.jwt.Jwt;  
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;  
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;  

import java.util.Collection;  


@EnableWebSecurity  
public class OAuth2ResourceServerSecurityCustomConfiguration extends WebSecurityConfigurerAdapter {  

  private static final String AUTHORITY_PREFIX = "ROLE_";  
 private static final String CLAIM_ROLES = "roles";  

  @Override  
  protected void configure(HttpSecurity http) throws Exception {  
    http  
        .httpBasic().disable()  
        .formLogin(AbstractHttpConfigurer::disable)  
        .csrf(AbstractHttpConfigurer::disable)  
        .authorizeRequests(authorize -> authorize  
            .mvcMatchers(HttpMethod.GET, "/messages/**").hasRole("student")  
            .mvcMatchers(HttpMethod.POST, "/messages/**").hasRole("admin")  
            .anyRequest().authenticated()  
        )  
        .oauth2ResourceServer(oauth2ResourceServer ->  
            oauth2ResourceServer  
                .jwt(jwt ->  
                    jwt.jwtAuthenticationConverter(getJwtAuthenticationConverter()))  
        )  
        .sessionManagement(sessionManagement ->  
            sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
    ;  
  }  

  private Converter<Jwt, AbstractAuthenticationToken> getJwtAuthenticationConverter() {  
    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();  
  jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(getJwtGrantedAuthoritiesConverter());  
 return jwtAuthenticationConverter;  
  }  

  private Converter<Jwt, Collection<GrantedAuthority>> getJwtGrantedAuthoritiesConverter() {  
    JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();  
  converter.setAuthorityPrefix(AUTHORITY_PREFIX);  
  converter.setAuthoritiesClaimName(CLAIM_ROLES);  
 return converter;  
  }  
}


Enter fullscreen mode Exit fullscreen mode

Notice that I provide only the role name(admin, student) without the prefix ROLE_ to the hasRole() method, because the implementation of hasRole() does not expect us to put the prefix ROLE_, it will do for us.

Testing with Custom claim: roles

Request message endpoint using HTTP GET. The token contains student role



GET http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwicm9sZXMiOlsic3R1ZGVudCJdLCJleHAiOjQ3NDA1NzAxMjMsImp0aSI6IjM3OWVhNzYxLTNlNTAtNDM2Mi04ZTEyLWQwNzIzNDZhN2JlMSIsImlhdCI6MTU4Njk3MDEyM30.qJCgYrMb17D6Y6MWKrpsaTLBmWKZXvc4wTGsZg2YGGY


Enter fullscreen mode Exit fullscreen mode


HTTP/1.1 200 
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: text/plain;charset=UTF-8
Content-Length: 33
Date: Wed, 15 Apr 2020 17:11:01 GMT

secret message

Response code: 200; Time: 1261ms; Content length: 337bytes


Enter fullscreen mode Exit fullscreen mode

Request message endpoint using HTTP POST. The token contains student role. But it expects admin role.



POST http://localhost:8080/message
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwOi8vbXkubWljcm9zZXJ2aWNlLmNvbS8iLCJzdWIiOiJzdWJqZWN0Iiwicm9sZXMiOlsic3R1ZGVudCJdLCJleHAiOjQ3NDA1NzAxMjMsImp0aSI6IjM3OWVhNzYxLTNlNTAtNDM2Mi04ZTEyLWQwNzIzNDZhN2JlMSIsImlhdCI6MTU4Njk3MDEyM30.qJCgYrMb17D6Y6MWKrpsaTLBmWKZXvc4wTGsZg2YGGY

Enter fullscreen mode Exit fullscreen mode


HTTP/1.1 403
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 15 Apr 2020 22:10:05 GMT

{
"timestamp": "2019-04-15T12:27:25.020+0000",
"status": 403,
"error": "Forbidden",
"message": "Access Denied",
"path": "/message"
}

Response code: 403; Time: 28ms; Content length: 125 byte

Enter fullscreen mode Exit fullscreen mode




Conclusion

The OAuth 2 Resource Sever library provide us a minimal configuration. We do not need to write a filter anymore.

💖 💪 🙅 🚩
toojannarong
Jannarong Wadthong

Posted on April 15, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related