Claude Media
ClaudeのWIFをSPIFFE/SPIREと連携する手順

ClaudeのWIFをSPIFFE/SPIREと連携する手順

SPIREが発行するJWT-SVIDをClaude APIの認証に使う設定を、OIDC Discovery ProviderとKubernetesの構成で説明する。

SPIREを使うのはどんな環境か

Workload Identity Federation(WIF)が対応するIdPは、AWS・Google Cloud・GitHub Actionsのような単一クラウドのネイティブIDだけではありません。SPIFFEはCNCFが定めるワークロードID発行の標準規格で、SPIREはそのオープンソース実装です。Pod・仮想マシン・ベアメタルのどこで動くワークロードでも、同じSPIFFE IDの仕組みでClaude APIにつなげます。

SPIFFEはワークロードごとにspiffe://<trust-domain>/<path>という安定したID URIを割り当て、SPIREはそれをJWT-SVID(JWT形式のSVID)として必要な時に発行します。JWT-SVIDは普通の署名付きJWTで、subクレームがSPIFFE ID、audクレームは取得時にワークロード側が指定します。AnthropicはOIDC互換のJWT-SVIDを発行するSPIFFE実装であれば、SPIREに限らずどれでも連携できます。商用のSPIFFE実装も対象です。

前提条件

  • ワークロードIDが発行済みのSPIFFE環境(この記事の例はSPIRE Server/Agent)と、Claude APIを呼ぶワークロード向けの登録エントリ
  • trust domain用のOIDC discoveryエンドポイント(SPIREではOIDC Discovery Provider)が公開HTTPSで到達可能であること、または登録用にエクスポート済みのJWKS
  • JWT-SVIDのissクレームを、federation issuerのissuer_urlとして登録する値に一致させる設定(discoveryモードならdiscoveryエンドポイントの公開URL、SPIREではjwt_issuerサーバー設定)
  • JWT-SVIDにiatクレームが入ること(JWT-SVIDの仕様では必須ではなく、SPIREは自動で設定する。他の実装では設定が要る)
  • JWT-SVID(X.509-SVIDではない)がワークロードから取得可能であること
  • Claude Consoleでサービスアカウント・federation issuer・federation ruleを作成できる権限

JWT-SVID取得時にリクエストするaudience値は常にhttps://api.anthropic.comです。spiffe-helperのjwt_audience、Workload APIのFetchJWTSVID呼び出し、federation ruleのaudienceマッチャーのすべてでこの値を使います。

SPIREを構成する

このセクションはSPIRE固有です。別のSPIFFE実装を使う場合は、そのプロバイダーのOIDC discoveryとJWT-SVID取得の手順に従い、Anthropic側の設定セクションから読み進めてください。

JWT issuerを揃える

AnthropicはJWT-SVIDのissクレームを登録済みのfederation issuerと突き合わせ、そのissuerのdiscoveryドキュメントからJWKSを取得して署名を検証します。ここで2つのSPIRE設定が同じURLを指している必要があります。SPIRE Serverのjwt_issuer(発行されるすべてのJWT-SVIDのissクレームになる値)と、OIDC Discovery Providerのdomains(discoveryドキュメントとJWKSを配信するホスト)です。

server {
    trust_domain         = "prod.example.com"
    jwt_issuer           = "https://oidc-discovery.prod.example.com"
    default_jwt_svid_ttl = "5m"
    # ...
}

SPIREの既定のJWT-SVID寿命は5分と短く、継続的なローテーションが前提です。Anthropicのトークン交換エンドポイントは、identity tokenの寿命がfederation issuerに設定した上限(既定1時間)を超えると拒否します。これはSPIRE固有ではなく、すべてのSPIFFE実装に共通するルールなので、default_jwt_svid_ttlやエントリ単位の上書き値をこの上限以下に保ちます。

trust domain(prod.example.com)と発行者URLは別物で、同じホスト名である必要はありません。trust domainはsubの範囲を決め、発行者URLはAnthropicが署名鍵を取りに来る場所を決めます。

OIDC Discovery Provider側の設定にも同じホスト名をdomainsに含め、SPIRE ServerのAPIソケットに到達できるようにします。TLSはproviderのACME機能で終端するか、手前のロードバランサーに任せます。

domains = ["oidc-discovery.prod.example.com"]
 
server_api {
    address = "unix:///run/spire/sockets/private/api.sock"
}
 
acme {
    email        = "platform@example.com"
    tos_accepted = true
}

この例のserver_apiは、providerをSPIRE Serverの特権APIソケットにつなぎます。providerがServer APIに触れてはいけない場合や、Serverに到達できないノードで動かす場合は、workload_apiブロック(socket_pathとtrust_domain)でSPIRE AgentのWorkload API経由にバンドルを取れます。

