以下の続き 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エンドポイントをやっていく。