Skip to content

feat: cache the OAuth access token across invocations - #140

Merged
leet-c1 merged 5 commits into
mainfrom
perf/cache-access-token
Sep 19, 2026
Merged

leet-c1 merged 5 commits into
mainfrom
perf/cache-access-token

Conversation

@leet-c1

@leet-c1 leet-c1 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What

Cache the OAuth access token across c1i invocations so a run of one-shot
commands mints — and audit-logs — a token once instead of every time.

Why

Every c1i process minted a fresh client_credentials token before its first
request, and C1 records each mint as an OCSF Authentication event (class 3002)
in the tenant's system log. Agent workflows are long sequences of one-shot
processes, so c1i was writing one authentication event per command into
customers' audit logs / SIEMs.

Measured live on a lab tenant (audit log = ground truth, filtered to this
CLI's own client id, polled to stabilization to beat ingestion lag):

12 sequential commands client_credentials events
before 12/12 ok 12
after 12/12 ok 1

How

  • NewCachingTokenSource wraps the mint with an on-disk cache: a 0600 file
    under the user config dir, keyed by sha256(host \0 client-id), written
    atomically (temp-per-PID + rename). Tiers: in-memory → disk → mint.
  • Only the bearer c1i attaches automatically (the REST client) is cached.
    auth token and the MCP gateway keep minting fresh via the uncached
    NewTokenSource, since they hand the raw bearer to the caller — so
    auth token's "not written to disk; minted each invocation" contract holds.
  • Self-heal: a cached token the server rejects (clock skew past the 60s
    skew, or a server-side revocation) is dropped and re-minted once via a
    401-only retry (retryOnTokenReject), so a poisoned entry can't 401 every
    invocation until local expiry.
  • Opt out with C1I_NO_TOKEN_CACHE=1.

Security

The cached token is strictly shorter-lived than the client secret already
stored beside it and grants nothing that secret couldn't re-mint on demand, so
it widens no exposure. (Owner decision: token-at-rest ≈ the client-id/secret
pair at rest.)

Testing

  • Unit tests for every cache tier and the 401 self-heal; mutation-tested
    (11 injected defects, all caught — one surfaced a vacuous test, since fixed
    to isolate the in-memory tier).
  • Live-verified on leet: REST burst 0 grants (cached), auth token 2 grants
    (uncached); corrupt cache → mint, expired → re-mint, perms 0600.
  • All CI gates green locally: build, vet, test, gofmt, golangci-lint, gosec,
    govulncheck, go mod tidy.
  • Two independent reviews (adversarial + CodeRabbit) on the final commit; both
    cleared it. Two earlier review findings (a skew mismatch and the auth token
    contract) were fixed before this PR.

Deferred (non-blocking, LOW)

  • A genuine non-token 401 costs one extra mint+event before failing (bounded to
    one, and 401-for-authz is server-dependent).
  • Stale cache files (rotated client id / changed --url) are never reaped.
  • A crash between write and rename can leave a *.tmp<pid> file (never read).

🤖 Generated with Claude Code

Every c1i process minted a fresh client_credentials token before its first
request, writing one OCSF Authentication event to the tenant's audit log each
invocation. Measured live, 12 sequential commands produced 12 events; agent
workflows are long sequences of one-shot processes, so this floods customers'
system logs. The same 12 commands now produce 1.

Cache the minted token in a 0600 file under the user config dir, keyed by
host + client id, reused until it nears expiry (60s skew, applied to both the
on-disk and in-memory tiers). Only the bearer c1i attaches automatically (the
REST client) is cached; auth token and the MCP gateway still mint fresh, since
they hand the bearer to the caller.

A cached token the server rejects -- clock skew past the skew window, or a
server-side revocation -- is dropped and re-minted once via a 401-triggered
retry, so a poisoned entry cannot 401 every invocation until local expiry.

Opt out with C1I_NO_TOKEN_CACHE=1. The cached token is strictly shorter-lived
than the client secret already stored beside it and grants nothing that secret
could not re-mint on demand, so it widens no exposure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

leet-c1 commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

Review findings — changes required before live acceptance validation:

  1. internal/client/client.go:175-187: the 401 recovery does not actually obtain a new bearer. oauth2.NewClient wraps cacheTokenSource in its own ReuseTokenSource; invalidating the inner cache at retryOnTokenReject.RoundTrip leaves the outer source holding the rejected token, so the retry resends it. The 60-second expirySkew is bypassed for the same reason once that outer cache holds a token. Make the retry clear every caching layer or own the OAuth transport/source, and add an integration-level test that proves a 401 retry changes the Authorization bearer.

  2. internal/tokensource/cache.go:39-42 keys only by host and client ID. Replacing a client secret with the same ID can cause auth login verification to succeed using the old cached bearer rather than the supplied secret. Include a secret-generation discriminator in the cache namespace or bypass/invalidate the cache during credential verification; cover credential replacement.

  3. internal/tokensource/cache.go:98-111 follows pre-existing directories/files and uses a predictable temp filename. The implementation can write a freshly minted bearer through unsafe pre-existing paths, while its test only checks fresh paths. Tighten safe file creation/path validation before caching a bearer.

  4. internal/tokensource/cache.go:66 reads an unbounded cache file before validation. A large corrupt file can OOM the CLI instead of degrading to a cache miss. Bound the read and test the malformed oversized path.

  5. cmd/agents.md:43-48 says the minted access token is cached without qualifying the path. That conflicts with the deliberate auth token and MCP gateway contracts, both of which must mint fresh and must not persist the exposed bearer. Scope the text to automatic REST-client bearer attachment and state the exceptions.

leet-c1 and others added 4 commits September 19, 2026 01:37
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@leet-c1
leet-c1 merged commit dc10491 into main Sep 19, 2026
2 checks passed
@leet-c1
leet-c1 deleted the perf/cache-access-token branch September 19, 2026 01:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant