KtorでFirebase Authentication

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

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

## 依存関係

* ktor-auth
* firebase-admin

## コード

```kotlin
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する想定のサンプル｡

```kotlin
/**
 * 認証済みの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)
    }
}
```

あとは

```kotlin
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