Repository navigation
feat(cache): copy-code login for remote shells, logout and sign-in hardening - #185
Merged
Merged
Conversation
Contributor
Author
|
ArchDev is watching this pull request. State: settled (cycle 3 of 3 for head 5ebaadd).
Paused by the |
calvin-archastro
marked this pull request as draft
October 23, 2026 21:09
calvin-archastro
force-pushed
the
calvin/aster-login-copy
branch
from
October 23, 2026 21:24
4a40b74 to
2930682
Compare
calvin-archastro
marked this pull request as ready for review
October 23, 2026 21:36
calvin-archastro
marked this pull request as draft
October 23, 2026 21:36
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
force-pushed
the
calvin/aster-login-copy
branch
from
October 23, 2026 21:45
2930682 to
5ebaadd
Compare
calvin-archastro
marked this pull request as ready for review
October 23, 2026 21:55
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 tohttp://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-browseronly printed the URL and still waited on loopback, so the only workaround wasssh -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)
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 aportparameter. Copy mode never redirects and never renders a token; the page shows only the code and a request id (first 8 chars ofstate).statein 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.src/cache/login.rs,src/cli/commands.rs,src/main.rs):--no-browsernow means "paste a code".--loopbackforces the old redirect (--loopback --no-browserprints the URL forssh -R).SSH_CONNECTION/SSH_CLIENT/SSH_TTYis set, when Linux has neitherDISPLAYnorWAYLAND_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.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-browserswitches 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 insrc/cache/login.rs.iss,aud(exact-audience fix lives in the iap.rs PR),exp; email domain fromASTER_SRV_UI_EMAIL_DOMAINS(set toarchastro.aiin prod, behind the IAP IAM gate)crates/aster-srv/src/iap.rs:142-170; called fromcli_auth.rs:245A::sign_in_needs_an_iap_assertion_from_an_allowed_domain; iap.rs unit tests. Gap: none beyond the audience PRiatat most 5 min old on GET and POST (also rejects futureiat, missingiat)cli_auth.rs:332(assertion_is_recent), used inviewer:255S::an_old_iap_assertion_cannot_mint_a_code; unitan_assertion_is_recent_for_five_minutes_and_needs_an_iatchallenge_methodrejectedcli_auth.rs:173S::copy_mode_and_pkce_requests_are_validatedstaterequired and echoed (redirect, and request id in copy)cli_auth.rsparse_request:183,authorize:435A::sign_in_refuses_a_bad_port_challenge_or_state,S::copy_mode_shows_a_one_time_code...cli_auth.rs:112S::copy_mode_shows...(row TTL <= 60 s),A::a_code_redeems_once...DELETE ... RETURNING, one winner across replicascli_auth.rs:611A::two_racing_redemptions_of_one_code_have_exactly_one_winner,A::a_code_minted_on_one_server_redeems_on_anothercli_auth.rs:611-662S::a_code_is_bound_to_its_mode_user_code_challenge_and_verifier,E::a_remote_shell_signs_in...http://127.0.0.1:<port>/callback, port 1024-65535, no other hostcli_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)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...Origin(elseReferer) must equal the public origincli_auth.rs:278S::the_confirm_post_must_come_from_the_servers_own_origin; unitonly_the_servers_own_origin_may_submitframe-ancestors 'none'+X-Frame-Options: DENYon every HTML page (dashboard too)crates/aster-srv/src/ui/mod.rs:34S::copy_mode_shows...(headers),crates/aster-srv/tests/ui.rslimits::client_ip,ASTER_SRV_TRUSTED_PROXY_HOPS; one trust model, no second parser), user agent (escaped, truncated), CLI-provided hostnamecli_auth.rs:388(confirm),http.rs:595(cli_browser)S::copy_mode_shows...(address203.0.113.7from9.9.9.9, 203.0.113.7, 130.211.0.1);limits.rsunitthe_client_is_the_right_most_address_we_do_not_ownSameSite=Strictcookie / 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.Replyhas no cookie support andhttp.rsis owned by the router PR; a cookie would be a follow-upcli_auth.rs:491S::copy_mode_shows...(noeyJ, token absent from pages),E::a_remote_shell_signs_in...0O1I), printed by the CLI, shown on the page ("Confirm only if your terminal shows"), in MAC, stored row, and required at exchangecli_auth.rs:217,:656, migration v4:106; clientsrc/cache/login.rsrandom_user_codeS::a_code_is_bound...(wrong/missing user code),S::a_confirm_form...(tampered code),L::a_pasted_code_signs_in...src/cache/login.rs:185(readable_by_others),load_tokenL::a_token_file_other_users_can_read_is_not_used,L::a_stored_token_is_private...E(both tests assert transcript/stderr lack the token)aster cache logoutdeletes the file and callsPOST /v1/auth/revokesrc/cache/login.rs:476, servercli_auth.rs:676L::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)auth.revocations(subject pk, not_before)keyed by #188's stable IAPsub, not the email (new migration v3), enforced on every person-token use; DB error fails closedcli_auth.rs:91;person.rs:242is_revoked,:256revoke_all;audit::token_revokeincli_auth.rs:676(revoke); route-table rowroutes.rs:87; hook inApp::principallib.rs:223S::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)ASTER_SRV_PERSON_TOKENS=off:/cli-auth, exchange and revoke 404, all person tokens 401config.rs:388,person.rs:102S::the_kill_switch_turns_off_sign_in_and_every_person_token; config unit testhttp://unless the host is127.0.0.1,::1orlocalhost, in login, logout andload_token(the bearer-sending cache client reads tokens through it, so a token for a cleartext server is never sent)src/cache/login.rs:106L::plain_http_is_for_this_machine_onlyicacls); if it cannot be set the token is not storedsrc/cache/login.rs:231jtiis not recorded at mint:not_beforeper subject revokes byiat, 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 usesperson.rs:242S::logout_refuses...policy.rsperson_readerreads any allowlisted repo. Acceptable for one repo today; scoping is a follow-upcli_auth.rs:611S::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 stablesub, callsaudit::token_revoke, and reuseslimits::client_ip.Rebase onto #188
cli_codes.subject; this PR is now v3 (auth.revocations) and v4 (cli_codes.user_code).upgrade.rspasses.revocations.subjectholds the IAPsub;is_revokedandrevoke_allusePerson.subject.Personkeepsissued_at(the token'siat)./v1/auth/revoke(see Rebase onto feat(aster-srv): person tokens read every dashboard dataset; aster srv; route-table auth gate #189) callsaudit::token_revoke(subject, subject)after revoking. It stays outsidepreauth, so the kill switch still answers 404 (not 401) and the route keeps its own principal check.Browser.forwarded_foris replaced byBrowser.client_ip, resolved once bylimits::client_ipinhttp::handleand threaded throughguardedandroute. The local second-from-last parser is deleted.pkce_mismatch,user_code_mismatch,port_mismatch,code_without_subject. The MAC covers mode and user code and a bad MAC is audited asbad_confirmation.hardening.rsnow sendsuser_codeand theOriginheader;cli_auth_copy.rsrevokes 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 whataster cache logoutsends); new testlogout_follows_the_stable_subject_when_the_email_changes_and_is_audited. The branch'sissue_atcommit (DB-clock issue time) is carried over as its own commit, adapted toissue_at(subject, email, now); a duplicate ctutils bump is dropped since main has it.token_mintaudit lines reportport0 for copy codes (the mode is not an audit field).Rebase onto #189
/v1/auth/revokeis a row inroutes::ROUTESwith a new auth classAuth::Personand handlerHandler::TokenRevoke.gateauthenticates the bearer (revocation list included), charges the subject's rate limit, and answers 403 to a CI token (audited asci_token_not_allowed).routes::labelmaps it to/v1/auth/revoke. The old hand-written revoke arm andcli_auth::is_routeare gone; the table is the only source. The route takes no body, so a body gets 413 as on any body-less route.ASTER_SRV_PERSON_TOKENS=offstill answers 404.gatechecksapp.person_tokens.is_none()first for thePersonclass, 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.revoketakes thePersonfrom the gate instead of re-authenticating.route(app, client, request)intocli_browser.gateneeds no IP, so its signature is unchanged.read;cache:readstays accepted asLEGACY_SCOPE.tests/routes.rswalks the table, so/v1/auth/revokeis in the every-bad-credential-is-401 test with no edit (the helper sends no body for that route). The person-allowlist walk skipsAuth::Personroutes; the newsign_out_takes_a_person_only_and_is_404_when_person_sign_in_is_offpins 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_issuedstill takes aniat.Scope
Full-stack within aster:
aster-srv(cli_auth, person, config, lib, http, ui CSP header) and theasterCLI. Beyond the planned files, small edits:lib.rs(App::principalrevocation hook),auth.rs/policy.rs(test literals for the newPerson.issued_at),ui/mod.rs+tests/ui.rs(CSP gainsframe-ancestors 'none'),http.rs(/v1/auth/revokeroute and aBrowsercontext 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.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).user_codein the URL, which the page refuses), so users runaster cache loginwith the 0.24.0 binary. A new client against an old server cannot exchange or log out.User impact
aster cache loginjust works and asks for a pasted code. Everyone: confirm only if the page shows the code the terminal printed;aster cache logoutexists; non-loopbackhttp://servers are refused; a world-readable token file is ignored.--no-browserbehavior 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: realaster-srv(in-process, real Postgres), realasterbinary as CI (GitHub OIDC stand-in), realaster cache loginbinary with piped stdin andSSH_CONNECTIONset (no flag), a scripted browser sending a signed IAP header. It asserts:mode=copyand 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.aster cache logoutdeletes the file and the server then answers 401 for the old token.Automatic coverage:
.github/workflows/ci.ymljobaster-srvrunscargo test --locked --test cache_remote_e2e --test cache_login_e2e(the new function is in that file, no wiring change) andcargo test --locked -p aster-srv(picks up the newcli_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 incli_auth.rs,config.rs,src/cache/login.rs(13 -> 19). Existingcli_auth.rstests 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
iatFidelity: 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-Forshape), 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=Strictcookie or double-submit token (needs cookie support inReply).🤖 Generated with Claude Code