以下の続き kamatimaru.hatenablog.com
公開鍵をRPに提供するエンドポイント
公開鍵をRPに提供するエンドポイントを実装する。「JWKsエンドポイント」というらしい。
RFC7517に仕様が存在するとのこと。
ディスカバリへの登録
jwks_uriをディスカバリに登録する。URLは/jwks/と/certs/をよくみる印象だが、今回は/jwks/にしておく。
views.py
class DiscoveryView(View): def get(self, request): return JsonResponse( { # ...省略 "jwks_uri": "http://localhost:8000/sample/jwks/", } )
Viewの雛形の実装
まずはViewの雛形を実装する。
views.py
class JwksView(View): def get(self, request): return JsonResponse({})
urls.py
urlpatterns = [
# ...省略
path("jwks/", views.JwksView.as_view(), name="jwks"),
]
Viewの内部の実装
Viewの内部を実装していく。JWKsエンドポイントが返すのは以下のリストである。
| フィールド名 | 説明 |
|---|---|
| kid | 鍵のID |
| alg | アルゴリズム。今回はRS256という文字列を返す。 |
| kty | 鍵の種類。今回はRSAという文字列を返す。 |
| use | 鍵の用途。今回は署名の検証なのでsigという文字列を返す。 |
| e | 公開鍵の指数部分 |
| n | 公開鍵の法部分 |
この内、よく分からないのはeとnなので、まずはそれ以外を実装する。
views.py
class JwksView(View): def get(self, request): key_pairs = JwtKeyPair.objects.all() jwks = [] for key_pair in key_pairs: jwks_record = { "kty": "RSA", "alg": key_pair.algorithm, "use": "sig", "kid": str(key_pair.id), "n": "todo", "e": "todo", } jwks.append(jwks_record) return JsonResponse({"keys": jwks})
eとnは、本質はssh-keygenで生成した公開鍵だが表現形式が違うものらしい。
たしかに『暗号技術入門 第3版 秘密の国のアリス』には以下のような説明がある。
ところで、暗号化の式に登場する2つの数、EとNとは何でしょうか?RSAの暗号化は、平文をE乗してmod N をとることですから、EとNという一組の数がわかれば、誰でも暗号化を行うことができます。したがって、EとNがRSAによる暗号化の鍵になります。すなわち、このEとNの組が公開鍵なのです。(p132)
ChatGPTに聞きつつ、ssh-keygenで生成した公開鍵をeとnに変換する関数を実装する。ここでもcryptographyを使う。
jwts.py
import base64 from cryptography.hazmat.primitives.serialization import load_pem_private_key, load_pem_public_key # ...省略 def convert_e_n_from_pem(public_key: str) -> dict: public_key_obj = load_pem_public_key(public_key.encode()) e = public_key_obj.public_numbers().e n = public_key_obj.public_numbers().n e_b64 = base64.urlsafe_b64encode(e.to_bytes((e.bit_length() + 7) // 8, byteorder="big")).decode() n_b64 = base64.urlsafe_b64encode(n.to_bytes((n.bit_length() + 7) // 8, byteorder="big")).decode() return { "e": e_b64, "n": n_b64, }
私はこの辺のAIが提案したコードをレビューできる知見がないが、RFC7517にIn both cases, integers are represented using the base64url encoding
of their big-endian representations.とあり、それっぽい実装になっているのであってそう。
https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.1
View側で作成した関数を呼んで、eとnを埋める。
views.py
class JwksView(View): def get(self, request): key_pairs = JwtKeyPair.objects.all() jwks = [] for key_pair in key_pairs: e_n = convert_e_n_from_pem(key_pair.public_key) jwks_record = { "kty": "RSA", "alg": key_pair.algorithm, "use": "sig", "kid": str(key_pair.id), "n": e_n["n"], "e": e_n["e"], } jwks.append(jwks_record) return JsonResponse({"keys": jwks})
→ それっぽい結果が返ってくるようにはなった。

署名を検証できるか確認
実際にRP側で署名を検証できるか確認してみる。以下の記事を参考にPyjwtで検証する。
検証用のコードを書く。
jwt_verify.py
import jwt token = "ここにIDトークンを貼る" jwks_client = jwt.PyJWKClient("http://localhost:8000/sample/jwks/") signing_key = jwks_client.get_signing_key_from_jwt(token) jwt.decode( token, signing_key.key, algorithms=["RS256"], audience="test", )
Pyjwtのコードを読んだところ、以下のような呼び出しになっていたのでjwt.decodeで例外が送出されなければ署名の検証に成功したと判断できそうである。
jwt.decodeはapi_jwt.pyのdecode関数への参照になっているapi_jwt.pyのdecode関数は同ファイルのjwt.decode_complete関数を呼ぶapi_jwt.pyのjwt.decode_complete関数はapi_jws.pyのdecode_completeを呼ぶapi_jws.pyのdecode_completeは同ファイルのPyJWSクラスのdecode_completeメソッドへの参照になっているPyJWSクラスのdecode_completeメソッドは同クラスの_verify_signatureメソッドを呼ぶ_verify_signatureメソッドが署名を検証 & 失敗時には例外を送出する
https://github.com/jpadilla/pyjwt/blob/master/jwt/api_jws.py
実行してみたところ、Subject must be a stringという署名の検証とは関係ないエラーが出た。
/site-packages/jwt/api_jwt.py", line 300, in _validate_sub
raise InvalidSubjectError("Subject must be a string")
jwt.exceptions.InvalidSubjectError: Subject must be a string
たしかにsubにDjangoのUserのidをintのまま渡してしまっていたので、strに変換する。
class TokenView(View): def post(self, request): # ...省略 jwt_payload = { # ...省略 "sub": str(authorization_code.user.id), # ...省略 }
再度実行したところ、例外は送出されなかった。従って、JWKsエンドポイントは正しく実装できていそうである。
$ python jwt_verify.py $
次はUserinfoエンドポイントをやっていく。