デジタル認証アプリAPI その2
[08]トークンリクエスト
セッションに保存した情報をもとにトークンリクエスト発行に必要なパラメータを準備していきます。
postだからクエリーパラメータではない
当初、漫然と認可リクエストと同じように考え以下のようなコードを書いて実行したところinvalid_requestが通知されました。
response = requests.post(token_url, params=query_params_token) # 誤り
error: {"error":"invalid_request","error_description":"Missing form parameter: grant_type"}
grant_typeが変と通知されていますが、そもそも認可リクエストはGETでありクエリーパラメータを使ってパラメータを渡すのに対し、トークンリクエストはPOSTであるため、パラメータはContent-Type application/x-www-form-urlencodedのデータとして渡さないといけなかったです。
というわけで、以下のようにコードに改めました。
response = requests.post(token_url, data=data_token) # 正しい
code_verifier
セッションにcode_verifierそのものは保存していなかったのですが、トークンリクエストの必須パラメータとして参照することがわかったため、認可リクエスト送信パラメータを生成した時点で保存するようコードを改めました。
client_assertion 必須クレームjti
詳しく理解していませんがUUIDの生成にはuuid4()がオススメと確認したのでこんな感じで使いました。
payload['jti'] = str(uuid.uuid4())
client_assertion 必須クレームexp
expはUTCでありユニックス時間での設定が必要とのこと。有効期限を5分間として設定しました。
expiration_time_utc = current_time_utc + timedelta(minutes=5)
exp = int(expiration_time_utc.timestamp())
payload['exp'] = exp
client_assertionをJWTへ
デジタル庁発信情報2.トークンエンドポイントに、JWTペイロードに含めるべきクレームの一覧が説明されているため何をどう設定すればいいのかで困ることはありません。書いてあるとおりの情報を設定すればよいです。また、JWTの作り方についてもたくさん情報があったので悩まずに済みました。
実際のJWT生成のコードです。
client_assertion = jwt.encode(payload, private_key, algorithm="RS256")
client_assertion = client_assertion.decode('utf-8') if isinstance(client_assertion, bytes) else client_assertion
ここで指定する署名用の秘密鍵private_keyは、あらかじめデジタル庁に提出したテスト環境・本番環境設定書の記入欄のひとつ「クライアント認証用のJWKS」に記載した公開鍵と対になる秘密鍵となります。秘密鍵情報のフォーマットは-----BEGIN...のヘッダーなどを含め決まっているそうなので、改行コードを含め生成されたまんまを渡せば大丈夫です。
こんな感じのフォーマットですね。
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAAEFAQSCBKgwsgSkAgEAAoIBAQC7ph+LjQsKcv8R
...
vjJWmcW689U7uFVkFoUroJMW
-----END PRIVATE KEY-----
encode()は途中からバイト列から文字列を返す仕様に変わったとのことで、使っているのは文字列を返すバージョンのつもりだったのですが、テスト環境ではなぜかバイト列が返ったためそれを考慮したコードとなっています。
[09]トークンレスポンス
デジタル庁発信情報1.実装にあたっての必須対応事項では、受信したトークンレスポンスに対して以下の実施が必要と記載があります。
- IDトークンの検証
- リプレイ攻撃への対策
- アクセストークン置き換え攻撃への対策
IDトークンの検証
IDトークンについてはやることがたくさんあります。JWTであるため署名の検証は当然として、署名検証以外にも以下のこともやるように書いてあります。
- ヘッダー部の検証
- kid kidが公開鍵のkidと一致すること
- alg 公開鍵のalgともに署名アルゴリズムがES256であること
- ペイロード部の検証
- iss クレームissがOpenID Providerメタデータのissuerと一致すること
- aud クレームaudにクライアントIDが含まれること
- exp クレームexpが有効期限切れでないこと。つまり、検証時刻よりexpが大きいこと
- iat 制限時間内に処理できていること。つまり、検証時刻-所要時間よりiatが大きいこと
- at_hash クレームat_hashがハッシュ化したアクセストークン左半分と一致すること
以下、実装時に少し知恵を使った部分について説明していきます。
kid
ヘッダー部クレームkidが、署名検証に用いる公開鍵のKey IDと一致していることを確認します。
署名検証に用いる公開鍵は、あらかじめJWK Set公開エンドポイントから取得しておかなければなりません。取得できるのは、JWK Set、つまりJWKの配列なので複数のJWKが含まれる可能性があるわけですが、資料にはJWKがひとつだけなのか複数なのかは明記されていないため、複数ある前提のコードとしておきます。
受け取ったjwksをループでまわしながらKey IDを比較し、一致した公開鍵を取り出す、ひとつも一致しなければエラーとします。
public_key = None
public_key_alg = None
for jwk in jwks["keys"]:
if kid == jwk["kid"]:
public_key = dauth.load_ec_public_key_from_jwk(jwk)
public_key_alg = jwk.get("alg")
break
if not public_key:
raise HTTPException(status_code=400,
detail={
"error": "invalid_token",
"error_description": "public key not found."
}
)
署名の検証
署名の検証といっても自ら頭を使って…というわけではなく、IDトークンをまるごと(ヘッダー、ペイロード、署名に分割することなく)、生成した公開鍵など求められるパラメータを渡してjwt.decode()を呼ぶだけです。署名検証に失敗すると、例外が通知されpayloadは手に入りません。
try:
payload = jwt.decode(
id_token,
public_key,
algorithms=["ES256"],
audience=db_state['client_id'],
issuer=issuer,
)
except jwt.exceptions.InvalidSignatureError:
署名で使う公開鍵はあらかじめECAlgorithmオブジェクトとしておく必要があるようで、当初ECAlgorithm.from_jwk()を使うつもりだったのですがビルドすると例外NotImplementedErrorが通知されてしまいました。PyJWTのバージョンが古かったようです(v1.7.1)。なので、PyJWTをv2.10.1にバージョンアップし再度ビルドを試みたところ、今度はfastapi-jwt-auth 0.5.0 depends on PyJWT<2.0.0 and >=1.7.1が通知され、fastapi-jwt-auth側が期待するPyJWTのバージョンと合致していないことがわかりました。
ECAlgorithm.from_jwk()を使うにはfastapi-jwt-auth側の対応を待つ必要があるようで、今回はあきらめてAIに相談し以下のロジックで対応しました。
def load_ec_public_key_from_jwk(jwk):
x = int.from_bytes(base64.urlsafe_b64decode(jwk['x'] + '=='), 'big')
y = int.from_bytes(base64.urlsafe_b64decode(jwk['y'] + '=='), 'big')
crv = jwk['crv']
if crv == 'P-256':
curve = ec.SECP256R1()
else:
raise ValueError('Unsupported curve')
public_numbers = ec.EllipticCurvePublicNumbers(x, y, curve)
return public_numbers.public_key()
iss
ペイロード部クレームissがOpenID Providerメタデータのissuerの値と一致することを確認します。そのOpenID Providerメタデータがどこにあるかというと、OpenID Provider Configuration エンドポイントを呼ぶと手に入るというしかけです。OpenID Providerメタデータのうち、今のところ必要なものはissuerだけなので、この検証のためOpenID Provider Configuration エンドポイントを呼ぶことになります。
aud
デジタル庁の資料に「IDトークンのaudienceクレーム(aud)に、RPアプリのクライアントIDが含まれていることを検証」と記載があります。このペイロード部クレームaudは、単一の値の場合は文字列、複数の値の場合はリストで返される仕様、とのこと。一致していることを検証、ではなく含まれていることを検証、という日本語に違和感がありましたが、audがリストである可能性があることの意味を込めた日本語だったのでしょうか。
このことを考慮して文字列ならリスト化して単一のロジックで対応できるようにします。
aud = payload["aud"]
if isinstance(aud, str):
aud = [aud]
if not (db_state['client_id'] in aud):
iat
デジタル庁の資料には「IDトークンの発行日時を示す Issued atクレーム(iat)が、 検証時のUNIXタイムスタンプ値 - 所要時間(秒) の値以上であることを検証してください。」と説明されていますが、言い換えるとIDトークンの発行日時を示すペイロード部クレームiatを基準に制限時間内に処理できていることを確認して、という理解です。ここでいう制限時間とは、IDトークン発行日時に所要時間を足した時間まで、であり、所要時間はRPアプリで決めてよい、さらに「nonce 保存からRPアプリがIDトークン受け取るまでの時間が目安です。」と記載があるため、nonceの保存、つまり、認可リクエストの送信から、利用者とデジタル認証アプリサービスの認証・認可手順を経てトークンレスポンスを受信するまでの時間、余裕をみて所要時間=10分程度が適当なのかなと考えていました。しかし、実験して確認した範囲ではiatには常にトークンレスポンス受信日時の直前の時刻、正にIDトークンの発行日時がスタンプされていました。実装では、トークンレスポンスを受信直後にIDトークン検証処理を実行するため、所要時間は数秒で十分であることがわかりました。余裕をみて、所要時間1分として実装しましたが、資料の「nonce 保存からRPアプリがIDトークン受け取るまでの時間が目安です。」の意図はわからないままです。
at_hash(アクセストークン置き換え攻撃への対策)
この検証処理は、デジタル庁の資料にアクセストークン置き換え攻撃への対策、として説明されており「アクセストークンを、IDトークンヘッダ部のalgorithmクレーム(alg)と同じハッシュアルゴリズムを用いてハッシュ化し、ハッシュ化した結果の左半分のビット群をBase64URLにてエンコードしたうえで、IDトークンのat_hashと値が一致することを確認してください。」と具体的な検証手順が説明されています。
実装では、AIに相談し以下のロジックで対応しました。
def verify_at_hash(access_token, at_hash_from_token):
digest = hashlib.sha256(access_token.encode()).digest()
left_half = digest[:len(digest)//2]
at_hash_calc = base64.urlsafe_b64encode(left_half).rstrip(b'=').decode()
return at_hash_calc == at_hash_from_token
ハッシュアルゴリズムについてはOIDC仕様(OpenID Connect Core 3.3.2.11)にて、at_hashの計算に「署名アルゴリズムで使われているハッシュ関数」を使うと定められています。
algクレーム"ES256"は「ECDSA署名(楕円曲線デジタル署名アルゴリズム)+SHA-256ハッシュ」を意味し、署名アルゴリズムとしては「楕円曲線署名(ECDSA)」ですが、ハッシュ関数としてはSHA-256が使われます。なお、他の"RS256"でもSHA-256、"RS384"ならSHA-384とのこと。ここではhashlib.sha256()を決め打ちで使っています。
また、.rstrip(b'=')は、末尾の = を削除する処理。Base64エンコードでは、データ長を4の倍数にするために末尾に = が付与されることがあるらしく、OIDCのat_hash仕様ではこれを削除するとのことです。
リプレイ攻撃への対策
セッションに保存しておいた認可リクエスト送信時生成nonceと、IDトークンから取り出したnonceが一致することを確認する処理です。
ここまでの実装で受信トークンレスポンスに対する確認処理が終わります。すべてOKなら次のステップUserInfoリクエスト送信処理へ、ひとつでもNGならその時点でエラーログを吐いて処理を中断する実装としました。
Discussion