KeycloakのJWTをVerifyする

2021/08/25に公開

はじめに

Keycloak のOpenID ConnectでJWTの署名検証(Verify)をしたメモです。

以下のことは書いていません。

1. 環境

  • macOS Big Sur 11.3.1

  • Docker Desktop 3.5.1

  • Keycloak 15.0.2

  • サンプルPG : Kotlin

    • Spring Boot でプロジェクト作ってます
    • JWT を取り扱うライブラリは java-jwt を使います

2. IDトークンをゲットする

こちらでやってる手順で Keycloak をセットアップして OpenID Connect で IDトークン をゲットします。このIDトークンがJWT形式となっています。

memo

  • KeycloakはJWT形式のIDトークンを生成するサーバーです。OpenID Connect仕様ではOPと表記します。
  • OpenID Connect を利用する側はJWT形式のIDトークンを受け取って検証するクライアントです。OpenID Connect仕様ではRPと表記します。
  • ここではJWTを生成する側をKeycloak、JWTを受け取って検証する側をクライアントと表記します。また、IDトークンのことをJWTと表記します。

3. JWT の 署名検証(Verify)

Keycloak の JWT 署名方法 は色々ありまして今回は RS256,HS256,ES256 で署名されたJWTの署名検証をやってみます。

どの方法でトークンに署名するかは以下画面から設定します。

Realm Settings -> Tokens -> Default Signature Algorithm

RS256(RSA SHA-256)

RSA256は 非対称鍵のRSAを使って JWTに署名 を行います。

  1. Keycloak内部でRSA256のキーペア(Private key,Public key)を生成する
  2. Keycloak は Public key を公開する
  3. Keycloak はJWTを生成するときには Private key を使って署名する
  4. クライアント は Keycloak が公開している Public key を入手する
  5. クライアントは Public key を使って JWT を検証する

JWTの署名はサーバー側で以下の方法で生成されています

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload) ,
  Privatekey  
)

クライアント側での JWT署名検証は以下の方法で行います

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload) ,
  Publickey  
)

RSA256 ID Token サンプル

https://jwt.io/ で デコードしたら中が見えます。

eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2elNERnpQYWZRQjVBMi1aeGhUdmYyVGZEOUlnbDJfdlZEd1VORE9nQXI4In0.eyJleHAiOjE2MzAxMDY0NjksImlhdCI6MTYzMDEwNjQwOSwiYXV0aF90aW1lIjoxNjMwMTA2NDA2LCJqdGkiOiI3MTkyNDJiZS0xMzFmLTRjOTktOWQ2ZS01MTY4YTc1NzdkNzUiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgxL2F1dGgvcmVhbG1zL21hc3RlciIsImF1ZCI6InRlc3QiLCJzdWIiOiIxNmQzMTIyZS0wYTUzLTQ4YmMtODI4Yy02ZmIyZTFiM2IyNzkiLCJ0eXAiOiJJRCIsImF6cCI6InRlc3QiLCJzZXNzaW9uX3N0YXRlIjoiZGI1ZmQ4ZDQtNjFmZC00NDZmLWE1YWUtMTRhMmQ2ZDA5ZGJlIiwiYXRfaGFzaCI6IlUzV1FyUm1ZaEdPVkFxeUVUVTdWOVEiLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJob2dlIiwiZW1haWwiOiJob2dlQGdlYm9nZWJvLmNvbSJ9.UZECbrfMOlDv6zw-poxk6aDq4eXtnJVpFv9RtWNnMbbTM59P1OROsXq4gC91wpbEanOKghohTa8O2hEe-UTkmyDufIEI9Jq1fEpWpuFXETGaj25HefT_BxRpeXZ-m4lhOP3zBfjO5Qac0R9LZYwCI_pLQfx1wgLL7nyUN_AZWYIE-6oDvWUZa7td10WjW7GJxxd8KukHhh3icxeS7cLRtYOhTNLiotOSYH1TkUMsvLeeN9pWnPXuBtk-Q4EvVNfEC0deB4Wsw1ZfaZcDaHKF3L_ijW8IX5LX_mom7LG4KQjQwr7g0aQy9r0yuoDHEMohFtxfUKLK3_FdjwdPkO00WQ

Public keyを入手する

クライアント側で検証するためには Public key が必要 です。以下のいずれかの方法で入手します。

a) Keycloakの管理画面からコピペする

JWT署名の公開鍵 は以下の場所から入手できます

Realm Settings -> Keys -> RS256 -> Public key

