📑

KtorでFirebaseAuthを実装する

2022/12/01に公開

タイトルのままですが、Ktorというフレームワークで認証バックエンドにFirebaseを利用できるようにするお話です。  
Ktor、ある程度の認証機構は揃ってるからそのままでも使いやすいよね。
でもFirebase Authはデフォルトでないんだよね
説明角のめんどくさいからコード内のコメントでも読んでくれ

環境

この記事を執筆したタイミングでの環境はこんな感じ

build.gradle.kts

implementation("com.google.firebase:firebase-admin:9.0.0")


var ktor_version = "2.1.0"
implementation("io.ktor:ktor-server-core:$ktor_version")
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("io.ktor:ktor-server-resources:$ktor_version")
implementation("io.ktor:ktor-server-auth:$ktor_version")

実装

認証機構本体の実装

FirebaseAuthProvider.kt

class FirebaseAuthProvider(configuration: Configuration) {
  companion object Plugin : BaseApplicationPlugin<ApplicationCallPipeline, Configuration, FirebaseAuthProvider> {
    override val key = AttributeKey<FirebaseAuthProvider>("FirebaseAuthProvider")

    // デコードされたトークンを取りだすためのキー
    internal val DecodedTokenKey: AttributeKey<FirebaseToken> = AttributeKey("FirebaseAuthenticationDecodedTokenKey")

    /**
      * モジュールのインストールをする
      */
    override fun install(pipeline: ApplicationCallPipeline, configure: Configuration.() -> Unit): FirebaseAuthProvider {
      val plugin = FirebaseAuthProvider(Configuration().apply(configure))

      // 適切なフェーズがわからんのでとりあえずモニタリングにしてるよ
      pipeline.intercept(ApplicationCallPipeline.Monitoring) {
        plugin.intercept(this)
      }
      return plugin
    }
  }

  private val auth = FirebaseAuth.getInstance(configuration.firebaseApp ?: FirebaseApp.getInstance())
  private val authorizationType = configuration.authorizationType
  private val checkRevoked = configuration.checkRevoked
  private val skipWhen = configuration.skipWhen
  private val whenReject = configuration.whenReject

  class Configuration {
    // Firebaseインスタンス
    var firebaseApp: FirebaseApp? = null

    // 認証ヘッダ
    var authorizationType = AuthorizationType.Jwt

    // JWTに対してユーザが有効かどうかまで確認する?
    // false: 確認しない
    // true: 確認する
    var checkRevoked = false

    // 条件に当てはまるときに認証をスキップさせる
    var skipWhen: (suspend (ApplicationCall) -> Boolean)? = null

    // 認証処理中に例外が発生したらそのハンドルをさせる
    var whenReject: (suspend (ApplicationCall, Throwable) -> Unit)? = null
  }

  sealed class AuthorizationType {
    abstract val getIdToken: (ApplicationCall) -> String?

    object Bearer : AuthorizationType() {
      override val getIdToken: (ApplicationCall) -> String? = {
        it.request.headers.get(HttpHeaders.Authorization)?.trim()?.split(" ")?.let { authorization ->
          val (phrase, token) = authorization.let { it.getOrNull(0) to it.getOrNull(1) }
          phrase?.lowercase()?.equals("bearer")?.takeIf { it }?.let { token?.trim() }
        }
      }
    }

    object Jwt : AuthorizationType() {
      override val getIdToken: (ApplicationCall) -> String? = {
        it.request.headers.get(HttpHeaders.Authorization)?.trim()?.split(" ")?.let { authorization ->
          val (phrase, token) = authorization.let { it.getOrNull(0) to it.getOrNull(1) }
          phrase?.lowercase()?.equals("jwt")?.takeIf { it }?.let { token?.trim() }
        }
      }
    }
  }

  private suspend fun intercept(context: PipelineContext<Unit, ApplicationCall>) {
    with(context.call) {
      if (skipWhen?.invoke(this) == true) {
        return@with
      }

      kotlin.runCatching {
        val idToken = authorizationType.getIdToken(this)
        if (idToken.isNullOrBlank()) {
            throw TokenNotProvidedException()
        }
        val decodedToken = auth.verifyIdToken(idToken, checkRevoked)
        attributes.put(DecodedTokenKey, decodedToken)
      }.onFailure {
        whenReject?.invoke(this, it)
      }
    }
  }

  internal class InvalidTokenException : Exception()
  internal class TokenNotProvidedException : Exception()
}

fun ApplicationCall.getDecodedToken(): FirebaseToken? = attributes.getOrNull(DecodedTokenKey).also { Result }

アプリへの組み込み

KtorApplication.kt
install(FirebaseAuthProvider) {
  firebaseApp = FirebaseApp.getInstance()
  authorizationType = FirebaseAuthProvider.AuthorizationType.Jwt
  checkRevoked = true
  skipWhen = { call ->
    // 例えば mock=1 がクエリパラメータに入っていたら認証をスキップしたり.....
    call.request.queryParameters.get("mock")?.toIntOrNull()?.equals(1)
  }

  whenReject = { call, cause ->
    when (cause) {
      // 例外ごとに対処したり、call.responseしたり.....
      is FirebaseAuthProvider.TokenNotProvidedException -> {
        // .....
      }
      is FirebaseAuthException ->{
        //
      }
      is IllegalArgumentException ->{
        // ......
      }
      else ->{
        // ......
      }
    }
  }
}

検証結果の取り出し

routing{
  get("/"){
    val firebaseUser = call.getDecodedToken()
    
    // do something.....
  }
}

クライアントからの呼び出し

今回はJWTキーを指定したとして、そうするとこんな感じで認証ヘッダを詰める

curl --header "authorization: JWT xxxxxxxxxxxx"

Discussion