Skip to content

feat(cache): copy-code login for remote shells, logout and sign-in hardening - #185

Merged
calvin-archastro merged 2 commits into
mainfrom
calvin/aster-login-copy
Oct 23, 2026
Merged

calvin-archastro merged 2 commits into
mainfrom
calvin/aster-login-copy

Conversation

@calvin-archastro

@calvin-archastro calvin-archastro commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review on ArchCode

Ships in 0.24.0. Builds on #182 (person-token login through aster-srv). Rebased onto #188 (aster-srv hardening) and #189 (route-table auth gate), plus #183, #184, #186, #187.

Problem and author intent

aster cache login (#182) redirects the browser to http://127.0.0.1:<port>/callback, where the CLI listens. On SSH sessions and devboxes the browser runs on a different machine, so that redirect never reaches the CLI and the login hangs until it times out. --no-browser only printed the URL and still waited on loopback, so the only workaround was ssh -R.

Intent: a copy-code fallback for those shells, plus the security items the 0.24.0 review required for the whole login flow (loopback and copy).

What changed (before -> after)

before: CLI binds 127.0.0.1:PORT -> browser confirms -> 302 to loopback -> CLI exchanges {code, verifier, port}
after, loopback (unchanged shape): same, plus user_code in URL/MAC/row/exchange
after, copy:    CLI prints URL (mode=copy, no port) -> browser confirms -> 200 page shows the code
                -> person pastes it on stdin -> CLI exchanges {code, verifier, user_code}
  1. Mode (crates/aster-srv/src/cli_auth.rs): mode=copy|loopback. A copy code is stored with port 0 and the exchange must send the same port (omitted means 0), so a copy code cannot be redeemed as a loopback code and the reverse. The MAC covers mode and port, so a loopback form cannot be resubmitted as copy (and the reverse). Copy mode rejects a port parameter. Copy mode never redirects and never renders a token; the page shows only the code and a request id (first 8 chars of state).
  2. TTL: 60 s in both modes. A longer copy TTL was considered (human paste) and rejected per the security requirements; Authorize then paste is a few seconds.
  3. state in copy mode is not a secret. It is MAC-bound, echoed as the request id on the page and printed by the CLI so the person can see the page belongs to their command. What stops someone else's code from signing the person in is the PKCE verifier.
  4. Client (src/cache/login.rs, src/cli/commands.rs, src/main.rs):
    • --no-browser now means "paste a code". --loopback forces the old redirect (--loopback --no-browser prints the URL for ssh -R).
    • Auto-detection (no flag): copy when SSH_CONNECTION/SSH_CLIENT/SSH_TTY is set, when Linux has neither DISPLAY nor WAYLAND_DISPLAY, or when the system browser fails to start (spawn error). macOS and Windows without those variables still open a browser. A browser that spawns but cannot be seen is not detected; use --no-browser.
    • aster cache logout.
  5. Docs: docs/test-tracking.md ("Reading the cache from a laptop": SSH/devbox flow, logout, kill switch), README, src/cli/skills.md.

Breaking vs #182: --no-browser switches from loopback-with-printed-URL to pasted code (unreleased, so no deprecation).

Security checklist

Test files: S = crates/aster-srv/tests/cli_auth_copy.rs, A = crates/aster-srv/tests/cli_auth.rs, E = tests/cache_login_e2e.rs, L = unit tests in src/cache/login.rs.

# Item Code Test
8 IAP JWT: ES256 only, Google keys, iss, aud (exact-audience fix lives in the iap.rs PR), exp; email domain from ASTER_SRV_UI_EMAIL_DOMAINS (set to archastro.ai in prod, behind the IAP IAM gate) existing crates/aster-srv/src/iap.rs:142-170; called from cli_auth.rs:245 A::sign_in_needs_an_iap_assertion_from_an_allowed_domain; iap.rs unit tests. Gap: none beyond the audience PR
F3a IAP assertion iat at most 5 min old on GET and POST (also rejects future iat, missing iat) cli_auth.rs:332 (assertion_is_recent), used in viewer :255 S::an_old_iap_assertion_cannot_mint_a_code; unit an_assertion_is_recent_for_five_minutes_and_needs_an_iat
9 S256 only; any other challenge_method rejected cli_auth.rs:173 S::copy_mode_and_pkce_requests_are_validated
9 state required and echoed (redirect, and request id in copy) cli_auth.rs parse_request :183, authorize :435 A::sign_in_refuses_a_bad_port_challenge_or_state, S::copy_mode_shows_a_one_time_code...
9 256-bit code, SHA-256 at rest, TTL 60 s both modes cli_auth.rs:112 S::copy_mode_shows... (row TTL <= 60 s), A::a_code_redeems_once...
9 Atomic DELETE ... RETURNING, one winner across replicas cli_auth.rs:611 A::two_racing_redemptions_of_one_code_have_exactly_one_winner, A::a_code_minted_on_one_server_redeems_on_another
9 Code bound to challenge + subject + mode (port) + user code cli_auth.rs:611-662 S::a_code_is_bound_to_its_mode_user_code_challenge_and_verifier, E::a_remote_shell_signs_in...
10 Loopback redirect only to http://127.0.0.1:<port>/callback, port 1024-65535, no other host cli_auth.rs:173 (parse_request, MIN_PORT :118) A::sign_in_refuses_a_bad_port..., A::the_confirmation_form_is_bound... (callback() asserts the prefix)
10 Confirm POST: MAC (person, challenge, mode, port, state, user code, expiry) cli_auth.rs:307 (mac_fields) S::a_confirm_form_for_one_mode_cannot_be_submitted_as_the_other, A::the_confirmation_form_is_bound...
10 Confirm POST: Origin (else Referer) must equal the public origin cli_auth.rs:278 S::the_confirm_post_must_come_from_the_servers_own_origin; unit only_the_servers_own_origin_may_submit
10 frame-ancestors 'none' + X-Frame-Options: DENY on every HTML page (dashboard too) crates/aster-srv/src/ui/mod.rs:34 S::copy_mode_shows... (headers), crates/aster-srv/tests/ui.rs
10 Confirm page shows client IP (from #188's single limits::client_ip, ASTER_SRV_TRUSTED_PROXY_HOPS; one trust model, no second parser), user agent (escaped, truncated), CLI-provided hostname cli_auth.rs:388 (confirm), http.rs:595 (cli_browser) S::copy_mode_shows... (address 203.0.113.7 from 9.9.9.9, 203.0.113.7, 130.211.0.1); limits.rs unit the_client_is_the_right_most_address_we_do_not_own
10 Not done: SameSite=Strict cookie / double-submit token. The form carries a MAC bound to the IAP identity (a per-person, per-request token), and the Origin check is added. Reply has no cookie support and http.rs is owned by the router PR; a cookie would be a follow-up
11 Copy shows only the one-time code, never a token; same TTL and single use cli_auth.rs:491 S::copy_mode_shows... (no eyJ, token absent from pages), E::a_remote_shell_signs_in...
F3b/F11 User code (8 chars, no 0O1I), printed by the CLI, shown on the page ("Confirm only if your terminal shows"), in MAC, stored row, and required at exchange cli_auth.rs:217, :656, migration v4 :106; client src/cache/login.rs random_user_code S::a_code_is_bound... (wrong/missing user code), S::a_confirm_form... (tampered code), L::a_pasted_code_signs_in...
14 Token file 0600 in 0700 dir (existing); client refuses a group/world-readable file and says to log in again src/cache/login.rs:185 (readable_by_others), load_token L::a_token_file_other_users_can_read_is_not_used, L::a_stored_token_is_private...
14 Token never printed existing E (both tests assert transcript/stderr lack the token)
14 aster cache logout deletes the file and calls POST /v1/auth/revoke src/cache/login.rs:476, server cli_auth.rs:676 L::logout_revokes_with_the_bearer_then_deletes_the_file, L::logout_deletes_the_file_even_when_the_server_cannot_be_told, E::a_remote_shell_signs_in... (phase 5)
14 auth.revocations(subject pk, not_before) keyed by #188's stable IAP sub, not the email (new migration v3), enforced on every person-token use; DB error fails closed migration cli_auth.rs:91; person.rs:242 is_revoked, :256 revoke_all; audit::token_revoke in cli_auth.rs:676 (revoke); route-table row routes.rs:87; hook in App::principal lib.rs:223 S::logout_refuses_every_earlier_token_for_that_person_only, S::logout_follows_the_stable_subject_when_the_email_changes_and_is_audited (other user unaffected, second replica sees it, token issued after works)
14 Kill switch ASTER_SRV_PERSON_TOKENS=off: /cli-auth, exchange and revoke 404, all person tokens 401 config.rs:388, person.rs:102 S::the_kill_switch_turns_off_sign_in_and_every_person_token; config unit test
F10 Client refuses http:// unless the host is 127.0.0.1, ::1 or localhost, in login, logout and load_token (the bearer-sending cache client reads tokens through it, so a token for a cleartext server is never sent) src/cache/login.rs:106 L::plain_http_is_for_this_machine_only
F10 Windows has no mode bits: token file gets an ACL naming only the current user (icacls); if it cannot be set the token is not stored src/cache/login.rs:231 Not tested (no Windows runner available here; compiled on Windows by CI if that job exists)
F4 jti is not recorded at mint: not_before per subject revokes by iat, which is enough for "sign out everywhere" and avoids a row per token. The cutoff is the second after the logout, so a token issued in the same second as the logout is also refused; the exchange issues on the database clock (issue_at), the same clock the cutoff uses person.rs:242 S::logout_refuses...
F4 Person tokens are not repo-scoped: policy.rs person_reader reads any allowlisted repo. Acceptable for one repo today; scoping is a follow-up unchanged
F4 TTL stays 12 h: revocation and the kill switch bound exposure, and a refresh token would add a second long-lived secret unchanged
F13 The code row is deleted before PKCE is checked, so an intercepted code can burn a login but never complete one. Accepted as the single-use semantic cli_auth.rs:611 S::a_code_is_bound... ("the refused attempt spent the code")

Landed in #188 and now part of the base: exact IAP audience, /v1-only listener, audit logging, rate limits, HSTS, strict claim set (sub, nbf, iat, jti, HS256 pinned). This PR builds on them: it uses the stable sub, calls audit::token_revoke, and reuses limits::client_ip.

Rebase onto #188

  • Migrations: auth v2 is feat(aster-srv): harden the non-IAP /v1 channel #188's cli_codes.subject; this PR is now v3 (auth.revocations) and v4 (cli_codes.user_code). upgrade.rs passes.
  • Revocation by subject: revocations.subject holds the IAP sub; is_revoked and revoke_all use Person.subject. Person keeps issued_at (the token's iat).
  • /v1/auth/revoke (see Rebase onto feat(aster-srv): person tokens read every dashboard dataset; aster srv; route-table auth gate #189) calls audit::token_revoke(subject, subject) after revoking. It stays outside preauth, so the kill switch still answers 404 (not 401) and the route keeps its own principal check.
  • Client IP: Browser.forwarded_for is replaced by Browser.client_ip, resolved once by limits::client_ip in http::handle and threaded through guarded and route. The local second-from-last parser is deleted.
  • Exchange: keeps feat(aster-srv): harden the non-IAP /v1 channel #188's cheap rejects, auth pool and concurrency cap. Port 0 now passes the shape check (copy mode); a loopback code still needs its port. Refusal reasons: pkce_mismatch, user_code_mismatch, port_mismatch, code_without_subject. The MAC covers mode and user code and a bad MAC is audited as bad_confirmation.
  • Tests: hardening.rs now sends user_code and the Origin header; cli_auth_copy.rs revokes with an empty body, since feat(aster-srv): harden the non-IAP /v1 channel #188 refuses a body on a route that takes none (this is also what aster cache logout sends); new test logout_follows_the_stable_subject_when_the_email_changes_and_is_audited. The branch's issue_at commit (DB-clock issue time) is carried over as its own commit, adapted to issue_at(subject, email, now); a duplicate ctutils bump is dropped since main has it.
  • token_mint audit lines report port 0 for copy codes (the mode is not an audit field).

Rebase onto #189

  • Route table: /v1/auth/revoke is a row in routes::ROUTES with a new auth class Auth::Person and handler Handler::TokenRevoke. gate authenticates the bearer (revocation list included), charges the subject's rate limit, and answers 403 to a CI token (audited as ci_token_not_allowed). routes::label maps it to /v1/auth/revoke. The old hand-written revoke arm and cli_auth::is_route are gone; the table is the only source. The route takes no body, so a body gets 413 as on any body-less route.
  • Kill switch: ASTER_SRV_PERSON_TOKENS=off still answers 404. gate checks app.person_tokens.is_none() first for the Person class, before reading any token. Without that check a switched-off server would answer 401, since it cannot verify person tokens at all. Choice: 404 before authentication, so a server with sign-in off shows no sign-out route.
  • revoke takes the Person from the gate instead of re-authenticating.
  • client_ip: threaded through route(app, client, request) into cli_browser. gate needs no IP, so its signature is unchanged.
  • Scopes: tokens still mint read; cache:read stays accepted as LEGACY_SCOPE.
  • Tests: tests/routes.rs walks the table, so /v1/auth/revoke is in the every-bad-credential-is-401 test with no edit (the helper sends no body for that route). The person-allowlist walk skips Auth::Person routes; the new sign_out_takes_a_person_only_and_is_404_when_person_sign_in_is_off pins the person-only list, CI token 403, kill switch 404 (with and without a token) and a successful sign-out. fix(aster-srv): count one failure once; shared-cause failures are one incident #186 needed no conflict fixes beyond test fixtures. IAP fixtures use feat(aster-srv): person tokens read every dashboard dataset; aster srv; route-table auth gate #189's full audience; iap_assertion_issued still takes an iat.
  • The e2e file now has three tests: feat(aster-srv): person tokens read every dashboard dataset; aster srv; route-table auth gate #189's dashboard read test, the loopback test and the copy test.

Scope

Full-stack within aster: aster-srv (cli_auth, person, config, lib, http, ui CSP header) and the aster CLI. Beyond the planned files, small edits: lib.rs (App::principal revocation hook), auth.rs / policy.rs (test literals for the new Person.issued_at), ui/mod.rs + tests/ui.rs (CSP gains frame-ancestors 'none'), http.rs (/v1/auth/revoke route and a Browser context for /cli-auth). Each is a few lines and will conflict only trivially.

~1,700 lines changed, about half tests and docs. It is one PR because the copy flow, the user code and the MAC changes touch the same form and exchange.

Risk: medium

  • Auth path. Mitigations: PKCE and single-use unchanged, the stricter checks only add refusals, and the revocation lookup fails closed.
  • Migrations v3 (auth.revocations) and v4 (cli_codes.user_code) are additive and follow feat(aster-srv): harden the non-IAP /v1 channel #188's v2 (cli_codes.subject). Rows minted before v4 have no user code and can never be redeemed (they live 60 s).
  • Rollout order: deploy the server first. An old client against a new server cannot sign in (its exchange has no user code, and it sends no user_code in the URL, which the page refuses), so users run aster cache login with the 0.24.0 binary. A new client against an old server cannot exchange or log out.
  • One extra DB read per person-token request (primary-key lookup).

User impact

  • Remote shells: aster cache login just works and asks for a pasted code. Everyone: confirm only if the page shows the code the terminal printed; aster cache logout exists; non-loopback http:// servers are refused; a world-readable token file is ignored.
  • --no-browser behavior changed (see above).

Testing

Canonical end-to-end proof: tests/cache_login_e2e.rs::a_remote_shell_signs_in_by_pasting_a_code_and_restores_what_ci_wrote. Actors: real aster-srv (in-process, real Postgres), real aster binary as CI (GitHub OIDC stand-in), real aster cache login binary with piped stdin and SSH_CONNECTION set (no flag), a scripted browser sending a signed IAP header. It asserts:

  1. A stolen code (redeemed with a wrong verifier before the paste) makes the CLI's paste fail and stores nothing.
  2. The printed URL has mode=copy and no port; the page shows the user code; adding a port, dropping the mode, or converting a loopback form to copy gives 400 and no code.
  3. Authorize answers 200 with a code page (no redirect, no token in the HTML); the pasted code signs in; transcript lacks the token; file is 0600 in a 0700 dir.
  4. The devbox restores CI's output byte for byte without running the command.
  5. aster cache logout deletes the file and the server then answers 401 for the old token.

Automatic coverage: .github/workflows/ci.yml job aster-srv runs cargo test --locked --test cache_remote_e2e --test cache_login_e2e (the new function is in that file, no wiring change) and cargo test --locked -p aster-srv (picks up the new cli_auth_copy.rs), both with the job's Postgres. These are PR checks. CI log lines to look for: test a_remote_shell_signs_in_by_pasting_a_code_and_restores_what_ci_wrote ... ok.

Other tests: crates/aster-srv/tests/cli_auth_copy.rs (8 server tests), unit tests in cli_auth.rs, config.rs, src/cache/login.rs (13 -> 19). Existing cli_auth.rs tests updated for Origin, iat, user code. Local: fmt, clippy --workspace --all-targets --all-features -D warnings, cargo doc -D warnings, lib tests (887), aster-srv suite, both e2e files all pass.

Integration matrix

Entry point loopback copy wrong verifier replay wrong/missing user code cross-mode old IAP iat bad Origin revoked kill switch
GET /cli-auth covered covered n/a n/a n/a n/a covered n/a n/a covered
POST /cli-auth covered covered n/a n/a covered (form tamper) covered covered covered n/a covered
POST /v1/auth/token covered covered (real CLI) covered covered covered covered n/a n/a n/a covered
POST /v1/auth/revoke n/a n/a n/a covered n/a n/a n/a n/a covered covered
person bearer on cache routes covered covered n/a n/a n/a n/a n/a n/a covered (2 replicas) covered
Concurrent exchange, 2 replicas covered (existing race test) not repeated for copy (same SQL path)

Fidelity: real server code, Postgres and CLI binaries. Faked: IAP (the test signs assertions with its own key, as in #182), GCS (in-memory), GitHub OIDC (stand-in endpoint), the browser (scripted HTTP client), SSH (an environment variable).

Not proven here: real IAP and Google LB headers (X-Forwarded-For shape), a real browser rendering and copying the code, a real SSH session or devbox, the Windows ACL path, a browser that spawns but is invisible (needs --no-browser). No follow-up task gates the release on these; they need a manual check on staging before 0.24.0.

Follow-ups

  • SameSite=Strict cookie or double-submit token (needs cookie support in Reply).
  • Scope person tokens per repo.
  • 12 h vs 1 h TTL is Calvin's call.
  • Manual staging check of copy flow behind real IAP over a real SSH session.

🤖 Generated with Claude Code

@calvin-archastro
calvin-archastro requested a review from a team October 23, 2026 17:21
@calvin-archastro

calvin-archastro commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

ArchDev is watching this pull request. State: settled (cycle 3 of 3 for head 5ebaadd).

when trigger decision result
2026-10-23T17:32:19.858Z ci + 4 review enqueue job_edd4615c1785c2261bf4f5a7 published 477a802
2026-10-23T17:50:25.954Z review feedback enqueue job_39d329b8b139e47587a4cace published 4a40b74
2026-10-23T20:32:03.529Z merge conflict enqueue job_656f3f476a8c2904b36cdbbe cancelled pull request converted to draft

Paused by the no-autofix label or a draft; never resolves reviewer threads. Jobs: job_edd4615c1785c2261bf4f5a7, job_39d329b8b139e47587a4cace, job_656f3f476a8c2904b36cdbbe.

calvin-archastro and others added 2 commits October 23, 2026 14:40
aster cache login prints a URL and reads a pasted one-time code when the
browser cannot reach the CLI's loopback listener (SSH, devboxes). Also adds
a user code, IAP assertion age check, origin check, PKCE method check,
aster cache logout with POST /v1/auth/revoke, ASTER_SRV_PERSON_TOKENS=off,
token file permission checks and an https-only client.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Carried over from the PR branch's 4a40b74 and adapted to subject-keyed
tokens: issue_at(subject, email, now).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@calvin-archastro
calvin-archastro marked this pull request as ready for review October 23, 2026 21:55
@calvin-archastro
calvin-archastro merged commit 616b232 into main Oct 23, 2026
13 checks passed
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