Skip to content

Spring-security JWT with JWK

Hello!

In this post, we will improve the application from the previous post – https://akazakov.dev/?p=39. All code could be found at Github https://github.com/kazakovav/spring-security-jwt/tree/master/generate-jwk

In modern applications are used microservices architecture. One common approach is when microservices are connected like resource services – they are only validating user permissions. For demonstration token-based security in microservices architectures, we will divide the application into 2 services – authentication service and resource service.

Authentication service will provide user authentication and token creation

The resource server will provide some protected resources, for demonstration, it only returns parsed token value.

Like in the previous post initialize two applications via start.spring.io. After that, we copy all the code associated with token generation and user authentication to auth-service and all code associated with token validation and “/me” endpoint to resource-service.

The resulting directory tree in resource-service:

and for auth-service:

Resource service

Resource service contains only one controller, which provides “/me” endpoint:

@RestController
@RequestMapping("/")
class AuthenticationController(
        private val tokenBuilder: TokenBuilder
) {
    @PostMapping("login")
    fun authenticate(@RequestBody authenticationRequest: AuthenticationRequest): AuthenticationResponse {
        if ("admin" != authenticationRequest.userName || "adminPassword" != authenticationRequest.password) {
            throw UsernameNotFoundException("User name not found")
        }
        val userName = authenticationRequest.userName
        val authorities = listOf(UserRoles.USER.name, UserRoles.ADMIN.name)

        val jwt = tokenBuilder.buildToken(authorities, userName)
        return AuthenticationResponse(jwt)
    }
}

the main difference is in the config file application.yml:

app.version: v1
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: <http://localhost:8484/.well-known/jwks.json>

We set URI for loading JWK set for token validation. All work for token validation and key loading is made by spring-security.

Authentication service

The main difference from the application in the previous post is a key generation on server startup. For this, we modify KeyProvider and KeyProviderImpl.

In KeyProvider we just add two new functions:

import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey

interface KeyProvider {
    fun getPrivateKey(): RSAPrivateKey
    fun getPublicKey(): RSAPublicKey
    fun getKeyId(): String
}

and in implementation, we remove code that loads the key from the “.pem” file and adds code for a keyPair generation. The full source of KeyProviderImpl:

@Component
class KeyProviderImpl(private val jwtProperties: JwtProperties) : KeyProvider {
    private lateinit var keyPair: KeyPair
    private lateinit var keyId: UUID

    @PostConstruct
    fun afterPropertiesSet(): Unit {
        log.info("Loaded properties: $jwtProperties")
        keyPair = generateKeyPair();
        keyId = UUID.randomUUID();
    }

    private fun generateKeyPair(): KeyPair {
        Security.addProvider(BouncyCastleProvider())
        val keyPairGenerator = KeyPairGenerator.getInstance(ALGORITHM, "BC")
        keyPairGenerator.initialize(KEY_SIZE, SecureRandom())
        return keyPairGenerator.generateKeyPair()
    }

    override fun getPrivateKey(): RSAPrivateKey = keyPair.private as RSAPrivateKey

    override fun getPublicKey(): RSAPublicKey = keyPair.public as RSAPublicKey

    override fun getKeyId(): String = keyId.toString()

    companion object {
        @Suppress("JAVA_CLASS_ON_COMPANION")
        @JvmStatic
        private val log = LoggerFactory.getLogger(javaClass.enclosingClass)

        @JvmStatic
        val ALGORITHM = "RSA"

        @JvmStatic
        val KEY_SIZE = 2048
    }
}

