delhi09の勉強日記

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

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

以下の続き

kamatimaru.hatenablog.com

トークンエンドポイント

前提知識の確認

IDトークンはJWT(JSON Web Token)形式であることが仕様で決まっている。

https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#IDToken

ID Token は JSON Web Token (JWT) [JWT] である.

JWTには署名が必要である。署名のアルゴリズムに関するは知識がないが、Auth屋さんの本には「RS256を使うのが一般的」とあり、M3さんのIdP自作の記事でもRS256を使っていたので、ここでもRS256を使うこととする。

authya.booth.pm

www.m3tech.blog

鍵の生成とDBへの保存

以下のコマンドで秘密鍵を生成する。

$ openssl genrsa -out idp_practice_private.key 2048

以下のコマンドで公開鍵を生成する。先に生成した秘密鍵をインプットとして使う。

openssl rsa -in idp_practice_private.key -pubout -out idp_practice_public.key

→ コマンドを実行したディレクトリにidp_practice_private.keyidp_practice_public.keyが作成される。

次に鍵を保存するDjangoのModelを定義する。(ChatGPTに聞いたところ、本来は秘密鍵は可逆暗号化して保存した上で、秘密鍵の暗号化に使った鍵はKMSなど別の場所に保存するべきらしいが、練習なのでそこまではやらない)

models.py

# ...省略
class JwtKeyPair(models.Model):
    private_key = models.TextField()
    public_key = models.TextField()
    algorithm = models.CharField(max_length=32)

生成した鍵をDjango Adminから登録できるようにする。

admin.py

from .models import (
    # ...省略
    JwtKeyPair
)

# ...省略
admin.site.register(JwtKeyPair)

登録する。AlgorithmRS256とする。

署名の作成

署名を作成する。なおPyJWTというライブラリを使うと、辞書型のペイロードから簡単にJWTを作成できるらしいが、今回は勉強のために使わずにやってみる。

pyjwt.readthedocs.io

まずはDBから先ほど保存した鍵を取得する。1つしか保存していないので、Django ORMのgetで検索条件なしで取得する。

class TokenView(View):
    def post(self, request):
        # ...省略
        key_pair = JwtKeyPair.objects.get(algorithm="RS256")

JWTのヘッダー部分を定義する。ペイロード部分はid_token_dataという変数ですでに実装済みだが、統一感を持たせる & JWTの用語体系に従ってjwt_payloadという変数名に直す。

class TokenView(View):
    def post(self, request):
        # ...省略
        key_pair = JwtKeyPair.objects.get(algorithm="RS256")
        jwt_header = {
            "alg": key_pair.algorithm,
            "typ": "JWT",
            "kid": str(key_pair.id),
        }
        jwt_payload = {
            "iss": "http://localhost:8000",
            "sub": authorization_code.user.id,
            "aud": relying_party.client_id,
            "nonce": authorization_code.nonce,
            "exp": int((datetime.now() + timedelta(minutes=10)).timestamp()),
            "iat": int(datetime.now().timestamp()),
        }

ヘッダとペイロードをそれぞれJSON文字列に変換した上でbase64エンコードする。

import base64
# ...省略
import json
# ...省略

class TokenView(View):
    def post(self, request):
        # ...省略
        jwt_header_encoded = base64.urlsafe_b64encode(
            json.dumps(jwt_header).encode()
        ).decode()
        jwt_payload_encoded = base64.urlsafe_b64encode(
            json.dumps(jwt_payload).encode()
        ).decode()

ここでcryptographyというPythonの暗号化用のライブラリを使うので、requirements.txtに追加してインストールする。

requirements.txt

# ...省略
cryptography==44.0.0

jwts.pyという新規ファイルを作り、その中にcreate_signatureという関数を実装することにする。この辺はよくわからないのでかなりChatGPTの力を借りた。

jwts.py

import base64
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding


def create_signature(
    jwt_header_encoded: str, jwt_payload_encoded: str, private_key: str
) -> str:
    signing_input = f"{jwt_header_encoded}.{jwt_payload_encoded}"
    private_key_obj = load_pem_private_key(private_key.encode(), password=None)
    signature = private_key_obj.sign(
        signing_input.encode(),
        padding.PKCS1v15(),
        hashes.SHA256(),
    )

    return base64.urlsafe_b64encode(signature).decode()

