トークンが定期的に失効・ローテーションしても再デプロイ不要にする。 - 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)検証済み
126 lines
4.9 KiB
Python
126 lines
4.9 KiB
Python
#!/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
|