arrow-rightgithublinkedinscroll-to-topzig-zag

Configure OAuth2 Spring Authorization Server with JWT support

Last Updated On

Table of Contents:

Update: 04/15/2020 Spring team announced a new initiative of "Spring Authorization server" development. At the time of writing this article it's in "experimental state" and available at the following GitHub repository.

Introduction

Before continuing with the article, it's worth mentioning that Spring Authorization Server is deprecated (as was written in the official spring blog post Spring Security OAuth 2.0 Roadmap Update). However, it's often very convenient to start up own authorization server for various demos and proofs-of-concept rather than using third-party products like Okta or spinning up CloudFoundry/UAA or KeyCloak in a docker container.

Spring's official Security OAuth 2.X guide is very detailed and well written. There is an auto-configuration for opaque tokens. Unfortunately, to set up OAuth2 with JWT (which pretty much standard in our days) it's required to do a little bit extra work which might be not straightforward. After following Spring's post, I still ended up getting exceptions during app startup. After some research, I was able to configure it the way I needed and want to share the config in this article.

It should be noted that we will not be covering OAuth2 concepts such as authorization grants, access and refresh tokens that represented by JWT tokens and involved parties such as client, resource server, authentication server in detail. If you landed on this page there is an assumption that you have a basic understanding of the material.

Before diving into the details, let's set some expectations from our Authorization Server.

Requirements

  1. Should use JWT tokens (not opaque tokens, which is the default)
  2. Should expose JWK (JSON Web Key) endpoint so that Resource Server can retrieve JWK to validate JWS (JSON Web Signature) of the token
  3. Should support OAuth2 "Password" Grant
  4. Should be able to refresh "access_token" via "refresh_token" (Spring uses "refresh_token" grant type for this)
  5. Should not use Basic Auth (which is the default). Many REST clients don't support Basic Auth and the fact of exposing sensitive data in the URL is no longer a good fit in our days even for small projects.

How to complete this guide

If you just need a working code you can go to the GitHub repository, download the code and jump to "Test the configuration" section. Alternatively, you can follow along with the steps below.

Authorization server setup

Dependencies

build.gradle.kts

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")//1
    implementation("org.springframework.boot:spring-boot-starter-security")//2
    implementation("org.springframework.security.oauth:spring-security-oauth2:2.4.0.RELEASE")//3
    implementation("org.springframework.security:spring-security-jwt:1.1.0.RELEASE")//4
    implementation("com.nimbusds:nimbus-jose-jwt:8.11")//5
}
  1. Provides defaults Filter for Servlets. Also needed for requirement .2.
  2. It will be used to configure credentials for users. It's worth clarifying that "Spring Security" module is used for the individual user whereas "Spring Security OAuth2" module is used for Authorization Server configuration. We will later see that things like username and password belong to a user and things like grant_type, client_id and client_secret belong to Authorization Server.
  3. This is where the core logic of Spring Authorization Server resides.
  4. Provides JwtAccessTokenConverter bean that is used to configure the use of JWT tokens instead of opaque tokens
  5. Provides convenient methods for JWK configuration

Utilities

Before we start with our Authorization Server config class, we need to set up a few utility beans such as:

