Skip to content

fix: reuse the ChurchTools session across invocations (#145) - #149

Merged
2000game merged 4 commits into
mainfrom
fix/145-session-cache
Aug 24, 2026
Merged

2000game merged 4 commits into
mainfrom
fix/145-session-cache

Conversation

@2000game

Copy link
Copy Markdown
Member

The problem

ct is one-shot, but the session it buys lived in CtClient instance fields — so every
invocation re-ran GET /api/whoami?login_token=… + GET /api/csrftoken. ChurchTools
rate-limits logins, so three ct get raw calls in a row were enough to be throttled, and
every command then died with ✗ Login failed (whoami) (HTTP 429) before it reached its
endpoint. The login token was persisted; the session it buys was not.

What changed

A per-host session cache (src/auth/sessionStore.ts) holding
{host, cookie, csrfToken, obtainedAt, tokenHash}.

Resume instead of handshake. CtClient.authenticate() loads the cached session, adopts
the cookie + CSRF token, and confirms them with a plain GET /whoami — no login_token, so
it is an ordinary authenticated read and costs nothing against the login rate limit. Two
requests (one of them a login) become one non-login request. The identity still comes from
the server rather than from a stale local copy, so ct auth status stays honest.

Self-healing. A 401 on a real request drops the cached session, re-logs in once and
replays the request, so an expired or server-invalidated session never surfaces to the user.
A 401 means the request was rejected before it was processed, so the replay is safe for
writes too. Capped at one automatic re-login per process, so a server that always 401s
cannot become a login storm — the very failure mode this PR exists to fix.

ct auth login is exempt (authenticate(token, { fresh: true })): a command whose whole
job is to prove a credential must not be answered from a cache. It still stores the
session it buys.

ct auth logout deletes the session with the credentials — in clearCredentials, so
both the plain and the --env logout paths get it. A cookie left behind is a live handle on
the instance; leaving it would mean logout had not logged out.

Clearer 429 + Retry-After. fetchWithRetry already honoured Retry-After (429 is
retried for writes too, capped at 60s per sleep) — that behaviour is now pinned by tests. On
top of it, a 429 from the handshake no longer reads like a rejected credential:

✗ ChurchTools is rate-limiting logins on https://… — your token was not rejected.
  Wait about 3 minutes and try again.

parseRetryAfterMs is split out of the internal clamped wait so the message can quote the
server's real ask instead of the 60s sleep cap.

Storage decision: the Keychain, not a 0600 file

The issue suggested a 0600 file next to the credential. I put it in the macOS Keychain
instead, under a separate session:<host> account in the existing ct-cli service.

  • A live session cookie is a bearer handle on a logged-in ChurchTools account: for as long
    as it lives it is exactly as powerful as the login token. Same power ⇒ same protection.
    The Keychain is encrypted at rest and ACL'd by the OS; a 0600 file under $HOME is
    neither — it lands in backups, in tarred home directories, and is readable by every
    process running as the user.
  • It keeps one local secret store instead of two, so ct auth logout has one place to
    clean and SECURITY.md has one story to tell.
  • It settles the non-macOS question in the only acceptable way. keychain.ts has no file
    fallback, so on Linux/Windows there is no session cache at all — behaviour there is
    exactly today's, a handshake per invocation. This repo deliberately refuses to write the
    login token to a plaintext file on those platforms; the cookie must not become the
    loophole that writes a credential-equivalent there anyway. (Those platforms use
    CT_LOGINTOKEN/CT_HOST and typically run one command per CI job, where the cache buys
    little.)

The keychain plumbing (security CLI + the per-process read memoization) moved to
src/auth/keychain.ts, shared by tokenStore and sessionStore — no behaviour change,
resetKeychainCache is re-exported from tokenStore so existing callers and tests are
unaffected.

Host binding (#30) is preserved

The rule from #30 — a stored credential is only ever sent to the host it was captured
against — now covers the session as well, enforced in three places:

  1. The cache is host-keyed (session:<host>), and readSession(host, …) only ever
    looks under that host's account.
  2. The blob re-states its host, re-checked on read, so a mis-keyed or tampered entry
    cannot smuggle host A's cookie into a request to host B.
  3. authedSession builds the client after the existing token↔host refusal, with the same
    resolved host — so a CT_HOST override that refuses to send the token cannot send a
    cached session either.

Additionally the session is bound to the token that bought it via a SHA-256 fingerprint
(the hash is stored, never the token), so logging in as somebody else does not silently
reuse the previous user's session. Sessions age out after 12h; a clock moved backwards
invalidates rather than extends.

Neither the cookie nor the CSRF token is ever logged, printed or included in an error — a
test pins that the token does not leak into the 429 message either.

Tests

tests/sessionStore.test.ts (store round-trip, own keychain account, token stored only as a
hash, host-A-never-for-host-B, mismatched-host blob refused, different-token refused, TTL +
backwards clock, corrupt blob, nothing stored or read on a non-macOS platform, per-host
clear, and logout clearing the session for both logout paths) and
tests/session-cache.test.ts (session written and reused across two client constructions
with the handshake skipped, cached cookie/CSRF carried into later requests, host A's session
never sent to host B, rejected cached session replaced by a real handshake, 401 → drop →
re-auth → retry succeeds, no login storm, no-cache client unchanged, fresh: true, 500
during the resume probe propagates, and the 429 message/Retry-After/no-token-leak). Plus
Retry-After coverage in tests/http.test.ts.

All mocked — no live API calls were made while building this (the stored token points at a
production instance, and probing it is what surfaced the issue).

npm test (890 passed), npm run typecheck, npm run lint, npm run build all pass;
docs/handbuch/field-definitions.md was re-read against its sources and re-signed since
src/api/ctClient.ts changed.

Deliberately left out

  • ct auth status still handshakes per env. Its --all preflight exists to prove each
    token works; answering it from a session cache would make a green line mean less than it
    does today. It gets no cache.
  • No mid-flight re-save. The session is written once per login, not on every rotated
    Set-Cookie, to avoid a Keychain write per request. A rotated cookie is used for the rest
    of the process and re-bought next time.
  • ajax() / legacyPostForm() keep their existing behaviour — the 401 self-heal covers
    the REST request path only. The legacy endpoints answer an expired session with a redirect
    rather than a clean 401, which is a separate problem from this one.
  • No ct auth refresh / cache-inspection command. Nothing yet asks for one.

Closes #145.

https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG

Every `ct` invocation re-ran the login handshake (`whoami?login_token=` +
`csrftoken`), because the session lived in `CtApiClient` instance fields and the
CLI is one-shot. Three `ct get raw` calls in a row were enough to trip CT's
login rate limit, and every command then failed with
`✗ Login failed (whoami) (HTTP 429)` before reaching its endpoint.

The session (cookie + CSRF token) is now cached per host and reused:

- `src/auth/sessionStore.ts` keeps `{host, cookie, csrfToken, obtainedAt,
  tokenHash}` in the macOS Keychain under a `session:<host>` account — not a
  file. A live session cookie is as powerful as the login token, so it gets the
  same protection, and on Linux/Windows (no keychain backend) there is simply no
  cache rather than a plaintext cookie on disk.
- Host binding (#30) is enforced twice: the entry is host-keyed AND the blob
  re-states its host, checked on read. A session is also bound to a SHA-256
  fingerprint of the token that bought it (the hash, never the token).
- `CtClient.authenticate` resumes a cached session and confirms it with a plain
  `GET /whoami` (no `login_token`, so no login rate-limit cost) instead of the
  handshake; `me` still comes from the server.
- A 401 on a real request drops the cache, re-logs in once and replays the
  request, so an expired or server-invalidated session never surfaces as an
  error. Capped at one automatic re-login per process.
- `ct auth logout` removes the cached session with the credentials.
- A 429 from the handshake now says the login rate limit was hit and how long to
  wait (from `Retry-After`) instead of reading like a rejected credential.
- `parseRetryAfterMs` is split out of `http.ts`'s clamped retry wait so the
  message can quote the server's real ask.

Closes #145.

Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG
…hain read per account

Two review findings on the session cache:

- `reauthAttempts` was a lifetime cap, so one 401 that was never about the
  session (CT answers 401 for a CSRF failure too) spent the run's only
  self-heal — a genuine expiry later in a long apply then surfaced as a bare
  401. Reset the budget once a re-login has produced a successful replay; a
  replay that 401s again still leaves it spent, so an always-401 server cannot
  become a login storm.
- `storeSession` cleared the memoized keychain reads for *every* account, so a
  fresh login evicted the credential blob's cached read and could re-prompt for
  a locked Keychain. `keychainSet`/`keychainDelete` now invalidate only the
  account they touched.

Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG
The page documents `ct get`'s schema reads, which the session self-heal does not
touch — re-read against both sources, no content change needed.

Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG
Resolves the #138 overlap: `isSecureStorageAvailable` now sits on the imported
`isMac` this branch moved into keychain.ts, and `ct auth login` keeps both the
guided token bootstrap and the fresh-but-cached session handshake.

Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG
@2000game
2000game merged commit 6c29d47 into main Aug 24, 2026
3 checks passed
@2000game
2000game deleted the fix/145-session-cache branch August 24, 2026 18:31
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.

Session is not reused across invocations — every ct call re-runs the login handshake and trips CT's 429 rate limit

1 participant