ワークロードを登録する

Claude APIを呼ぶ各ワークロードには、実行時のセレクタをSPIFFE IDにマッピングするSPIRE登録エントリが必要です。すでに登録済みならそのSPIFFE IDをfederation ruleのsubject_prefixに使い、未登録なら新規作成します。Kubernetes Podの場合、セレクタは通常namespaceとKubernetesサービスアカウントです。

# NODE_UIDはノードのUIDに置き換える
#   kubectl get node <node-name> -o jsonpath='{.metadata.uid}'
spire-server entry create \
    -spiffeID spiffe://prod.example.com/ns/inference/sa/worker \
    -parentID spiffe://prod.example.com/spire/agent/k8s_psat/prod-cluster/NODE_UID \
    -selector k8s:ns:inference \
    -selector k8s:sa:worker

このparentIDは特定の1ノードのAgent IDです。クラスタ全体に効かせるときは、エントリの親をノードエイリアスにします。Kubernetes以外のワークロードはunix:uid:1000のようなホスト側のセレクタを使い、unix:pathは、Agentのunix workload attestorでdiscover_workload_path = trueにしたときだけ使えます。spire-controller-managerを入れたクラスタなら、ClusterSPIFFEIDリソースでエントリを宣言する手もあります。

spiffe-helperを動かす

spiffe-helperは、SPIRE Agentのソケットに接続してJWT-SVIDを取得し、ファイルに書き出して期限前に再取得するサイドカーです。

agent_address = "/run/spire/sockets/agent.sock"
cert_dir      = "/var/run/secrets/anthropic.com"
daemon_mode   = true
 
jwt_svids = [{
    jwt_audience       = "https://api.anthropic.com"
    jwt_svid_file_name = "token"
}]

Kubernetesでは、spiffe-helperをサイドカーとして動かし、アプリケーションコンテナとemptyDir(medium: Memory)を共有します。SPIRE Agentのソケットはホストからサイドカーにマウントし、共有ボリュームは両方のコンテナの/var/run/secrets/anthropic.comに置きます。アプリ側にはANTHROPIC_IDENTITY_TOKEN_FILE=/var/run/secrets/anthropic.com/tokenをセットします。SVIDがノードのディスクに書かれないよう、ボリュームはメモリ上です。VMやベアメタルでは、spiffe-helperをワークロードと並べてシステムサービスで動かし、共有ディレクトリを指させます。

Anthropic側でissuerとruleを作る

Claude ConsoleでSettings → Workload identityからConnect workload → Custom OIDCを選びます。ウィザードが作るリソースは、Admin APIにコードから送っても同じ結果になります。

federation issuer: discoveryモードでOIDC Discovery Providerの公開URLを登録します。

{
  "name": "spire-prod",
  "issuer_url": "https://oidc-discovery.prod.example.com",
  "jwks": { "type": "discovery" }
}

discoveryモードが使えない環境では、JWKSを自分で取得してinlineモードで登録します。次の2つは、鍵の更新を誰が担うかで分かれます。

くらべる

discoveryとinline、どちらで登録するか

Anthropicが鍵を取りに来る

discovery

発行者URLは公開のHTTPS(ポート443、公開DNSのホスト名)で、IPアドレス直書きは使えません。SPIREが鍵を回しても登録の更新は要らず、Anthropic側のJWKSキャッシュが入れ替わるまで最大1分、署名エラーになることがあります。

鍵を自分で登録する

inline

providerを公開しなくて済みます。取得はcurl https://oidc-discovery.prod.example.com/keysで、返ってきたkeys配列を登録します。issuer_urlはJWT-SVIDのissとの文字列比較にだけ使われ、Anthropicは接続しに行きません。SPIREは既定でCAと同じ周期(ca_ttl、24時間)で署名鍵を回すので、新しい鍵を先に足し、古い鍵は旧トークンが切れた後に消します。残った古い鍵は無期限に信頼され続けます。

inlineの更新を自動化したい場合は、SPIRE ServerのBundlePublisherプラグイン(aws_s3・gcp_cloudstorage・k8s_configmap)をformat = "jwks"にします。鍵のローテーションのたびに外部ストレージへJWKSが書き出され、それを使ってAdmin APIでissuerのinline鍵を更新できます。

発行者URLを内部ホスト名のままにしたい場合は、jwks.discovery_baseにdiscoveryを公開しているURLを別に指定する方法もあります。discovery_baseを設定するとissuer_urlは文字列比較にしか使われないので、jwt_issuerを非公開のホスト名にできます。providerをprivate CAのTLSで公開しているなら、ca_cert_pemで証明書を渡せます。

federation rule: JWT-SVIDのsub(SPIFFE ID)とspiffe-helperに要求させたaudを照合します。

