Building a Firebase Authenticator for Ktor

I’ve been exploring with the Jetbrains web framework ktor for just over a year now thanks to the pandemic, and I must say I absolutely fell in love with this platform. The difficulty though is when you get into a platform that hasn’t yet had the time to mature due to the shiny newness, things can get a bit rough in implementing certain things due to the lack of documentation.

Hopefully this post goes a little way in changing that 🙂 This is the first part in a couple of articles I’ll publish about expanding the capabilities of ktor, which I definitely found helpful. Hint: You will need to know a bit about JWT and the authentication capabilities of Firebase to follow along with this article. You may also need to know just a super-high level about ktor pipelines

For those of you who have been playing around with Google’s Firebase for a while now, know that they support authentication server-side via the Firebase Admin SDK. However ktor doesn’t support this out the box and instead give you documentation about how to mint your own tokens. For a while I did it the manual way, just calling firebase.verifyToken(idToken: String) as a regular method, but the issue here is that it doesn’t feel natural to do so. knowing that ktor lets you install authentication capabilities that should help you do this anyway, this process should be easier without filling your server code with random auth checks all over the place. So what was the solution then? Adding firebase as an authenticator of course! Here’s what well do :

  • Define a Firebase authentication service much like how ktor already helps you do it with a default JWT
  • Define an internal method to this service that does the checks with firebase to ensure the token is valid
  • Define the validate method that we can use to check what properties of our token have to be there, otherwise we fail the validation ourselves.
  • Define our own Principal so that when the authentication passes, we have our own FirebaseUserPrincipal with all the relevant firebase user properties that we require.

Let’s dig in!

Firebase init.

For this project you will need your private json keyfile that you can get from your Firebase project console as this is needed for the Firebase admin SDK. I I’m still trying to figure out the best way to set this up for a prod app but for the purposes of this project you can just add it to your ‘resources’ folder, where your application.conf and logback.xml files are found.

You will also need to add the following libraries to your ktor project:

    implementation("io.ktor:ktor-auth:1.6.6")
    implementation("com.google.firebase:firebase-admin:8.1.0")

In the main Application file for the server, you define this method to load up your firebase config:

private fun initializeFirebase() {
    val firebaseConfig = applicationEngineEnvironment { }.classLoader
        .getResourceAsStream("myfirebaseconfig.json")

    val firebaseOptions = FirebaseOptions.builder()
        .setCredentials(GoogleCredentials.fromStream(firebaseConfig))
        .setDatabaseUrl("https://dummyfirebaseurl.com")
        .build()

    FirebaseApp.initializeApp(firebaseOptions)
}

Then call this method within your Application function scope. I usually define it before any of the features that are meant to be installed.

Once this is done, run your server to confirm that everything loads up correctly. Hopefully you have no issues with this. From here we can finally continue defining our Auth.

Ktor Feature

If you look into any existing ktor feature , you will see that it’s a class made up of an inner config class together with a builder pattern that assembles all the properties either as defaults or configured via user init. Here we define the authentication header config that also includes an auth header parsing method on any request that goes through the platform. We also define a config name as you can make several configs that may use different firebase accounts configured with this service, as well as an error that gets thrown if there is no implementation of the authentication.

class FirebaseAuthenticationProvider(config: Configuration) : AuthenticationProvider(config) {

    val authHeader: (ApplicationCall) -> HttpAuthHeader? = config.authHeader
    val authFunction = config.firebaseAuthenticationFunction

    class Configuration(configName: String) : AuthenticationProvider.Configuration(configName) {

        internal var authHeader: (ApplicationCall) -> HttpAuthHeader? =
            { call -> call.request.parseAuthorizationHeaderOrNull() }


        var firebaseAuthenticationFunction: AuthenticationFunction<FirebaseToken> = {
            throw NotImplementedError(FirebaseImplementationError)
        }

        fun validate(validate: suspend ApplicationCall.(FirebaseToken) -> FirebaseUserPrincipal?) {
            firebaseAuthenticationFunction = validate
        }

        fun build() = FirebaseAuthenticationProvider(this)
    }
}

The parseAuthorizationHeaderorNull() method is actually defined within ktor auth library itself. I just took the function and used it here. I had to extract it from the framework as it’s marked private. It’s basically the below:

private fun ApplicationRequest.parseAuthorizationHeaderOrNull() = try {
    parseAuthorizationHeader()
} catch (ex: IllegalArgumentException) {
    println("failed to parse token")
    null
}

