Documentation
¶
Overview ¶
Package credrefresh is the subscription-safe keep-warm OAuth refresh daemon (Part B of the recurring "/login" outage fix). It reads the single canonical `.credentials.json` for a profile, and when the access token is within a short window of expiry, exchanges the rotating refresh token for a fresh one at Anthropic's OAuth token endpoint and atomically writes the result back.
Why a daemon at all. Claude Code stores OAuth credentials in `$CLAUDE_CONFIG_DIR/.credentials.json` and re-reads that file from disk on access-token expiry (confirmed by disassembling v2.1.159 — the Linux plaintext store has no in-memory cache and watches the file mtime). When N agent-deck worker sessions all symlink their scratch `.credentials.json` to ONE canonical file, Claude's cross-process lock — keyed on `realpath(...)` — collapses to a single lock, so the workers serialize their refreshes instead of racing. A refresh daemon that keeps the canonical token warm removes even the brief lock-contention storm and the cold-start case where the whole host was asleep past expiry. No API key, no per-worker restart: workers pick up the new token on their next disk read. See /tmp/oauth-fix/SUBSCRIPTION-FIX.md and the bug_oauth_multisession_rotation root-cause memo.
This package NEVER hits the real endpoint in tests and NEVER hardcodes a token: the endpoint and HTTP client are injectable, and tests pass a mock.
Index ¶
Constants ¶
const ( // DefaultTokenEndpoint is Claude Code's OAuth token endpoint (the // `claudeAiOauth` subscription flow), read from the shipped binary. // #nosec G101 -- this is a public OAuth endpoint URL, not a credential. DefaultTokenEndpoint = "https://platform.claude.com/v1/oauth/token" // ClaudeCodeClientID is Claude Code's well-known public OAuth client_id, // used as a fallback when the stored credentials omit `clientId`. The // token endpoint rejects refresh requests without a client_id. // #nosec G101 -- public OAuth client identifier, not a secret. ClaudeCodeClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" // DefaultThreshold is the early-refresh window: refresh when the access // token expires within this much time. Access tokens live ~1h. DefaultThreshold = 20 * time.Minute // DefaultInterval is the recommended daemon cadence. Shorter than the // ~1h token lifetime so a single missed tick never lets the token lapse. DefaultInterval = 25 * time.Minute )
Variables ¶
This section is empty.
Functions ¶
func CanonicalCredPath ¶
CanonicalCredPath returns the canonical credentials path for a profile config dir (e.g. ~/.claude → ~/.claude/.credentials.json).
func Run ¶
func Run(ctx context.Context, cfg DaemonConfig)
Run keeps the canonical credentials warm until ctx is cancelled. It refreshes immediately on startup (so a host that woke past expiry is healed at once), then on every Interval tick. Each profile is refreshed independently; one profile's error never blocks another. Run blocks until ctx is done.
Types ¶
type DaemonConfig ¶
type DaemonConfig struct {
// ConfigDirs are the profile config dirs to keep warm (one per profile,
// e.g. ~/.claude and ~/.claude-work). The canonical credentials path is
// derived from each.
ConfigDirs []string
// Interval is the tick cadence. Defaults to DefaultInterval.
Interval time.Duration
// Refresh is the per-attempt config (endpoint, client, threshold, clock).
Refresh RefreshConfig
// OnResult, if set, is called after every refresh attempt with the
// canonical path, result, and error. Used for logging.
OnResult func(credPath string, res RefreshResult, err error)
}
DaemonConfig configures the keep-warm refresh loop.
type RefreshConfig ¶
type RefreshConfig struct {
// TokenEndpoint is the OAuth /token URL. Defaults to DefaultTokenEndpoint.
TokenEndpoint string
// HTTPClient performs the token request. Defaults to a client with httpTimeout.
HTTPClient *http.Client
// Threshold is the early-refresh window. Defaults to DefaultThreshold.
Threshold time.Duration
// Now is an injectable clock for the expiry decision. Defaults to time.Now.
Now func() time.Time
}
RefreshConfig configures a single refresh attempt. All fields are optional; zero values fall back to the package defaults.
type RefreshResult ¶
type RefreshResult struct {
// Refreshed is true iff a rotation happened and canonical was rewritten.
Refreshed bool
// Reason explains a no-op (e.g. "token not near expiry").
Reason string
// ExpiresAt is the access-token expiry after the call (rotated or current).
ExpiresAt time.Time
}
RefreshResult reports the outcome of a single RefreshIfNeeded call.
func RefreshIfNeeded ¶
func RefreshIfNeeded(credPath string, cfg RefreshConfig) (RefreshResult, error)
RefreshIfNeeded reads the canonical credentials at credPath, and if the access token expires within cfg.Threshold, exchanges the rotating refresh token for a fresh one and atomically rewrites canonical (temp+rename, 0600).
It serializes against Claude's own refreshes by holding a proper-lockfile-compatible lock at `realpath(credPath)+".lock"` for the duration of the read-rotate-write. On any endpoint error the canonical file is left byte-for-byte unchanged — a transient 4xx/5xx must never corrupt or half-write the only token. Unknown fields (subscriptionType, rateLimitTier, and any extra keys) are preserved verbatim across the round-trip.