{
  "name": "spire-inference-worker",
  "issuer_id": "fdis_...",
  "match": {
    "subject_prefix": "spiffe://prod.example.com/ns/inference/sa/worker",
    "audience": "https://api.anthropic.com"
  },
  "target": { "type": "service_account", "service_account_id": "svac_..." },
  "workspace_id": "wrkspc_...",
  "oauth_scope": "workspace:developer",
  "token_lifetime_seconds": 600
}

token_lifetime_secondsはAnthropicアクセストークンの寿命であり、JWT-SVID自体の寿命ではありません。アクセストークンの更新はSDKが自動で処理します。

subject_prefixは完全一致か、末尾*の前方一致です。それより細かい条件が要る場合は、CELのconditionを使います。ルールのfdrl_...のIDは、ワークロードのANTHROPIC_FEDERATION_RULE_IDに入れます。

SVIDの5分とアクセストークンの寿命はどう噛み合うか

登場する時間の数字は5つあり、決める主体がそれぞれ違います。

数字

SPIREとWIFの時間の数字

  • SVIDの寿命

    5分

    SPIREの既定。`default_jwt_svid_ttl`で変更

  • issuerの上限

    1時間

    `exp`と`iat`の差がこれを超えると拒否

  • アクセストークン

    600秒

    ルールの`token_lifetime_seconds`(コンソールの初期値)

  • 時計のずれ

    30秒

    `exp`・`nbf`・`iat`に許される猶予

  • JWKSキャッシュ

    最大1分

    新しい署名鍵が通るまでの遅れ

SVIDの寿命はSPIRE側、残りはAnthropic側の値

発行されるアクセストークンの寿命は、ルールのtoken_lifetime_secondsと、提示したJWTの残り寿命の2倍のうち短い方です。ただし下限は60秒です。SVIDが5分なら残り寿命は最大300秒で、2倍の600秒がルールの値と一致します。交換のタイミングでSVIDの残りが100秒しかなければ、トークンは200秒になります。

jtiクレームを持つJWTは、1つのissuerに対して1回しか交換できません。SDKは更新のたびにANTHROPIC_IDENTITY_TOKEN_FILEを読み直すので、spiffe-helperがファイルを書き換える前に再交換が走ると、前回と同じトークンがjti_reusedで拒否されます。ファイルの更新をアクセストークンの寿命より十分短い間隔で回すと、更新がSDKの再交換に先行します。間隔を縮められないトークン源には、issuerのcheck_jtiを無効にする逃げ道がありますが、そのissuerの全ルールでリプレイ防止が外れます。SPIFFE実装がSVIDにjtiを入れているかは、次の検証で実物を見て分かります。

トークンを取得してClaude APIを呼ぶ

Anthropic SDKは、spiffe-helperが維持するファイルからJWT-SVIDを読む方法と、SPIFFE Workload APIを直接呼ぶcallableを渡す方法のどちらにも対応します。ファイル経由が最もシンプルで、どのSDK言語でも動きます。

JWT=$(cat "$ANTHROPIC_IDENTITY_TOKEN_FILE")
 
ACCESS_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \
  -H "content-type: application/json" \
  --data @- <<JSON | jq -r .access_token
{
  "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
  "assertion": "$JWT",
  "federation_rule_id": "$ANTHROPIC_FEDERATION_RULE_ID",
  "organization_id": "$ANTHROPIC_ORGANIZATION_ID",
  "service_account_id": "$ANTHROPIC_SERVICE_ACCOUNT_ID",
  "workspace_id": "$ANTHROPIC_WORKSPACE_ID"
}
JSON
)
 
curl https://api.anthropic.com/v1/messages \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{"model": "claude-opus-5-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "Hello, Claude"}]}' \
  | jq -r '.content[] | select(.type == "text") | .text'

Workload APIクライアントがある言語(Pythonのpy-spiffe、Goのgo-spiffe)では、spiffe-helperを省いてSDKにcallableを渡せます。PythonではWorkloadIdentityCredentialsのidentity_token_providerに、SVIDを返す関数を渡します。GoのSDKではoption.WithFederationTokenProviderが同じ役割です。それ以外の言語は、spire-agent api fetch jwtでトークンをファイルに書き、ANTHROPIC_IDENTITY_TOKEN_FILEで読ませます。SDKはトークン交換のたびにこのcallableを呼ぶため、常に有効期限内のSVIDを提示できます。

検証方法

SDKを組み込む前に、SPIRE AgentからJWT-SVIDを直接取得し、federation ruleが期待するクレームと一致しているかを確認します。

spire-agent api fetch jwt \
    -audience https://api.anthropic.com \
    -socketPath /run/spire/sockets/agent.sock \
    -output json \
  | jq -r '.[0].svids[0].svid' \
  | jq -rR 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'