PasswordEncoderConfig.class

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class PasswordEncoderConfig {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

Spring Security 5.x changed password storage format. Storage of plain passwords is not allowed anymore and the new format is described here. DelegatingPasswordEncoder will take care of that configuration for us.

JwtSetKeyGeneration.class

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;

@Configuration
public class JwtSetKeyGeneration {
    @Bean
    public KeyPair keyPairBean() throws NoSuchAlgorithmException {
        KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
        gen.initialize(2048);
        return gen.generateKeyPair();
    }
}

Generates public/private key pair to supply later for accessTokenCoverter as a part of its configuration and more importantly, to automatically configure JSON Web Key Set (JWKS). The latter would be exposed via public URL so that the resource server can key use it to verify access_token signature.

Authorization Server setup

JwkSetConfiguration.class

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerEndpointsConfiguration;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;

import java.security.KeyPair;

@Import(AuthorizationServerEndpointsConfiguration.class)
@Configuration
public class JwkSetConfiguration extends AuthorizationServerConfigurerAdapter {

    AuthenticationManager authenticationManager;
    KeyPair keyPair;
    PasswordEncoder passwordEncoder;
    UserDetailsService userDetailsService;

    public JwkSetConfiguration(AuthenticationConfiguration authenticationConfiguration, KeyPair keyPair, PasswordEncoder passwordEncoder, UserDetailsService userDetailsService) throws Exception {
        this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
        this.keyPair = keyPair;
        this.passwordEncoder = passwordEncoder;
        this.userDetailsService = userDetailsService;
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) {
        security
          .tokenKeyAccess("permitAll()")
          .checkTokenAccess("isAuthenticated()")
          .allowFormAuthenticationForClients() // (1)
        ;
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients
            .inMemory() // (2)
            .withClient("test-client")
            .secret(passwordEncoder.encode("noonewilleverguess")) // (3)
            .scopes("any") // (4)
            .autoApprove(true)
            .authorizedGrantTypes("password", "refresh_token")
        ;
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(this.authenticationManager)
            .accessTokenConverter(accessTokenConverter())
            .userDetailsService(userDetailsService)
            .tokenStore(tokenStore());
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(accessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(this.keyPair);
        return converter;
    }
}

That's the main class that is responsible for OAuth2 configuration and there is a noticeable amount of actions happen inside of it. Most of them are borrowed from the official Spring Guide, but few ones are different:

  1. Replaces Basic Authentication and allows you to pass all necessary params as a part of a request body. This is how with Basic Auth request looks like:
POST http://user:password@localhost:6060/oauth/token?grant_type=password&client_id=test-client&client_secret=noonewilleverguess

Even for demos, it's still better to have the following approach, especially when adding one line of config allows us to do so:

POST http://localhost:6060/oauth/token
Content-Type: application/x-www-form-urlencoded
 
username=user&password=password&grant_type=password&client_id=test-client&client_secret=noonewilleverguess
  1. To keep things easier we're specifying OAuth2 client settings in the codebase. However, it's also possible to use an external data source. Refer to Marcos Barbero's post at the end of the article for more examples.

  2. That's where Spring Security 5.x encoder will come into play. However, it's also possible to replace

.secret(passwordEncoder.encode("noonewilleverguess")) with .secret("{noop}noonewilleverguess")to delegate encoding to NoOpPasswordEncoder (more about it here).

  1. Although it's not required to specify scope when requesting a token, it's required to set one during the configuration.

JwkSetEndpoint.class

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.security.oauth2.provider.endpoint.FrameworkEndpoint;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;

@FrameworkEndpoint
class JwkSetEndpoint {
    KeyPair keyPair;

    public JwkSetEndpoint(KeyPair keyPair) {
        this.keyPair = keyPair;
    }

    @GetMapping("/.well-known/jwks.json")
    @ResponseBody
    public Map<String, Object> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) this.keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

That's how our Resource Server will communicate to Authorization server to retrieve JWKs and verify that the access token's signature supplied in the Authorization header request from the end-user is genuine.

JwkSetEndpointConfiguration.class

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerSecurityConfiguration;

@Configuration
class JwkSetEndpointConfiguration extends AuthorizationServerSecurityConfiguration {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http
        .requestMatchers()
        .mvcMatchers("/.well-known/jwks.json")
        .and()
        .authorizeRequests()
        .mvcMatchers("/.well-known/jwks.json").permitAll();
    }
}

Since Spring Security locks access to all endpoints by default and recently created jwks endpoint is not an exception, it's necessary to allow public access so that the resource server can retrieve necessary data.

WebSecurityConfig.class

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    PasswordEncoder passwordEncoder;

    public WebSecurityConfig(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager(
            User.withUsername("user")
                .password(passwordEncoder.encode("password"))
                .roles("USER")
                .build()
        );
    }
}

In this situation WebSecurityConfig.class is responsible for setting up end users and has nothing to do with OAuth2 config. As we saw earlier, it's possible to replace .password(passwordEncoder.encode("password")) with .password("{noop}password") as well as use external source for the user management.

Resource server setup

There are two main parts when it comes to resource server configuration:

  1. Tell Resource Server where to find JWKs. That's done in the application.yaml file in spring.security.oauth2.resourceserver.jwt.jwk-set-uri property (more theory behind it can be found in the official Spring Security Docs)
  2. Configure access to each endpoint using Spring Security DLS

Test the configuration

Retrieving an access token:

raw request:

POST http://localhost:6060/oauth/token
Content-Type: application/x-www-form-urlencoded

username=user&password=password&grant_type=password&client_id=test-client&client_secret=noonewilleverguess

curl:

curl --request POST \
  --url http://localhost:6060/oauth/token \
  --header 'content-type: application/x-www-form-urlencoded' \
  --cookie JSESSIONID=ED725F67A3CEB0CB3F64C6ACFD50DB17 \
  --data username=user \
  --data password=password \
  --data grant_type=password \
  --data client_id=test-client \
  --data client_secret=noonewilleverguess

