Skip to content

feat: automatically rotate SDK API tokens#445

Open
timmarkhuff wants to merge 18 commits into
mainfrom
thuff/gl-1709-token-auto-refresh-independent
Open

feat: automatically rotate SDK API tokens#445
timmarkhuff wants to merge 18 commits into
mainfrom
thuff/gl-1709-token-auto-refresh-independent

Conversation

@timmarkhuff

@timmarkhuff timmarkhuff commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Mint working tokens from the configured API token and rotate them in the background via a locked on-disk cache.
  • On first mint, park the configured ("bootstrap") token as previous for delayed cleanup instead of revoking it immediately.
  • Recover from HTTP 401s by adopting a fresher cached token, clean up superseded tokens after a grace period, and support graceful client shutdown.
  • Regenerate the API token client from the updated OpenAPI spec (by-snippet lookup, create, delete).

Interim testing constants

TOKEN_TTL_DAYS is ~3 minutes and REFRESH_INTERVAL_DAYS is ~1 minute for live rotation testing. TODOs in token_manager.py call out reverting these before production merge, and a follow-up once zuuul exposes identity token_ttl on public /v1/api-tokens:

  • discover rotate-vs-not from by-snippet token_ttl (null => no refresh thread)
  • derive refresh_interval from mint expires_at as observed_ttl / 30

Dependency

  • Requires merged zuuul GET /v1/api-tokens/by-snippet/<snippet> (zuuul#6579).
  • ApiIdentity server contract is in production (zuuul#6614–#6616); public token_ttl discovery is a follow-up (see zuuul docs/gl-1709-public-api-token-ttl.md).

Test plan

  • Token caching, rotation, previous-parking, cleanup, and 401 recovery tests
  • Generated API token tests
  • Ruff, Pylint, and mypy

Rotate short-lived working tokens through a locked disk cache while preserving bootstrap credentials for recovery.

Co-authored-by: Cursor <cursoragent@cursor.com>
@timmarkhuff timmarkhuff changed the title Add automatic API token refresh feat: automatically rotate SDK API tokens Jul 9, 2026
Tim Huff and others added 17 commits July 9, 2026 11:59
Keep the new close method from being auto-registered as a shell command so CLI initialization and tests continue to work.

Co-authored-by: Cursor <cursoragent@cursor.com>
Back off after failed refreshes, preserve pending cleanup metadata, and make 401 recovery safe for streamed and raw HTTP requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fall back to the bootstrap token when a server does not yet expose token management, preserving compatibility during deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replay generated stream bodies correctly, cover note requests, and make locking and shutdown safe for in-flight refresh work.
The bootstrap token has only one job: mint the first working token.
Keeping it alive afterward is a security risk -- a leaked bootstrap
can silently mint tokens indefinitely. Revoke it immediately after
the first slot is written.

Consequences:
- 401 recovery no longer falls back to bootstrap to re-mint; it
  adopts a fresher cached token from another process or raises loudly
  requiring human intervention (provision a new bootstrap token).
- refresh() raises on a missing slot rather than re-minting from
  bootstrap, since the bootstrap is gone.

Updates plan doc and Google Doc accordingly.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ing rotation

The bootstrap token's name (suffix-stripped) is now written into the slot cache
as base_name once, at first mint. Subsequent rotations read it from the slot
instead of doing a paginated list call to rediscover the current token's name.

Key changes:
- TokenSlot gains a base_name field; old slots with no field fall back to a
  live lookup via the new _resolve_base_name helper.
- _initialize_token looks up the bootstrap token once; the result seeds
  base_name and is passed directly to _revoke_bootstrap, eliminating the
  second paginated scan that revocation previously required.
- _mint_replacement now receives base_name as a parameter rather than
  doing its own lookup.
- _new_token_name simplified: it no longer accepts None or strips suffixes
  (that responsibility moved to _resolve_base_name).

Co-authored-by: Cursor <cursoragent@cursor.com>
The GET /v1/api-tokens/by-snippet/<snippet> endpoint (zuuul#6579) is
now deployed. Replace the paginated list scan with a single direct
call, removing _find_token_by_snippet and TOKEN_PAGE_SIZE entirely.

- _initialize_token and _resolve_base_name both call _get_token_by_snippet;
  NotFoundException means "token not found" and falls back gracefully.
- Revert TOKEN_TTL_DAYS to 30 and REFRESH_INTERVAL_DAYS to 1 (temporary
  short values were only for live rotation testing).
- Update tests: list_api_tokens mocks replaced with
  get_api_token_by_snippet; _page helper and EXPECTED_PAGE_COUNT removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the configured token alive for delayed cleanup after the first
working token is minted, and document the interim hardcoded testing
TTL/refresh cadence until identity token_ttl discovery lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
Allow null last_used_at on API token responses so minting a never-used
token does not fail OpenAPI deserialization during client init, and
annotate next_previous for mypy.

Co-authored-by: Cursor <cursoragent@cursor.com>
Short testing refresh intervals raced with urllib3 mocks and inflated
call counts. Close the configure-time probe client and pause refresh on
the retry-test fixture.

Co-authored-by: Cursor <cursoragent@cursor.com>
Short testing refresh intervals left orphaned refresh threads that raced
urllib3 mocks in HTTP retry tests. Default tests to
GROUNDLIGHT_DISABLE_TOKEN_REFRESH=1 and close client fixtures.

Co-authored-by: Cursor <cursoragent@cursor.com>
Rotate whenever the current token is due: revoke previous, demote
current, mint a replacement. Drop grace-blocked minting that could
outrun short test TTLs, and clarify configured-token error messages.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the helper for local testing only; it does not belong in the
branch under review.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pass the server's unauthorized detail through to ApiTokenError so
expired or revoked tokens are reported clearly, and silence the
known broad-exception catches that pylint flags in TokenManager.

Co-authored-by: Cursor <cursoragent@cursor.com>
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