実装したcreate_signatureを使ってIDトークンを完成させる。

from sampleapp.jwts import create_signature
# ...省略

class TokenView(View):
    def post(self, request):
        # ...省略
        jwt_signature = create_signature(
            jwt_header_encoded, jwt_payload_encoded, key_pair.private_key
        )
        id_token = f"{jwt_header_encoded}.{jwt_payload_encoded}.{jwt_signature}"
        print("id_token", id_token)

出力されたIDトークンをjwt.ioに貼ったところ、データの中身はよさそうだがinvalid signatureと出てしまった。

jwt.io

エラーメッセージで検索したら以下の記事がヒットした。JWTの仕様で=は全て削除する必要があるらしい。

stackoverflow.com

→ 対応する。

views.py

class TokenView(View):
    def post(self, request):
        # ...省略
        jwt_header_encoded = base64.urlsafe_b64encode(
            json.dumps(jwt_header).encode()
        ).decode().strip("=")
        jwt_payload_encoded = base64.urlsafe_b64encode(
            json.dumps(jwt_payload).encode()
        ).decode().strip("=")

jwts.py

def create_signature(
    jwt_header_encoded: str, jwt_payload_encoded: str, private_key: str
) -> str:
    # ...省略
    return base64.urlsafe_b64encode(signature).decode().strip("=")

→ 修正後のコードで再作成したIDトークンを貼ったところ、signature verifiedになった。

トークンエンドポイントのレスポンス実装

IDトークンが完成したので、最後にトークンエンドポイントのレスポンスを実装する。必要なフィールドは以下。

フィールド名 説明
access_token アクセストーク
token_type 固定でBearerという文字列を返す
expires_in アクセストークンの期限(秒)
refresh_token リフレッシュトーク
id_token IDトーク

アクセストークンとリフレッシュトークンは必要になったらちゃんと実装するとして、いったん素朴なランダム文字列を返しておく。最後に認可コードを削除する。

views.py

class TokenView(View):
    def post(self, request):
        # ...省略
        id_token = f"{jwt_header_encoded}.{jwt_payload_encoded}.{jwt_signature}"
        access_token = crypto.get_random_string(8)
        refresh_token = crypto.get_random_string(8)
        authorization_code.delete()
        return JsonResponse(
            {
                "access_token": access_token,
                "token_type": "Bearer",
                "expires_in": 3600,
                "refresh_token": refresh_token,
                "id_token": id_token,
            }
        )

これで一旦トークンエンドポイントを実装できた。

$ curl -X POST "http://localhost:8000/sample/token/" -d "redirect_uri=http://localhost/callback" -d "grant_type=authorization_code" -d "code=5tYq6Z2F" -d "client_id=test" -d "client_secret=aU1YmB0VwWqjk3po"
{
  "access_token": "l5fGOp6W",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "8rddhWnY",
  "id_token": "eyJhbGciOiAiUlMyNTYiLCAidHlwIjogIkpXVCIsICJraWQiOiAiMSJ9.eyJpc3MiOiAiaHR0cDovL2xvY2FsaG9zdDo4MDAwIiwgInN1YiI6IDEsICJhdWQiOiAidGVzdCIsICJub25jZSI6ICJlZmdoIiwgImV4cCI6IDE3MzU5MDM3NTAsICJpYXQiOiAxNzM1OTAzMTUwfQ.A5IKdF0CA4_tT9xegG1CLIWHorPZLylFGsDpUaQP66_-hxdPRHQTcl2C7BKNwGAr5EYm_eGrlgzAEbrxT5DydzDCXgNHiMjlfkW7D7MxIRRyDR1jd4AySjVKifPgcU1im7jJ2GB88PDZQ3Uab4p980reXIO1BKLOddfhicw_manVpbS37QeXfni_lT3fAhgMN0gsgdw_0ODR1OZM0lduxQ-8SnPaI5VpL3BH4px5C-mASQje6WcJJivPGZJdp41H2qklgkZ0eqti0fVqvH1mW8at2nOr-tWdvrgRiJcwzJdUQskS9_ZXmuRZH5zxOQNhNIkWWbSuDexfIn8GP_kMQw"
}

次は公開鍵をRPに提供するエンドポイントかUserinfoエンドポイントをやっていく。