Files
claude-code/files/token-refresher.py
T
claude 528a48b667
Helm Chart Release / release-chart (push) Successful in 9s
Build Session Image / build-push (push) Successful in 2m8s
feat: credentials 認証モードとトークン自動リフレッシュ CronJob を追加 (v2.1.263-b)
トークンが定期的に失効・ローテーションしても再デプロイ不要にする。

- anthropic.authMode: credentials(新既定)
  ~/.claude/.credentials.json 全体(refreshToken 込み)を Secret に格納。
  セッション Pod にはファイルとしてマウントし $HOME/.claude へコピー(書き込み可)、
  セッション内 claude が refreshToken で自己更新
- anthropic.refresher CronJob(credentials 時・既定有効, files/token-refresher.py)
  Secret 内 refreshToken で OAuth トークンを更新し Secret を書き戻す。
  専用 SA + resourceNames 限定 Role で該当 Secret の get/patch のみ。
  tokenUrl/clientId は Claude Code CLI の非公式値(values 上書き可)
- manager.py build_pod: credentials は envFrom を使わずファイルマウント +
  SEED_CREDENTIALS_FILE。apiKey/oauth は従来どおり envFrom
- Secret に helm.sh/resource-policy: keep(credentials 時)で refresher の書込を保持
- fix: values.schema.json の $schema 行を削除(一部の Helm で
  "no schema with key or ref https://json-schema.org/draft-07/schema#" エラー)
- helm lint / template(credentials/apiKey/oauth)検証済み
2026-09-08 15:18:21 +09:00

126 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
claude-code token-refresher — credentials モードのアクセストークン更新(CronJob)
Secret 内の ~/.claude/.credentials.json(claudeAiOauth.refreshToken)を使って
新しい accessToken を取得し、Secret を書き戻す。これにより、認証情報を注入した
マシンがオフラインでも、新規セッションは常に有効なトークンで起動できる。
依存: Python 標準ライブラリのみ。
権限: セッション NS の対象 Secret 1 個に対する get / patch のみ(RBAC で限定)。
⚠ tokenUrl / clientId は Claude Code CLI の OAuth クライアント値(非公式)。
仕様変更で 4xx が返るようになったらここを更新する。その場合でも、
セッション内 claude の自己リフレッシュは独立して機能する。
"""
import base64
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
NS = os.environ["SESSIONS_NAMESPACE"]
SECRET = os.environ["ANTHROPIC_SECRET"]
KEY = os.environ.get("CREDENTIALS_KEY", ".credentials.json")
TOKEN_URL = os.environ.get("TOKEN_URL", "https://console.anthropic.com/v1/oauth/token")
CLIENT_ID = os.environ.get("CLIENT_ID", "9d1c250a-e61b-44d9-88ed-5944d1962f5e")
MARGIN = int(os.environ.get("REFRESH_IF_EXPIRES_IN", "3600"))
_SA = "/var/run/secrets/kubernetes.io/serviceaccount"
with open(_SA + "/token", encoding="utf-8") as fh:
_K8S_TOKEN = fh.read().strip()
_CTX = ssl.create_default_context(cafile=_SA + "/ca.crt")
_API = "https://kubernetes.default.svc"
def log(msg):
print(time.strftime("%Y-%m-%dT%H:%M:%S"), msg, flush=True)
def k8s(method, path, body=None, ctype="application/json"):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(_API + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + _K8S_TOKEN)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", ctype)
with urllib.request.urlopen(req, context=_CTX, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def secret_path():
return "/api/v1/namespaces/%s/secrets/%s" % (urllib.parse.quote(NS), urllib.parse.quote(SECRET))
def oauth_refresh(refresh_token):
payload = json.dumps({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
}).encode()
req = urllib.request.Request(TOKEN_URL, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
req.add_header("User-Agent", "claude-code-helm-token-refresher")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")[:400]
raise SystemExit("token endpoint HTTP %s: %s" % (exc.code, body))
def main():
sec = k8s("GET", secret_path())
b64 = (sec.get("data") or {}).get(KEY)
if not b64:
raise SystemExit("secret %s/%s にキー %r がありません" % (NS, SECRET, KEY))
try:
creds = json.loads(base64.b64decode(b64).decode("utf-8"))
except Exception as exc: # noqa: BLE001
raise SystemExit("credentials JSON をパースできません: %s" % exc)
oauth = creds.get("claudeAiOauth")
if not isinstance(oauth, dict) or not oauth.get("refreshToken"):
raise SystemExit("claudeAiOauth.refreshToken がありません(credentials モードの Secret ではない?)")
now_ms = int(time.time() * 1000)
expires_at = int(oauth.get("expiresAt") or 0)
if expires_at and now_ms < expires_at - MARGIN * 1000:
remain = (expires_at - now_ms) // 1000
log("まだ有効(残り %ds、しきい値 %ds)。更新不要。" % (remain, MARGIN))
return
log("トークンを更新します(expiresAt=%s, now=%s)" % (expires_at or "?", now_ms))
resp = oauth_refresh(oauth["refreshToken"])
access = resp.get("access_token")
if not access:
raise SystemExit("応答に access_token がありません: %s" % json.dumps(resp)[:300])
oauth["accessToken"] = access
if resp.get("refresh_token"):
oauth["refreshToken"] = resp["refresh_token"]
if resp.get("expires_in"):
oauth["expiresAt"] = now_ms + int(resp["expires_in"]) * 1000
creds["claudeAiOauth"] = oauth
new_b64 = base64.b64encode(json.dumps(creds).encode("utf-8")).decode("ascii")
patch = {"data": {KEY: new_b64}}
k8s("PATCH", secret_path(), patch, ctype="application/merge-patch+json")
log("Secret %s/%s を更新しました(新 expiresAt=%s)" % (NS, SECRET, oauth.get("expiresAt")))
if __name__ == "__main__":
try:
main()
except SystemExit as exc:
if exc.code not in (0, None):
log("ERROR: %s" % exc.code)
raise