Skip to content

feat(auth): bootstrap a login token from credentials and 2FA (#138) - #148

Merged
2000game merged 1 commit into
mainfrom
feat/138-auth-bootstrap
Aug 24, 2026
Merged

2000game merged 1 commit into
mainfrom
feat/138-auth-bootstrap

Conversation

@2000game

Copy link
Copy Markdown
Member

The flow

ct auth login without --token (and on a TTY) now asks:

How do you want to authenticate?
  1. Username and password
  2. Existing login token
  3. Skip login
Choice [1]:
  1. Username and password — asks for username/email, then the password with input
    hidden. POST /api/login; when ChurchTools answers status: "totp", asks for the
    six-digit code (also hidden) and POST /api/login/totp with code + personId on the
    same session cookie; then GET /api/persons/{personId}/logintoken for the
    persistent personal login token. Only {host, token} is then verified and stored
    through the existing storeCredentials path.
  2. Existing login token — unchanged behaviour: asked hidden, verified against the host
    with CtClient.authenticate, stored.
  3. Skip — nothing collected; prints the command to run later.

The host is prompted for too when neither --host nor CT_HOST is set.

Fetching the login token belongs to the person who just authenticated — that is
authentication, not people management. No other person surface is added.

Scoping — this lands on auth login, not init

The issue frames this as part of ct init (#131). #131's PR #139 is still a draft and
conflicting with main, so this PR is based on main and lands the flow on
ct auth login instead. It does not touch ct init and is independently mergeable.

The seam for #139: the whole interactive flow is one exported function,

// src/auth/login.ts
bootstrapLoginToken(host: string, deps?: BootstrapDeps): Promise<BootstrapOutcome>

type BootstrapOutcome =
  | { kind: "token"; token: string }      // verify + storeCredentials({host, token})
  | { kind: "skipped"; hint: string }     // hint = the `ct auth login …` command
  | { kind: "unsupported"; hint: string } // hint = the CT_HOST/CT_LOGINTOKEN guidance

ct init can call it with the host the user just chose and drop the returned token into
the same storeCredentials call ct auth login makes. deps carries injectable
prompts / isTTY / secureStorage / fetchImpl, so init can reuse its own prompt
surface. It stores nothing itself, so the two callers cannot disagree about where
credentials live.

Security properties, and how each is tested

Property How it is enforced Test
Username / password / TOTP code never persisted They are parameters and locals of loginWithPassword; only {host, token} is passed to the store. ct auth login still calls storeCredentials({host, token}) and nothing else. bootstrapLoginToken returns {kind:"token", token} and nothing else — asserted by equality, so an added field fails the test.
No secret is printed or logged Every message built from a ChurchTools response goes through redactSecrets(text, secrets); errors are LoginError(message, status) and never carry a request body. "no secret reaches the terminal anywhere in the interactive flow" asserts the whole prompt/notify transcript of a full password+TOTP run contains none of the password, code or token; "redacts a server that echoes the credentials straight back" pins the redaction against a server that reflects the password in its error message; the invalid-credential and invalid-code tests assert the message contains CT's explanation and the status but not the secrets.
No password flag None added. A test reads the command's own options and asserts no flag matches /password/i, `/--totp
Prompts do not echo askHidden mutes readline's _writeToOutput immediately after the prompt label is written. The token path asserts the token was asked through the secret prompt, not the visible one.
Secrets never reach a URL Bodies are JSON POSTs. "the password and code are sent once and only to the login endpoints" asserts the password appears only in the /api/login body, the code only in /api/login/totp, and neither in any URL.
Only host + token stored See above. Above.
No secure storage ⇒ collect nothing isSecureStorageAvailable() (exported from tokenStore.ts — the same platform() === "darwin" check storeCredentials already made) is consulted before the first prompt; the flow returns unsupported with the CT_HOST/CT_LOGINTOKEN guidance. A test runs the flow with secureStorage: false and prompts that throw if called, and asserts the env-var hint comes back. A second test pins isSecureStorageAvailable() to the platform.

Also covered: password login without 2FA, TOTP challenge detected and completed on the
same cookie, no code asked when there is no challenge, both ChurchTools envelope shapes
(top-level and nested data), a non-six-digit code refused before it is sent anywhere, a
challenge with no interactive way to answer it, an instance returning no token, the skip
choice, an unknown choice, and "never prompts on a non-TTY".

Notes

  • No live API calls were made; everything is a fetch stub, and the fixtures use a
    .invalid host so a missed stub fails loudly rather than reaching an instance.
  • src/api/ctClient.ts, src/api/session.ts and authenticate() are untouched, to
    keep the merge with the parallel Session is not reused across invocations — every ct call re-runs the login handshake and trips CT's 429 rate limit #145 session-caching work cheap. The login→TOTP session
    cookie is handled by a small local jar in src/auth/login.ts; the resulting token is
    still verified through CtClient.authenticate unchanged.
  • Left out: no ct init changes (see scoping), and no live/opt-in test — there is nothing
    here to probe live without typing a real password at a prod instance.

Closes #138.

Copying a personal login token out of the ChurchTools web UI is the first
thing a new user has to do and the first thing they get wrong. `ct auth
login` without `--token` now offers three ways to authenticate:

  1. Username and password — POST /api/login, complete POST /api/login/totp
     on the SAME session cookie when the instance answers `status: "totp"`,
     then GET /api/persons/{personId}/logintoken for the persistent token.
  2. Existing login token — the previous behaviour, asked hidden.
  3. Skip — nothing collected, with the command to run later.

Security properties, each covered by a test:

- Username, password and TOTP code are locals for the duration of the two
  requests that consume them. Only {host, token} reaches the credential
  store. There is deliberately NO password flag: a password on the command
  line lands in shell history and in `ps`.
- Nothing secret is printed. Messages derived from a ChurchTools response
  pass through `redactSecrets`, and an authentication error carries the HTTP
  status and CT's own message — never the request body that was sent.
- Password/TOTP/token prompts do not echo (`askHidden` mutes readline).
- Platforms with no credential store (`isSecureStorageAvailable`, the same
  macOS check `storeCredentials` already made) are detected BEFORE anything
  is asked for, so a password is never collected only to be discarded; they
  keep the CT_HOST / CT_LOGINTOKEN guidance.

Non-interactive `ct auth login --host … --token …` is unchanged, and a
non-TTY never prompts.

The flow lives in `bootstrapLoginToken(host, deps)` so `ct init` (#131) can
call the same prompts from its own sequence.

Closes #138.
Claude-Session: https://claude.ai/code/session_018JShVZYNLaRb4hF5KbHCXG
@2000game
2000game merged commit f59bbe5 into main Aug 24, 2026
3 checks passed
@2000game
2000game deleted the feat/138-auth-bootstrap branch August 24, 2026 18:28
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.

feat(auth): bootstrap login token from credentials and 2FA during init

1 participant