Ktor2.3.6 + Firebase AuthenticationをsecretKeyのJSONをDIで受け取るように作る
前提
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())
Discussion