Help us understand the problem. What is going on with this article?

KtorでFirebase Authentication

More than 1 year has passed since last update.

Ktor-auth + Firebase Admin SDK による FirebaseToken認証処理を行うパイプラインのインストールをするコードを雑に書いたのでメモです。

ktor-jwt等と同じような使い方できるように意識しました。

依存関係

  • ktor-auth
  • firebase-admin

コード

import com.google.firebase.auth.*
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.auth.*
import io.ktor.request.*
import io.ktor.response.*

private const val FirebaseAuthKey = "FirebaseAuth"
private const val scheme = "Bearer"


/**
 * FirebaseTokenについての認証処理をインストールする
 *
 * @param name [FirebaseAuthenticationProvider]の名前
 */
fun Authentication.Configuration.firebase(name: String = "", configure: FirebaseAuthenticationProvider.() -> Unit) {
    val provider = FirebaseAuthenticationProviderImpl(name).apply(configure)
    provider.pipeline.intercept(AuthenticationPipeline.RequestAuthentication) { context ->
        val authHeader = call.request.parseAuthorizationHeaderOrNull()
        if (authHeader == null) {
            context.bearerChallenge(AuthenticationFailedCause.NoCredentials)
            return@intercept
        }

        val principal = authHeader.getBlob()?.let { provider.verifyIdToken(call, it) }

        try {
            if (principal != null) context.principal(principal)
            else context.bearerChallenge(AuthenticationFailedCause.InvalidCredentials)
        } catch (t: Throwable) {
            context.error(FirebaseAuthKey, AuthenticationFailedCause.Error(t.message ?: t.javaClass.simpleName))
        }
    }
    register(provider)
}

/**
 * ApplicationRequestからHttpRequestHeaderを取り出す
 */
private fun ApplicationRequest.parseAuthorizationHeaderOrNull() = try {
    parseAuthorizationHeader()
} catch (e: IllegalArgumentException) {
    null
}

/**
 * 認証失敗時のレスポンスを行う
 */
private fun AuthenticationContext.bearerChallenge(
    failedCause: AuthenticationFailedCause
) = challenge(FirebaseAuthKey, failedCause) {
    call.respond(UnauthorizedResponse(HttpAuthHeader.bearerAuthChallenge()))
    it.complete()
}

/**
 * 認証失敗時のレスポンスのヘッダーを組み立てる
 */
private fun HttpAuthHeader.Companion.bearerAuthChallenge() =
    HttpAuthHeader.Parameterized(scheme, mapOf())

/**
 * HttpAuthHeaderから生のトークン文字列を取り出す
 */
private fun HttpAuthHeader.getBlob() = when {
    this is HttpAuthHeader.Single && authScheme.toLowerCase() == scheme.toLowerCase() -> blob
    else -> null
}

/**
 * Firebaseのトークン認証用プロバイダ
 */
interface FirebaseAuthenticationProvider {
    /**
     * Token検証用のFirebaseAuth
     */
    var firebaseAuth: FirebaseAuth

    /**
     * 検証済みのFirebaseTokenを受け取って、それについて検証を行う
     *
     * @param validate 検証に成功すればPrincipalを返す。
     */
    fun validate(validate: suspend ApplicationCall.(FirebaseToken) -> Principal?)

    /**
     * 特定の条件に一致するApplicationCallに認証が必要かどうかを指定する認証フィルタ
     *
     * 指定しない場合は、すべてのApplicationCallに認証が必要になる
     *
     * 指定したいずれかのフィルタがtrueを返す場合に認証がスキップされる
     *
     * @param predicate 認証をスキップする場合の条件
     */
    fun skipWhen(predicate: (ApplicationCall) -> Boolean)
}

/*
AuthenticationProvider#skipWhenが存在するのでFirebaseAuthenticationProvider#skipWhenを呼び出すとそっちを呼びに行く
 */
private class FirebaseAuthenticationProviderImpl(
    name: String
) : AuthenticationProvider(name), FirebaseAuthenticationProvider {
    private var authenticationFunc: suspend ApplicationCall.(FirebaseToken) -> Principal? = { null }

    private var mFirebaseAuth: FirebaseAuth? = null
    override var firebaseAuth: FirebaseAuth
        get() = mFirebaseAuth ?: FirebaseAuth.getInstance()
        set(value) {
            mFirebaseAuth = value
        }

    suspend fun verifyIdToken(call: ApplicationCall, tokenStr: String): Principal? = try {
        val firebaseToken = firebaseAuth.verifyIdToken(tokenStr)
        call.authenticationFunc(firebaseToken)
    } catch (e: FirebaseAuthException) {
        null
    } catch (e: IllegalArgumentException) {
        null
    }

    override fun validate(validate: suspend ApplicationCall.(FirebaseToken) -> Principal?) {
        authenticationFunc = validate
    }
}

使用例

KodeinでいろいろとDIする想定のサンプル。

/**
 * 認証済みのFirebaseTokenを表すクラス
 *
 * @property value 認証済みのFirebaseToken
 */
data class PrincipalToken(val value: FirebaseToken) : Principal

/**
 * ApplicationCallから[PrincipalToken]を取り出す
 *
 * @throws IllegalArgumentException [PrincipalToken]が取り出せなかった場合
 */
fun ApplicationCall.principalToken(): PrincipalToken = requireNotNull(principal())


fun Application.installAuthentication(kodein: Kodein) = install(Authentication) {
    val auth: FirebaseAuth by kodein.instance()
    firebase("firebase") {
        firebaseAuth = auth
        //メールアドレスが確認済みでない場合は失敗にする
        validate { it.takeIf(FirebaseToken::isEmailVerified)?.let(::PrincipalToken) }
    }
}

fun Route.firebaseAuthenticateRoute(kodein: Kodein) = authenticate("firebase") {
    route("/test") {
        get {
            call.respond("hogehoge")
        }
    }
}

fun Application.main() {
    val kodein = createKodein(environment.config)
    installAuthentication(kodein)
    route("/api/v1/") {
        firebaseAuthenticateRoute(kodein)
    }
}

あとは

fun main() {
    embeddedServer(Netty, port = 8080) {
        main()
    }.start()
}

みたいにしてやれば https://localhost:8080/api/v1/test にたいして Authorization: Bearer <FirebaseToken> なヘッダーをつけてGETリクエストを送ればよしなに認証処理を挟んでくれます。

なにかおかしな点などがあれば指摘して頂けれると嬉しいです。

参考

https://ktor.io/servers/features/authentication.html
https://ktor.io/servers/lifecycle.html
https://firebase.google.com/docs/admin/setup?hl=ja

naito-y
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away