loading...

Spring Security with JWT: OAuth 2 Resource Server

toojannarong profile image Jannarong Wadthong ・7 min read

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

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"
}

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
}

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()
}

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);  
  }  
}

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))  
    ;  
  }  
}

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.

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
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

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
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

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

Payload

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

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;  
  }  
}

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;  
  }  
}

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
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

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
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

Conclusion

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

Posted on Apr 15 by:

toojannarong profile

Jannarong Wadthong

@toojannarong

I am an experienced Developer. Eager to learn and share knowledge. Help others.

Discussion

markdown guide
 

Great post!

Just a notice, I think this post has nothing to do with the post that you relate it to. Here you need an authorization server(sometimes you may not have an authorization server) to issue you tokens. While in the other post, he implements from scratch.

EDIT:
Not relevant, reference deleted.

 

You're right. I'll remove it so that it won't confuse.

 

Does the code work with Okta out of the box?

 

Yes, it should support any kind of JWT authorization sever. But you need to check if Okta provides the JWT containing the well-kwon claims: scope / scp or not. Otherwise; you have to customise the configurations to tell Spring which claims you need, as the shown the example in the Custom Claims section .

 
 

I'm using at+jwt type, how do I tell spring to use this type when validating JWT?

 

Hi Nice Post,

Is there a way for me to access the org.springframework.security.oauth2.jwt.Jwt object in a filter rather than a controller. I would like the place some validations before the Controller gets invoked.

Thanks in Advance,
Vimod