🔥

Ktor2.3.6 + Firebase AuthenticationをsecretKeyのJSONをDIで受け取るように作る

2024/02/23に公開

前提

Koinを使ったDIで、secretKeyのJSONのを受け取るようにしました。

  • ktor = 2.3.6
  • firebase-admin = 9.2.0
  • koin = 3.5.0

ちなみに、ktor-koin3.5.3または3.5.2-RC1だと、auto-reload後に応答しなくなるようです。

以下記事を参考にしました。

How to use Firebase Authentication with Ktor 2.0 - Plus Mobile Apps

Install

build.gradle.ktsのdependenciesに以下追加する

// ktor
implementation("io.ktor:ktor-server-auth:2.3.6")
implementation("io.ktor:ktor-server-auth-jwt:2.3.6")

// firebase
implementation("com.google.firebase:firebase-admin:9.2.0")

// koin
implementation("io.insert-koin:koin-core:3.5.0")
implementation("io.insert-koin:koin-ktor:3.5.0")
implementation("io.insert-koin:koin-logger-slf4j:3.5.0")
testImplementation("io.insert-koin:koin-test:3.5.0") {
    // https://github.com/InsertKoinIO/koin/issues/1526
    exclude("org.jetbrains.kotlin", "kotlin-test-junit")
}

Setting Firebase

※slf4jでログ出力のコードが入っていますが、適宜、ログの出力を変更してください。

KtorのAuthenticationプラグインのinstall時に渡すための、設定を書いていきます。

// FirebaseConfig.kt
import com.google.auth.oauth2.GoogleCredentials
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.auth.FirebaseToken
import io.ktor.http.auth.HttpAuthHeader
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.AuthenticationConfig
import io.ktor.server.auth.AuthenticationFunction
import io.ktor.server.auth.AuthenticationProvider
import io.ktor.server.auth.Principal
import io.ktor.server.auth.parseAuthorizationHeader

data class FirebaseUser(val userId: String, val displayName: String) : Principal

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

class FirebaseConfig(name: String) : AuthenticationProvider.Config(name) {
    private val log = org.slf4j.LoggerFactory.getLogger(this::class.java)
    internal var authHeader: (ApplicationCall) -> HttpAuthHeader? = { call ->
        try {
            call.request.parseAuthorizationHeader()
        } catch (ex: IllegalArgumentException) {
            log.warn("failed to parse authorization header", ex)
            null
        }
    }

    var firebaseAuthenticationFunction: AuthenticationFunction<FirebaseToken> = {
        throw NotImplementedError(
            "Firebase  auth validate function is not specified, use firebase { validate { ... } } to fix this"
        )
    }

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

object FirebaseAdmin {
    fun init(secretKeyJson: String): FirebaseApp {
        val option = FirebaseOptions.builder()
            .setCredentials(GoogleCredentials.fromStream(secretKeyJson.byteInputStream()))
            .build()
        // Development modeによるAuto-reloadの際に、前回起動時のFirebaseAppが残っていると正常動作しないため、消す
        FirebaseApp.getApps().forEach {
            it.delete()
        }
        return FirebaseApp.initializeApp(option)
    }
}

AuthenticationConfig.firebaseから呼ばれる、Firebaseの認証のメインとなるロジックについて書いていきます。

// FirebaseAuthProvider.kt
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.FirebaseToken
import io.ktor.http.HttpStatusCode
import io.ktor.http.auth.HttpAuthHeader
import io.ktor.server.application.ApplicationCall
import io.ktor.server.auth.AuthenticationContext
import io.ktor.server.auth.AuthenticationFailedCause
import io.ktor.server.auth.AuthenticationProvider
import io.ktor.server.auth.Principal
import io.ktor.server.auth.UnauthorizedResponse
import io.ktor.server.response.respond
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.slf4j.Logger

const val FIREBASE_AUTH = "FIREBASE_AUTH"

class FirebaseAuthProvider(config: FirebaseConfig) : AuthenticationProvider(config) {
    private val log = org.slf4j.LoggerFactory.getLogger(this::class.java)
    val authHeader: (ApplicationCall) -> HttpAuthHeader? = config.authHeader
    private val authFunction = config.firebaseAuthenticationFunction

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

        if (token == null) {
            log.warn("authHeader is null")
            context.call.respond(UnauthorizedResponse(HttpAuthHeader.bearerAuthChallenge(realm = FIREBASE_AUTH)))
            return
        }

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

            if (principal != null) {
                context.principal(principal)
            } else {
                context.call.respond(HttpStatusCode.Unauthorized)
            }
        } catch (cause: Throwable) {
            val message = cause.message ?: cause.javaClass.simpleName
            log.warn(message, cause)
            context.error(FIREBASE_JWTAUTH_KEY, AuthenticationFailedCause.Error(message))
        }
    }
}

private suspend fun verifyFirebaseIdToken(
    call: ApplicationCall,
    authHeader: HttpAuthHeader,
    tokenData: suspend ApplicationCall.(FirebaseToken) -> Principal?,
    log: Logger
): Principal? {
    val token: FirebaseToken? = try {
        if (authHeader.authScheme == "Bearer" && authHeader is HttpAuthHeader.Single) {
            withContext(Dispatchers.IO) {
                FirebaseAuth.getInstance().verifyIdToken(authHeader.blob)
            }
        } else {
            log.warn("invalid auth header: $authHeader")
            null
        }
    } catch (ex: Exception) {
        log.warn("cannot verify firebase token", ex)
        null
    }
    return token?.let { tokenData(call, token) }
}

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

private const val FIREBASE_JWTAUTH_KEY: String = "FirebaseAuth"

工夫したところ

元の記事の以下のように、embeddedServerのblockに渡して、設定をする場合、ktor-koinによるinjectが使えなかったため、以下のようなembeddedServerのblockで処理しないようにしました。

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

FirebaseApp.initializeAppが最初に実行される必要がある且つdeleteせずに複数回実行されるとまずいようで、ktorのauto-reloadでdeleteせずに複数回実行されると、ktorのサーバーが応答しなくなるようでした。
なので、auto-reloadが発生した場合に、前回初期化したものを消すためにFirebaseAppをdeleteした後に、FirebaseApp.initializeAppで初期化するようにしました。

使い方

Ktorのmoduleで以下を呼び出す。

fun Application.configureAuth() {
    val config by inject<AuthConfig>() // ktor--koinによるinject

    install(Authentication) {
        firebase(name = FIREBASE_AUTH, secretKeyJson = config.firebase.secretKeyJson) {
            validate {
                FirebaseUser(it.uid, it.name.orEmpty())
            }
        }
    }
}

Firebaseの認証情報を取得するには、firebaseUser関数を作って呼び出す。

import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.auth.principal
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.util.pipeline.PipelineContext

fun Route.route() {
    get("/hello") {
        val user = firebaseUser()
        call.respondText("Hello World!$user")
    }
}

fun PipelineContext<Unit, ApplicationCall>.firebaseUser(): FirebaseUser = checkNotNull(call.principal())

GitHubで編集を提案

Discussion