以下の続き
トークンエンドポイント
前提知識の確認
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
を使うこととする。
鍵の生成と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.key
とidp_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)
登録する。Algorithm
はRS256
とする。
署名の作成
署名を作成する。なおPyJWT
というライブラリを使うと、辞書型のペイロードから簡単にJWTを作成できるらしいが、今回は勉強のために使わずにやってみる。
まずは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の仕様で=
は全て削除する必要があるらしい。
→ 対応する。
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エンドポイントをやっていく。