#!/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