credrefresh

package
v1.10.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT Imports: 10 Imported by: 0

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

View Source
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

func CanonicalCredPath(configDir string) string

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL