📘

1つの JWT で複数 service を verify したかったら、python-jose の audience list 仕様の罠を踏

に公開

audience must be a string or None

python-jose の jwt.decode に audience の list を渡すと、これが出る。

事前にちゃんと考えて実装した「複数 service で 1 token を verify する仕組み」は、走らせた瞬間にこの 1 行で落ちた。RFC 7519 では aud claim は string でも list でも OK と書いてあるし、複数 audience に対する verify を library 側がよしなにやってくれるだろう、と何の疑いもなく list を audience 引数に渡してた。完全に library 仕様の読みが甘かったやつです。

最終的には python-jose の audience verify を切って自前で intersection チェックする、という割と地味な workaround に着地したんですが、そこに至るまでの判断と「decode と verify を分離する」 という pattern として後から効いてくる部分が面白かったので書きます。

背景: 1 MCP token を複数 service で verify したい

Codens の構成上の話を先に。

Codens には codens-mcp という unified MCP server があって、これ 1 つで Red (auto-fix) / Blue (QA) / Green (PRD) / Purple (orchestration) / Auth の 5 product surface ぶん、合計 31 個の tool を expose してます。Claude Code が Red の tool を呼んだら MCP server は Red backend に HTTP リクエスト投げるし、Green の tool 呼ばれたら Green backend に投げる。client から見ると 1 つの MCP server だけど、後ろでは 5 つの service と話してる、という構造です。

問題は auth で、token は 1 つしか持たせたくなかった。

最初は素朴に「各 service 用の token を発行して、MCP server 側で振り分ければいいじゃん」と考えました。が、これやると token 管理がしんどい。rotation のタイミングが service ごとにズレるし、Claude Code 側に 5 つの token を持たせるのも気持ち悪いし、何より MCP server 自体が「Purple の orchestration を肩代わりしてる文脈で各 service に話す」 という位置付けなので、token も 1 つで一貫してた方が筋がいい。

で、token の aud claim は purple-codens-mcp で発行することにしました。MCP server が自分自身を識別する audience です。これを各 service に持っていって verify させる、という方針。

ここで JWT の audience verify の話になります。各 service には自分の primary audience がある (例: Green backend は green-codens)。Green が aud=purple-codens-mcp の token を受け取ると、当然 default の verify は「お前向けじゃない」 と reject する。当たり前です。

「1 token を N service で verify する」 と書くと簡単そうなんですが、JWT は元々「特定の audience 向けに発行する」 思想の規格なので、複数 audience を許容する verify を library 側がどう実装してるか、ここがちゃんと library 仕様に依存します。後で書く通り、ここを甘く見てたのが今回の話です。

fix v1: OAUTH_ADDITIONAL_AUDIENCES を設定する

最初の方針は素直で、各 service の settings に「primary audience に加えて、追加で受け入れる audience」 を持たせる、というやつでした。

# 各 service の config.py
OAUTH_AUDIENCE: str = "green-codens"  # primary
OAUTH_ADDITIONAL_AUDIENCES: list[str] = ["purple-codens-mcp"]

設計としては「primary は自分宛、additional は信頼してる別 issuer/MCP の audience」 という意味付け。将来別の MCP server 経由で呼ばれる service が増えても、この list に追加するだけで済む。

そして verify する側の実装はこんな感じで書きました。

audiences = [self.audience] if verify_audience and self.audience else []
if audiences and settings.OAUTH_ADDITIONAL_AUDIENCES:
    audiences.extend(settings.OAUTH_ADDITIONAL_AUDIENCES)

payload = jwt.decode(
    token,
    self.secret_key,
    algorithms=[self.algorithm],
    audience=audiences if audiences else None,
)

list を作って、それを jwt.decodeaudience 引数に渡す。python-jose 側が token の aud と list の各要素を照合して、どれか 1 つでも一致すれば pass、という挙動を期待してました。

実際 PyJWT (こっちは別 library) は audience=["a", "b", "c"] のような渡し方を正式に support してるので、python-jose も同じだろうと信じ込んでた。両方とも有名な JWT library だし、こういう基本的な仕様は揃ってるだろう、というのが思い込みでした。

unit test の段階では mock で audience 検証を skip してたので普通に green になって、「いけたな」 と思って integration test に進んだら、そこで罠を踏みました。

罠: python-jose は list audience を拒否する

走らせた瞬間、こうなりました。

JWTError: audience must be a string or None

これ、token 側の問題じゃなくて、jwt.decodeaudience 引数の type validation で蹴られてる error です。python-jose の decode 実装を読みに行くと、audience parameter は string 1 個または None しか受け付けない、という assertion が入ってる。list は渡せない。

これは正直、library 仕様としては結構意外でした。RFC 7519 の 4.1.3 ("aud" Claim) には、

The "aud" (audience) claim identifies the recipients that the JWT is intended for. (...) In the general case, the "aud" value is an array of case-sensitive strings (...). In the special case when the JWT has one audience, the "aud" value MAY be a single case-sensitive string (...).

と書いてあって、token 側の aud が list 取れるのは spec で明記されてる。にも関わらず、python-jose の verifier 側は「verify したい audience は 1 つだけ受け取る」 という API になってる。

