delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

勉強のためにOpen ID ConnectのIDプロバイダー側をDjangoで実装する⑨

以下の続き kamatimaru.hatenablog.com

公開鍵をRPに提供するエンドポイント

公開鍵をRPに提供するエンドポイントを実装する。「JWKsエンドポイント」というらしい。

developer.yahoo.co.jp

RFC7517に仕様が存在するとのこと。

datatracker.ietf.org

ディスカバリへの登録

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 公開鍵の法部分

この内、よく分からないのはenなので、まずはそれ以外を実装する。

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})

enは、本質はssh-keygenで生成した公開鍵だが表現形式が違うものらしい。

たしかに『暗号技術入門 第3版 秘密の国のアリス』には以下のような説明がある。

ところで、暗号化の式に登場する2つの数、EとNとは何でしょうか?RSAの暗号化は、平文をE乗してmod N をとることですから、EとNという一組の数がわかれば、誰でも暗号化を行うことができます。したがって、EとNがRSAによる暗号化の鍵になります。すなわち、このEとNの組が公開鍵なのです。(p132)

www.sbcr.jp

ChatGPTに聞きつつ、ssh-keygenで生成した公開鍵をenに変換する関数を実装する。ここでも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側で作成した関数を呼んで、enを埋める。

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で検証する。

dev.classmethod.jp

検証用のコードを書く。

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で例外が送出されなければ署名の検証に成功したと判断できそうである。

  1. jwt.decodeapi_jwt.pydecode関数への参照になっている
  2. api_jwt.pydecode関数は同ファイルのjwt.decode_complete関数を呼ぶ
  3. api_jwt.pyjwt.decode_complete関数はapi_jws.pydecode_completeを呼ぶ
  4. api_jws.pydecode_completeは同ファイルのPyJWSクラスのdecode_completeメソッドへの参照になっている
  5. PyJWSクラスのdecode_completeメソッドは同クラスの_verify_signatureメソッドを呼ぶ
  6. _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

たしかにsubDjangoの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エンドポイントをやっていく。