response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODU3NDcwNTksInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiYTk0M2QxZDgtYjYyOS00MjhjLTlhZDUtNjUxMWVhMTU5N2UxIiwiY2xpZW50X2lkIjoidGVzdC1jbGllbnQiLCJzY29wZSI6WyJhbnkiXX0.ktMUl20f0pbosR9S8MUGY5NmCiqPhQ2BJ1fn3_Gz7Asa01_d2IAhG0BC5vd1rVeAq8VbDPNSy1-ZWl9Y91KBas_2w4PqeaUbHadj4KnTeDm_50NqKKJfqdv2jfemj7CSY2tYb9VkLqqUPuYrWjmAJx-uz9BgqgE93hrYbd4ddeWlOavR1dSHl6U16EJPEX5T3aChQHedSSNlusAzpQoFA89HaPTtVevyyQ_DTSWPI-Mkt0P3W6yaMkWoUyVfzE6ImaHnNq7cR90pGtiqaOEG0vHXXXQ8rybsK3Yb56iP5ofm3AG-XI86zFdXEr1RVp-rZ_P4HqvgZr2yfSiaT0LklA",
  "token_type": "bearer",
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYW55Il0sImF0aSI6ImE5NDNkMWQ4LWI2MjktNDI4Yy05YWQ1LTY1MTFlYTE1OTdlMSIsImV4cCI6MTU4ODI5NTg1OSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6IjcyNWFjZTNmLTNkMDYtNDc4YS1hN2ViLTQ2M2M4NWI4YmRjMiIsImNsaWVudF9pZCI6InRlc3QtY2xpZW50In0.opm8NRHK_2fiBOB4rob3JLaXSilyfS2CiGYqHjvTL8Q4dVqh_u1BaamwD_xDFjt-t6MkU10rf1bz0I02KY-U26sd356HgyKbbxUeZUKBM2mTvAJX4h4jWhximM7t1weX-9zkQL7DLbohH5ci54RDdwgjcc7Woli3hEWcEqnklZkVgOTjNv1yNC0yEj-8b4eJBpb8adOsT98m69whD6oXXFLdd8ccyl2aoIX4F5e3wCFq3oaEXTuDzro1T3fsZyTPMmzeXxbMV4zz8-GU9pl7o-fc_hkmeez3G5VBUhNzjvchMy2hLtU97xe1w-Tlyh52BeQttLvubAScQpKWbFOIBA",
  "expires_in": 43199,
  "scope": "any",
  "jti": "a943d1d8-b629-428c-9ad5-6511ea1597e1"
}

Refreshing an access token:

This is where we need to save the value of the refresh_token field of the previous response and supply it in the request:

raw request:

POST http://localhost:6060/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYW55Il0sImF0aSI6ImE5NDNkMWQ4LWI2MjktNDI4Yy05YWQ1LTY1MTFlYTE1OTdlMSIsImV4cCI6MTU4ODI5NTg1OSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6IjcyNWFjZTNmLTNkMDYtNDc4YS1hN2ViLTQ2M2M4NWI4YmRjMiIsImNsaWVudF9pZCI6InRlc3QtY2xpZW50In0.opm8NRHK_2fiBOB4rob3JLaXSilyfS2CiGYqHjvTL8Q4dVqh_u1BaamwD_xDFjt-t6MkU10rf1bz0I02KY-U26sd356HgyKbbxUeZUKBM2mTvAJX4h4jWhximM7t1weX-9zkQL7DLbohH5ci54RDdwgjcc7Woli3hEWcEqnklZkVgOTjNv1yNC0yEj-8b4eJBpb8adOsT98m69whD6oXXFLdd8ccyl2aoIX4F5e3wCFq3oaEXTuDzro1T3fsZyTPMmzeXxbMV4zz8-GU9pl7o-fc_hkmeez3G5VBUhNzjvchMy2hLtU97xe1w-Tlyh52BeQttLvubAScQpKWbFOIBA&client_id=test-client&client_secret=noonewilleverguess

curl:

