きり丸の技術日記

技術検証したり、資格等をここに残していきます。

同じJSONやDictでも生成されるJWTは変わることがある(Pythonで例示)

同一のJSONやDictを与えているはずなのに、生成されたJWTが変わってしまったというメモ。

なお、ライブラリの特性である可能性はあるので、すべてのJWTライブラリで発生するわけではありません。

環境

  • Python
    • 3.11
  • python-jose
    • 3.3.0

原因

JSONやDictとしては等価だったが、JSONを文字列化(シリアライズ)したタイミングでキーの順番が変わってしまったため。シリアライズした結果を元にencodeするので、生成されるJWTが等価にならない。

事象発生時のシリアライズ

  • {"id": 1, "exp": 1710152710, "iss": "kirimaru"}
  • {"exp": 1710152710, "id": 1, "iss": "kirimaru"}

対応

JWTに関しては検証を行わない。JWTからJSONの復元ができることのみを確認する。

動作確認時のコード

次の3つを確認していました。

  • JSON(Dict)のキーが異なった場合でも等しいこと
  • キーをソートしたら同一のJWTが生成されること
  • 異なるJWTから同一のJSONを得られること
from jose import jwt

async def test_expected_same_token():
    KEY = "SECRET"
    ALGORITHM = "HS256"
    json_token = {
        "id": 1,
        "exp": 1710152710,
        "iss": "kirimaru"
    }

    pydantic_token = {
        "exp": 1710152710,
        "id": 1,
        "iss": "kirimaru"
    }

    # NOTE: JSON のキーの順番が違ってもTrue になる
    assert json_token == pydantic_token

    # NOTE: JWTの payload の順番が違ったら別のトークンになるのでソートしたら True になる
    json_encode = jwt.encode(dict(sorted(json_token.items())), KEY, algorithm=ALGORITHM)
    pydantic_encode = jwt.encode(dict(sorted(pydantic_token.items())), KEY, algorithm=ALGORITHM)
    assert json_encode == pydantic_encode

    # NOTE: ソートしなくても別のトークンから同じJSONに戻せることのチェック
    json_encode = jwt.encode(json_token, KEY, algorithm=ALGORITHM)
    pydantic_encode = jwt.encode(pydantic_token, KEY, algorithm=ALGORITHM)
    json_decode = jwt.decode(json_encode, KEY, algorithms=ALGORITHM)
    pydantic_decode = jwt.decode(pydantic_encode, KEY, algorithms=ALGORITHM)

    assert json_decode == pydantic_decode

ソースコード

事象発生時のpython-joseではなく、PyJwtでGitHubでは公開しています。

終わりに

複数の言語を使用しているアプリケーションを運用しているので、別アプリケーションで生成したJWTを元に、他アプリケーションでも同一のJWTを生成していることを期待してハマりました。

パラメータとしてJSONを期待しているなら、キーのソートまでライブラリ側でやってほしいのですがね…。PullRequestを出そうかと思っていましたが、python-joseの最終マージが2023/05/04だったので、取り込まれそうになかったのもありPullRequestは作ってません。

もし興味があれば該当箇所までは判別したので、PullRequestを出してみてください。

Pythonのエラーチェイン(例外を元に例外を投げる)には3種類あるがあまり気にしなくていい

Pythonでフレームワークから発生した例外を元に、適切に自作した例外に変換する方法に複数あることを知ったので、それを素振りしました。

なお、結果だけ先にお伝えするとraiseするだけでも9割問題ありません。

環境

  • Python
    • 3.11.6

確認方法

Pythonでは単純にエラーをraiseするだけでなく、from efrom Noneという構文を付けてraiseすることもできます。

try:
    try:
        1 / 0
    except ZeroDivisionError as e:
        raise ValueError("ERROR") # 1つ目
        raise ValueError("ERROR") from e # 2つ目
        raise ValueError("ERROR") from None # 3つ目
except Exception as e:
    print(traceback.format_exc())

具体的にtraceback.formt_exc()で得られるstacktraceは次のとおりです。

# 1つ目のraise
tests/unit/test_raise_error.py Traceback (most recent call last):
  File "/tests/unit/test_raise_error.py", line 9, in test_01
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/tests/unit/test_raise_error.py", line 11, in test_01
    raise ValueError("ERROR")
