KotlinでOAuth徹底入門のclientサーバーを実装する


OAuth徹底入門を読んでjsでclinetサーバーを実装したのでKotlinでも実装することでOAuthを利用する側の基礎を復習する。githubにて公開しているので基本的にはApplication.ktを参照してもらえれば問題ない。以下には実装で詰まった箇所、気づいた点をメモしていく。

前提

Kotlin/jvm
Kotiln 1.3.50
ktor 1.2.4
authorization server,protected resource serverはOAuth徹底入門のch-3-1ディレクトリにあるものを使用する。
nodeの都合からauthorization serverが正常に動作しないのでgithubのissueを参考にする。2019/11/03時点で修正されていない。

TOPページ

Application.kt
    get("/") {
      call.respondHtml {
        body {
          h1 { +"OAuth徹底入門 with Kotlin" }
          a(href = "http://localhost:9000/authorize") { +"AUTHORIZE" }
        }
      }
    }

認証する前のためのhtmlを用意する。

Authorization Codeを要求。

Application.kt
    get("/authorize") {
      call.respondRedirect(false) {
        host = "localhost"
        port = 9001
        path("authorize")

        parameters["response_type"] = "code"
        parameters["client_id"] = ConstValue.CLIENT_ID
        parameters["redirect_uri"] = ConstValue.REDIRECT_URI

        val state = getRandomString()
        DB.states[ConstValue.CLIENT_ID] = state
        parameters["state"] = state
      }
    }

ポイントは上のstateパラメータです。OAuth徹底入門の7章にて解説されていますがCSRF対策のためにqueryパラメータの一つとして付与する必要がある。
また生成されるstateについては2^160以上のランダム性を持ったものが推奨されている。

Application.kt
fun getRandomString(): String {
  return BigInteger(160, SecureRandom()).toString(32)
}

getRandomString関数は160桁の2進数で生成された数値を32進数の文字列に変換して生成している。

ユーザーはAuthorization Serverが提供する認可UIを使用して許可

Application.kt
    get("/callback") {
      val code = call.request.queryParameters["code"]
      val state = call.request.queryParameters["state"]

      if (code == null || state == null || state != DB.states[ConstValue.CLIENT_ID]) {
        throw Exception("invalid authorization code")
      }

authorizationサーバーが叩くcallbackAPIにて受け取ったstateが攻撃者によってすり替えられたものではないのか検証する。

tokenを要求

Application.kt
      val req = Request.Builder().apply {
        val utf8 = StandardCharsets.UTF_8.toString()
        val clientIdByteArray = URLEncoder.encode(ConstValue.CLIENT_ID, utf8)
        val secretByteArray = URLEncoder.encode(ConstValue.CLIENT_SECRET, utf8)
        addHeader("Content-Type", "application/x-www-form-urlencoded")
        val idAndPass = Base64.encodeBase64String("$clientIdByteArray:$secretByteArray".toByteArray())
        addHeader("Authorization", "Basic $idAndPass")
        val body = "grant_type=authorization_code&code=$code&redirect_uri${ConstValue.REDIRECT_URI}".toRequestBody()
        method("POST", body)
        url("http://localhost:9001/token")
      }.build()
      val res = OkHttpClient().newCall(req).execute()
      val responseBody = Gson().fromJson(res.body!!.string(), TokenResp::class.java)!!
      ConstValue.TOKEN = responseBody.access_token

Authorization Code GrantではAuthorizationヘッダーを利用して認証することが推奨されている。これはtokenを交換する際も同様。
認証方法にてBasicが使用されている点とbodyがapplication/jsonではない点が慣れないが本質には関係ないので意識しない。

tokenの取得に成功

OAuth2にて保護されたリソースを要求

Application.kt
      val req = Request.Builder().apply {
        addHeader("Content-Type", "application/x-www-form-urlencoded")
        addHeader("Authorization", "Bearer ${ConstValue.TOKEN}")
        url("http://localhost:9002/resource")
        method("POST", "".toRequestBody())
      }.build()
      val res = OkHttpClient().newCall(req).execute()

今回はトークンの種類としてBearerトークンが指定されているのでAuthorizationヘッダーにのvalueのprefixとして"Bearer "をつける。
Bearerトークンはbase64でエンコードされている必要がある。clientは何も確認せず使用することができる。

まとめ

OAuth2は認可フローから作成されたフレームワークである。OpenIDConnectと同時に使用されることが多いので認証、認可を行えるものとして勘違いされるが今回使用した「Authorization Code Grant」を行うために最適化されたもの。これ以外にも認証フローはいくつか用意されているものの何かしらの脆弱性を許容していることを意識する。
clientサイドの実装を行う予定しか今のところないのでclientをkotlinにて実装したがそれ以外のサーバーを扱う必要があるときは今回のように別言語で実装し直してみると得られることが多い。