How to use Firebase Authentication with Ktor 2.0

 

With the release of Ktor 2.0, one of the migrations I had to do was for Firebase Authentication which I first learned about how to use with Ktor 1.6 from this medium article last year. Learn how to setup Firebase Authentication with Ktor 2.0 and how to test it.

Project Setup

Firebase Project Setup

Before downloading the starter project, follow these instructions to create a new firebase project and enable authentication. Then click on the settings button in the side bar -> project settings -> service accounts tab -> generate a new private key which should then download a JSON file to your machine.

Download and Configure Project

Then download the Ktor Project Template from the Ktor Project Generator site. It will setup Ktor with 2.0.3 and the following plugins:

  • Authentication
  • Content Negotiation
  • kotlinx.serialization
  • Routing

Now with the JSON file downloaded from the service account creation, rename this file to ktor-firebase-auth-adminsdk.json and move it into this project under src/main/resources/ktor-firebase-auth-adminsdk.json

The service account JSON configuration should not be checked into your git repository as this should be kept secret. To prevent this, add the file src/main/resources/ktor-firebase-auth-adminsdk.json to your .gitignore file.

Finally add the Firebase Admin Java SDK to the build.gradle.kts file in order to user Firebase Authentication.

dependencies {
    implementation("com.google.firebase:firebase-admin:9.0.0")
}

Setup Firebase App

With the project configured, the FirebaseApp on the server must be initialized using the service account JSON file placed in the resources folder.

object FirebaseAdmin {
    private val serviceAccount: InputStream? =
        this::class.java.classLoader.getResourceAsStream("ktor-firebase-auth-adminsdk.json")

    private val options: FirebaseOptions = FirebaseOptions.builder()
        .setCredentials(GoogleCredentials.fromStream(serviceAccount))
        .build()

    fun init(): FirebaseApp = FirebaseApp.initializeApp(options)
}

Then simply call the init() function when the server is first started.

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        FirebaseAdmin.init()
        // configure rest of project
    }.start(wait = true)
}

Now the Firebase Admin SDK is ready to use and we will learn to configure a Ktor authentication plugin to work with Firebase Authentication.

Setup Firebase Authentication

With the Firebase Admin SDK initialized, it is time to create a Ktor Authentication Provider that can verify the JSON web token(JWT) from incoming requests are from an authenticated Firebase user.

Create a Principal

First create a simple data class called User which will have some basic properties to represent a Firebase user, note how this extends the Principal interface to indicate to Ktor this class represents an authenticated principal. Feel free to add more properties to this file that fit your needs of what represents a user in your application.

data class User(val userId: String = "", val displayName: String = "") : Principal

Create AuthenticationProvider

Now a Ktor AuthenticationProvider can be created which will verify the incoming request’s JWT and set the principal on the request to the current User if they are unauthenticated. I will have to credit Aleksei Tirman for the inspiration for this solution, although I did make a couple small tweaks to improve the error messaging and will try to break it down.

First create a FirebaseConfig class that extends AuthenticationProvider.Config which will provide a lambda to convert a Ktor Request and verified FirebaseToken to the User class.

class FirebaseConfig(name: String?) : AuthenticationProvider.Config(name) {
    internal var authHeader: (ApplicationCall) -> HttpAuthHeader? =
        { call -> call.request.parseAuthorizationHeaderOrNull() }

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

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

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

private const val FirebaseImplementationError =
    "Firebase  auth validate function is not specified, use firebase { validate { ... } } to fix this"

Now create the FirebaseAuthProvider class and extend the AuthenticationProvider. Here is where the bulk of the logic doing the verification with the Firebase Authentication will happen and set the User as the principal if the user request is authenticated.


class FirebaseAuthProvider(config: FirebaseConfig) : AuthenticationProvider(config) {
    val authHeader: (ApplicationCall) -> HttpAuthHeader? = config.authHeader
    private val authFunction = config.firebaseAuthenticationFunction

    override suspend fun onAuthenticate(context: AuthenticationContext) {
        val token = authHeader(context.call)

        if (token == null) {
            context.challenge(
                FirebaseJWTAuthKey,
                AuthenticationFailedCause.InvalidCredentials
            ) { challengeFunc, call ->
                challengeFunc.complete()
                call.respond(UnauthorizedResponse(HttpAuthHeader.bearerAuthChallenge(realm = FIREBASE_AUTH)))
            }
            return
        }

        try {
            val principal = verifyFirebaseIdToken(context.call, token, authFunction)

            if (principal != null) {
                context.principal(principal)
            }
        } catch (cause: Throwable) {
            val message = cause.message ?: cause.javaClass.simpleName
            context.error(FirebaseJWTAuthKey, AuthenticationFailedCause.Error(message))
        }
    }
}

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)
}

fun HttpAuthHeader.Companion.bearerAuthChallenge(realm: String): HttpAuthHeader =
    HttpAuthHeader.Parameterized("Bearer", mapOf(HttpAuthHeader.Parameters.Realm to realm))

const val FIREBASE_AUTH = "FIREBASE_AUTH"
const val FirebaseJWTAuthKey: String = "FirebaseAuth"

Finally create an extension function on AuthenticationConfig which will create an instance of the FirebaseAuthProvider and register it to the Ktor application.