b) jwks_uri から入手する

Keycloakが公開している jwks_uri というエンドポイントを叩くと鍵がもらえます。

今回は Keycloak の管理画面から以下の Public key をゲットしました

MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp5thRGZf7ucvC1n//QayybwC3AK53CJLEHfm1aslJASUdcEW1Z0Ku2DOOvsslX7EI2cjUZNpjPm7iWzfL19Sfa1kJ2VrU7nl2XuGkCofBUBFkdKESTKmZJ7gae8SJJT5k6fvmuSRYlNCEYGlS698jX1wbNocXxiGbbFO9JvlxQKuo7u8eToP69pAZX0xaLmi/w4wk4380JBFToqN+c7jOCT1A5IJ8dPh0SN8yu5fgbzq4XNCWkeC5arafWD1v8Nb2gNbYBHnFEbFZWB1k38Py9GgH7Q5Kv1/L154qSMjdx9H9MVfo8hgEiWg6nbHjdl2XFtaFgkuTq53NFp9La+wbwIDAQAB

RS256 JWT の Verify

java-jwt の README.md のサンプルをみると Verify で Private key と Public key 両方使っており、あまり参考にならずでした。

以下は Public key でVerify する Kotlin のサンプル(抜粋)です。

  • issuer 、audience も 検証要素に入れています。
    • issuer、audienceに入れる値は環境によって異なります。署名検証というよりもIDトークンとしての正当性を検証するための情報です。
    • 今回の環境では issuer = http://localhost:8080/auth/realms/master, audienct = test と指定しています。
import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.algorithms.Algorithm.HMAC256
import com.auth0.jwt.exceptions.JWTCreationException
import com.auth0.jwt.exceptions.JWTDecodeException
import java.security.KeyFactory
import java.security.interfaces.RSAPublicKey
import java.security.spec.X509EncodedKeySpec
import java.util.Base64

class TokenService {
  
    fun verifyTokenRS256(
        publicKeyPEM: String,
        token: String,
        issuer: String,
        audience: String
    ): Boolean {
        try {
            val encoded = Base64.getDecoder().decode(publicKeyPEM.toByteArray())
            val keyFactory = KeyFactory.getInstance("RSA")
            val keySpec = X509EncodedKeySpec(encoded)
            val rsaPublicKey: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey
            val algorithm = Algorithm.RSA256(rsaPublicKey)

            val verifier: JWTVerifier = JWT.require(algorithm, null)
                .withIssuer(issuer)
                .withAudience(audience)
                .build()

            val decodedJwt = verifier.verify(token)

            // 検証OK!
            return true
        } catch (e: JWTCreationException) {
            println("JWTCreationException!")
        } catch (e: Exception) {
            println("Exception!")
        }
        return false
    }
}

HS256(HMAC SHA-256)

HS256は 共通鍵(Secret) で署名、検証を行います。

  1. 共通鍵(Secret)はKeycloak内部で生成する
  2. KeycloakはJWTを作成するときには Secret を使って署名する
  3. クライアントは Secret を入手する
  4. クライアントは Secret を使ってJWTを検証する

署名するときも検証するときもこの方法で行います

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload) ,
  secret  
)

HS256 ID Token サンプル

https://jwt.io/ で デコードしたら中が見えます。

eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI2Y2JmM2I5MC01Y2U2LTRkN2ItYWUyOC05ODdhYzE1MjkwNTkifQ.eyJleHAiOjE2MzAxMDc3MjgsImlhdCI6MTYzMDEwNzY2OCwiYXV0aF90aW1lIjoxNjMwMTA3NjY1LCJqdGkiOiJlMjNmMTllNS05YzIxLTQ5ZmUtODI0NS0yOWE4YjE5YmIyOGYiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgxL2F1dGgvcmVhbG1zL21hc3RlciIsImF1ZCI6InRlc3QiLCJzdWIiOiIxNmQzMTIyZS0wYTUzLTQ4YmMtODI4Yy02ZmIyZTFiM2IyNzkiLCJ0eXAiOiJJRCIsImF6cCI6InRlc3QiLCJzZXNzaW9uX3N0YXRlIjoiZmI5ODQ0ZTUtY2M0Ny00OGFlLWIzMjItODBjMGQyYjgxNzE5IiwiYXRfaGFzaCI6Inh1VUFRNjlTSUh1c1lXclRsakVyNHciLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJob2dlIiwiZW1haWwiOiJob2dlQGdlYm9nZWJvLmNvbSJ9.2XMtNBevCIBke8MmrrC_4z4lihdHFS5522bRQyWGtvs