Workload APIは呼び出し元のプロセスをattestationで確認するため、Kubernetesの登録エントリなら該当Podの中(kubectl exec経由など)で実行します。VM・ベアメタルならunix:セレクタに一致するユーザー・プロセスとして実行します。attestation対象外のホストシェルから実行するとno identity issuedになり、検証時に最も多い失敗です。

デコードした結果は次の形です(値は例)。

... | jq '{iss, sub, aud, ttl: (.exp - .iat), has_jti: has("jti")}'
{
  "iss": "https://oidc-discovery.prod.example.com",
  "sub": "spiffe://prod.example.com/ns/inference/sa/worker",
  "aud": [
    "https://api.anthropic.com"
  ],
  "ttl": 300,
  "has_jti": false
}

ttlが300ならSPIREの既定の5分で、issuerの上限(既定1時間)に収まっています。has_jtiがtrueなら、前の節の単回使用の話がそのまま当てはまります。

issが発行者URL、subがワークロードのSPIFFE ID、audにhttps://api.anthropic.comが入っていれば、上のcURL例を実行します。成功するとsk-ant-oat01-で始まるaccess_tokenが返ります。ルールの検証に失敗すると、401のauthentication_error(Authentication failed)が返ります。

交換が失敗したときの切り分け

401は、どの検査で落ちても同じ文面で返ります。エラーの一覧はWIFリファレンスにあります。理由はClaude Consoleの認証履歴ページにだけ出ます。履歴を見られない場合は、デコードしたJWTを次の順に当てます。

手順

SPIFFE連携の失敗を切り分ける

  1. 1

    iss を発行者URLと1文字ずつ比べる

    SPIRE側のjwt_issuerが、登録したissuer_urlと末尾のスラッシュやポートまで一致しているかを見ます。SPIRE側で最も多い原因はこれです。

  2. 2

    aud と sub をルールに当てる

    audにhttps://api.anthropic.comが含まれることと、subがルールのsubject_prefixで始まることを確かめます。subject_prefixは大文字小文字を区別します。

  3. 3

    iat と exp と時計を見る

    iatが未来、またはexpが過去(どちらも30秒の猶予あり)なら拒否されます。ワークロードのホストの時計がずれていても同じ結果になります。

  4. 4

    jwks の取得経路を確かめる

    discoveryなら、/.well-known/openid-configurationが公開HTTPSの443番ポートで開け、jwks_uriが解決できるかを見ます。取得先は、discovery_baseを設定していればそのURL、なければ発行者URLです。inlineなら、SPIREが鍵を回した後に登録を更新したかを見ます。

  5. 5

    jti_reused が出ていないか履歴を見る

    履歴にjti_reusedが出ていれば、ファイルが更新される前に同じトークンで再交換しています。

ルールのスコープを絞る

SPIFFE IDのパス規則は運用者が自由に決めるため、subject_prefixは登録エントリで使っているパス構成に合わせます。よくある構成は、Kubernetesのspire-controller-managerがClusterSPIFFEIDリソースで自動生成するspiffe://<trust-domain>/ns/<namespace>/sa/<service-account>と、VM・ベアメタル向けのspiffe://<trust-domain>/host/<hostname>/<service>です。

subject_prefixをspiffe://prod.example.com/*にすると、trust domain内の全ワークロードが対象になります。audienceがなければ、無関係な相手向けに発行されたSVIDも通ります。なおmatchがaudienceだけのルールは、subject_prefix・claims・conditionのいずれもないので登録時に拒否されます。

  • 1ワークロードに固定: subject_prefixを末尾*なしの完全なSPIFFE IDにする
  • audienceを必ず設定: ルールにaudienceを必須にし、spiffe-helper(またはWorkload API呼び出し)にも同じ値を要求させ、別の依拠者向けに発行されたSVIDを拒否する
  • パスセグメントで絞る: spiffe://prod.example.com/ns/inference/*のようにnamespace単位で許可し、1つのルールを広げるのではなくnamespaceごとに別ルール・別サービスアカウントを作る
  • trust domainごとに1 issuer: SPIREのtrust domainごとに署名鍵とOIDC Discovery Providerが異なるため、それぞれ別のfederation issuerとして登録し、対応するSPIFFE IDにルールを紐づける

まとめ

SPIREのjwt_issuerが登録したissuer_urlと一致していて、SVIDの更新がSDKの再交換に先行していれば、連携は通ります。subject_prefixとaudienceは、登録エントリのパス構成に合わせて絞ります。OktaのようなIdPと役割を切り分けたい場合は、ClaudeのWIFをOktaと連携する手順も参照してください。

この記事を共有:XはてブLinkedIn