fun AuthenticationConfig.firebase(
    name: String? = FIREBASE_AUTH,
    configure: FirebaseConfig.() -> Unit
) {
    val provider = FirebaseAuthProvider(FirebaseConfig(name).apply(configure))
    register(provider)
}

Install Authentication Plugin

The firebase() extension function can now be used when installing the Authentication plugin on the Ktor Application. The validate {} lambda is where any additional information of a user could be looked up that does not exist on a FirebaseToken object.

fun Application.configureFirebaseAuth() {
    install(Authentication) {
        firebase {
            validate {
                // TODO look up user profile from DB
                User(it.uid, it.name.orEmpty())
            }
        }
    }
}

Now call this extenstion function after the FirebaseAdmin.init() function to complete the integration.

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        FirebaseAdmin.init()
        configureFirebaseAuth()
    }.start(wait = true)
}

Create Authenticated Route

Now with Firebase Authentication configured in the Ktor project, authenticated routes can be made using the same FIREBASE_AUTH constant that was used to register the plugin. Simply wrap any Route with authenticate(FIREBASE_AUTH) { }. If the user’s request has an invalid/expired JWT in the original request, the route should respond with an unauthorized 401 http status.

fun Route.authenticatedRoute() {
    authenticate(FIREBASE_AUTH) {
        get("/authenticated") {
            val user: User =
                call.principal() ?: return@get call.respond(HttpStatusCode.Unauthorized)
            call.respond("User is authenticated: $user")
        }
    }
}

Testing

Manual Testing

To manually test the Firebase integration, you will need to get a valid JWT to send to the server in the authorization header. You may retrieve one the sign up or sign in Firebase restful API. The example curl request below will make the sign up request, replace insert-api-key with your Firebase web api key which can be found in the Firebase Console under Project Settings.

curl --location --request POST 'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=insert-api-key' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email" : "test@plusmobileapps.com",
    "password" : "Password123!",
    "returnSecureToken" : true
}'

This should return a JSON object and you will need the idToken property for authenticated requests later.

{
    "idToken": "extract this token value"
}

Now you can make the request to your server injecting the token from the last step as the bearer for authentication.

curl --location --request GET 'http://0.0.0.0:8080/authenticated' \
--header 'Authorization: Bearer insert-token-value'

"User is authenticated: User(userId=some-user-id, displayName=Andrew)"

Unit Testing Authenticated Routes

To write unit tests for an authenticated route, we will create a FirebaseAuthTestProvider which will allow a mocked User to be provided and set as the principal.

class FirebaseAuthTestProvider(config: FirebaseTestConfig) : AuthenticationProvider(config) {

    private val authFunction: () -> User? = config.mockAuthProvider

    override suspend fun onAuthenticate(context: AuthenticationContext) {
        val mockUser: User? = authFunction()
        if (mockUser != null) {
            context.principal(mockUser)
        } else {
            context.error(
                FirebaseJWTAuthKey,
                AuthenticationFailedCause.Error("User was mocked to be unauthenticated")
            )
        }
    }
}

class FirebaseTestConfig(name: String?) : AuthenticationProvider.Config(name) {

    var mockAuthProvider: () -> User? = { null }

}

Then create an extension function on ApplicationTestBuilder that will install the authentication plugin and register the FirebaseAuthTestProvider.

val defaultTestUser = User(userId = "some-user-id", displayName = "Darth Vader")

fun ApplicationTestBuilder.mockAuthentication(mockAuth: () -> User? = { defaultTestUser }) {
    install(Authentication) {
        val provider = FirebaseAuthTestProvider(FirebaseTestConfig(FIREBASE_AUTH).apply {
            mockAuthProvider = mockAuth
        })
        register(provider)
    }
}

Create a Ktor Test

To write a Ktor test for an authenticated route, make use of the newly created mockAuthentication { } function, install the authenticated route under test, and call it with the client.

class AuthenticatedRouteTest {
    @Test
    fun `authenticated route - is authenticated`() = testApplication {
        val user = User("some id", "Andrew")
        mockAuthentication { user }
        routing { authenticatedRoute() }

        client.get("/authenticated").apply {
            assertEquals(HttpStatusCode.OK, status)
            assertEquals("User is authenticated: $user", bodyAsText())
        }
    }
}

Also worth mentioning since the mockAuth function parameter defaults to returning the defaultTestUser, this authenticated test could also be rewritten like so:

@Test
fun `authenticated route - is authenticated`() = testApplication {
    mockAuthentication()
    routing { authenticatedRoute() }

    client.get("/authenticated").apply {
        assertEquals(HttpStatusCode.OK, status)
        assertEquals("User is authenticated: $defaultTestUser", bodyAsText())
    }
}

If you were so inclined to test an unauthorized user, simply return null in the mockAuthentication { } lambda.

@Test
fun `authenticated route - is unauthorized`() = testApplication {
    mockAuthentication { null }
    routing { authenticatedRoute() }

    client.get("/authenticated").apply {
        assertEquals(HttpStatusCode.Unauthorized, status)
    }
}

Conclusion

At this point, you should have a Ktor server configured with Firebase authentication and learned how to write Ktor tests with a Firebase test authentication provider. If you wish to see all the source code for this project please check out the link below. Happy coding!

Resources