Secretを入手する

共通鍵なんでRSAのように公開されておらず、KeycloakのDBを直接見る必要があります。

KeycloakのDBにどうやって接続するの?

私の環境のKeycloakはデフォルトのまま H2 Database です。ここに接続してSQLでSecretをゲットします。

H2 Database に接続する

org.h2.tools.Console という管理ツールで接続します。これを使うとブラウザからH2 Databaseに接続できるので大変便利です。

管理ツールはKeycloakが動いているDockerコンテナの中に入っています。

/opt/jboss/keycloak/modules/system/layers/base/com/h2database/h2/main

上記ディレクトリに h2-1.4.197.jar というファイルがありましてこの中に org.h2.tools.Console が入っています。バージョン等によって異なっているかもしれません。

管理ツールを起動します。

$ cd (ホームに移動)
$ cd keycloak/modules/system/layers/base/com/h2database/h2/main/
$ java -cp h2-1.4.197.jar org.h2.tools.Console -webAllowOthers

これで管理ツールが起動します。しますけど、Dockerコンテナ内の8082ポートで起動するんでホストPCのブラウザからは接続できません。

なので、Dockerでポートフォワードの設定をします。

docker-compose.yml

version: '3'
services:
  keycloak:
    image: jboss/keycloak
    container_name: test-oidc-keycloak
    ports:
      - 18081:8080
      - 18082:8082  # ← ホストPCの18082をDockerの8082にポートフォワードする
    environment:
      - KEYCLOAK_USER=admin
      - KEYCLOAK_PASSWORD=password

これでホストPCのブラウザから http://127.0.0.1:18082/ で管理ツールに接続できます。

さて、ここで Connect すればいいだけなんですが、JDBC URL にDBの接続文字列を入れないといけません。(デフォルトの値のままではダメでした)

JDBC URL は Keycloak が入っているDockerコンテナの中の以下のファイルで確認します。

/opt/jboss/keycloak/standalone/configuration/standalone.xml

このファイルになんやかんや書いてある中で以下の connection-url の値が接続文字列です。

<datasource jndi-name="java:jboss/datasources/KeycloakDS" pool-name="KeycloakDS" enabled="true" use-java-context="true" statistics-enabled="${wildfly.datasources.statistics-enabled:${wildfly.statistics-enabled:false}}">
    <connection-url>jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE</connection-url>
    <driver>h2</driver>
    <security>
        <user-name>sa</user-name>
        <password>sa</password>
    </security>
</datasource>

この値が 管理ツールの JDBC URL となります。

ただし、管理ツールに指定する際 ${jboss.server.data.dir} は自身の環境の値に書き換えて接続します。

JDBC URL: jdbc:h2:/opt/jboss/keycloak/standalone/data/keycloak;AUTO_SERVER=TRUE

User Name: sa

Password:sa

Secret を入手する

これでやっとKeycloakのDBに接続できました。管理ツールから以下のSQLをクエリします。

SELECT value FROM component_config CC INNER JOIN component C ON(CC.component_id = C.id) WHERE provider_id = 'hmac-generated' AND CC.name = 'secret';

この部分は公式な資料が見つからずでした。

めでたく Secret がゲットできます。

3ZpOu8Hy_HMXyAjtlxlL86dIqVy92h7ZOsXvRine3LQTkgN-1i6skNsCAP8a8f0I5lK8Fp0GSDvv-zrRcU21QQ

HS256 JWT の Verify

Secretがわかればもう簡単です。VerifyするメソッドをKotlinで書きました。

引数 secret には 上記SQLで取得した値をそのまま指定します。

import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.algorithms.Algorithm.HMAC256
import com.auth0.jwt.exceptions.JWTCreationException
import com.auth0.jwt.exceptions.JWTDecodeException
import java.security.KeyFactory
import java.security.spec.X509EncodedKeySpec
import java.util.Base64

class TokenService {
    fun verifyTokenHS256(
        secret: String,
        token: String,
        issuer: String,
        audience: String
    ): Boolean {
        try {
            val encoded = Base64.getUrlDecoder().decode(secret.toByteArray())
            val algorithm = HMAC256(encoded)
            val verifier: JWTVerifier = JWT.require(algorithm)
                .withIssuer(issuer)
                .withAudience(audience)
                .build()

            val decodedJwt = verifier.verify(token)

            return true
        } catch (e: JWTCreationException) {
            println("Invalid Token!")
        } catch (e: Exception) {
            println("exception !")
        }
        return false
    }  
}

