In my previous article, I wrote about setting up Auth0 using Terraform. In this article, I want to put that setup to work to authorize requests to a backend API written in Kotlin, using SpringBoot.
This API is used to upload recipes. It is consumed through a frontend application. Login is optional for viewing them. I want to authorize requests to create new ones, however. That's what we'll be doing now, thanks to JSON Web Tokens.
JSON Web Tokens
I'm delegating the user management to Auth0. Our frontend communicates with it using OAuth to obtain a bearer token, but that is outside the purpose of this article. We'll assume that this step happened already and that the requests to our backend come with an Authorization header.
The token is encoded in base64, which can be conveniently decoded using jwt.io. Once decoded, we can have a look at the payload, which is just a JSON object:
{
"iss": "https://{{auth0_tenant}}.eu.auth0.com/",
"sub": "google-oauth2|{{user_id}}",
"aud": [
"{{our_domain}}",
"https://{{auth0_tenant}}.eu.auth0.com/userinfo"
],
"iat": 1584296421,
"exp": 1584303621,
"scope": "openid profile create:recipes"
}
So we have an Issuer (iss
), an Audience (aud
), an Expiration Time (exp
), and the scopes granted (scope
). All those are fields that we can verify. How will that work? Here's a diagram:
In order to authorize requests, we need three components:
- A Verifier that checks if the token is valid, and extract information from it if so
- A JwtAuthorizationFilter that runs before every request to the backend, and run the Verifier
- A connection to Spring Security, which rejects the request if it is invalid
Let's have a look at each separately.
Verifying the token
So, our backend has received a request that includes a JWT, what do we do now? We can picture it as two distinct steps. Checking the signature and verifying the token itself. We combine both steps into an interface (Verifier
), which defines one method:
interface Verifier {
/**
* @param jwt a jwt token
* @return whether the token is valid or not
*/
fun verify(jwt: String): Either<JWTVerificationException, TokenAuthentication>
}
I'm using an Either
type for the return type. I wrote about them recently. Essentially, we'll get a TokenAuthentication
if verify
succeeds and an instance of JWTVerificationException
if not.
The signature
The token is signed by the issuer (in our case Auth0). The signature ensures that the token comes from the issuer, and that hasn't been tampered with.
To avoid having to share a secret key, we prefer to use a public/private key combination. That's where JWKS
comes in. Auth0 publishes the key for your tenant under https://{{auth0_tenant}}.eu.auth0.com/.well-known/jwks.json
. I am injecting that key into our RemoteVerifier
, which implements the interface I defined above. We provide it as a Bean
:
@Configuration
class JWTConfiguration {
@Value("\${auth.jwks}")
lateinit var jwks: String
@ConditionalOnProperty(value = ["auth.enabled"], havingValue = "true")
@Bean
fun verifier(): Verifier {
val keySet = JWKSet.load(URL(jwks))
return RemoteVerifier(keySet)
}
}
The Bean
is conditional so that we can provide an alternate implementation for our tests, thus avoiding network requests.
Setting up the verifier
For the verification itself, we are going to use auth0's own library. First, we initialize a JWTVerifier
:
class RemoteVerifier(private val keySet: JWKSet, private val leeway: Long = 10) : Verifier {
companion object {
private fun key(keySet: JWKSet) = keySet.keys.first() as RSAKey
private fun algorithm(key: RSAKey) = Algorithm.RSA256(key.toRSAPublicKey(), null)
private fun verifier(algorithm: Algorithm, leeway: Long) = JWT
.require(algorithm)
.acceptExpiresAt(leeway)
.build()
}
private fun verifier(): JWTVerifier {
val key = key(keySet)
val algorithm = algorithm(key)
return verifier(algorithm, leeway)
}
}
You can see we include the key we got before, plus a leeway limit for the expiration time of the token. If you want to test for the issuer and the audience explicitly, you can use the withIssuer
and withAudience
methods, before calling build()
. In my case, checking that my tenant signs the token feels safe enough.
We trigger the actual verification by calling the verify
method of the JWTVerifier
we just created, wrapping it so that it does not throw exceptions into the wild.
override fun verify(jwt: String): Either<JWTVerificationException, TokenAuthentication> {
return verifier()
.unsafeVerify(jwt)
.map { it.asToken() }
}
Wait, what is that asToken
method doing there? As I mentioned, we want to extract information from the token. We need the list of scopes in the token for later, which we fetch and include in our TokenAuthentication
private fun DecodedJWT.asToken() =
TokenAuthentication(token, User(subject, scopes()))
private fun DecodedJWT.scopes() = getClaim("scope")
.asString()
.split(" ")
The authorization filter
The verifier runs inside a filter, which inherits from OncePerRequestFilter
. As you probably guessed, it runs before every request, but only once. Let's call it JwtAuthorizationFilter
. It does the following steps:
We covered the Verify Token step in the previous section. Before that, we have to extract the token from the header. If the verification succeeds, we set the SecurityContext
, which is relevant for Spring Security. The code looks like this:
@Component
class JwtAuthorizationFilter(val verifier: Verifier) : OncePerRequestFilter() {
companion object {
private fun String.extractToken() = startsWith("Bearer ")
.maybe { split(" ").last() }
}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain) {
val token = Option.fx {
val (header) = request.getHeader(Headers.AUTHORIZATION).toOption()
val (jwt) = header.extractToken()
val (token) = verifier.verify(jwt).toOption()
SecurityContextHolder.getContext().authentication = token
}
filterChain.doFilter(request, response)
}
}
It is fairly straightforward ... wait, what is that Option.fx
thing? And the parentheses? Also, according to the diagram, ever step could have an empty answer, where are we handling that?
The long explanation is an article on its own. The short version is that thanks to Arrow, we can use monad comprehensions to flatten our code and make a collection of operations on an Option
look like a regular block of code.
Binding Spring Security
I am using Spring Security to integrate my filter into my application. The easiest way to get started is to use this dependency:
implementation 'org.springframework.boot:spring-boot-starter-security'
In the configuration, I'm specifying the following:
- My
JwtAuthorizationFilter
will run before every request -
GET
requests don't need authorization - For mutating requests (such as a
POST
), we expect thecreate:recipes
scope there. Remember that we mapped that into ourTokenAuthentication
as part of the verification.
There is a fluent interface to configure this in code, which looks like this:
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
class SecurityConfiguration : WebSecurityConfigurerAdapter() {
@Autowired
lateinit var filter: JwtAuthorizationFilter
override fun configure(http: HttpSecurity?) {
http?.let {
it.cors().and()
.csrf().disable()
.authorizeRequests()
.antMatchers(HttpMethod.GET).permitAll()
.anyRequest().hasAuthority("create:recipes")
.and()
.addFilterBefore(filter, BasicAuthenticationFilter::class.java)
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
So now all three parts are finally connected. We defined which requests we want to authorize. The filter parses and verifies the token. If that token fulfills all our requirements, the request goes through. Otherwise, it fails with an unauthorized response.
Summary
Building authorization into your API is something you need to do if you want to make sure that your backend is properly ensuring only the right parties are allowed to do certain things. I rolled a custom solution for a project that I worked on, but if you use standard tools and libraries, it is a lot more convenient and a lot easier for other people to understand.
Top comments (0)