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