これは GitHub issue にも数年前から指摘されてて、「PyJWT は list audience を受け付けるのに python-jose は受け付けない、互換性ない、なんとかしてくれ」 という議論が長く続いてる状態でした。が、library 側の対応は入ってない (執筆時点)。python-jose 自体が active な maintenance mode じゃなくなってきてるという文脈もあって、しばらく解決しなさそう。

ここで方針の分岐がありました。

  1. python-jose を捨てて PyJWT に乗り換える
  2. python-jose のまま、自前で audience verify を書く

1 は綺麗だけど、Codens の backend 5 つ全部で auth 周りの依存を入れ替えるのはまあまあ重い。token 生成と verify の細かい挙動差 (algorithm 名の解釈、optional claim の扱い、error class の階層) を全 service で再確認するコストを払いたくなかった。

なので 2 を選びました。python-jose の audience verify を切って、その部分だけ自分で書く。decode 自体はちゃんと python-jose にやらせる。

fix v2: verify_aud を落として manual で intersection を取る

実装はこうなりました。

should_verify_aud = verify_audience and bool(self.audience)

payload = jwt.decode(
    token,
    self.secret_key,
    algorithms=[self.algorithm],
    options={"verify_aud": False},
)

if should_verify_aud:
    allowed_audiences = {self.audience, *settings.OAUTH_ADDITIONAL_AUDIENCES}
    token_aud = payload.get("aud")
    token_aud_set = (
        set(token_aud) if isinstance(token_aud, list)
        else {token_aud} if token_aud is not None
        else set()
    )
    if not (token_aud_set & allowed_audiences):
        raise InvalidTokenError(
            f"Invalid audience: token aud={token_aud!r}, "
            f"expected one of {sorted(allowed_audiences)}"
        )

ポイントは 3 つあって、まず options={"verify_aud": False} で python-jose 側の audience verify を明示的に off。これで audience 引数自体を渡さなくなるので、type validation で落ちることはなくなる。signature 検証や exp / nbf の verify は default で走り続けるので、危ない手抜きにはならない。

次に、token の aud claim は spec 上 string 1 個 でも list でも来うるので、両方をきれいに扱える形に正規化してる。set に入れちゃえば後の intersection 一発で済むので、ここは set にしました。isinstance(token_aud, list) で分岐してる部分です。

そして allowed 側も set にして、token_aud_set & allowed_audiences で交差を取る。空 set じゃなければ「token の audience のどれか 1 つ以上が allowed list に含まれている」 ことが保証される。これは集合演算 1 回で意味がはっきり書けるので、loop 回して flag 立てるよりだいぶ読みやすい。

error message に token aud=...expected one of [...] 両方入れてるのは過去の経験で、audience 系の error は「どの token の話で、何を期待してたか」 が両方ないと debug できないからです。token は redact した上で aud claim だけ出す、ぐらいが運用上ちょうどいい。

副次効果として、「decode と verify を分離した」 構造が後から効いてきました。Codens は service 間で micro 単位だけど auth policy が違う部分があって (Auth service だけは scope 検証も厳しめにやる、とか)、verify ロジックを decode から切り離しておくと、service ごとに少しずつ違う policy を後付けで足しやすい。最初は仕方なく分離したんですが、library quirk に逃げ場所を作るための手段が、結果的に拡張性として効いた感じです。

副次的な学び

python-jose の options dict は知っておくと便利でした。

{"verify_signature": True/False, "verify_aud": True/False, "verify_iat": True/False, "verify_exp": True/False, "verify_nbf": True/False, "verify_iss": True/False, "verify_sub": True/False, "verify_jti": True/False, "require_aud": True/False, ...}

みたいに、各 claim の verify を個別に on/off できる。今回みたいに「signature と exp は library に任せて、audience だけ自前」 という mix-and-match ができるのは大きい。何でも standard library に任せてると library quirk に当たった時に動けなくなるので、library 提供の hook で claim ごとに分解できるかは、JWT 系 library を選ぶ時のチェックポイントとして覚えておこうと思いました。

逆に PyJWT みたいに list audience を最初から受け付ける library を選ぶなら、こういう workaround は要らない。今回は乗り換えコストとの天秤で python-jose 続投を選んだだけで、新規 project なら PyJWT も普通に選択肢に入ります。「FastAPI tutorial が python-jose を使ってるから」 という理由だけで選び続けるのは、こういう quirk を踏んだ後だと考え直したくなる部分です。

JWT の audience verify は spec 上「string でも list でも OK」 になってるので、感覚的には library 側もそれに合わせて多 audience を扱えそうに見える。でも実際は library ごとに微妙に違うし、特に python-jose みたいに古めの library は spec のサブセットしか cover してなかったりする。

multi-service で 1 token を verify したいケースは、SaaS 系の architecture では結構普通に出てくる。N service ぶん token を別々に発行するより rotation も少ないし、agent から各 service に話す時の認証も一貫させやすい。なので「1 token + multi audience」 という方針自体は引き続き有効だと思ってます。ただし library がそれをそのまま助けてくれるとは限らない。

decode と verify を分けておくと、library quirk が降ってきた時の逃げ道が増える。標準 lib の API 1 個で済む部分を分解するのは一見冗長に見えるけど、後から claim ごとに policy を変えたくなった時にもこの構造はそのまま使えるので、決して無駄ではないなと。

Codens では同じ pattern を auth service 全体で踏襲してます。興味あれば LP どうぞ: https://www.codens.ai/

Discussion