KeycloakのJWTをVerifyする
はじめに
Keycloak のOpenID ConnectでJWTの署名検証(Verify)をしたメモです。
以下のことは書いていません。
- JSON Web Token (JWT) とは何か、については以下を見てください
- OpenID Connectとは何か、トークンの役割、意味については以下を見てください
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に署名 を行います。
- Keycloak内部でRSA256のキーペア(Private key,Public key)を生成する
- Keycloak は Public key を公開する
- Keycloak はJWTを生成するときには Private key を使って署名する
- クライアント は Keycloak が公開している Public key を入手する
- クライアントは 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) で署名、検証を行います。
- 共通鍵(Secret)はKeycloak内部で生成する
- KeycloakはJWTを作成するときには Secret を使って署名する
- クライアントは Secret を入手する
- クライアントは 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