What this method does is it checks to make sure the authorization header is valid with the “Bearer” marker.

Next, we define an extension function on the Authentication framework with our own configuration extension defined for Firebase. In here we can define the config name as well as the configuration provider we defined earlier (above). The extension function looks like this:

fun Authentication.Configuration.firebase(
    configName: String = "firebaseAuth",
    configure: FirebaseAuthenticationProvider.Configuration.() -> Unit
) {
    val provider = FirebaseAuthenticationProvider.Configuration(configName).apply(configure).build()
    val authenticate = provider.authFunction

    provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val token = provider.authHeader(call)
        if (token == null) {
            context.challenge(FirebaseJWTAuthKey, AuthenticationFailedCause.InvalidCredentials) {
                it.completed
                call.respond(UnauthorizedResponse(HttpAuthHeader.bearerAuthChallenge(realm = "firebaseAuth")))
            }
            return@intercept
        }
        try {
            val principal = verifyFirebaseIdToken(call, token, authenticate)
            if (principal != null) {
                context.principal(principal)
                return@intercept
            }
        } catch (cause: Throwable) {
            val message = cause.message ?: cause.javaClass.simpleName
            context.error(FirebaseJWTAuthKey, AuthenticationFailedCause.Error(message))
        }
    }
    register(provider)
}

Our internal firebase method for verifying the token looks like this:

suspend fun verifyFirebaseIdToken(
    call: ApplicationCall,
    authHeader: HttpAuthHeader,
    tokenData: suspend ApplicationCall.(FirebaseToken) -> Principal?
): Principal? {
    val token: FirebaseToken = try {
        if (authHeader.authScheme == "Bearer" && authHeader is HttpAuthHeader.Single) {
            withContext(Dispatchers.IO) {
                FirebaseAuth.getInstance().verifyIdToken(authHeader.blob)
            }
        } else {
            null
        }
    } catch (ex: Exception) {
        ex.printStackTrace()
        return null
    } ?: return null
    return tokenData(call, token)
}

What’s basically going on here is that we get the auth header within our suspend function, then call the firebase service within a coroutine dispatcher. The blob here refers to the contents of then header i.e the authentication token. Once our token passes firebase verification, we invoke our suspend return call with our FirebaseToken that we can do anything with.

Finally, in the main application class we can install the Firebase auth service as an additional authentication provider as we would the built-in JWT function. lWe can also select what parts of the Firebase token we would like to verify. For this example we can check to make sure that the user UID and the email address is not null.

    install(Authentication) {
        firebase("firebaseAuth") {
            validate { credential ->
                if (credential.uid != null && credential.email != null) {
                } else {
                    null
                }
            }
        }
    }

As you may or may not have seen, the authentication here needs to return an authenticated user of type Principal which is an interface defined with in ktor. So we can define our own Principal that will contain the Firebase parameters we would like to use in our app. Here is a copy of a basic Firebase User Principal that extends the Principal interface:

data class FirebaseUserPrincipal(val uid: String, val emailAddress: String) : Principal

And here is the complete defined auth block with our principal defined:

    install(Authentication) {
        firebase("firebaseAuth") {
            validate { credential ->
                if (credential.uid != null && credential.email != null) {
                    FirebaseUserPrincipal(uid = credential.uid, emailAddress = credential.email)
                } else {
                    null
                }
            }
        }
    }

Our setup here is complete! The last thing we need to do is within our defined routes, we need to tell the framework what authentication mechanism to use:

fun Routing.sayHello() {
    route("/greetings") {
        authenticate("firebaseAuth") {
            get("/basic") {
                val uid = call.principal<FirebaseUserPrincipal>()?.uid
                if(!uid.isNullOrEmpty()){
                    call.respond(
                        status = HttpStatusCode.OK,
                        mapOf("message" to "hello $uid!")
                    )
                } else {
                    call.respond(status = HttpStatusCode.UnAuthorized, "Authentication failed for user")
                }
            }

And that’s pretty much it. You can find a complete sample of this on GitHub. Just remember to update your Firebase config file correctly or you will have problems. If you are going to test this with Postman, then authenticate manually with Firebase first and then use the Firebase ID token for your service.

I also have to say huge thank you to Scott Brady for his blog post that helped me get started in working with this feature.

I hope you found this relevant and helpful and thanks for reading! If you have issues testing this out, don’t hesitate to reach out to me on twitter