curl --request POST \
  --url http://localhost:6060/oauth/token \
  --header 'content-type: application/x-www-form-urlencoded' \
  --cookie JSESSIONID=ED725F67A3CEB0CB3F64C6ACFD50DB17 \
  --data grant_type=refresh_token \
  --data refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYW55Il0sImF0aSI6ImE5NDNkMWQ4LWI2MjktNDI4Yy05YWQ1LTY1MTFlYTE1OTdlMSIsImV4cCI6MTU4ODI5NTg1OSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6IjcyNWFjZTNmLTNkMDYtNDc4YS1hN2ViLTQ2M2M4NWI4YmRjMiIsImNsaWVudF9pZCI6InRlc3QtY2xpZW50In0.opm8NRHK_2fiBOB4rob3JLaXSilyfS2CiGYqHjvTL8Q4dVqh_u1BaamwD_xDFjt-t6MkU10rf1bz0I02KY-U26sd356HgyKbbxUeZUKBM2mTvAJX4h4jWhximM7t1weX-9zkQL7DLbohH5ci54RDdwgjcc7Woli3hEWcEqnklZkVgOTjNv1yNC0yEj-8b4eJBpb8adOsT98m69whD6oXXFLdd8ccyl2aoIX4F5e3wCFq3oaEXTuDzro1T3fsZyTPMmzeXxbMV4zz8-GU9pl7o-fc_hkmeez3G5VBUhNzjvchMy2hLtU97xe1w-Tlyh52BeQttLvubAScQpKWbFOIBA \
  --data client_id=test-client \
  --data client_secret=noonewilleverguess

response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODU3NDUwMTUsInVzZXJfbmFtZSI6InVzZXIiLCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiN2U2MjhlN2QtMWQ0Yy00MjliLWFhYTEtYmE1ZWQ4M2Y4MzEyIiwiY2xpZW50X2lkIjoidGVzdC1jbGllbnQiLCJzY29wZSI6WyJhbnkiXX0.ZjsEMX2b-XorKShgTIWMnHE2E0blHM6zGEOIqiTrowGIHE2VthdTqG_m4wxU_KrZsDNrs8MvqoUJpfntbzgKGeQQLKpSZ91S-Pv7dtcrygMF5IGU-NuJQH7x56fyzdkTRiW6jJ0cDgo-qN0iMg9i9f86vQXjzNfAUb4oetRRA_Umn1hRIh4R969PdT6slo-_MpKjT9D62Bn3-rIR6KQRex1LZTrWfj3bKIlbqZpVCfqOcK3X25IsGOgk13fGtP9R9o6iYdcuoHHZLsXb6tBeOgTy7XXi6-9r5UzyRCrGddEBueojBCJHWy0rIu4ywqt20GgX_aqvaCgkgVxWCeWeNg",
  "token_type": "bearer",
  "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYW55Il0sImF0aSI6IjdlNjI4ZTdkLTFkNGMtNDI5Yi1hYWExLWJhNWVkODNmODMxMiIsImV4cCI6MTU4ODI5MzgwMiwiYXV0aG9yaXRpZXMiOlsiUk9MRV9VU0VSIl0sImp0aSI6IjFlMzQ3YjIzLWJkNjMtNGU4OS1hOWYyLTc4OWUwMWY1MGM1YiIsImNsaWVudF9pZCI6InRlc3QtY2xpZW50In0.j6NNXVQgAxbwdqu_Cs9duF0Vc94kWmX7idAZ4clDSoOg-_iZnO01vpdrvX1KCcdtaIvQ2f2WnIlwiX_NsuNzeRehTFI3LQG_OJJEZ5rrGibh9uAuBiXQXIV7IyiiXHNvLGOc2FGVxuPn39cw2DW65KcdSPJSnpCjr2ERLggeIbp7cfpFaBbUnp5tUacrUeV_0RVG899W-DXQ39eXn3xFkOuAC8dYvygWUL5Dp3tA1K6aJkmN6ch_FTv2JdXSYQ11U7xbD9jTN7IA0RFQvTVwaBYVFa252ouOMQGx1SUobsNKYsUF9jcBS3QYpJYFf7vTLW4vER4a5YZGVupkGV8zTg",
  "expires_in": 43199,
  "scope": "any",
  "jti": "7e628e7d-1d4c-429b-aaa1-ba5ed83f8312"
}

It looks exactly the same as in the case of retrieving an access token. This gives an ability to indefinitely keep toking alive without need to supply username and password unless refresh token becomes expired.

Accessing secured endpoint on a resource server

Spring OAuth2 Resource Server like majority other frameworks and platforms is configured to accept access token in the Authorization header (this is where access_token field comes into play):

raw request:

GET http://localhost:6061/hello
Authorization: Bearer your_access_token_here

curl:

curl --request GET \
  --url http://localhost:6061/hello \
  --header 'authorization: Bearer your_access_token_here

If access was set correctly, the response should be the following:

response:

{
  "message": "hello"
}

Conclusion

This concludes our demo. The goal of it is to keep the configuration as simple as possible, but provide necessary tweaks to align with the other products in the field. There are many ways to improve it, for example, substitute hardcoded values (username and password) with an external source. Marcos Barbero wrote a great blog post about more advanced setup - Centralized Authorization with OAuth2 + JWT using Spring Boot 2.