Skip to content

feat(cli-auth): sliding board-key renewal + expiry visibility - #579

Merged
kkroo merged 2 commits into
masterfrom
feat/cli-auth-refresh
Jul 2, 2026
Merged

feat(cli-auth): sliding board-key renewal + expiry visibility#579
kkroo merged 2 commits into
masterfrom
feat/cli-auth-refresh

Conversation

@kkroo

@kkroo kkroo commented Jul 2, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Board API keys (pcp_board_*) are how CLIs, MCP bridges, the ccrotate model proxy, and k8s agents authenticate to the board API
  • Keys are minted with a hard 30-day TTL through the interactive cli-auth challenge flow, nothing can renew them, and clients cannot see expiry coming — /api/cli-auth/me doesn't expose it
  • On 2026-07-02 every key from a June-2 rotation hit its TTL inside a two-hour window (10:28/11:31/12:38 UTC) and the whole fleet 401'd at once: ccrotate proxy, MCP servers, k8s agents
  • This pull request makes expiry visible on /me and adds a sliding-renewal endpoint so an actively-used key extends itself before dying
  • The benefit is that routine use never fire-drills into a fleet outage, while abandoned tokens still expire within 30 days

Linked Issues or Issue Description

No pre-existing issue — incident-driven. Inline issue description following .github/ISSUE_TEMPLATE/bug_report.yml:

What happened?
All board-key consumers began receiving 401 {"error":"invalid or expired token"} from the ccrotate auth proxy and board API on 2026-07-02 ~10:28–12:38 UTC. Every board API key minted in a June-2 rotation hit its hard 30-day TTL in the same window; there is no renewal API, no expiry surface on /api/cli-auth/me, and recovery requires the interactive browser challenge flow on every affected machine. Impact: ccrotate model proxy, all paperclip-fronted MCP servers, the penstock-serve-anthropic vault credential, and k8s opencode agents.

Expected behavior
Actively-used credentials should not mass-expire with no warning and no non-interactive recovery path — clients should be able to see expiry coming and renew a still-valid key.

Steps to reproduce

  1. Mint a board API key via the cli-auth challenge flow (30-day TTL).
  2. Wait 30 days without re-running the interactive flow.
  3. Every consumer of that key 401s simultaneously; /api/cli-auth/me gave no advance signal.

Paperclip version or commit
432f0de78 (master, 2026-07-02)

  • I searched the GitHub PR list for similar PRs (dedup-search)

What Changed

  • GET /api/cli-auth/me now returns the board key's expiresAt (null for session actors and never-expires keys); the auth middleware carries keyExpiresAt on the actor from the key row it already selects — zero extra queries
  • New POST /api/cli-auth/refresh (board-key bearers only): sliding renewal to a fresh 30-day window via a single guarded conditional UPDATE — never shortens keys that already outlive a fresh TTL (365d service-account keys pass through), never resurrects expired/revoked keys, no-ops on never-expires keys
  • Refreshes are activity-logged as board_api_key.refreshed
  • OpenAPI registry + BOARD_ONLY_OPERATIONS updated for the new route

Verification

  • server/src/__tests__/cli-auth-refresh.test.ts (embedded postgres, real actorMiddleware + accessRoutes, six cases: /me expiry surface; near-expiry extend + activity log + DB assertion; never-shorten long-lived key; never-expires no-op; expired-token 401; non-board-key actor 400)
  • pnpm run typecheck clean; openapi route-consistency tests pass
  • Embedded-pg suites skip on the dev machine (probe unsupported, same as all existing embedded-pg suites) — they run in CI

Risks

  • Low. Additive API surface; no schema change; no existing endpoint's response loses fields (only expiresAt added to /me)
  • Renewal requires a still-valid token, so the abandoned-token security property of the 30-day TTL is preserved
  • Client counterpart (SessionStart hook renewal + setup-script re-mint on dead token): Blockcast/onprem-k8s#1073

Model Used

Claude Fable 5 (claude-fable-5) via Claude Code

🤖 Generated with Claude Code

Board API keys expire 30 days after the interactive cli-auth mint and
nothing could renew them, so every consumer 401s in lockstep when the
TTL lapses (fleet-wide outage on 2026-07-02).

- GET /api/cli-auth/me now returns the key's expiresAt so clients can
  see expiry coming instead of discovering it via 401.
- POST /api/cli-auth/refresh lets a still-valid board key extend itself
  to a fresh 30-day window (no browser challenge; the valid token is
  the proof of identity). Never shortens long-lived service keys, never
  resurrects expired/revoked keys, no-ops on never-expires keys.
- Abandoned machines keep the old security property: no renewal means
  the key still dies within 30 days.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Jul 2, 2026

Copy link
Copy Markdown

Hey @kkroo! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Jul 2, 2026

Copy link
Copy Markdown

Hey @kkroo! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

…rtion

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kkroo
kkroo merged commit 7e56fa0 into master Jul 2, 2026
2 checks passed

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.

Looks good — reviewed the full diff (auth middleware, board-auth.ts, access.ts routes, OpenAPI registry, and the new cli-auth-refresh.test.ts suite). No Critical or Important findings.

The core guarded-UPDATE logic (refreshBoardApiKeyExpiry) is sound: isNull(revokedAt) blocks resurrecting revoked keys, gt(expiresAt, now) blocks resurrecting already-expired keys (which can't reach this handler anyway since findBoardApiKeyByToken already excludes them from auth), isNotNull(expiresAt) correctly no-ops never-expires keys, and lt(expiresAt, target) correctly prevents shortening long-lived (365d) keys. Verified against findBoardApiKeyByToken/getBoardApiKeyForUser in board-auth.ts — the WHERE-clause scoping matches how the token was authenticated, so there's no cross-user key-refresh path. Test suite's six cases line up with the actual guard conditions.

Suggestions (3)

  • [code] server/src/routes/access.ts:2766-2767 — When refreshed is null (never-expires or long-lived key), the handler does a second DB round-trip via getBoardApiKeyForUser to report the current expiry. req.actor.keyExpiresAt was already populated by actorMiddleware from the same key row that authenticated this request, so it can be used directly on the "not refreshed" branch instead of re-querying.
  • [quality] server/src/routes/access.ts:2735 / server/src/services/board-auth.ts:157 — No rate limit or per-key debounce on POST /cli-auth/refresh. Since the sliding-window guard (expiresAt < now + 30d) re-satisfies on almost every call once a key is in its renewal window, a tight retry loop would fan out a board_api_key.refreshed logActivity write per company membership on every request (existing pattern from board_api_key.created/.revoked, so not a new class of risk, but this is a hotter path than those). A cheap debounce (e.g. skip re-refresh if last refresh was <1h ago) would keep the audit log from getting noisy under a misbehaving client.
  • [tests] server/src/__tests__/cli-auth-refresh.test.ts — Good coverage of the guard conditions, but there's no test for calling /cli-auth/refresh twice in quick succession to confirm the second call still extends further (documenting the sliding-window-under-repeat behavior noted above) or a test asserting /me's expiresAt is null for non-board-key (session) actors.

Strengths

  • Zero-extra-query design for /me's expiresAt — reuses the key row already selected during auth instead of adding a query.
  • Guarded conditional UPDATE is the right primitive here: atomic, race-safe, and self-documenting via the WHERE clause rather than a read-then-write.
  • resolveBoardActivityCompanyIds + per-company logActivity fan-out matches the existing convention used by board_api_key.created/.revoked, so the audit trail stays consistent.

Recommended Action

  1. No blockers — safe to merge as-is.
  2. Consider the keyExpiresAt reuse and debounce suggestions opportunistically.

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