OpenID Connect のIDトークンを PyJWT で検証する

目次

はじめに

昨日、PyJWT で JWT (JSON Web Token) の生成や検証を行う方法を書きました。

PyJWT で JWT の生成と検証を行う

今回はさらに発展させ、OpenID Connect の認証フローで得られるIDトークンを PyJWT で検証してみます。

今回用いた環境とトークン

本記事では OpenID Provider として OSS の Keycloak を利用しました。

また、認証フローについては本記事の対象外であり、今回は Implicit Flow により次のペイロードのIDトークンを得ました。 (IDトークンのペイロード部分を Base64 デコードして見やすくしました。)

 1{
 2    "exp": 1644112575,
 3    "iat": 1644111675,
 4    "auth_time": 1644111675,
 5    "jti": "94a9cb24-910c-4322-9368-de1bdd3f9749",
 6    "iss": "http://localhost:8080/auth/realms/example-realm",
 7    "aud": "example-client",
 8    "sub": "4eca8d6d-956c-4589-9e29-dea54bd2db37",
 9    "typ": "ID",
10    "azp": "example-client",
11    "nonce": "n-0S6_WzA2Mj",
12    "session_state": "beaf5c1a-5433-4bff-9af6-4bd64cc05661",
13    "at_hash": "o7mfWVDAgHCEhZwYlVAo5w",
14    "acr": "1",
15    "s_hash": "bOhtX8F73IMjSPeVAqxyTQ",
16    "sid": "beaf5c1a-5433-4bff-9af6-4bd64cc05661",
17    "email_verified": true,
18    "name": "firstname1 lastname1",
19    "preferred_username": "user1",
20    "given_name": "firstname1",
21    "family_name": "lastname1",
22    "email": "user1@example.com"
23}

IDトークンに含まれるクレームの意味については、OpenID Connect Core 仕様の Section 2.や5.辺り、および RFC 7519 に記載されています。
https://openid.net/specs/openid-connect-core-1_0.html
https://datatracker.ietf.org/doc/html/rfc7519

IDトークンの検証

IDトークンをどのように検証するかは OpenID Connect Core 仕様の 3.1.3.7. に記載されています。

これらの検証は、PyJWT を用いて次のようなプログラムで行えます。

 1import jwt
 2from typing import Any
 3
 4public_key_body = "Keycloakの公開鍵"
 5public_key = "-----BEGIN PUBLIC KEY-----\n" \
 6                + public_key_body \
 7                + "\n-----END PUBLIC KEY-----"
 8
 9token = "IDトークン"
10
11payload: dict[str, Any] = jwt.decode(  # type: ignore
12    jwt=token,
13    key=public_key,
14    algorithms=["RS256"],
15    audience="example-client",
16    issuer="http://localhost:8080/auth/realms/example-realm"
17)
18
19print(payload)

Keycloak の署名の公開鍵は、管理画面の Realm 設定画面 (※下の画像の赤丸の部分) または JWK (JSON Web Key) のエンドポイントから取得できます。 取得できる公開鍵の文字列にはヘッダやフッタが付いていないため、プログラム上で付けています。

IDトークンの検証は PyJWT の jwt モジュールの decode 関数で行い、公開鍵による署名の検証が行われることについては前回の記事の通りです。
https://pyjwt.readthedocs.io/en/stable/api.html#jwt.decode

ペイロード部分の検証は PyJWT の jwt/api_jwt.py#L122-L215 辺りで実装されています。

jwt.decode 関数の引数 issuer に期待する Issuer の値を指定すると、ペイロードの iss と一致するかどうかチェックされます。 引数 issuer を指定しなければチェックは行われません。

また、引数 audience に期待するクライアントIDの値を指定すると、ペイロードの aud と一致するかどうかチェックされます。 OpenID Connect では aud の検証は必須であり、引数 audience を指定せずペイロードに aud が含まれていると InvalidAudienceError が発生します。 ただし、クライアントIDの検証を行わない場合は、次のように options を指定すれば検証を無効化できます。

 1options = {
 2    "verify_aud": False
 3}
 4
 5payload: dict[str, Any] = jwt.decode(  # type: ignore
 6    jwt=token,
 7    key=public_key,
 8    algorithms=["RS256"],
 9    options=options,
10    issuer="http://localhost:8080/auth/realms/example-realm"
11)

IDトークンが有効期限内かどうかのチェックについては、ペイロードの exp の値が現在時刻よりも前であった場合に ExpiredSignatureError が発生します。 OpenID Connect の仕様にはありませんが、ペイロードに nbf (Not Before) が含まれている場合、その値が現在時刻よりも後であった場合には ImmatureSignatureError が発生します。

jwt.decode 関数でエラーが発生することなく戻り値のペイロードを取得できたら、ペイロードに含まれるユーザ情報などのクレームを信用して利用できます。