ValueError: ERROR
# 2つ目のraise
Traceback (most recent call last):
  File "/tests/unit/test_raise_error.py", line 9, in test_01
    1 / 0
    ~~^~~
ZeroDivisionError: division by zero

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/tests/unit/test_raise_error.py", line 11, in test_01
    raise ValueError("ERROR") from e
ValueError: ERROR
# 3つ目のraise
Traceback (most recent call last):
  File "/tests/unit/test_raise_error.py", line 11, in test_01
    raise ValueError("ERROR") from None
ValueError: ERROR

差分としては次のとおりです。

  1. 1つ目のやり方
  2. エラーメッセージにDuring handling of the above exception, another exception occurrが出力される
    1. 例外処理中に例外が発生しました
  3. 2つ目のやり方
  4. エラーメッセージにThe above exception was the direct cause of the following exceptionが出力される
    1. 上記の例外が直接の原因で次の例外が発生しました
  5. 3つ目のやり方
  6. 元の例外を握りつぶす

結論

正確に例外を表現するならraise Error from eを使用してください。

ライブラリやフレームワークを作成したり、テスト用の便利なモジュールを作っているときに、例外を伝播させたくないときにはraise Error from Noneを使用してください。

Pythonとしては、例外は意図的に握りつぶさないので単純にraiseするだけでも9割のケースで問題ないでしょう。

ソースコード

終わりに

個人的に、raise from eにはもう少し情報が増えることを期待していました。もちろん、正確に表現するならraise from eがあったほうが良いです。しかし、stacktraceを読もうとしているときは不具合の原因をいち早く確認したいので、まずは発生箇所を特定することを最優先してしまい、結果としてDuring handling of the above exception, another exception occurrというメッセージは今まで目に入ってこなかったです。

まぁ、知識として知ったうえで単純にraiseすることは今後無くなるでしょうが、かといって無理やり既存のコードにraise from eを付与するほどではないので、結論としてはあまり気にしなくていいです。

FastAPIのpydanticの422UnprocessableEntityはExceptionのExceptionHandlerではキャッチできない

FastAPIで意図しないエラーが発生したときにExceptionでハンドリングしていましたが、それだけではpydanticで発生するエラーがキャッチできなかったのでメモします。

なお、pydanticはAPIのRequestResponseのモデルで使用していますので、フロントのバリデーションエラーと考えてください。

環境

  • Python
    • 3.11
  • FastAPI
    • 0.105.0
  • pydantic
    • 2.5.3

ゴール

pydanticで発生していた次の詳細すぎるメッセージが返却されないこと。

{
  "detail": [
    {
      "type": "greater_than_equal",
      "loc": [
        "body",
        "age"
      ],
      "msg": "Input should be greater than or equal to 18",
      "input": 0,
      "ctx": {
        "ge": 18
      },
      "url": "https://errors.pydantic.dev/2.5/v/greater_than_equal"
    }
  ]
}

実装

Exceptionだけでなく、FastAPIRequestValidaionErrorもException_handlerに追加する必要があります。

# 全てのエラーをキャッチするハンドラ
# ただし、これだけではキャッチできないエラーも存在する
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
    return JSONResponse(
        status_code=500,
        content={"detail": f"SystemError"},
    )

# 追記したハンドラ
# pydanticのエラーをFastAPIでラップしたRequestValidationErrorを使用します。
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    return JSONResponse(
        status_code=400,
        content={"detail": f"BadRequest"},
    )

原因

FastAPI側でハンドリングしていたから。

次の3つのエラーに関してはFastAPI側で定義しています。

  • HTTPException
  • RequestValidationError
  • WebSocketRequestValidationError

継承元のExceptionよりも継承先の特化したExceptionを定義しているので、特化したエラーが発生したときは特化したハンドリングが優先されます。FastAPIを経由したテストを書かないと、エラーにひっかかるので気づくのに遅れました。

ソースコード

exception_handlerと対応したテストを書いてます。ただ、通常のExceptionが発生した場合は、Pytest上だとうまく確認できませんでした。

終わりに

最悪、urlをレスポンスしなければそのまま使用していいんですけどね。完全にpydanticを使ってます!ってメッセージはちょっといただけないですね。

Exceptionでハンドリングしているから大丈夫なはず、というので油断しました。今後も新しいフレームワークを使うときは、既存のexception_handlersを確認したほうが良いですね。

参考文献