Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ impact, the things worth knowing:
backend on Linux or Windows — those platforms must use `CT_LOGINTOKEN`. `ct.envs.json`
stores only the _name_ of a token env var, never a value, which is why it is safe to
commit.
- **The session is cached, and treated as a second secret.** Since #145, the session cookie
and CSRF token a login yields are kept per host so a one-shot CLI does not re-run the
login handshake on every command (which trips ChurchTools' login rate limit). A live
session cookie is as powerful as the token, so it is stored the same way — in the
**macOS Keychain**, under a separate `session:<host>` entry, never in a file — and
therefore not at all on Linux/Windows, which keep handshaking per invocation. It is
bound to the host it was captured against and to a hash of the token that bought it,
never logged or printed, and removed by `ct auth logout` along with the token.
- **Storing a token exposes it briefly to `ps`.** `ct auth login` shells out to
`security add-generic-password -w <token>`, so the value sits in that process's argv for
the duration of the call. On a shared machine, assume another local user could observe
Expand Down
2 changes: 1 addition & 1 deletion docs/handbuch/field-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: Field definitions & security levels
sources:
- src/commands/get.ts
- src/api/ctClient.ts
sources_hash: 047a27a2bd82b120
sources_hash: 659b65504025ebb8
reviewed: 2026-08-13
---

Expand Down
186 changes: 183 additions & 3 deletions src/api/ctClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* here can be swapped for `openapi-fetch` while keeping this class's surface.
*/
import { type CtConfig } from "../config.js";
import { fetchWithRetry } from "./http.js";
import { fetchWithRetry, parseRetryAfterMs } from "./http.js";
import { meetsMinVersion, MIN_CT_VERSION, type CtInfo } from "./version.js";

export interface WhoAmI {
Expand Down Expand Up @@ -66,12 +66,61 @@ export interface CtPage<T> {
const MAX_PAGES = 1000;
const DEFAULT_PAGE_LIMIT = 100;

/**
* A place to keep the session (cookie + CSRF token) BETWEEN `ct` invocations, so
* a one-shot CLI does not have to re-run the login handshake — and trip
* ChurchTools' login rate limit — on every single command (#145).
*
* The client only ever asks for a session for `host` and hands back one captured
* against `host`, so the store cannot leak a session across instances (#30). The
* implementation lives in `src/auth/sessionStore.ts`; it is injected rather than
* imported so a client without one (tests, `ct auth status`' preflight) behaves
* exactly as it always has.
*/
export interface SessionCache {
load(host: string, token: string): Promise<{ cookie: string; csrfToken: string } | null>;
save(host: string, token: string, session: { cookie: string; csrfToken: string }): Promise<void>;
drop(host: string): Promise<void>;
}

export interface CtClientOptions {
sessionCache?: SessionCache;
}

/** Human-readable "wait this long" for a 429, from `Retry-After` when the server sent one. */
function describeRetryAfter(res: Response): string {
const ms = parseRetryAfterMs(res);
if (ms === null) {
return "about a minute";
}
const seconds = Math.ceil(ms / 1000);
return seconds >= 120
? `about ${Math.ceil(seconds / 60)} minutes`
: `about ${Math.max(seconds, 1)} seconds`;
}

export class CtClient {
private cookie: string | null = null;
private csrfToken: string | null = null;
private ctVersion: string | null = null;
/** Kept so an expired session can be re-bought without the caller having to notice (#145). */
private loginToken: string | null = null;
/** Re-entrancy guards: no self-heal while a login (or a resume probe) is already in flight. */
private loggingIn = false;
private resuming = false;
/**
* At most ONE automatic re-login per *unrecovered* 401 — a server that always 401s must not
* become a login storm. Reset once a re-login actually produced a successful replay, so the
* budget is a guard against a hopeless loop rather than a lifetime cap: a long `apply` whose
* session dies twice (or that burned the budget on a 401 that was never about the session —
* CT answers 401 for a CSRF failure too) can still self-heal.
*/
private reauthAttempts = 0;

constructor(private readonly config: CtConfig) {}
constructor(
private readonly config: CtConfig,
private readonly options: CtClientOptions = {},
) {}

get host(): string {
return this.config.host;
Expand Down Expand Up @@ -117,8 +166,82 @@ export class CtClient {
}
}

/**
* Become an authenticated client — reusing the cached session for this host
* when there is one, and only otherwise running the login handshake (#145).
*
* `fresh: true` forces the handshake (`ct auth login`, which exists precisely
* to prove the credential works, must not be answered from a cache).
*/
async authenticate(loginToken: string, opts: { fresh?: boolean } = {}): Promise<WhoAmI> {
this.loginToken = loginToken;
if (!opts.fresh) {
const resumed = await this.resumeCachedSession(loginToken);
if (resumed) {
return resumed;
}
}
return this.login(loginToken);
}

/**
* Try the cached session: adopt the cookie + CSRF token and confirm them with
* a plain `GET /whoami`.
*
* That GET carries no `login_token`, so it is an ordinary authenticated read —
* it does NOT count against ChurchTools' *login* rate limit, which is the
* whole point of the cache. It also keeps the identity honest: `me` still
* comes from the server rather than from a stale local copy.
*
* A session the server no longer accepts (401/403) is dropped and `null` is
* returned, so the caller falls through to a real handshake. Anything else
* (network trouble, a 500) is a real failure and propagates.
*/
private async resumeCachedSession(loginToken: string): Promise<WhoAmI | null> {
const cache = this.options.sessionCache;
if (!cache) {
return null;
}
let cached: { cookie: string; csrfToken: string } | null = null;
try {
cached = await cache.load(this.config.host, loginToken);
} catch {
return null; // an unreadable cache is never a reason to fail a command
}
if (!cached) {
return null;
}
this.cookie = cached.cookie;
this.csrfToken = cached.csrfToken;
this.resuming = true;
try {
return await this.get<WhoAmI>("/whoami");
} catch (err) {
if (!isSessionRejection(err)) {
throw err;
}
this.cookie = null;
this.csrfToken = null;
await cache.drop(this.config.host).catch(() => {});
return null;
} finally {
this.resuming = false;
}
}

/** Run the login-token handshake and cache the session cookie + CSRF token. */
async authenticate(loginToken: string): Promise<WhoAmI> {
private async login(loginToken: string): Promise<WhoAmI> {
this.loggingIn = true;
this.cookie = null;
this.csrfToken = null;
try {
return await this.performLogin(loginToken);
} finally {
this.loggingIn = false;
}
}

private async performLogin(loginToken: string): Promise<WhoAmI> {
// The token rides as a URL query param (it lands in the server's access logs). This is
// unavoidable for this token class: the handshake above is documented to require the
// `login_token` query param — an `Authorization` header yields a null CSRF token and breaks
Expand All @@ -132,18 +255,54 @@ export class CtClient {
);
this.captureCookie(res);
if (!res.ok) {
if (res.status === 429) {
// Not a credential problem, and saying "Login failed" reads like one. CT
// throttles LOGINS per instance, so this fires after a burst of short `ct`
// invocations — the very thing the session cache exists to stop.
throw new CtApiError(
`ChurchTools is rate-limiting logins on ${this.config.host} — your token was not rejected. ` +
`Wait ${describeRetryAfter(res)} and try again.`,
res.status,
await safeBody(res),
);
}
throw new CtApiError(`Login failed (whoami)`, res.status, await safeBody(res));
}
if (!this.cookie) {
throw new CtApiError("Login succeeded but no session cookie was returned", res.status, null);
}
await this.refreshCsrfToken();
// Keep the freshly bought session for the NEXT invocation. Best-effort: a store
// that refuses (no Keychain, locked Keychain) must not fail the command.
if (this.cookie && this.csrfToken) {
await this.options.sessionCache
?.save(this.config.host, loginToken, { cookie: this.cookie, csrfToken: this.csrfToken })
.catch(() => {});
}
// Same tolerant unwrap as request(): prefer `.data`, but fall back to the raw body if the
// envelope is absent, so authenticate and request() agree on the shape.
const body = (await res.json()) as { data?: WhoAmI };
return (body.data ?? body) as WhoAmI;
}

/**
* Recover from a 401 on a real request: drop the cached session, log in again,
* and let the caller retry once. Returns false when re-authentication is not
* available or not appropriate, in which case the 401 surfaces as usual.
*
* A 401 means the request was rejected before it was processed, so the retry is
* safe for writes too.
*/
private async reauthenticate(): Promise<boolean> {
if (this.loginToken === null || this.loggingIn || this.resuming || this.reauthAttempts >= 1) {
return false;
}
this.reauthAttempts++;
await this.options.sessionCache?.drop(this.config.host).catch(() => {});
await this.login(this.loginToken);
return true;
}

async get<T = unknown>(path: string): Promise<T> {
return this.request<T>("GET", path);
}
Expand Down Expand Up @@ -322,6 +481,22 @@ export class CtClient {
{ isIdempotent: method === "GET" || method === "HEAD" },
);
this.captureCookie(res);
if (res.status === 401) {
// The session died (expired, or invalidated server-side — a logout elsewhere,
// a restart). Buy a new one once and replay the request, so a stale cached
// session self-heals instead of surfacing as "Not authenticated" (#145).
if (await this.reauthenticate()) {
// Drain the response we're discarding so its socket isn't left buffered.
await res.body?.cancel().catch(() => {});
const replayed = await this.requestEnvelope(method, path, body);
// The replay went through, so the self-heal worked and the budget it spent is
// available again for a later expiry in the same run. A re-login whose replay
// 401s again throws from the line above with the budget still spent, which is
// what stops an always-401 server from becoming a login storm.
this.reauthAttempts = 0;
return replayed;
}
}
if (!res.ok) {
throw new CtApiError(`${method} ${path} failed`, res.status, await safeBody(res));
}
Expand Down Expand Up @@ -421,6 +596,11 @@ export class CtClient {
}
}

/** True when an error says "this session is no longer good" rather than "the request was bad". */
function isSessionRejection(err: unknown): boolean {
return err instanceof CtApiError && (err.status === 401 || err.status === 403);
}

async function safeBody(res: Response): Promise<unknown> {
try {
return await res.json();
Expand Down
23 changes: 20 additions & 3 deletions src/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,44 @@ function backoffMs(base: number, attempt: number): number {
return clampDelay(exp + Math.floor(Math.random() * base));
}

function retryAfterMs(res: Response): number | null {
/**
* The wait a response's `Retry-After` header asks for, in ms — `null` when the
* header is absent or unusable, and deliberately NOT clamped: this is the
* server's actual ask. Exported so a caller that has run out of retries can
* still *tell the user* how long to wait (see the 429 login message in
* `ctClient.authenticate`), where clamping to the sleep cap would understate it.
*/
export function parseRetryAfterMs(res: Response): number | null {
const header = res.headers.get("retry-after")?.trim();
if (!header) {
return null;
}
// `Retry-After` is either a non-negative delta-seconds count or an HTTP-date.
if (/^\d+$/.test(header)) {
return clampDelay(Number.parseInt(header, 10) * 1000);
return Number.parseInt(header, 10) * 1000;
}
// Only treat it as a date when it actually looks like one — `Date.parse` is
// lax enough to accept e.g. "-5" as a year, which must not become a 0ms wait.
if (/[a-zA-Z]/.test(header)) {
const dateMs = Date.parse(header);
if (Number.isFinite(dateMs)) {
return clampDelay(dateMs - Date.now());
return dateMs - Date.now();
}
}
// Unparseable (e.g. a negative or malformed value): fall back to backoff.
return null;
}

/**
* The `Retry-After` wait clamped to what this client is willing to sleep for —
* an outsized (or already-elapsed) value can neither hang the CLI nor turn into
* a busy retry.
*/
function retryAfterMs(res: Response): number | null {
const ms = parseRetryAfterMs(res);
return ms === null ? null : clampDelay(ms);
}

function shouldRetryStatus(status: number, isIdempotent: boolean): boolean {
if (status === 429) {
return true;
Expand Down
5 changes: 4 additions & 1 deletion src/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import { CtClient, type WhoAmI } from "./ctClient.js";
import { readCredentials } from "../auth/tokenStore.js";
import { keychainSessionCache } from "../auth/sessionStore.js";
import { normalizeHost, resolveConfig } from "../config.js";

export interface AuthedSession {
Expand Down Expand Up @@ -45,7 +46,9 @@ export async function authedSession(): Promise<AuthedSession> {
);
}

const client = new CtClient(config);
// The session cache is keyed by the SAME host the binding check above just cleared,
// so a cached session can only ever be replayed against the instance it came from (#30/#145).
const client = new CtClient(config, { sessionCache: keychainSessionCache() });
const me = await client.authenticate(token);
// Hard-fail below the minimum CT version before any command reads or writes —
// a stale instance half-applies (tier-0 writes succeed, hierarchy endpoints 404).
Expand Down
Loading
Loading