Instead of loading the key from the “.pem” file, the application generates key pairs during startup. The benefits of this approach are:

  • No needed copying public key to all microservices in our system
  • Key pair could be generated periodically, that improves application security
  • We could switch to another authentication service, i.e. “Keycloak” (https://www.keycloak.org), with minimal changes

Also, we need to provide an endpoint for the JWK key set, see JwkSetRestController below:

@RestController
@RequestMapping("/.well-known/")
class JwkSetRestController(private val jwkSetResolver: JwkSetResolver) {

    @GetMapping("jwks.json")
    fun getKeys(): MutableMap<String, Any>? {
        return jwkSetResolver.getJwkSet().toJSONObject(true);
    }
}

JwkSetResolver uses “nimbus” for key set creating from RsaPublicKey:

@Service
class JwkSetResolverImpl(private val keyProvider: KeyProvider) : JwkSetResolver {

    private lateinit var jwkSet: JWKSet

    @PostConstruct
    fun afterPropertiesSet(): Unit {
        jwkSet = buildJwkSet()
    }

    private fun buildJwkSet(): JWKSet {
        val builder: RSAKey.Builder = RSAKey.Builder(keyProvider.getPublicKey())
                .keyUse(KeyUse.SIGNATURE)
                .algorithm(JWSAlgorithm.RS256)
                .keyID(keyProvider.getKeyId())
        return JWKSet(builder.build())
    }

    override fun getJwkSet(): JWKSet = jwkSet

    companion object {
        @Suppress("JAVA_CLASS_ON_COMPANION")
        @JvmStatic
        private val log = LoggerFactory.getLogger(javaClass.enclosingClass)
    }
}

Of course, we need to provide key id in our token, change TokenBuilder:

@Component
class TokenBuilderImpl(
        @Value("\\${jwt.token-validity-in-seconds}")
        private val tokenValidityInSeconds: Long,
        private val keyProvider: KeyProvider
) : TokenBuilder {

    override fun buildToken(authorities: List<String>, userName: String): String {
        val expiration = getExpiration()
        val claims: Map<String, Any> = buildClaims(authorities);
        return Jwts.builder()
                .setClaims(claims)
                .setId(UUID.randomUUID().toString())
                .setSubject(userName)
                .setHeaderParam("kid", keyProvider.getKeyId())
                .signWith(getKey(), SignatureAlgorithm.RS256)
                .setExpiration(expiration)
                .compact()
    }

    private fun getExpiration(): Date =
            Date(System.currentTimeMillis() + tokenValidityInSeconds * 1000)

    private fun getKey() = keyProvider.getPrivateKey()

    private fun buildClaims(authorities: List<String>): Map<String, Any> {
        val claims = HashMap<String, Any>()
        claims["authorities"] = authorities
        return claims
    }
}

The main difference from the previous version is setting key id param to the token header:

setHeaderParam("kid", keyProvider.getKeyId())

Let’s test our application. Run both applications and run test requests from IntelliJ Idea:

###
POST <http://localhost:8484/login>
Content-Type: application/json

{
  "userName": "admin",
  "password": "adminPassword"
}

> {% client.global.set("auth_token", response.body.token); %}

###
GET <http://localhost:8080/me>
Content-Type: application/json
Authorization: Bearer {{auth_token}}

Result of the last request:

GET <http://localhost:8080/me>

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: application/json
Transfer-Encoding: chunked
Date: Wed, 28 Oct 2020 06:23:52 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "tokenValue": "eyJraWQiOiJkMzhiMzk2OS05MWU0LTQwNzMtYmUwZC01MTVhOTNjOGRhYzQiLCJhbGciOiJSUzI1NiJ9.eyJhdXRob3JpdGllcyI6WyJVU0VSIiwiQURNSU4iXSwianRpIjoiYTBiNDQ2MTAtMDNjOC00NGYxLThjZmUtMmEyODg2ODU3NGY4Iiwic3ViIjoiYWRtaW4iLCJleHAiOjE2MDM4NjYyNTR9.NKb7QtDC2vHnq0uPfW1w5khaNqg23rspkxwcXRYFAH836u-4u0BHwaiKAOZIuJb8-tb8zK3vZW_b3jMQaHJh562m6kNdDDOSnmn1zV7LbfnFwLXA-hGNI2tbQHazZquJViTTqHd_dxoB1Wt1V3Bu_YkqlHL8nIfG--cqStqxqh0xWopyyT2iPnB22n-e1xdrs4OziKzSXUzf3MYLFby1vK0JR2qB06IOq7S7UCqwTmI9-P14VuA_z3fCEvqg1jc09DMlzAWxfMyVpnuY_gu9pXqZgmQw_knMlx6lar6lkYCJNhzy1iM9no0oJ5nVxBjduEwH9IWn9rt2X8sdb4G7BQ",
  "issuedAt": "2020-10-28T06:24:13Z",
  "expiresAt": "2020-10-28T06:24:14Z",
  "headers": {
    "kid": "d38b3969-91e4-4073-be0d-515a93c8dac4",
    "alg": "RS256"
  },
  "claims": {
    "sub": "admin",
    "exp": "2020-10-28T06:24:14Z",
    "iat": "2020-10-28T06:24:13Z",
    "authorities": [
      "USER",
      "ADMIN"
    ],
    "jti": "a0b44610-03c8-44f1-8cfe-2a28868574f8"
  },
  "id": "a0b44610-03c8-44f1-8cfe-2a28868574f8",
  "issuer": null,
  "subject": "admin",
  "audience": null,
  "notBefore": null
}

Response code: 200; Time: 340ms; Content length: 993 bytes

If we restart services, keys and key-id will be generated soon. And we couldn’t verify our previous token.

The result after restarting services, with the previous version of token:

GET [<http://localhost:8080/me>](<http://localhost:8080/me>) 
HTTP/1.1 401
WWW-Authenticate: Bearer error="invalid_token", error_description="An error occurred while attempting to decode the Jwt: Signed JWT rejected: Invalid signature", error_uri="[<https://tools.ietf.org/html/rfc6750#section-3.1>](<https://tools.ietf.org/html/rfc6750#section-3.1>)"
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-Length: 0
Date: Tue, 27 Oct 2020 06:56:02 GMT
Keep-Alive: timeout=60
Connection: keep-alive

Thanks! Happy coding!

Comments 1

Leave a Reply

Your email address will not be published.