ES256(ECDSA SHA-256)

RSAとは異なる 楕円曲線暗号 という方式で生成する非対称鍵です。RSAと比較して高速処理でありながら同レベルの安全性があるというものです。

JWT署名はサーバー側で以下の方法で生成されています

ECDSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload) ,
  Privatekey  
)

クライアント側での JWT署名検証は以下の方法で行います

ECDSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload) ,
  Publickey  
)

ES256 ID Token サンプル

https://jwt.io/ で デコードしたら中が見えます。

eyJhbGciOiJFUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDUHk4MmFTdzA0elJXalpWRGItUFQ5SkN3NUNDZDhOMlJzaG5MUmNZTHZrIn0.eyJleHAiOjE2MzAxMDkwMjYsImlhdCI6MTYzMDEwODk2NiwiYXV0aF90aW1lIjoxNjMwMTA4OTYyLCJqdGkiOiJjZjNhZTBkNS03NWM0LTQ4YzAtYjUyMy1jNDI3ZWI0OTAyMDIiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjE4MDgxL2F1dGgvcmVhbG1zL21hc3RlciIsImF1ZCI6InRlc3QiLCJzdWIiOiIxNmQzMTIyZS0wYTUzLTQ4YmMtODI4Yy02ZmIyZTFiM2IyNzkiLCJ0eXAiOiJJRCIsImF6cCI6InRlc3QiLCJzZXNzaW9uX3N0YXRlIjoiMjNkODY0MWItOTk5Zi00ODFhLWI5MGYtY2VjOTE3MWFkNGM4IiwiYXRfaGFzaCI6IkN3dFFwc0MxMHpxalE3ckc3dGhwaVEiLCJhY3IiOiIxIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJob2dlIiwiZW1haWwiOiJob2dlQGdlYm9nZWJvLmNvbSJ9.ZQa9ba8h3g5cd1est7QMt9dXS-alszLOB1HKA_ZTn3Mqq65D9xmzQ4IqTEOqFHpo90w9IqD1z4mCFK7YH5OImg

Public keyを入手する

RSAと同じくKeycloakの管理画面から入手できます。

Realm Settings -> Keys -> ES256 -> Public key

ES256 JWT の Verify

Verify についても RSA とほぼ同じです。publicKeyPEM は Keycloak の設定画面からコピペします。

以下は Kotlin のサンプル(抜粋)です。

import com.auth0.jwt.JWT
import com.auth0.jwt.JWTVerifier
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.algorithms.Algorithm.HMAC256
import com.auth0.jwt.exceptions.JWTCreationException
import com.auth0.jwt.exceptions.JWTDecodeException
import java.security.KeyFactory
import java.security.interfaces.ECPublicKey
import java.security.spec.X509EncodedKeySpec
import java.util.Base64

class TokenService {
    fun verifyTokenES256(
        publicKeyPEM: String,
        token: String,
        issuer: String,
        audience: String
    ): Boolean {
        try {
            val publicKeyDER = Base64.getDecoder().decode(publicKeyPEM.toByteArray())
            val keySpec = X509EncodedKeySpec(publicKeyDER)

            val keyFactory = KeyFactory.getInstance("EC")
            val ecPublicKey = keyFactory.generatePublic(keySpec) as ECPublicKey
            val algorithm = Algorithm.ECDSA256(ecPublicKey,null)

            val verifier: JWTVerifier = JWT.require(algorithm)
                .withIssuer(issuer)
                .withAudience(audience)
                .build()

            val decodedJWT = verifier.verify(token)

            return true
        } catch (e: JWTCreationException) {
            println("JWTCreationException!")
        } catch (e: Exception) {
            println("Exception!")
        }
        return false
    }
}

まとめ

Keycloak で OpenID Connect の時に生成される JWT のVerify方法を調査しました。

  • JWT の Verify には 鍵が必要
  • RS256 , ES256 は 非対称鍵(Public Key)
    • Keycloak の管理画面 または jwks_uri から取得する
  • HS256 は 共通鍵(Secret)
    • Keycloak の DB から取得する

内容に誤り等あればご指摘いただければと思います。

おつかれさまでした

Keycloak で OpenID Connect を利用するための情報はWebにたくさんありますが、JWTの検証についての情報は意外と少なかったように思います。

Discussion