JWT authentication for Spring Boot simplified using GoTrue and Supabase

In a quest to have a simpler JWT Authentication flow and not have to deal with security-related related userdata in my backend I discovered Supabase Auth which is an implementation of Netlify GoTrue.

For Kotlin there is the awesome supabase gotrue-kt library.

In your User Registration and Login Services you need to create a GoTrueClient

val goTrueClient = GoTrueClient.defaultGoTrueClient(
    url = "<base-url>",
    headers = mapOf("Authorization" to "foo", "apiKey" to "bar")
)

If you are using supabase, the base URL will be: https://<your-project-id>.supabase.co/auth/v1

Then in your signup method you can just call signUpWithEmail().

val authDTO = goTrueClient()
    .signUpWithEmail(credentials["email"]!!, credentials["password"]!!)
websiteUserRepository.save(WebsiteUser(authDTO))

With the default client, this returns a GoTrueUserResponse which most importantly contains a id that you then can persist in a WebsiteUser Authentication Pojo which holds information related to the user

With the goTrue Kotlin Library, you can also specify a custom return type for example if you turned email confirmation off.

We define our DTO:

data class AuthDTO(
    val accessToken: String,
    val tokenType: String,
    val refreshToken: String,
    val user: User
)

data class User(
    val id: UUID,
    val email: String,
    val phone: String

)

and then create a Client where we pass this DTO:

return GoTrueClient.customApacheJacksonGoTrueClient<AuthDTO, GoTrueTokenResponse>(url,headers)

In the Login method, we call signInWithEmail and then return the JWT from the GoTrue Response as Cookie

val repsonse = goTrueClient().signInWithEmail(
  credentials["email"],
  credentials["password"]
)
response.addCookie(
    Cookie("JWT", resp.accessToken).also {
        it.secure = true
        it.isHttpOnly = true
        it.path = "/"
        it.maxAge = 6000
    }
)

But we need to verify that the JWT is correct when a User requests a page and that the user has the required access rights.

We do this in a JWT Filter that overrides the doFilterInternal method from the OncePerRequestFilter().

When our current SecurityContext Authentication is empty we need to extract the JWT from the Cookie and get the UserID from GoTrue to find the WebsiteUser we persisted earlier. We then set the SecurityContext with the retrieved WebsiteUser

@Component
class JwtFilter(
    val websiteUserRepository: WebsiteUserRepository
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        if (SecurityContextHolder.getContext().authentication == null) {
            val auth = SecurityContextHolder.getContext()
            request.cookies?.find { it.name == "JWT" }?.let { cookie ->
                try {
                    goTrueClient.getUser(cookie.value).let {
                       SecurityContextHolder.getContext().authentication = websiteUserRepository.findByIdWithJPQL(it.id)
                    }
                } catch (e: GoTrueHttpException) {
                    if (e.data?.contains("Invalid token") == true) {
                        val oldCookie = request.cookies.find { it.name == "JWT" }
                        oldCookie?.maxAge = 0

                        response.addCookie(oldCookie)
                        response.sendRedirect("/")
                    }
                }
            }
        }

        filterChain.doFilter(request, response)
    }
}

At last, we add this filter in our WebSecurityConfiguration:

@Configuration
@EnableWebSecurity(debug = false)
class SpringSecurityConfig(
    val jwtFilter: JwtFilter
) : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http.formLogin()
            .loginPage("/login")
            .and()
            .logout()
            .deleteCookies("JWT","authenticated")
            .logoutUrl("/logout")
            .logoutSuccessUrl("/")
            // Our private endpoints
            .antMatchers("/konto").hasRole("USER")
            .antMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
            .and()
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
    }