From 5730405b602dd6c071bd6296f223e8c36d54e8e1 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Fri, 23 Oct 2026 10:21:03 -0700 Subject: [PATCH 1/2] feat(cache): copy-code sign-in for remote shells, logout and revocation 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) --- README.md | 4 +- crates/aster-srv/src/auth.rs | 10 +- crates/aster-srv/src/cache/policy.rs | 1 + crates/aster-srv/src/cli_auth.rs | 461 ++++++++++++-- crates/aster-srv/src/config.rs | 29 + crates/aster-srv/src/http.rs | 29 +- crates/aster-srv/src/lib.rs | 19 +- crates/aster-srv/src/person.rs | 57 +- crates/aster-srv/src/routes.rs | 27 +- crates/aster-srv/src/ui/mod.rs | 2 +- crates/aster-srv/tests/cli_auth.rs | 49 +- crates/aster-srv/tests/cli_auth_copy.rs | 765 ++++++++++++++++++++++++ crates/aster-srv/tests/common/mod.rs | 14 +- crates/aster-srv/tests/hardening.rs | 24 +- crates/aster-srv/tests/routes.rs | 45 +- crates/aster-srv/tests/ui.rs | 2 +- docs/test-tracking.md | 67 ++- src/cache/login.rs | 620 +++++++++++++++++-- src/cache/mod.rs | 3 +- src/cli/commands.rs | 31 +- src/cli/skills.md | 7 +- src/main.rs | 94 ++- tests/cache_login_e2e.rs | 358 ++++++++++- 23 files changed, 2532 insertions(+), 186 deletions(-) create mode 100644 crates/aster-srv/tests/cli_auth_copy.rs diff --git a/README.md b/README.md index 844dbf2f..bc3f4905 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,9 @@ cache after one `aster cache login`: it opens your browser, you sign in with your company account and confirm, and Aster stores a read-only token under `$XDG_CONFIG_HOME/aster` (else `~/.config/aster`) until it expires. Signed out, Aster prints one line (`not signed in; run aster cache login`) and -continues with the local cache. See +continues with the local cache. Over SSH or on a devbox, where the browser +cannot reach the CLI, it prints a URL and you paste back a one-time code; +`aster cache logout` signs out everywhere. See [Reading the cache from a laptop](docs/test-tracking.md#reading-the-cache-from-a-laptop). The same sign-in reads the flaky, broken, cluster and quarantine data the dashboard shows: `aster srv flaky`, `aster srv clusters`, `aster srv get ` diff --git a/crates/aster-srv/src/auth.rs b/crates/aster-srv/src/auth.rs index b65cef97..238920ee 100644 --- a/crates/aster-srv/src/auth.rs +++ b/crates/aster-srv/src/auth.rs @@ -575,13 +575,11 @@ mod tests { .issue("accounts.google.com:1", "ada@example.com") .unwrap() .token; - assert_eq!( + assert!(matches!( principal(Some(&issuer), &person).await, - Ok(Principal::Person(crate::person::Person { - subject: "accounts.google.com:1".into(), - email: "ada@example.com".into() - })) - ); + Ok(Principal::Person(p)) + if p.subject == "accounts.google.com:1" && p.email == "ada@example.com" + )); let ci = token(claims("ArchAstro/firstlanding"), "k1"); assert!(matches!( principal(Some(&issuer), &ci).await, diff --git a/crates/aster-srv/src/cache/policy.rs b/crates/aster-srv/src/cache/policy.rs index d8357a88..4e31417e 100644 --- a/crates/aster-srv/src/cache/policy.rs +++ b/crates/aster-srv/src/cache/policy.rs @@ -265,6 +265,7 @@ mod tests { let person = |email: &str| Person { subject: "accounts.google.com:1".into(), email: email.into(), + issued_at: 0, }; let repo = "ArchAstro/firstlanding"; let allow = &config.repositories; diff --git a/crates/aster-srv/src/cli_auth.rs b/crates/aster-srv/src/cli_auth.rs index 622e0956..fae4398e 100644 --- a/crates/aster-srv/src/cli_auth.rs +++ b/crates/aster-srv/src/cli_auth.rs @@ -1,29 +1,53 @@ //! Sign-in for `aster cache login`: an OAuth-style authorization-code flow -//! with PKCE, run against the laptop's loopback port. +//! with PKCE (S256 only), in one of two modes. //! //! ```text +//! loopback (browser and CLI on one machine) //! aster browser (behind IAP) aster-srv //! | listen 127.0.0.1:PORT | | //! | open /cli-auth?port&challenge&state -----------------------> | GET: IAP email, confirm page + MAC -//! | | POST /cli-auth (form) ------> | check IAP, MAC; store hash(code) +//! | | POST /cli-auth (form) ------> | check IAP, origin, MAC; store hash(code) //! | <-- 302 http://127.0.0.1:PORT/callback?code&state ---------- | //! | POST /v1/auth/token {code, verifier, port} ------------------> | DELETE code row, check PKCE + port //! | <-- {token, expires_at} --------------------------------------| +//! +//! copy (SSH, devbox: the browser cannot reach the CLI) +//! aster (remote shell) browser (anywhere) aster-srv +//! | print /cli-auth?mode=copy&challenge&state | +//! | | GET, then POST /cli-auth ----> | as above, but no port +//! | | <-- 200 page showing the code | +//! | person pastes the code into the terminal | +//! | POST /v1/auth/token {code, verifier} ------------------------> | DELETE code row, check PKCE, no port //! ``` //! //! `/cli-auth` sits behind IAP, which is how the person is known. `/v1/auth/token` //! does not: the CLI has no IAP session, and the code plus PKCE verifier are //! its credential. A code is 256 random bits, kept only as its SHA-256, valid -//! for 60 seconds and spent by the `DELETE` that reads it, so it works once -//! even with several replicas. Every refusal at exchange is the same -//! `400 invalid_grant`. +//! for 60 seconds in both modes and spent by the `DELETE` that reads it, so it +//! works once even with several replicas. Every refusal at exchange is the +//! same `400 invalid_grant`. +//! +//! The CLI also makes an 8-character user code, prints it in the terminal and +//! sends it in the URL. The confirmation page shows it, so the person confirms +//! only a request their own terminal displays; it is in the form's MAC and the +//! stored row, and the exchange must present it. `/cli-auth` also refuses an +//! IAP assertion older than five minutes, so a leaked header cannot mint codes. +//! +//! A code is bound to the person, the PKCE challenge, the user code and the mode. The mode is +//! the stored port: a loopback code carries its port (1024 or more), a copy +//! code carries 0. The exchange must send the same port (none means 0), so a +//! copy code is useless to a loopback exchange and the reverse, and the +//! confirmation form's MAC covers the mode so a loopback form cannot be +//! resubmitted to produce a copy code. //! -//! The redirect goes to `http://127.0.0.1:/callback` and nowhere else. -//! The confirmation page asks before redirecting, and its form carries a MAC -//! over the person, challenge, port, state and expiry, so a page for one -//! person cannot be submitted for another (login CSRF). +//! The loopback redirect goes to `http://127.0.0.1:/callback` and +//! nowhere else. Copy mode never redirects: the page shows the code, never +//! a token. The confirmation page asks before minting, shows where the +//! request came from, and its form carries a MAC over the person, challenge, +//! mode, port, state and expiry, so a page for one person cannot be submitted +//! for another (login CSRF). The POST must also come from this server's own +//! origin. -use hyper::Method; use serde::Deserialize; use serde_json::json; use sha2::{Digest, Sha256}; @@ -62,29 +86,83 @@ CREATE INDEX cli_codes_expires_at ON auth.cli_codes (expires_at); version: 2, sql: "ALTER TABLE auth.cli_codes ADD COLUMN subject text;", }, + // Signing out everywhere: tokens issued to `subject` (the stable IAP + // subject) before `not_before` are refused. + Migration { + feature: "auth", + version: 3, + sql: r#" +CREATE TABLE auth.revocations ( + subject text PRIMARY KEY, + not_before timestamptz NOT NULL +); +"#, + }, + // The code a person confirms by eye against their terminal. Rows from + // before this column have none and can never be redeemed. + Migration { + feature: "auth", + version: 4, + sql: "ALTER TABLE auth.cli_codes ADD COLUMN user_code text;", + }, ]; -/// How long a code can be redeemed. +/// How long a code can be redeemed, in both modes. A pasted code gets no +/// longer than a loopback one: the person copies it right after Authorize. const CODE_SECONDS: i64 = 60; /// How long the confirmation page's form is good for. const FORM_SECONDS: i64 = 600; /// The largest form body. pub const MAX_FORM_BYTES: usize = 8 * 1024; const MIN_PORT: u16 = 1024; +/// How old an IAP assertion may be on `/cli-auth`. IAP signs a fresh one +/// per request, so a leaked header is useless for minting after this. +const IAP_MAX_AGE_SECONDS: i64 = 300; +/// The user code alphabet: no `0`, `O`, `1` or `I`. +const USER_CODE_ALPHABET: &str = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; -/// `GET|POST /cli-auth` or `POST /v1/auth/token`. -pub fn is_route(method: &Method, path: &str) -> bool { - matches!( - (method, path), - (&Method::GET | &Method::POST, "/cli-auth") | (&Method::POST, "/v1/auth/token") - ) +/// What a browser request carried that sign-in looks at. +#[derive(Debug)] +pub struct Browser { + /// The IAP assertion header. + pub assertion: Option, + pub origin: Option, + pub referer: Option, + /// The caller as [`crate::limits::client_ip`] resolves it: the one trust + /// model for `X-Forwarded-For`. + pub client_ip: std::net::IpAddr, + pub user_agent: Option, +} + +/// How the code gets back to the CLI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// A redirect to the CLI's loopback port. + Loopback, + /// A page that shows the code, for a shell the browser cannot reach. + Copy, +} + +impl Mode { + fn name(self) -> &'static str { + match self { + Mode::Loopback => "loopback", + Mode::Copy => "copy", + } + } } /// What the CLI asks for. struct Request { + mode: Mode, + /// The loopback port; 0 in copy mode. port: u16, challenge: String, state: String, + /// The short code the CLI printed, 8 characters of [`USER_CODE_ALPHABET`]. + user_code: String, + /// The CLI machine's name, shown to the person (a hint, not trusted). + host: Option, } fn url_safe(text: &str) -> bool { @@ -100,12 +178,33 @@ fn parse_request(pairs: &[(String, String)]) -> Result { .map(|(_, value)| value.as_str()) .unwrap_or("") }; + // PKCE `plain` would put the verifier itself in the URL. + if !matches!(find("challenge_method"), "" | "S256") { + return Err("Only the S256 challenge method is supported."); + } + let mode = match find("mode") { + "" | "loopback" => Mode::Loopback, + "copy" => Mode::Copy, + _ => return Err("The mode must be loopback or copy."), + }; let port = find("port"); - let port = (!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())) - .then(|| port.parse::().ok()) - .flatten() - .filter(|port| *port >= MIN_PORT) - .ok_or("The port must be a number from 1024 to 65535.")?; + let port = match mode { + Mode::Loopback => (!port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())) + .then(|| port.parse::().ok()) + .flatten() + .filter(|port| *port >= MIN_PORT) + .ok_or("The port must be a number from 1024 to 65535.")?, + Mode::Copy if port.is_empty() => 0, + Mode::Copy => return Err("Copy mode takes no port."), + }; + let host = find("host"); + if host.len() > 64 + || !host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) + { + return Err("The host must be at most 64 letters, digits, '-', '_' or '.'."); + } let challenge = find("challenge"); // A SHA-256 as unpadded base64url. if challenge.len() != 43 || !url_safe(challenge) { @@ -115,8 +214,15 @@ fn parse_request(pairs: &[(String, String)]) -> Result { if state.is_empty() || state.len() > 128 || !url_safe(state) { return Err("The state must be 1 to 128 base64url characters."); } + let user_code = find("user_code"); + if user_code.len() != 8 || !user_code.chars().all(|c| USER_CODE_ALPHABET.contains(c)) { + return Err("The user code must be 8 characters, letters and digits without 0, O, 1, I."); + } Ok(Request { + user_code: user_code.to_string(), + mode, port, + host: (!host.is_empty()).then(|| host.to_string()), challenge: challenge.to_string(), state: state.to_string(), }) @@ -129,7 +235,8 @@ fn page(status: u16, title: &str, message: &str) -> Reply { ) } -/// Whether sign-in is on: the keys and public URL are set and IAP (or the +/// Whether sign-in is on: the keys and public URL are set, the kill switch +/// (`ASTER_SRV_PERSON_TOKENS=off`) is not thrown, and IAP (or the /// development viewer) can name a person. fn enabled(app: &App) -> bool { app.person_tokens.is_some() && app.iap.enabled() @@ -137,6 +244,11 @@ fn enabled(app: &App) -> bool { async fn viewer(app: &App, assertion: Option<&str>) -> Result { match app.iap.verify(assertion).await { + Ok(_) if !assertion_is_recent(assertion) => Err(page( + 401, + "Sign in", + "Your sign-in is too old. Reload this page to sign in again.", + )), Ok(viewer) if !viewer.email.contains('\0') => Ok(viewer), Ok(_) => { audit::auth_failure(None, "iap_invalid", "the viewer's email holds a NUL"); @@ -153,12 +265,96 @@ async fn viewer(app: &App, assertion: Option<&str>) -> Result { } } +/// `scheme://authority` of `url`. +fn origin_of(url: &str) -> Option<&str> { + let (scheme, rest) = url.split_once("://")?; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + Some(&url[..scheme.len() + 3 + end]) +} + +/// Whether a form POST came from `public_url`'s own pages. Browsers send +/// `Origin` on every form POST, and `Referer` when they do not; a request +/// with neither, or with another site's, is refused. +fn from_own_origin(public_url: &str, browser: &Browser) -> bool { + let Some(own) = origin_of(public_url) else { + return false; + }; + match (&browser.origin, &browser.referer) { + (Some(origin), _) => origin == own, + (None, Some(referer)) => referer + .strip_prefix(own) + .is_some_and(|rest| rest.is_empty() || rest.starts_with(['/', '?', '#'])), + (None, None) => false, + } +} + +fn short_user_agent(user_agent: Option<&str>) -> Option { + let text: String = user_agent? + .chars() + .filter(|c| !c.is_control()) + .take(120) + .collect(); + (!text.is_empty()).then_some(text) +} + +/// The first characters of the state, which the CLI prints too so the +/// person can see the page belongs to the command they just ran. +fn request_id(state: &str) -> &str { + &state[..state.len().min(8)] +} + +/// The fields the confirmation MAC covers, in order. +fn mac_fields<'a>( + email: &'a str, + request: &'a Request, + port: &'a str, + expiry: &'a str, +) -> [&'a str; 7] { + [ + email, + &request.challenge, + request.mode.name(), + port, + &request.state, + &request.user_code, + expiry, + ] +} + +/// `ABCD-EFGH`. +fn display_user_code(code: &str) -> String { + format!("{}-{}", &code[..4], &code[4..]) +} + +/// The IAP assertion's age check. The signature is already verified by +/// [`App::iap`]; this reads its `iat`. No assertion means the development +/// viewer, which has no JWT. +fn assertion_is_recent(assertion: Option<&str>) -> bool { + let Some(assertion) = assertion else { + return true; + }; + use base64::Engine; + let iat = assertion + .split('.') + .nth(1) + .and_then(|payload| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload.trim_end_matches('=')) + .ok() + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|claims| claims.get("iat").and_then(serde_json::Value::as_i64)); + let now = chrono::Utc::now().timestamp(); + // A little clock skew either way; a missing `iat` is not recent. + iat.is_some_and(|iat| iat <= now + 60 && now - iat <= IAP_MAX_AGE_SECONDS) +} + /// `GET /cli-auth`: ask the person to confirm. -pub async fn confirm(app: &App, assertion: Option<&str>, query: Option<&str>) -> Reply { +pub async fn confirm(app: &App, browser: &Browser, query: Option<&str>) -> Reply { let Some(tokens) = app.person_tokens.as_ref().filter(|_| enabled(app)) else { return Reply::error(404, "not found"); }; - let viewer = match viewer(app, assertion).await { + let viewer = match viewer(app, browser.assertion.as_deref()).await { Ok(viewer) => viewer, Err(reply) => return reply, }; @@ -171,46 +367,66 @@ pub async fn confirm(app: &App, assertion: Option<&str>, query: Option<&str>) -> }; let expiry = (chrono::Utc::now().timestamp() + FORM_SECONDS).to_string(); let port = request.port.to_string(); - let mac = tokens.mac(&[ - &viewer.email, - &request.challenge, - &port, - &request.state, - &expiry, - ]); + let mac = tokens.mac(&mac_fields(&viewer.email, &request, &port, &expiry)); let hidden = |name: &str, value: &str| { format!( "", escape(value) ) }; + let place = match request.mode { + Mode::Loopback => format!("on this computer (port {port})"), + Mode::Copy => "on another computer, through a code you copy back to it".to_string(), + }; + let detail = |label: &str, value: Option| { + value.map_or_else(String::new, |value| { + format!("
  • {label}: {}
  • ", escape(&value)) + }) + }; + let origin_details = format!( + "
      {}{}{}
    • Request: {}
    ", + detail("From address", Some(browser.client_ip.to_string())), + detail("Browser", short_user_agent(browser.user_agent.as_deref())), + detail("Command run on", request.host.clone()), + escape(request_id(&request.state)), + ); + let mut fields = vec![ + hidden("user_code", &request.user_code), + hidden("challenge", &request.challenge), + hidden("state", &request.state), + hidden("exp", &expiry), + hidden("mac", &mac), + ]; + if request.mode == Mode::Copy { + fields.push(hidden("mode", "copy")); + } else { + fields.push(hidden("port", &port)); + } let body = format!( - "

    Authorize the aster command on this computer (port {port}) \ + "

    Confirm only if your terminal shows this code:

    \ +

    {user_code}

    \ +

    Authorize the aster command {place} \ to read the shared build cache as {email}?

    \ + {origin_details}\

    It can read cache entries only, and the sign-in ends after a \ - few hours. If you did not just run aster cache login, close this tab.

    \ + few hours. If you did not just run aster cache login, or these details \ + are not yours, close this tab.

    \
    {fields}\
    ", + user_code = escape(&display_user_code(&request.user_code)), email = escape(&viewer.email), - fields = [ - hidden("port", &port), - hidden("challenge", &request.challenge), - hidden("state", &request.state), - hidden("exp", &expiry), - hidden("mac", &mac), - ] - .concat(), + fields = fields.concat(), ); Reply::html(200, bare_page("Authorize aster CLI", &body)) } -/// `POST /cli-auth`: the person confirmed. Mint a code and send the browser -/// back to the CLI's loopback port. -pub async fn authorize(app: &App, assertion: Option<&str>, body: &[u8]) -> Reply { +/// `POST /cli-auth`: the person confirmed. Mint a code, then either send +/// the browser back to the CLI's loopback port or show the code. +pub async fn authorize(app: &App, browser: &Browser, body: &[u8]) -> Reply { let Some(tokens) = app.person_tokens.as_ref().filter(|_| enabled(app)) else { return Reply::error(404, "not found"); }; - let viewer = match viewer(app, assertion).await { + let viewer = match viewer(app, browser.assertion.as_deref()).await { Ok(viewer) => viewer, Err(reply) => return reply, }; @@ -221,6 +437,18 @@ pub async fn authorize(app: &App, assertion: Option<&str>, body: &[u8]) -> Reply "This confirmation is not valid or has expired. Run `aster cache login` again.", ) }; + if !app + .config + .public_url + .as_deref() + .is_some_and(|public| from_own_origin(public, browser)) + { + return page( + 403, + "Not allowed", + "This confirmation was not submitted from this server's own page.", + ); + } let Some(pairs) = std::str::from_utf8(body) .ok() .and_then(|text| parse_query(Some(text))) @@ -242,18 +470,7 @@ pub async fn authorize(app: &App, assertion: Option<&str>, body: &[u8]) -> Reply let fresh = expiry .parse::() .is_ok_and(|at| at > chrono::Utc::now().timestamp()); - if !fresh - || !tokens.check_mac( - &[ - &viewer.email, - &request.challenge, - &port, - &request.state, - expiry, - ], - mac, - ) - { + if !fresh || !tokens.check_mac(&mac_fields(&viewer.email, &request, &port, expiry), mac) { audit::auth_failure( Some(&viewer.subject), "bad_confirmation", @@ -266,10 +483,27 @@ pub async fn authorize(app: &App, assertion: Option<&str>, body: &[u8]) -> Reply Err(error) => return unavailable(error), }; audit::token_mint(&viewer.subject, &viewer.email, request.port); - Reply::redirect(format!( - "http://127.0.0.1:{}/callback?code={code}&state={}", - request.port, request.state - )) + match request.mode { + Mode::Loopback => Reply::redirect(format!( + "http://127.0.0.1:{}/callback?code={code}&state={}", + request.port, request.state + )), + Mode::Copy => Reply::html( + 200, + bare_page( + "Your sign-in code", + &format!( + "

    Paste this code into the terminal where you ran \ + aster cache login (request {id}):

    \ +
    {code}
    \ +

    It works once and expires in {CODE_SECONDS} seconds. \ + It is not your token and gives nothing without that terminal.

    ", + id = escape(request_id(&request.state)), + code = escape(&code), + ), + ), + ), + } } async fn mint_code(app: &App, viewer: &Viewer, request: &Request) -> anyhow::Result { @@ -278,8 +512,9 @@ async fn mint_code(app: &App, viewer: &Viewer, request: &Request) -> anyhow::Res purge_expired_with(&client).await?; client .execute( - "INSERT INTO auth.cli_codes (code_hash, email, subject, challenge, port, expires_at) \ - VALUES ($1, $2, $3, $4, $5, now() + make_interval(secs => $6::double precision))", + "INSERT INTO auth.cli_codes \ + (code_hash, email, subject, challenge, port, user_code, expires_at) \ + VALUES ($1, $2, $3, $4, $5, $7, now() + make_interval(secs => $6::double precision))", &[ &hash(&code).as_slice(), &viewer.email, @@ -287,6 +522,7 @@ async fn mint_code(app: &App, viewer: &Viewer, request: &Request) -> anyhow::Res &request.challenge, &(request.port as i32), &(CODE_SECONDS as f64), + &request.user_code, ], ) .await?; @@ -318,6 +554,10 @@ fn unavailable(error: anyhow::Error) -> Reply { struct Exchange { code: String, verifier: String, + /// What the person confirmed against their terminal. + user_code: String, + /// The loopback port; absent for a copy-mode code, which stores 0. + #[serde(default)] port: u16, } @@ -357,7 +597,9 @@ pub async fn exchange(app: &App, body: &[u8]) -> Reply { .verifier .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')); - if !verifier_ok || !is_base64url_of(&request.code, 43) || request.port < MIN_PORT { + // Port 0 is a copy-mode exchange; a loopback one names a real port. + let port_ok = request.port == 0 || request.port >= MIN_PORT; + if !verifier_ok || !is_base64url_of(&request.code, 43) || !port_ok { return refuse(None, "malformed_request"); } let row = async { @@ -367,7 +609,7 @@ pub async fn exchange(app: &App, body: &[u8]) -> Reply { client .query_opt( "DELETE FROM auth.cli_codes WHERE code_hash = $1 AND expires_at > now() \ - RETURNING email, subject, challenge, port", + RETURNING email, subject, challenge, port, user_code", &[&hash(&request.code).as_slice()], ) .await @@ -379,8 +621,11 @@ pub async fn exchange(app: &App, body: &[u8]) -> Reply { Ok(None) => return refuse(None, "unknown_or_expired_code"), Err(error) => return unavailable(error), }; + // The row is spent whatever follows: an intercepted code can burn a + // login, never complete one; that is what single use means. let (email, subject, challenge, port): (String, Option, String, i32) = (row.get(0), row.get(1), row.get(2), row.get(3)); + let user_code: Option = row.get(4); let Some(subject) = subject.filter(|subject| !subject.is_empty()) else { return refuse(None, "code_without_subject"); }; @@ -393,6 +638,15 @@ pub async fn exchange(app: &App, body: &[u8]) -> Reply { if !crate::auth::constant_time_eq(derived.as_bytes(), challenge.as_bytes()) { return refuse(Some(&subject), "pkce_mismatch"); } + // What the person confirmed against their terminal. A row from before + // the column has none and cannot be redeemed. + let user_code_ok = user_code.is_some_and(|stored| { + crate::auth::constant_time_eq(stored.as_bytes(), request.user_code.as_bytes()) + }); + if !user_code_ok { + return refuse(Some(&subject), "user_code_mismatch"); + } + // The port is the mode: a loopback code needs its port, a copy code none. if port != i32::from(request.port) { return refuse(Some(&subject), "port_mismatch"); } @@ -412,3 +666,78 @@ pub async fn exchange(app: &App, body: &[u8]) -> Reply { Err(error) => unavailable(error), } } + +/// `POST /v1/auth/revoke`: refuse every token issued to `person` up to now, +/// this one included. The route table's `Person` class has already checked +/// the bearer token (and the kill switch), so no IAP session is involved. +pub async fn revoke(app: &App, person: &crate::person::Person) -> Reply { + match crate::person::revoke_all(&app.pool, &person.subject).await { + Ok(()) => { + audit::token_revoke(&person.subject, &person.subject); + Reply::json(200, json!({"revoked": true})) + } + Err(error) => unavailable(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn jwt_with(payload: &str) -> String { + use base64::Engine; + let part = |text: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(text); + format!("{}.{}.sig", part("{}"), part(payload)) + } + + #[test] + fn an_assertion_is_recent_for_five_minutes_and_needs_an_iat() { + let now = chrono::Utc::now().timestamp(); + let at = |iat: i64| jwt_with(&format!("{{\"iat\":{iat}}}")); + assert!(assertion_is_recent(Some(&at(now - 10)))); + assert!(assertion_is_recent(Some(&at(now - 299)))); + assert!(!assertion_is_recent(Some(&at(now - 301)))); + assert!(!assertion_is_recent(Some(&at(now + 120)))); + assert!(!assertion_is_recent(Some(&jwt_with("{}")))); + assert!(!assertion_is_recent(Some(&jwt_with("{\"iat\":\"x\"}")))); + assert!(!assertion_is_recent(Some("not-a-jwt"))); + // The development viewer has no assertion at all. + assert!(assertion_is_recent(None)); + } + + #[test] + fn only_the_servers_own_origin_may_submit() { + let browser = |origin: Option<&str>, referer: Option<&str>| Browser { + assertion: None, + origin: origin.map(str::to_string), + referer: referer.map(str::to_string), + client_ip: std::net::Ipv4Addr::LOCALHOST.into(), + user_agent: None, + }; + let own = "https://aster-srv.example.com"; + assert!(from_own_origin(own, &browser(Some(own), None))); + assert!(from_own_origin( + "https://aster-srv.example.com/base", + &browser(Some(own), None) + )); + assert!(from_own_origin( + own, + &browser(None, Some("https://aster-srv.example.com/cli-auth?x=1")) + )); + assert!(!from_own_origin(own, &browser(None, None))); + assert!(!from_own_origin(own, &browser(Some("null"), None))); + assert!(!from_own_origin( + own, + &browser(Some("https://aster-srv.example.com.evil.io"), None) + )); + assert!(!from_own_origin( + own, + &browser(None, Some("https://aster-srv.example.com.evil.io/")) + )); + assert!(!from_own_origin( + own, + &browser(Some("http://aster-srv.example.com"), None) + )); + assert!(!from_own_origin("not a url", &browser(Some(own), None))); + } +} diff --git a/crates/aster-srv/src/config.rs b/crates/aster-srv/src/config.rs index 4510c924..41aeb08b 100644 --- a/crates/aster-srv/src/config.rs +++ b/crates/aster-srv/src/config.rs @@ -166,6 +166,9 @@ pub struct Config { /// `ASTER_SRV_DB_WAIT_SECS`: how long a request waits for a database /// connection before failing. Default 5. pub db_wait: Duration, + /// `ASTER_SRV_PERSON_TOKENS=off`: the kill switch. Every person token is + /// refused and `/cli-auth` answers 404, whatever else is configured. + pub person_tokens_enabled: bool, } /// One HS256 key for person tokens. @@ -382,6 +385,14 @@ impl Config { Some(value) => parse_cli_token_keys(&value)?, None => Vec::new(), }; + let person_tokens_enabled = match var("ASTER_SRV_PERSON_TOKENS") + .map(|value| value.trim().to_ascii_lowercase()) + .as_deref() + { + None | Some("" | "on") => true, + Some("off") => false, + Some(_) => bail!("ASTER_SRV_PERSON_TOKENS must be on or off"), + }; let cli_token_ttl = match var("ASTER_SRV_CLI_TOKEN_TTL") { Some(value) => value .trim() @@ -492,6 +503,7 @@ impl Config { header_timeout, max_connections, db_wait, + person_tokens_enabled, }) } } @@ -858,6 +870,23 @@ mod tests { assert!(config.public_url.is_none()); assert!(config.cli_token_keys.is_empty()); assert_eq!(config.cli_token_ttl, 12 * 60 * 60); + assert!(config.person_tokens_enabled); + } + + #[test] + fn the_person_token_kill_switch_is_on_or_off() { + let with = |value: &'static str| { + Config::from_env(|name| match name { + "ASTER_SRV_DATABASE_URL" => Some("postgres://x/y".into()), + "ASTER_SRV_REPOSITORIES" => Some("a/*".into()), + "ASTER_SRV_PERSON_TOKENS" => Some(value.into()), + _ => None, + }) + }; + assert!(!with("off").unwrap().person_tokens_enabled); + assert!(!with(" OFF ").unwrap().person_tokens_enabled); + assert!(with("on").unwrap().person_tokens_enabled); + assert!(with("maybe").is_err()); } #[test] diff --git a/crates/aster-srv/src/http.rs b/crates/aster-srv/src/http.rs index 16fb5fcb..583fff7c 100644 --- a/crates/aster-srv/src/http.rs +++ b/crates/aster-srv/src/http.rs @@ -398,10 +398,10 @@ async fn guarded( }, ); } - route(app, request).await + route(app, client, request).await } -async fn route(app: &App, request: Request) -> Reply { +async fn route(app: &App, client: std::net::IpAddr, request: Request) -> Reply { // Until the migrations are applied the tables may not match what the // handlers query. `/healthz` stays up so the process is not killed while // it waits for locks; `/readyz` reports the wait. @@ -449,11 +449,11 @@ async fn route(app: &App, request: Request) -> Reply { } }, Handler::CliAuthConfirm => { - let assertion = header_name(&request, crate::iap::IAP_HEADER); - crate::cli_auth::confirm(app, assertion.as_deref(), request.uri().query()).await + let browser = cli_browser(&request, client); + crate::cli_auth::confirm(app, &browser, request.uri().query()).await } Handler::CliAuthAuthorize => { - let assertion = header_name(&request, crate::iap::IAP_HEADER); + let browser = cli_browser(&request, client); let body = match read_body( request, crate::cli_auth::MAX_FORM_BYTES, @@ -464,8 +464,14 @@ async fn route(app: &App, request: Request) -> Reply { Ok(body) => body, Err(reply) => return reply, }; - crate::cli_auth::authorize(app, assertion.as_deref(), &body).await + crate::cli_auth::authorize(app, &browser, &body).await } + // The gate has already checked the bearer: a signed-in person, never + // a CI token. The route takes no body. + Handler::TokenRevoke => match principal { + Some(Principal::Person(person)) => crate::cli_auth::revoke(app, &person).await, + _ => Reply::error(403, "only a signed-in person can sign out"), + }, Handler::TokenExchange => { let body = match read_body( request, @@ -585,6 +591,17 @@ fn read_identity( routes::identity_for(principal, None) } +/// What `/cli-auth` reads from a browser request. +fn cli_browser(request: &Request, client: std::net::IpAddr) -> crate::cli_auth::Browser { + crate::cli_auth::Browser { + assertion: header_name(request, crate::iap::IAP_HEADER), + origin: header_name(request, "origin"), + referer: header_name(request, "referer"), + client_ip: client, + user_agent: header_name(request, "user-agent"), + } +} + fn header_name(request: &Request, name: &str) -> Option { request .headers() diff --git a/crates/aster-srv/src/lib.rs b/crates/aster-srv/src/lib.rs index 89e29664..dc74a9f2 100644 --- a/crates/aster-srv/src/lib.rs +++ b/crates/aster-srv/src/lib.rs @@ -215,7 +215,24 @@ impl App { &self, authorization: Option<&str>, ) -> Result { - auth::authenticate(&self.verifier, self.person_tokens.as_ref(), authorization).await + let principal = + auth::authenticate(&self.verifier, self.person_tokens.as_ref(), authorization).await?; + // A person who signed out everywhere (`aster cache logout`) loses + // every token issued before that. A database error fails closed. + if let auth::Principal::Person(person) = &principal { + match person::is_revoked(&self.pool, person).await { + Ok(false) => {} + Ok(true) => return Err(auth::AuthError::Invalid("the token was revoked".into())), + Err(error) => { + log::error( + "revocation check failed", + serde_json::json!({"error": format!("{error:#}")}), + ); + return Err(auth::AuthError::Invalid("cannot check the token".into())); + } + } + } + Ok(principal) } /// Apply every migration not yet applied, then mark the schema ready. diff --git a/crates/aster-srv/src/person.rs b/crates/aster-srv/src/person.rs index 2f9d160b..801a5908 100644 --- a/crates/aster-srv/src/person.rs +++ b/crates/aster-srv/src/person.rs @@ -64,6 +64,8 @@ pub struct Person { /// The stable IAP subject. pub subject: String, pub email: String, + /// Unix seconds the token was issued, checked against revocations. + pub issued_at: i64, } /// A token and when it stops working. @@ -97,6 +99,9 @@ pub struct TokenIssuer { impl TokenIssuer { pub fn from_config(config: &Config) -> Option { + if !config.person_tokens_enabled { + return None; + } let issuer = config.public_url.clone()?; if config.cli_token_keys.is_empty() { return None; @@ -197,6 +202,7 @@ impl TokenIssuer { Ok(Person { subject: claims.sub, email: claims.email, + issued_at: claims.iat, }) } @@ -221,6 +227,36 @@ impl TokenIssuer { } } +/// Whether `person`'s token was issued before the person last signed out +/// everywhere (`aster cache logout`). The cutoff is rounded up to the next +/// whole second, because `iat` has second resolution: a token minted in the +/// same second as the logout is refused too, never kept. +pub async fn is_revoked(pool: &deadpool_postgres::Pool, person: &Person) -> anyhow::Result { + let client = pool.get().await?; + let row = client + .query_opt( + "SELECT ceil(extract(epoch FROM not_before))::bigint \ + FROM auth.revocations WHERE subject = $1", + &[&person.subject], + ) + .await?; + Ok(row.is_some_and(|row| person.issued_at < row.get::<_, i64>(0))) +} + +/// Refuse every token issued to `subject` (the stable IAP subject, not the +/// email) before now. +pub async fn revoke_all(pool: &deadpool_postgres::Pool, subject: &str) -> anyhow::Result<()> { + let client = pool.get().await?; + client + .execute( + "INSERT INTO auth.revocations (subject, not_before) VALUES ($1, now()) \ + ON CONFLICT (subject) DO UPDATE SET not_before = greatest(auth.revocations.not_before, now())", + &[&subject], + ) + .await?; + Ok(()) +} + /// Fields joined by NUL under a fixed prefix. A JWT signing input starts /// with a base64url header, never with this prefix, so one key signs both /// without either being usable as the other. @@ -299,9 +335,20 @@ mod tests { Person { subject: SUB.into(), email: "ada@example.com".into(), + issued_at: 0, } } + /// `person` with its issue time cleared, to compare with [`ada`]. + fn untimed(mut person: Person) -> Person { + assert!( + (Utc::now().timestamp() - person.issued_at).abs() <= 5, + "issued_at is the token's iat" + ); + person.issued_at = 0; + person + } + fn refused(issuer: &TokenIssuer, token: &str) -> String { match issuer.verify(token) { Err(AuthError::Invalid(reason)) => reason, @@ -313,7 +360,7 @@ mod tests { fn a_token_round_trips_with_every_claim_and_a_12_hour_life() { let issuer = issuer_for(&k1()); let issued = issuer.issue(SUB, "ada@example.com").unwrap(); - assert_eq!(issuer.verify(&issued.token).unwrap(), ada()); + assert_eq!(untimed(issuer.verify(&issued.token).unwrap()), ada()); let lifetime = issued.expires_at - Utc::now().timestamp(); assert!( (12 * 3600 - 10..=12 * 3600).contains(&lifetime), @@ -354,7 +401,7 @@ mod tests { #[test] fn tokens_without_sub_nbf_email_iat_or_jti_are_refused() { let issuer = issuer_for(&k1()); - assert_eq!(issuer.verify(&hs256(&claims())).unwrap(), ada()); + assert_eq!(untimed(issuer.verify(&hs256(&claims())).unwrap()), ada()); for missing in [ "sub", "nbf", "email", "iat", "jti", "scope", "exp", "iss", "aud", ] { @@ -481,16 +528,16 @@ mod tests { .token; // Both listed, new first: k2 signs, k1 still verifies. let rotating = issuer_for(&format!("{},{}", k2(), k1())); - assert_eq!(rotating.verify(&old).unwrap(), ada()); + assert_eq!(untimed(rotating.verify(&old).unwrap()), ada()); let fresh = rotating.issue(SUB, "ada@example.com").unwrap().token; assert_eq!(decode_header(&fresh).unwrap().kid.as_deref(), Some("k2")); - assert_eq!(rotating.verify(&fresh).unwrap(), ada()); + assert_eq!(untimed(rotating.verify(&fresh).unwrap()), ada()); // A replica still on the old config accepts neither new tokens... assert!(issuer_for(&k1()).verify(&fresh).is_err()); // ...and with the previous key dropped, its tokens stop working. let rotated = issuer_for(&k2()); assert!(rotated.verify(&old).is_err()); - assert_eq!(rotated.verify(&fresh).unwrap(), ada()); + assert_eq!(untimed(rotated.verify(&fresh).unwrap()), ada()); } #[test] diff --git a/crates/aster-srv/src/routes.rs b/crates/aster-srv/src/routes.rs index 2a9ac34f..4e56af71 100644 --- a/crates/aster-srv/src/routes.rs +++ b/crates/aster-srv/src/routes.rs @@ -10,6 +10,7 @@ //! | `Public` | anyone: health checks and the sign-in code exchange | //! | `Iap` | a person behind Google IAP (the dashboard and `/cli-auth`); the handler verifies the IAP assertion | //! | `SignedUrl` | the test-only stand-in for a signed blob URL; only the in-memory store answers it | +//! | `Person` | a signed-in person's token only (sign out); a CI token is refused. With person sign-in switched off (`ASTER_SRV_PERSON_TOKENS=off`) the route answers 404 before any token is read | //! | `Ci` | a CI token (GitHub OIDC or a development static token); a person's token is refused | //! | `CiOrPerson` | a CI token for its own repository, or a signed-in person for any served repository: the read-only routes | //! | `CiWriter` | a CI token only; the handler also checks the writer rules (ref, workflow, event) | @@ -25,6 +26,7 @@ pub enum Auth { Public, Iap, SignedUrl, + Person, Ci, CiOrPerson, CiWriter, @@ -38,6 +40,7 @@ pub enum Handler { CliAuthConfirm, CliAuthAuthorize, TokenExchange, + TokenRevoke, ResultsUpload, Durations, ResultsRead, @@ -79,6 +82,14 @@ pub const ROUTES: &[Route] = &[ Auth::Public, Handler::TokenExchange, ), + // Sign out everywhere: the bearer is the credential, there is no IAP + // session on this route. + route( + "POST", + "/v1/auth/revoke", + Auth::Person, + Handler::TokenRevoke, + ), route( "POST", "/v1/test-results/runs", @@ -220,6 +231,7 @@ pub fn label(method: &str, path: &str) -> &'static str { Some(Handler::Readyz) => "/readyz", Some(Handler::CliAuthConfirm | Handler::CliAuthAuthorize) => "/cli-auth", Some(Handler::TokenExchange) => "/v1/auth/token", + Some(Handler::TokenRevoke) => "/v1/auth/revoke", Some(Handler::ResultsUpload) => "/v1/test-results/runs", Some(Handler::Durations) => "/v1/test-results/durations", Some(Handler::ResultsRead) => "/v1/test-results/query", @@ -256,7 +268,12 @@ pub async fn gate( let class = route.map_or(Auth::CiOrPerson, |route| route.auth); match class { Auth::Public | Auth::Iap | Auth::SignedUrl => Ok(None), - Auth::Ci | Auth::CiWriter | Auth::CiOrPerson => { + Auth::Person | Auth::Ci | Auth::CiWriter | Auth::CiOrPerson => { + // The kill switch: a server with person sign-in off has no + // sign-out to offer, whoever asks. + if class == Auth::Person && app.person_tokens.is_none() { + return Err(Reply::error(404, "not found")); + } let principal = match app.principal(authorization).await { Ok(principal) => principal, Err(error) => { @@ -286,6 +303,14 @@ pub async fn gate( ); return Err(Reply::error(403, "a person's token is read-only")); } + if class == Auth::Person && matches!(principal, Principal::Ci(_)) { + audit::auth_failure( + Some(&person_subject(&principal)), + "ci_token_not_allowed", + "only a signed-in person can sign out", + ); + return Err(Reply::error(403, "only a signed-in person can sign out")); + } Ok(Some(principal)) } } diff --git a/crates/aster-srv/src/ui/mod.rs b/crates/aster-srv/src/ui/mod.rs index a53111f8..0c47fc6a 100644 --- a/crates/aster-srv/src/ui/mod.rs +++ b/crates/aster-srv/src/ui/mod.rs @@ -31,7 +31,7 @@ use html::{Frame, Section}; pub const SECURITY_HEADERS: [(&str, &str); 4] = [ ( "content-security-policy", - "default-src 'none'; style-src 'unsafe-inline'; img-src data:", + "default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'", ), ("x-content-type-options", "nosniff"), ("referrer-policy", "same-origin"), diff --git a/crates/aster-srv/tests/cli_auth.rs b/crates/aster-srv/tests/cli_auth.rs index 7f89b105..a56ad17d 100644 --- a/crates/aster-srv/tests/cli_auth.rs +++ b/crates/aster-srv/tests/cli_auth.rs @@ -12,13 +12,17 @@ use std::sync::Arc; use aster_srv::cache::{BlobStore, FixedSigner, Signer}; use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL; use base64::Engine; -use common::{iap_assertion, iap_assertion_for, person_bearer, token_with_claims, Server}; +use common::{ + iap_assertion, iap_assertion_for, person_bearer, token_with_claims, Server, PUBLIC_URL, +}; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; const REPO: &str = "ArchAstro/firstlanding"; const ADA: &str = "ada@example.com"; +/// The code the CLI would print and the page would show (as ABCD-2345). +const USER_CODE: &str = "ABCD2345"; const CI_WORKFLOW: &str = "ArchAstro/firstlanding/.github/workflows/ci.yml@refs/heads/main"; fn server_env() -> Vec<(&'static str, &'static str)> { @@ -108,6 +112,8 @@ async fn confirm(server: &Server, fields: &[(&str, &str)], assertion: Option<&st let mut request = browser() .post(format!("{}/cli-auth", server.url)) .header("content-type", "application/x-www-form-urlencoded") + // A browser submitting the server's own form names its origin. + .header("origin", PUBLIC_URL) .body(body); if let Some(assertion) = assertion { request = request.header("x-goog-iap-jwt-assertion", assertion); @@ -143,7 +149,7 @@ fn as_refs(fields: &[(String, String)]) -> Vec<(&str, &str)> { } fn query(port: impl std::fmt::Display, challenge: &str, state: &str) -> String { - format!("port={port}&challenge={challenge}&state={state}") + format!("port={port}&challenge={challenge}&state={state}&user_code={USER_CODE}") } /// The code and state a successful confirmation sent to the loopback port. @@ -179,7 +185,7 @@ async fn exchange(server: &Server, code: &str, verifier: &str, port: u16) -> (u1 server .post_json( "/v1/auth/token", - &json!({"code": code, "verifier": verifier, "port": port}), + &json!({"code": code, "verifier": verifier, "user_code": USER_CODE, "port": port}), None, None, ) @@ -247,13 +253,16 @@ async fn sign_in_refuses_a_bad_port_challenge_or_state() { let (_, challenge) = pkce(); let long_state = "s".repeat(129); for (what, q) in [ - ("no port", format!("challenge={challenge}&state=s")), + ( + "no port", + format!("challenge={challenge}&state=s&user_code={USER_CODE}"), + ), ("port below 1024", query(1023, &challenge, "s")), ("port 0", query(0, &challenge, "s")), ("port too large", query(65_536, &challenge, "s")), ( "port not a number", - format!("port=80a&challenge={challenge}&state=s"), + format!("port=80a&challenge={challenge}&state=s&user_code={USER_CODE}"), ), ("short challenge", query(40_000, "abc", "s")), ( @@ -264,7 +273,10 @@ async fn sign_in_refuses_a_bad_port_challenge_or_state() { "challenge with a plus", query(40_000, &format!("{}+", &challenge[..42]), "s"), ), - ("no state", format!("port=40000&challenge={challenge}")), + ( + "no state", + format!("port=40000&challenge={challenge}&user_code={USER_CODE}"), + ), ("empty state", query(40_000, &challenge, "")), ("long state", query(40_000, &challenge, &long_state)), ("state with markup", query(40_000, &challenge, "%3Cb%3E")), @@ -291,7 +303,10 @@ async fn the_confirmation_form_is_bound_to_who_asked_what_and_when() { let form = open_login(&server, &q, Some(&ada)).await; let fields = form_fields(&form.body); let names: Vec<_> = fields.iter().map(|(n, _)| n.as_str()).collect(); - assert_eq!(names, ["port", "challenge", "state", "exp", "mac"]); + assert_eq!( + names, + ["user_code", "challenge", "state", "exp", "mac", "port"] + ); let with = |name: &str, value: &str| -> Vec<(String, String)> { fields @@ -322,11 +337,14 @@ async fn the_confirmation_form_is_bound_to_who_asked_what_and_when() { // A correctly MACed form whose time has passed. let issuer = server.app.person_tokens.as_ref().unwrap(); let past = (chrono::Utc::now().timestamp() - 5).to_string(); - let mac = issuer.mac(&[ADA, &challenge, "40000", "state-x", &past]); + let mac = issuer.mac(&[ + ADA, &challenge, "loopback", "40000", "state-x", USER_CODE, &past, + ]); attempts.push(( "an expired form", vec![ ("port".into(), "40000".into()), + ("user_code".into(), USER_CODE.into()), ("challenge".into(), challenge.clone()), ("state".into(), "state-x".into()), ("exp".into(), past), @@ -421,7 +439,10 @@ async fn every_refused_exchange_is_the_same_invalid_grant() { let (code, verifier, port) = sign_in(&server, ADA).await; let (_, other_verifier) = pkce(); assert_eq!( - post(json!({"code": code, "verifier": other_verifier, "port": port})).await, + post( + json!({"code": code, "verifier": other_verifier, "user_code": USER_CODE, "port": port}) + ) + .await, (400, invalid_grant()) ); assert_eq!( @@ -487,11 +508,11 @@ async fn every_refused_exchange_is_the_same_invalid_grant() { // Never issued, malformed, short verifier, wrong types. let (_, verifier) = pkce(); for body in [ - json!({"code": "nope", "verifier": verifier, "port": 40_123}), - json!({"code": "", "verifier": "", "port": 40_123}), - json!({"code": "x", "verifier": "short", "port": 40_123}), - json!({"code": 1, "verifier": verifier, "port": 40_123}), - json!({"code": "x", "verifier": verifier}), + json!({"code": "nope", "verifier": verifier, "user_code": USER_CODE, "port": 40_123}), + json!({"code": "", "verifier": "", "user_code": USER_CODE, "port": 40_123}), + json!({"code": "x", "verifier": "short", "user_code": USER_CODE, "port": 40_123}), + json!({"code": 1, "verifier": verifier, "user_code": USER_CODE, "port": 40_123}), + json!({"code": "x", "verifier": verifier, "user_code": USER_CODE}), json!({}), ] { assert_eq!(post(body.clone()).await, (400, invalid_grant()), "{body}"); diff --git a/crates/aster-srv/tests/cli_auth_copy.rs b/crates/aster-srv/tests/cli_auth_copy.rs new file mode 100644 index 00000000..90833666 --- /dev/null +++ b/crates/aster-srv/tests/cli_auth_copy.rs @@ -0,0 +1,765 @@ +//! Copy-code sign-in (`/cli-auth?mode=copy`) and the rest of the sign-in +//! hardening: mode binding, the user code, origin and IAP-age checks, PKCE +//! method, revocation and the kill switch. Real Postgres; the browser is a +//! scripted HTTP client that attaches a signed IAP assertion. +//! +//! Set `ASTER_SRV_TEST_DATABASE_URL`; without it the tests skip. + +mod common; + +use std::sync::Arc; + +use aster_srv::cache::{BlobStore, FixedSigner, Signer}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as BASE64URL; +use base64::Engine; +use common::{ + iap_assertion, iap_assertion_issued, person_bearer, Server, IAP_AUDIENCE, PUBLIC_URL, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +const REPO: &str = "ArchAstro/firstlanding"; +const ADA: &str = "ada@example.com"; +const USER_CODE: &str = "ABCD2345"; + +async fn start_with(extra: &[(&str, &str)]) -> Option { + let signer = Arc::new(FixedSigner { + email: "cache-test@example.iam.gserviceaccount.com".into(), + signature: b"test-signature".to_vec(), + signed: Default::default(), + }); + let store = BlobStore::memory("aster-cache-test", signer as Arc); + let mut env = vec![ + ("ASTER_SRV_CACHE_BUCKET", "aster-cache-test"), + ( + "ASTER_SRV_CACHE_SIGNER", + "cache-test@example.iam.gserviceaccount.com", + ), + ("ASTER_SRV_IAP_AUDIENCE", IAP_AUDIENCE), + ("ASTER_SRV_UI_EMAIL_DOMAINS", "example.com"), + ]; + env.extend_from_slice(extra); + env.reverse(); + common::start_with(&env, Some(store)).await +} + +async fn start() -> Option { + start_with(&[]).await +} + +fn pkce() -> (String, String) { + let mut bytes = [0u8; 32]; + aws_lc_rs::rand::fill(&mut bytes).unwrap(); + let verifier = BASE64URL.encode(bytes); + let challenge = BASE64URL.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +struct Page { + status: u16, + headers: reqwest::header::HeaderMap, + body: String, +} + +impl Page { + fn location(&self) -> Option<&str> { + self.headers.get("location").and_then(|v| v.to_str().ok()) + } +} + +fn browser() -> reqwest::Client { + aster_srv::http_client() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +async fn page(response: reqwest::Response) -> Page { + Page { + status: response.status().as_u16(), + headers: response.headers().clone(), + body: response.text().await.unwrap_or_default(), + } +} + +async fn open(server: &Server, query: &str, assertion: &str) -> Page { + page( + browser() + .get(format!("{}/cli-auth?{query}", server.url)) + .header("x-goog-iap-jwt-assertion", assertion) + .header("x-forwarded-for", "9.9.9.9, 203.0.113.7, 130.211.0.1") + .header("user-agent", "TestBrowser/1.0 ") + .send() + .await + .unwrap(), + ) + .await +} + +/// POST the form. `origin` and `referer` are what the browser would send. +async fn submit( + server: &Server, + fields: &[(String, String)], + assertion: &str, + origin: Option<&str>, + referer: Option<&str>, +) -> Page { + let body = fields + .iter() + .map(|(n, v)| format!("{n}={v}")) + .collect::>() + .join("&"); + let mut request = browser() + .post(format!("{}/cli-auth", server.url)) + .header("content-type", "application/x-www-form-urlencoded") + .header("x-goog-iap-jwt-assertion", assertion) + .body(body); + if let Some(origin) = origin { + request = request.header("origin", origin); + } + if let Some(referer) = referer { + request = request.header("referer", referer); + } + page(request.send().await.unwrap()).await +} + +async fn confirm(server: &Server, fields: &[(String, String)], assertion: &str) -> Page { + submit(server, fields, assertion, Some(PUBLIC_URL), None).await +} + +fn form_fields(html: &str) -> Vec<(String, String)> { + html.split(" Vec<(String, String)> { + fields + .iter() + .map(|(n, v)| (n.clone(), if n == name { value.into() } else { v.clone() })) + .collect() +} + +fn copy_query(challenge: &str, state: &str) -> String { + format!("mode=copy&challenge={challenge}&state={state}&user_code={USER_CODE}&challenge_method=S256&host=devbox-7") +} + +fn loopback_query(port: u16, challenge: &str, state: &str) -> String { + format!("port={port}&challenge={challenge}&state={state}&user_code={USER_CODE}") +} + +async fn exchange(server: &Server, body: Value) -> (u16, Value) { + server.post_json("/v1/auth/token", &body, None, None).await +} + +fn invalid_grant() -> Value { + json!({"error": "invalid_grant"}) +} + +/// The one-time code a copy-mode page shows, from `
    CODE
    `. +fn shown_code(html: &str) -> String { + let rest = html.split("
    ").nth(1).expect("a code block");
    +    rest.split("").next().unwrap().to_string()
    +}
    +
    +/// `POST /v1/auth/revoke` with no body, as `aster cache logout` sends it
    +/// (the route takes none).
    +async fn revoke(server: &Server, bearer: Option<&str>) -> (u16, Value) {
    +    let mut request = aster_srv::http_client()
    +        .build()
    +        .unwrap()
    +        .post(format!("{}/v1/auth/revoke", server.url));
    +    if let Some(bearer) = bearer {
    +        request = request.header("Authorization", bearer);
    +    }
    +    let response = request.send().await.unwrap();
    +    let status = response.status().as_u16();
    +    (status, response.json().await.unwrap_or(json!(null)))
    +}
    +
    +async fn code_rows(server: &Server) -> i64 {
    +    server
    +        .client()
    +        .await
    +        .query_one("SELECT count(*) FROM auth.cli_codes", &[])
    +        .await
    +        .unwrap()
    +        .get(0)
    +}
    +
    +#[tokio::test]
    +async fn copy_mode_shows_a_one_time_code_and_never_redirects_or_shows_a_token() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let ada = iap_assertion(ADA);
    +    let (verifier, challenge) = pkce();
    +
    +    // The confirm page names who, from where, and the code the terminal printed.
    +    let form = open(&server, ©_query(&challenge, "state-copy-1"), &ada).await;
    +    assert_eq!(form.status, 200, "{}", form.body);
    +    assert!(form.body.contains("ABCD-2345"), "{}", form.body);
    +    assert!(form.body.contains("devbox-7"), "{}", form.body);
    +    assert!(form.body.contains(ADA), "{}", form.body);
    +    // The client address is the one the load balancer appended, not the
    +    // client's own first entry; the user agent is shown escaped.
    +    assert!(form.body.contains("203.0.113.7"), "{}", form.body);
    +    assert!(!form.body.contains("9.9.9.9"), "{}", form.body);
    +    assert!(
    +        form.body.contains("TestBrowser/1.0 <b>"),
    +        "{}",
    +        form.body
    +    );
    +    assert!(
    +        !form.body.contains("name=\"port\"") && !form.body.contains("(port"),
    +        "copy mode has no port: {}",
    +        form.body
    +    );
    +    let fields = form_fields(&form.body);
    +    let names: Vec<_> = fields.iter().map(|(n, _)| n.as_str()).collect();
    +    assert_eq!(
    +        names,
    +        ["user_code", "challenge", "state", "exp", "mac", "mode"]
    +    );
    +
    +    // Authorize: a page with the code, never a redirect.
    +    let done = confirm(&server, &fields, &ada).await;
    +    assert_eq!(done.status, 200, "{}", done.body);
    +    assert!(done.location().is_none(), "copy mode must not redirect");
    +    assert!(!done.body.contains("127.0.0.1"), "{}", done.body);
    +    let code = shown_code(&done.body);
    +    assert_eq!(code.len(), 43, "a 256-bit code, base64url");
    +    assert!(done.body.contains("state-co"), "the request id is shown");
    +    for (name, value) in [
    +        ("cache-control", "no-store"),
    +        ("x-frame-options", "DENY"),
    +        ("referrer-policy", "same-origin"),
    +        ("x-content-type-options", "nosniff"),
    +    ] {
    +        assert_eq!(done.headers[name], value, "{name}");
    +    }
    +    let csp = done.headers["content-security-policy"].to_str().unwrap();
    +    assert!(csp.contains("frame-ancestors 'none'"), "{csp}");
    +    assert!(csp.contains("default-src 'none'"), "{csp}");
    +
    +    // The row lives at most 60 seconds, the same as a loopback code.
    +    let ttl: f64 = server
    +        .client()
    +        .await
    +        .query_one(
    +            "SELECT extract(epoch FROM expires_at - now())::float8 FROM auth.cli_codes",
    +            &[],
    +        )
    +        .await
    +        .unwrap()
    +        .get(0);
    +    assert!(ttl > 0.0 && ttl <= 60.0, "{ttl}");
    +
    +    // The code and verifier exchange for a token, with no port.
    +    let body = json!({"code": code, "verifier": verifier, "user_code": USER_CODE});
    +    let (status, reply) = exchange(&server, body.clone()).await;
    +    assert_eq!(status, 200, "{reply}");
    +    let token = reply["token"].as_str().unwrap().to_string();
    +    assert_eq!(reply["email"], ADA);
    +    // The token never appeared on a page.
    +    assert!(!done.body.contains(&token) && !form.body.contains(&token));
    +    assert!(!done.body.contains("eyJ"), "no JWT on the page");
    +    // It reads the cache.
    +    let (status, _) = server
    +        .post_json(
    +            "/v1/cache/lookup",
    +            &json!({"repo": REPO, "keys": ["k"]}),
    +            Some(&format!("Bearer {token}")),
    +            None,
    +        )
    +        .await;
    +    assert_eq!(status, 200);
    +    // Replaying the code fails.
    +    assert_eq!(exchange(&server, body).await, (400, invalid_grant()));
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn a_code_is_bound_to_its_mode_user_code_challenge_and_verifier() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let ada = iap_assertion(ADA);
    +    let (verifier, challenge) = pkce();
    +    let mint_copy = |state: &'static str| {
    +        let (server, ada, challenge) = (&server, &ada, challenge.clone());
    +        async move {
    +            let form = open(server, ©_query(&challenge, state), ada).await;
    +            let done = confirm(server, &form_fields(&form.body), ada).await;
    +            shown_code(&done.body)
    +        }
    +    };
    +    let mint_loopback = |state: &'static str| {
    +        let (server, ada, challenge) = (&server, &ada, challenge.clone());
    +        async move {
    +            let form = open(server, &loopback_query(40_321, &challenge, state), ada).await;
    +            let done = confirm(server, &form_fields(&form.body), ada).await;
    +            assert_eq!(done.status, 302, "{}", done.body);
    +            let location = done.location().unwrap().to_string();
    +            location
    +                .split("code=")
    +                .nth(1)
    +                .unwrap()
    +                .split('&')
    +                .next()
    +                .unwrap()
    +                .to_string()
    +        }
    +    };
    +
    +    // A copy code is useless to a loopback exchange (with a port)...
    +    let code = mint_copy("s-1").await;
    +    assert_eq!(
    +        exchange(
    +            &server,
    +            json!({"code": code, "verifier": verifier, "user_code": USER_CODE, "port": 40_321})
    +        )
    +        .await,
    +        (400, invalid_grant())
    +    );
    +    // ...and a loopback code is useless without its port, or with port 0.
    +    for body in [
    +        json!({"verifier": verifier, "user_code": USER_CODE}),
    +        json!({"verifier": verifier, "user_code": USER_CODE, "port": 0}),
    +    ] {
    +        let code = mint_loopback("s-2").await;
    +        let mut body = body;
    +        body["code"] = json!(code);
    +        assert_eq!(
    +            exchange(&server, body.clone()).await,
    +            (400, invalid_grant()),
    +            "{body}"
    +        );
    +    }
    +    // A wrong user code, a wrong verifier: refused, and the code is spent.
    +    for (what, body) in [
    +        (
    +            "wrong user code",
    +            json!({"verifier": verifier, "user_code": "ZZZZ9999"}),
    +        ),
    +        ("no user code", json!({"verifier": verifier})),
    +        (
    +            "wrong verifier",
    +            json!({"verifier": pkce().0, "user_code": USER_CODE}),
    +        ),
    +    ] {
    +        let code = mint_copy("s-3").await;
    +        let mut body = body;
    +        body["code"] = json!(code);
    +        assert_eq!(
    +            exchange(&server, body.clone()).await,
    +            (400, invalid_grant()),
    +            "{what}"
    +        );
    +        // A body that does not parse is refused before it touches the row.
    +        if what == "no user code" {
    +            continue;
    +        }
    +        let mut right = json!({"verifier": verifier, "user_code": USER_CODE});
    +        right["code"] = json!(code);
    +        assert_eq!(
    +            exchange(&server, right).await,
    +            (400, invalid_grant()),
    +            "{what}: the refused attempt spent the code"
    +        );
    +    }
    +    // The right pair works.
    +    let code = mint_copy("s-4").await;
    +    let (status, _) = exchange(
    +        &server,
    +        json!({"code": code, "verifier": verifier, "user_code": USER_CODE}),
    +    )
    +    .await;
    +    assert_eq!(status, 200);
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn a_confirm_form_for_one_mode_cannot_be_submitted_as_the_other() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let ada = iap_assertion(ADA);
    +    let (_, challenge) = pkce();
    +    let loopback = form_fields(
    +        &open(&server, &loopback_query(40_000, &challenge, "s"), &ada)
    +            .await
    +            .body,
    +    );
    +    let copy = form_fields(&open(&server, ©_query(&challenge, "s"), &ada).await.body);
    +
    +    let mut attempts: Vec<(&str, Vec<(String, String)>)> = vec![
    +        // Loopback form re-labelled copy, with and without its port.
    +        ("loopback form as copy", {
    +            let mut f = loopback.clone();
    +            f.push(("mode".into(), "copy".into()));
    +            f
    +        }),
    +        ("loopback form as copy, port dropped", {
    +            let mut f: Vec<_> = loopback
    +                .iter()
    +                .filter(|(n, _)| n != "port")
    +                .cloned()
    +                .collect();
    +            f.push(("mode".into(), "copy".into()));
    +            f
    +        }),
    +        // Copy form given a port, or switched to loopback.
    +        ("copy form with a port", {
    +            let mut f = copy.clone();
    +            f.push(("port".into(), "40000".into()));
    +            f
    +        }),
    +        ("copy form as loopback", {
    +            let mut f: Vec<_> = copy.iter().filter(|(n, _)| n != "mode").cloned().collect();
    +            f.push(("port".into(), "40000".into()));
    +            f
    +        }),
    +        (
    +            "copy form, mode removed",
    +            copy.iter().filter(|(n, _)| n != "mode").cloned().collect(),
    +        ),
    +        ("copy form, unknown mode", with(©, "mode", "device")),
    +        ("tampered user code", with(©, "user_code", "ZZZZ9999")),
    +        (
    +            "loopback tampered user code",
    +            with(&loopback, "user_code", "ZZZZ9999"),
    +        ),
    +        (
    +            "user code removed",
    +            copy.iter()
    +                .filter(|(n, _)| n != "user_code")
    +                .cloned()
    +                .collect(),
    +        ),
    +    ];
    +    attempts.push((
    +        "bad user code alphabet",
    +        with(©, "user_code", "ABCD0O1I"),
    +    ));
    +    for (what, fields) in &attempts {
    +        let got = confirm(&server, fields, &ada).await;
    +        assert_eq!(got.status, 400, "{what}: {}", got.body);
    +        assert!(got.location().is_none(), "{what} must not redirect");
    +        assert!(!got.body.contains("
    "), "{what} must not show a code");
    +    }
    +    assert_eq!(
    +        code_rows(&server).await,
    +        0,
    +        "no refused form may mint a code"
    +    );
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn copy_mode_and_pkce_requests_are_validated() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let ada = iap_assertion(ADA);
    +    let (_, challenge) = pkce();
    +    let base = format!("challenge={challenge}&state=s&user_code={USER_CODE}");
    +    for (what, query) in [
    +        ("copy with a port", format!("mode=copy&port=40000&{base}")),
    +        ("unknown mode", format!("mode=device&{base}")),
    +        (
    +            "plain challenge method",
    +            format!("mode=copy&challenge_method=plain&{base}"),
    +        ),
    +        (
    +            "lowercase s256",
    +            format!("mode=copy&challenge_method=s256&{base}"),
    +        ),
    +        (
    +            "loopback with plain",
    +            format!("port=40000&challenge_method=plain&{base}"),
    +        ),
    +        (
    +            "no user code",
    +            format!("mode=copy&challenge={challenge}&state=s"),
    +        ),
    +        (
    +            "short user code",
    +            format!("mode=copy&challenge={challenge}&state=s&user_code=ABCD"),
    +        ),
    +        ("host with markup", format!("mode=copy&host=%3Cb%3E&{base}")),
    +        (
    +            "long host",
    +            format!("mode=copy&host={}&{base}", "h".repeat(65)),
    +        ),
    +        (
    +            "no state",
    +            format!("mode=copy&challenge={challenge}&user_code={USER_CODE}"),
    +        ),
    +    ] {
    +        let got = open(&server, &query, &ada).await;
    +        assert_eq!(got.status, 400, "{what}: {}", got.body);
    +    }
    +    for query in [
    +        format!("mode=copy&challenge_method=S256&{base}"),
    +        format!("mode=loopback&port=40000&{base}"),
    +        format!("mode=copy&{base}"),
    +    ] {
    +        assert_eq!(open(&server, &query, &ada).await.status, 200, "{query}");
    +    }
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn the_confirm_post_must_come_from_the_servers_own_origin() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let ada = iap_assertion(ADA);
    +    let (_, challenge) = pkce();
    +    let form = form_fields(&open(&server, ©_query(&challenge, "s"), &ada).await.body);
    +    let own_referer = format!("{PUBLIC_URL}/cli-auth?mode=copy");
    +    for (what, origin, referer) in [
    +        ("no origin or referer", None, None),
    +        ("another site", Some("https://evil.example"), None),
    +        ("null origin", Some("null"), None),
    +        (
    +            "a look-alike host",
    +            Some("https://aster-srv.test.evil.example"),
    +            None,
    +        ),
    +        (
    +            "plain http for the https server",
    +            Some("http://aster-srv.test"),
    +            None,
    +        ),
    +        (
    +            "another referer",
    +            None,
    +            Some("https://evil.example/cli-auth"),
    +        ),
    +        (
    +            "a referer that only starts with our host",
    +            None,
    +            Some("https://aster-srv.test.evil.example/"),
    +        ),
    +        (
    +            "a wrong origin the right referer cannot rescue",
    +            Some("https://evil.example"),
    +            Some(own_referer.as_str()),
    +        ),
    +    ] {
    +        let got = submit(&server, &form, &ada, origin, referer).await;
    +        assert_eq!(got.status, 403, "{what}: {}", got.body);
    +        assert!(got.location().is_none());
    +    }
    +    assert_eq!(code_rows(&server).await, 0);
    +    // The server's own origin, or (with no Origin header) its own referer.
    +    let ok = submit(&server, &form, &ada, Some(PUBLIC_URL), None).await;
    +    assert_eq!(ok.status, 200, "{}", ok.body);
    +    let form = form_fields(
    +        &open(&server, ©_query(&challenge, "s2"), &ada)
    +            .await
    +            .body,
    +    );
    +    let ok = submit(&server, &form, &ada, None, Some(&own_referer)).await;
    +    assert_eq!(ok.status, 200, "{}", ok.body);
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn an_old_iap_assertion_cannot_mint_a_code() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let (_, challenge) = pkce();
    +    let now = chrono::Utc::now().timestamp();
    +    let audience = IAP_AUDIENCE;
    +    let stale = iap_assertion_issued(ADA, audience, now - 600);
    +    let future = iap_assertion_issued(ADA, audience, now + 3600);
    +    let fresh_enough = iap_assertion_issued(ADA, audience, now - 240);
    +    let q = copy_query(&challenge, "s");
    +    for (what, assertion) in [
    +        ("ten minutes old", &stale),
    +        ("issued in the future", &future),
    +    ] {
    +        let got = open(&server, &q, assertion).await;
    +        assert_eq!(got.status, 401, "GET, {what}");
    +        let fields = form_fields(&open(&server, &q, &iap_assertion(ADA)).await.body);
    +        let got = confirm(&server, &fields, assertion).await;
    +        assert_eq!(got.status, 401, "POST, {what}: {}", got.body);
    +    }
    +    assert_eq!(code_rows(&server).await, 0);
    +    // An assertion with no `iat` at all is not recent either.
    +    let no_iat = {
    +        use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
    +        // Reuse a real assertion's signature input is impossible without
    +        // the key, so a header-only forgery is refused by the signature
    +        // check before the age check even runs.
    +        let mut header = Header::new(Algorithm::HS256);
    +        header.kid = Some("iap".into());
    +        encode(
    +            &header,
    +            &json!({"iss": "https://cloud.google.com/iap", "aud": audience,
    +                    "exp": now + 300, "email": ADA}),
    +            &EncodingKey::from_secret(b"x"),
    +        )
    +        .unwrap()
    +    };
    +    assert_eq!(open(&server, &q, &no_iat).await.status, 401);
    +    // Within five minutes it works.
    +    assert_eq!(open(&server, &q, &fresh_enough).await.status, 200);
    +    let fields = form_fields(&open(&server, &q, &fresh_enough).await.body);
    +    assert_eq!(confirm(&server, &fields, &fresh_enough).await.status, 200);
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn logout_refuses_every_earlier_token_for_that_person_only() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let lookup = |token: String| {
    +        let server = &server;
    +        async move {
    +            server
    +                .post_json(
    +                    "/v1/cache/lookup",
    +                    &json!({"repo": REPO, "keys": ["k"]}),
    +                    Some(&token),
    +                    None,
    +                )
    +                .await
    +                .0
    +        }
    +    };
    +    let first = person_bearer(&server, ADA);
    +    let second_device = person_bearer(&server, ADA);
    +    let bob = person_bearer(&server, "bob@example.com");
    +    assert_eq!(lookup(first.clone()).await, 200);
    +
    +    // No bearer, or a CI token, cannot revoke anyone.
    +    let (status, _) = revoke(&server, None).await;
    +    assert_eq!(status, 401);
    +    let ci = format!("Bearer {}", common::token(REPO));
    +    let (status, _) = revoke(&server, Some(&ci)).await;
    +    assert_eq!(status, 403);
    +    assert_eq!(
    +        lookup(first.clone()).await,
    +        200,
    +        "a refused revoke changes nothing"
    +    );
    +
    +    let (status, reply) = revoke(&server, Some(&first)).await;
    +    assert_eq!((status, reply), (200, json!({"revoked": true})));
    +    // Both of Ada's earlier tokens are refused, Bob's is not.
    +    assert_eq!(lookup(first.clone()).await, 401);
    +    assert_eq!(lookup(second_device).await, 401);
    +    assert_eq!(lookup(bob).await, 200);
    +    // The revoked token cannot revoke again.
    +    let (status, _) = revoke(&server, Some(&first)).await;
    +    assert_eq!(status, 401);
    +
    +    // A token issued after the cutoff works. `iat` has second resolution and
    +    // the cutoff rounds up, so wait out the second.
    +    tokio::time::sleep(std::time::Duration::from_millis(2100)).await;
    +    assert_eq!(lookup(person_bearer(&server, ADA)).await, 200);
    +    // A different replica on the same database sees the revocation.
    +    let other = server.second(&[]).await;
    +    let (status, _) = other
    +        .post_json(
    +            "/v1/cache/lookup",
    +            &json!({"repo": REPO, "keys": ["k"]}),
    +            Some(&first),
    +            None,
    +        )
    +        .await;
    +    assert_eq!(status, 401);
    +    other.stop().await;
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn logout_follows_the_stable_subject_when_the_email_changes_and_is_audited() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    aster_srv::log::start_capture();
    +    let issuer = server.app.person_tokens.as_ref().unwrap();
    +    let subject = common::iap_subject(ADA);
    +    // The same IAP account before and after an email rename, and another person.
    +    let old_email = format!("Bearer {}", issuer.issue(&subject, ADA).unwrap().token);
    +    let renamed = format!(
    +        "Bearer {}",
    +        issuer.issue(&subject, "ada.new@example.com").unwrap().token
    +    );
    +    let bob = person_bearer(&server, "bob@example.com");
    +
    +    // Signing out with one token revokes the subject's other token, whatever
    +    // email it carries.
    +    let (status, _) = revoke(&server, Some(&renamed)).await;
    +    assert_eq!(status, 200);
    +    let (status, _) = revoke(&server, Some(&old_email)).await;
    +    assert_eq!(status, 401, "the old-email token is revoked by subject");
    +    let (status, _) = revoke(&server, Some(&bob)).await;
    +    assert_eq!(
    +        status, 200,
    +        "another subject is untouched until it signs out"
    +    );
    +
    +    // The audit hook records the subject, not the email.
    +    let line = aster_srv::log::captured()
    +        .into_iter()
    +        .find(|line| line.contains("\"token_revoke\"") && line.contains(&subject))
    +        .expect("a token_revoke audit line");
    +    assert!(!line.contains("ada.new@example.com"), "{line}");
    +    server.stop().await;
    +}
    +
    +#[tokio::test]
    +async fn the_kill_switch_turns_off_sign_in_and_every_person_token() {
    +    let Some(on) = start().await else {
    +        return;
    +    };
    +    let token = person_bearer(&on, ADA);
    +    let off = on.second(&[("ASTER_SRV_PERSON_TOKENS", "off")]).await;
    +    let (_, challenge) = pkce();
    +    let got = open(&off, ©_query(&challenge, "s"), &iap_assertion(ADA)).await;
    +    assert_eq!(got.status, 404);
    +    let (status, _) = exchange(
    +        &off,
    +        json!({"code": "x", "verifier": pkce().0, "user_code": USER_CODE}),
    +    )
    +    .await;
    +    assert_eq!(status, 404);
    +    let (status, _) = revoke(&off, Some(&token)).await;
    +    assert_eq!(status, 404);
    +    let (status, _) = off
    +        .post_json(
    +            "/v1/cache/lookup",
    +            &json!({"repo": REPO, "keys": ["k"]}),
    +            Some(&token),
    +            None,
    +        )
    +        .await;
    +    assert_eq!(status, 401, "a token issued while it was on is refused");
    +    // Back on, the same token works again.
    +    assert_eq!(
    +        on.post_json(
    +            "/v1/cache/lookup",
    +            &json!({"repo": REPO, "keys": ["k"]}),
    +            Some(&token),
    +            None,
    +        )
    +        .await
    +        .0,
    +        200
    +    );
    +    off.stop().await;
    +    on.stop().await;
    +}
    diff --git a/crates/aster-srv/tests/common/mod.rs b/crates/aster-srv/tests/common/mod.rs
    index 6bcbcd56..116b918f 100644
    --- a/crates/aster-srv/tests/common/mod.rs
    +++ b/crates/aster-srv/tests/common/mod.rs
    @@ -356,20 +356,25 @@ pub fn iap_assertion(email: &str) -> String {
     
     /// [`iap_assertion`] addressed to `audience`.
     pub fn iap_assertion_for(email: &str, audience: &str) -> String {
    -    iap_assertion_full(email, Some(&iap_subject(email)), audience)
    +    iap_assertion_issued(email, audience, Utc::now().timestamp())
    +}
    +
    +/// [`iap_assertion_for`] issued at `iat` (Unix seconds).
    +pub fn iap_assertion_issued(email: &str, audience: &str, iat: i64) -> String {
    +    iap_assertion_full(email, Some(&iap_subject(email)), audience, iat)
     }
     
     /// An assertion with no `sub` claim at all.
     pub fn iap_assertion_without_subject(email: &str) -> String {
    -    iap_assertion_full(email, None, IAP_AUDIENCE)
    +    iap_assertion_full(email, None, IAP_AUDIENCE, Utc::now().timestamp())
     }
     
     /// An assertion for a chosen subject, as when an account's email changes.
     pub fn iap_assertion_with_subject(email: &str, subject: &str) -> String {
    -    iap_assertion_full(email, Some(subject), IAP_AUDIENCE)
    +    iap_assertion_full(email, Some(subject), IAP_AUDIENCE, Utc::now().timestamp())
     }
     
    -fn iap_assertion_full(email: &str, subject: Option<&str>, audience: &str) -> String {
    +fn iap_assertion_full(email: &str, subject: Option<&str>, audience: &str, iat: i64) -> String {
         let mut header = Header::new(Algorithm::ES256);
         header.kid = Some("iap".into());
         encode(
    @@ -377,6 +382,7 @@ fn iap_assertion_full(email: &str, subject: Option<&str>, audience: &str) -> Str
             &json!({
                 "iss": "https://cloud.google.com/iap",
                 "aud": audience,
    +            "iat": iat,
                 "exp": Utc::now().timestamp() + 300,
                 "email": email,
                 "sub": subject,
    diff --git a/crates/aster-srv/tests/hardening.rs b/crates/aster-srv/tests/hardening.rs
    index 33292556..c5d12cc6 100644
    --- a/crates/aster-srv/tests/hardening.rs
    +++ b/crates/aster-srv/tests/hardening.rs
    @@ -25,6 +25,8 @@ use sha2::{Digest, Sha256};
     use tokio::io::{AsyncReadExt, AsyncWriteExt};
     
     const REPO: &str = "ArchAstro/firstlanding";
    +/// What the CLI would print and send; the confirm page shows it.
    +const USER_CODE: &str = "ABCD2345";
     const HSTS: &str = "max-age=31536000; includeSubDomains";
     
     async fn start_with(extra: &[(&str, &str)]) -> Option {
    @@ -73,7 +75,7 @@ async fn browser_login(main_url: &str, assertion: &str) -> (String, String) {
         let http = client();
         let form = http
             .get(format!(
    -            "{main_url}/cli-auth?port=40123&challenge={challenge}&state=s1"
    +            "{main_url}/cli-auth?port=40123&user_code={USER_CODE}&challenge={challenge}&state=s1"
             ))
             .header("x-goog-iap-jwt-assertion", assertion)
             .send()
    @@ -93,6 +95,8 @@ async fn browser_login(main_url: &str, assertion: &str) -> (String, String) {
         let done = http
             .post(format!("{main_url}/cli-auth"))
             .header("content-type", "application/x-www-form-urlencoded")
    +        // A browser sends its own page's origin with a form POST.
    +        .header("origin", common::PUBLIC_URL)
             .header("x-goog-iap-jwt-assertion", assertion)
             .body(body)
             .send()
    @@ -113,7 +117,7 @@ async fn browser_login(main_url: &str, assertion: &str) -> (String, String) {
     async fn redeem(url: &str, code: &str, verifier: &str) -> (u16, Value) {
         let response = client()
             .post(format!("{url}/v1/auth/token"))
    -        .json(&json!({"code": code, "verifier": verifier, "port": 40123}))
    +        .json(&json!({"code": code, "verifier": verifier, "port": 40123, "user_code": USER_CODE}))
             .send()
             .await
             .unwrap();
    @@ -128,7 +132,7 @@ async fn bad_exchange(url: &str, forwarded_for: Option<&str>) -> reqwest::Respon
         let (verifier, _) = pkce();
         let mut request = client()
             .post(format!("{url}/v1/auth/token"))
    -        .json(&json!({"code": BASE64URL.encode([7u8; 32]), "verifier": verifier, "port": 40123}));
    +        .json(&json!({"code": BASE64URL.encode([7u8; 32]), "verifier": verifier, "port": 40123, "user_code": USER_CODE}));
         if let Some(forwarded_for) = forwarded_for {
             request = request.header("x-forwarded-for", forwarded_for);
         }
    @@ -241,7 +245,7 @@ async fn api_listener_serves_only_v1_while_the_main_port_is_unchanged() {
             "/ui/cache?repo=ArchAstro%2Ffirstlanding",
             "/ui/tests",
             "/cli-auth",
    -        "/cli-auth?port=40123&challenge=x&state=s",
    +        "/cli-auth?port=40123&user_code=ABCD2345&challenge=x&state=s",
             "/healthz",
             "/readyz",
             "/metrics",
    @@ -692,7 +696,7 @@ async fn a_flood_of_exchanges_cannot_starve_the_shared_pool_and_a_starved_pool_f
         assert!(started.elapsed() < Duration::from_millis(900));
         let malformed = client()
             .post(format!("{url}/v1/auth/token"))
    -        .json(&json!({"code": "short", "verifier": "short", "port": 1}))
    +        .json(&json!({"code": "short", "verifier": "short", "port": 1, "user_code": USER_CODE}))
             .send()
             .await
             .unwrap();
    @@ -926,7 +930,7 @@ async fn auth_failures_are_audited_and_counted_and_no_token_reaches_a_log_or_an_
         for assertion in [forged_iap, wrong_aud_iap.as_str()] {
             let response = http
                 .get(format!(
    -                "{url}/cli-auth?port=40123&challenge={}&state=s",
    +                "{url}/cli-auth?port=40123&user_code={USER_CODE}&challenge={}&state=s",
                     pkce().1
                 ))
                 .header("x-goog-iap-jwt-assertion", assertion)
    @@ -1086,7 +1090,7 @@ async fn sign_in_binds_the_stable_iap_subject_not_the_email() {
         let nameless = common::iap_assertion_without_subject("ada@example.com");
         let got = client()
             .get(format!(
    -            "{url}/cli-auth?port=40123&challenge={}&state=s",
    +            "{url}/cli-auth?port=40123&user_code={USER_CODE}&challenge={}&state=s",
                 pkce().1
             ))
             .header("x-goog-iap-jwt-assertion", &nameless)
    @@ -1139,7 +1143,7 @@ async fn https_deployments_send_hsts_on_every_response_and_auth_responses_are_ne
             (
                 "token success",
                 http.post(format!("{api}/v1/auth/token"))
    -                .json(&json!({"code": code, "verifier": verifier, "port": 40123}))
    +                .json(&json!({"code": code, "verifier": verifier, "port": 40123, "user_code": USER_CODE}))
                     .send()
                     .await
                     .unwrap(),
    @@ -1189,7 +1193,7 @@ async fn https_deployments_send_hsts_on_every_response_and_auth_responses_are_ne
             (
                 "login page",
                 http.get(format!(
    -                "{}/cli-auth?port=40123&challenge={}&state=s",
    +                "{}/cli-auth?port=40123&user_code={USER_CODE}&challenge={}&state=s",
                     server.url,
                     pkce().1
                 ))
    @@ -1201,7 +1205,7 @@ async fn https_deployments_send_hsts_on_every_response_and_auth_responses_are_ne
             (
                 "login without IAP",
                 http.get(format!(
    -                "{}/cli-auth?port=40123&challenge={}&state=s",
    +                "{}/cli-auth?port=40123&user_code={USER_CODE}&challenge={}&state=s",
                     server.url,
                     pkce().1
                 ))
    diff --git a/crates/aster-srv/tests/routes.rs b/crates/aster-srv/tests/routes.rs
    index 8a7e0c0f..7d7f2733 100644
    --- a/crates/aster-srv/tests/routes.rs
    +++ b/crates/aster-srv/tests/routes.rs
    @@ -90,6 +90,8 @@ async fn send(
         let client = aster_srv::http_client().build().unwrap();
         let url = format!("{}{path}", server.url);
         let mut request = match method {
    +        // Sign-out takes no body, and the server refuses one before the gate.
    +        "POST" if path.starts_with("/v1/auth/revoke") => client.post(url),
             "POST" => client.post(url).body("{}"),
             "PUT" => client.put(url).body("{}"),
             "DELETE" => client.delete(url),
    @@ -347,7 +349,8 @@ async fn a_person_reaches_only_the_read_allowlist_and_every_other_route_is_403()
         let person = person_bearer(&server, "ada@example.com");
         let mut checked = 0;
         for route in ROUTES.iter().filter(|r| r.pattern.starts_with("/v1/")) {
    -        if route.auth == Auth::Public {
    +        // Sign-out takes a person's token by design; its own test is below.
    +        if matches!(route.auth, Auth::Public | Auth::Person) {
                 continue;
             }
             let path = path_of(route);
    @@ -383,6 +386,46 @@ async fn a_person_reaches_only_the_read_allowlist_and_every_other_route_is_403()
         server.stop().await;
     }
     
    +/// The routes only a person's token may call: spelled out, so a new route
    +/// classed `Person` is a decision made here too.
    +const PERSON_ONLY: [(&str, &str); 1] = [("POST", "/v1/auth/revoke")];
    +
    +#[tokio::test]
    +async fn sign_out_takes_a_person_only_and_is_404_when_person_sign_in_is_off() {
    +    let Some(server) = start().await else {
    +        return;
    +    };
    +    let person_routes: Vec<(&str, &str)> = ROUTES
    +        .iter()
    +        .filter(|route| route.auth == Auth::Person)
    +        .map(|route| (route.method, route.pattern))
    +        .collect();
    +    assert_eq!(
    +        person_routes,
    +        PERSON_ONLY.to_vec(),
    +        "the person-only routes changed"
    +    );
    +
    +    let ci = format!("Bearer {}", common::token(REPO));
    +    let off = server.second(&[("ASTER_SRV_PERSON_TOKENS", "off")]).await;
    +    let person = person_bearer(&server, "ada@example.com");
    +    for (method, path) in PERSON_ONLY {
    +        // A CI token is valid but is not a person: 403.
    +        let (status, _) = send(&server, method, path, &[("authorization", &ci)]).await;
    +        assert_eq!(status, 403, "{method} {path} with a CI token");
    +        // With sign-in switched off the route does not exist, token or not.
    +        for headers in [vec![], vec![("authorization", person.as_str())]] {
    +            let (status, _) = send(&off, method, path, &headers).await;
    +            assert_eq!(status, 404, "{method} {path} with the kill switch");
    +        }
    +        // A person signs out.
    +        let (status, body) = send(&server, method, path, &[("authorization", &person)]).await;
    +        assert_eq!(status, 200, "{method} {path}: {body}");
    +    }
    +    off.stop().await;
    +    server.stop().await;
    +}
    +
     async fn write_status(server: &Server, token: &str, path: &str) -> u16 {
         let manifest = base64::engine::general_purpose::STANDARD
             .encode(json!({"schema": 1, "key": "k", "target": "//a:b", "outputs": []}).to_string());
    diff --git a/crates/aster-srv/tests/ui.rs b/crates/aster-srv/tests/ui.rs
    index 70969c04..40cf07a2 100644
    --- a/crates/aster-srv/tests/ui.rs
    +++ b/crates/aster-srv/tests/ui.rs
    @@ -87,7 +87,7 @@ async fn the_leaderboard_lists_flaky_and_broken_tests_for_the_newest_repository(
         assert_eq!(headers["content-type"], "text/html; charset=utf-8");
         assert_eq!(
             headers["content-security-policy"],
    -        "default-src 'none'; style-src 'unsafe-inline'; img-src data:"
    +        "default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'none'"
         );
         assert_eq!(headers["x-content-type-options"], "nosniff");
         assert_eq!(headers["referrer-policy"], "same-origin");
    diff --git a/docs/test-tracking.md b/docs/test-tracking.md
    index 9f7b4567..68f71447 100644
    --- a/docs/test-tracking.md
    +++ b/docs/test-tracking.md
    @@ -127,25 +127,70 @@ export ASTER_SRV_URL=https://aster-srv.example.com
     aster cache login
     ```
     
    -1. `aster cache login` listens on a random port on `127.0.0.1` and opens
    -   `$ASTER_SRV_URL/cli-auth` in your browser. It prints the URL as well.
    +`ASTER_SRV_URL` must be `https://`; plain `http://` is accepted only for
    +`127.0.0.1`, `::1` and `localhost`, because the code, verifier and token would
    +cross the network in the clear.
    +
    +1. `aster cache login` makes a PKCE verifier and a short **user code**
    +   (`ABCD-EFGH`), prints the code, and opens
    +   `$ASTER_SRV_URL/cli-auth` in your browser.
     2. You sign in there with your company Google account (the server's
    -   identity-aware proxy asks) and click **Authorize**.
    +   identity-aware proxy asks). The page shows the user code, your address and
    +   browser. Click **Authorize** only if the code matches your terminal.
     3. The server redirects the browser to `http://127.0.0.1:/callback`
    -   with a single-use code (valid 60 seconds). Aster trades the code, plus a
    -   PKCE verifier only it knows, for a token.
    +   with a single-use code (valid 60 seconds). Aster trades the code, plus the
    +   PKCE verifier and user code, for a token.
     4. The token is stored in `$XDG_CONFIG_HOME/aster/cache-tokens/.json`
        (else `~/.config/aster/...`; `%APPDATA%\aster\...` on Windows), mode `0600` in
    -   a `0700` directory. It is never printed, and it lives outside the
    -   repository. It lasts 12 hours by default (`ASTER_SRV_CLI_TOKEN_TTL` on the
    -   server); then run `aster cache login` again.
    +   a `0700` directory (on Windows an ACL naming only you; if Aster cannot set
    +   it, it does not store the token). Aster ignores a token file that group or
    +   others can read and tells you to log in again. The token is never printed,
    +   and it lives outside the repository. It lasts 12 hours by default
    +   (`ASTER_SRV_CLI_TOKEN_TTL` on the server); then run `aster cache login`
    +   again.
     
     The next `aster run` with `ASTER_SRV_URL` set sends the token as a bearer
     token and restores a cached target. Nothing else needs configuring.
     
    -On a machine with no browser, run `aster cache login --no-browser`, open the
    -printed URL on a computer where you are signed in, and forward the port it
    -names to the machine (`ssh -R PORT:127.0.0.1:PORT`).
    +### Over SSH or on a devbox
    +
    +A browser on your laptop cannot reach `127.0.0.1` on the remote machine, so
    +Aster uses a pasted code there. It picks that flow by itself when
    +`SSH_CONNECTION`, `SSH_CLIENT` or `SSH_TTY` is set, when a Linux shell has
    +neither `DISPLAY` nor `WAYLAND_DISPLAY`, or when the system browser fails to
    +start. `aster cache login --no-browser` asks for it explicitly:
    +
    +```console
    +$ aster cache login
    +This looks like a remote shell, so Aster will ask for a code instead of a redirect.
    +Open this URL in a browser on any computer and sign in:
    +https://aster-srv.example.com/cli-auth?mode=copy&challenge=...&state=...&user_code=K7QM2XWD
    +Confirm only if the page shows this code: K7QM-2XWD
    +Request id: Xb3kP9aQ
    +Paste the code the page shows, then press Enter: 
    +Signed in as ada@example.com. Token stored at ...
    +```
    +
    +1. Open the printed URL in a browser on any computer. Confirm the page shows
    +   the same user code and your own address.
    +2. Click **Authorize**. The page shows a one-time code (never a token) and
    +   the request id.
    +3. Paste the code into the terminal. It works once and expires after 60
    +   seconds, like the loopback code; the PKCE verifier stays on the remote
    +   machine, so the code alone signs nobody in.
    +
    +`--loopback` forces the browser redirect, for example with
    +`ssh -R PORT:127.0.0.1:PORT` forwarding (add `--no-browser` to print the URL
    +instead of opening one).
    +
    +### Signing out
    +
    +`aster cache logout` deletes the stored token and asks the server to refuse
    +every token issued to you before now, on every machine. If the server cannot
    +be reached the local file is still deleted and the command fails, so run it
    +again later. Operators can turn person sign-in off for everyone with
    +`ASTER_SRV_PERSON_TOKENS=off` on the server: `/cli-auth` answers 404 and every
    +person token is refused.
     
     Signed out, or with an expired or refused token, `aster run` prints one line
     (`not signed in; run aster cache login`) and builds from the local cache. It
    diff --git a/src/cache/login.rs b/src/cache/login.rs
    index 93d9fffe..0837f9bf 100644
    --- a/src/cache/login.rs
    +++ b/src/cache/login.rs
    @@ -1,14 +1,24 @@
     //! `aster cache login`: a person's sign-in to an aster-srv.
     //!
     //! Aster opens the browser at `/cli-auth`, where the person
    -//! signs in through the server's identity-aware proxy and confirms. The
    -//! server then redirects the browser to a one-shot listener Aster holds on
    -//! `127.0.0.1`, carrying a single-use code. Aster trades the code, plus the
    +//! signs in through the server's identity-aware proxy and confirms. Aster
    +//! gets a single-use code back one of two ways, then trades it, plus the
     //! PKCE verifier only it knows, for a short-lived token and keeps it in the
     //! user's config directory. Later `aster run`s send it as a bearer token
     //! to read the shared cache.
     //!
    +//! 1. **Loopback**: the server redirects the browser to a one-shot listener
    +//!    Aster holds on `127.0.0.1`.
    +//! 2. **Copy** (`mode=copy`): for a remote shell (SSH, a devbox), where the
    +//!    browser runs on another machine and cannot reach that listener. The
    +//!    server shows the code on a page, the person pastes it into the
    +//!    terminal, and there is no listener and no port. Copy is chosen by
    +//!    `--no-browser`, or automatically when [`remote_session`] says there
    +//!    is probably no usable browser here or the browser fails to open.
    +//!    `--loopback` forces the first way.
    +//!
     //! ```text
    +//! loopback
     //! aster                                browser                    aster-srv
     //!   | bind 127.0.0.1:PORT                 |                          |
     //!   | open /cli-auth?port&challenge&state |------------------------->| IAP names the person
    @@ -17,10 +27,28 @@
     //!   |<-- GET /callback?code&state --------|<-- 302 loopback ---------|
     //!   | POST /v1/auth/token {code, verifier, port} ------------------->|
     //!   |<-- {token, expires_at} ----------------------------------------|
    +//!
    +//! copy
    +//! aster (remote shell)                 browser (anywhere)         aster-srv
    +//!   | print /cli-auth?mode=copy&challenge&state                      |
    +//!   |                                     |--- GET, POST confirm --->|
    +//!   |                                     |<-- page with the code ---|
    +//!   | read the pasted code from stdin     |                          |
    +//!   | POST /v1/auth/token {code, verifier} (no port) --------------->|
    +//!   |<-- {token, expires_at} ----------------------------------------|
     //! ```
     //!
    +//! The `state` is not a secret in copy mode. The server shows its first 8
    +//! characters on the page and Aster prints them (the request id), so the
    +//! person can see the page belongs to the command they ran. What stops
    +//! someone else's code from signing the person in is the PKCE verifier:
    +//! the code only exchanges with the verifier of the request that made it.
    +//!
     //! The token file is `/aster/cache-tokens/.json`, mode
    -//! 0600 in a 0700 directory. The token is never printed or logged.
    +//! 0600 in a 0700 directory. Aster refuses to read a token file that other
    +//! users can read. The token is never printed or logged. `aster cache
    +//! logout` deletes the file and has the server refuse every token the
    +//! person was issued before.
     
     use std::fs::{self, OpenOptions};
     use std::io::{BufRead, BufReader, Read, Write};
    @@ -72,6 +100,23 @@ fn server_origin(url: &str) -> Option {
         Some(format!("{scheme}://{authority}"))
     }
     
    +/// Whether `origin` (as [`server_origin`] returns it) may carry a sign-in:
    +/// https, or http to this machine. A code, a verifier and a bearer token
    +/// over plain http to another host could be read by anyone on the path.
    +fn is_secure_origin(origin: &str) -> bool {
    +    let Some((scheme, authority)) = origin.split_once("://") else {
    +        return false;
    +    };
    +    if scheme == "https" {
    +        return true;
    +    }
    +    let host = match authority.strip_prefix('[') {
    +        Some(rest) => rest.split(']').next().unwrap_or(""),
    +        None => authority.split(':').next().unwrap_or(""),
    +    };
    +    matches!(host, "127.0.0.1" | "::1" | "localhost")
    +}
    +
     /// Where to keep sign-ins: `$XDG_CONFIG_HOME/aster`, else `~/.config/aster`
     /// (`%APPDATA%\aster` on Windows).
     pub fn config_dir() -> Option {
    @@ -115,8 +160,16 @@ pub fn stored_token(server_url: &str, now: SystemTime) -> Option {
     }
     
     fn load_token(config_dir: &Path, server_url: &str, now: SystemTime) -> Option {
    -    let origin = server_origin(server_url)?;
    -    let text = fs::read_to_string(token_path(config_dir, &origin)?).ok()?;
    +    let origin = server_origin(server_url).filter(|origin| is_secure_origin(origin))?;
    +    let path = token_path(config_dir, &origin)?;
    +    if readable_by_others(&path) {
    +        eprintln!(
    +            "[aster] ignoring {}: other users can read it; run aster cache login again",
    +            path.display()
    +        );
    +        return None;
    +    }
    +    let text = fs::read_to_string(&path).ok()?;
         let stored: StoredToken = serde_json::from_str(&text).ok()?;
         if stored.server != origin || stored.token.is_empty() {
             return None;
    @@ -127,6 +180,21 @@ fn load_token(config_dir: &Path, server_url: &str, now: SystemTime) -> Option bool {
    +    #[cfg(unix)]
    +    {
    +        use std::os::unix::fs::PermissionsExt;
    +        fs::metadata(path).is_ok_and(|meta| meta.permissions().mode() & 0o077 != 0)
    +    }
    +    #[cfg(not(unix))]
    +    {
    +        let _ = path;
    +        false
    +    }
    +}
    +
     /// Write `token` so only the owner can read it.
     fn store_token(config_dir: &Path, token: &StoredToken) -> Result {
         let path = token_path(config_dir, &token.server).context("the server URL is not valid")?;
    @@ -149,6 +217,7 @@ fn store_token(config_dir: &Path, token: &StoredToken) -> Result {
             .open(&temporary)
             .with_context(|| format!("cannot write {}", temporary.display()))?;
         set_mode(&temporary, 0o600);
    +    restrict_to_owner(&temporary)?;
         file.write_all(text.as_bytes())
             .with_context(|| format!("cannot write {}", temporary.display()))?;
         drop(file);
    @@ -156,6 +225,34 @@ fn store_token(config_dir: &Path, token: &StoredToken) -> Result {
         Ok(path)
     }
     
    +/// Windows ignores mode bits, so the file gets an ACL that names only the
    +/// current user. If that cannot be set the token is not stored.
    +#[cfg(windows)]
    +fn restrict_to_owner(path: &Path) -> Result<()> {
    +    let user = std::env::var("USERNAME").context("cannot tell which user owns the token file")?;
    +    let status = std::process::Command::new("icacls")
    +        .arg(path)
    +        .args(["/inheritance:r", "/grant:r"])
    +        .arg(format!("{user}:(F)"))
    +        .stdout(std::process::Stdio::null())
    +        .stderr(std::process::Stdio::null())
    +        .status()
    +        .context("cannot run icacls to make the token file private")?;
    +    if !status.success() {
    +        let _ = fs::remove_file(path);
    +        bail!(
    +            "cannot make {} private to you; not storing the sign-in",
    +            path.display()
    +        );
    +    }
    +    Ok(())
    +}
    +
    +#[cfg(not(windows))]
    +fn restrict_to_owner(_path: &Path) -> Result<()> {
    +    Ok(())
    +}
    +
     fn set_mode(path: &Path, mode: u32) {
         #[cfg(unix)]
         {
    @@ -165,15 +262,60 @@ fn set_mode(path: &Path, mode: u32) {
         let _ = (path, mode);
     }
     
    +/// How the code gets back to Aster.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum LoginMode {
    +    /// Loopback when a browser can plausibly be used here, else copy.
    +    Auto,
    +    /// Always the loopback redirect.
    +    Loopback,
    +    /// Always a pasted code.
    +    Copy,
    +}
    +
     /// How to run a sign-in.
     pub struct LoginOptions {
    -    /// Open the system browser at the sign-in URL. Off prints the URL only,
    -    /// for a machine with no browser or a remote shell.
    +    pub mode: LoginMode,
    +    /// Open the system browser at the sign-in URL (loopback only). Off
    +    /// prints the URL only.
         pub open_browser: bool,
    -    /// How long to wait for the browser to come back.
    +    /// How long to wait for the browser to come back (loopback only; a
    +    /// pasted code waits for the person, and the server's code expires).
         pub timeout: Duration,
     }
     
    +/// Why a pasted code is used.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum CodeReason {
    +    /// The person asked (`--no-browser`).
    +    Requested,
    +    /// This looks like a remote shell or a machine with no display.
    +    RemoteSession,
    +    /// The system browser could not be started.
    +    BrowserFailed,
    +}
    +
    +/// What `announce` is told before Aster waits.
    +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
    +pub enum Flow {
    +    /// A browser was started; the callback arrives on the loopback port.
    +    OpenedBrowser,
    +    /// The person opens the URL; the callback arrives on the loopback port.
    +    LoopbackUrl,
    +    /// The person opens the URL anywhere and pastes the code shown there.
    +    Code(CodeReason),
    +}
    +
    +pub struct Announcement<'a> {
    +    pub url: &'a str,
    +    pub flow: Flow,
    +    /// The first characters of the state, which the sign-in page shows.
    +    pub request_id: &'a str,
    +    /// `ABCD-EFGH`: the page asks the person to confirm only if the
    +    /// terminal shows this.
    +    pub user_code: &'a str,
    +}
    +
     /// What a finished sign-in left behind.
     #[derive(Debug, PartialEq, Eq)]
     pub struct SignedIn {
    @@ -182,40 +324,123 @@ pub struct SignedIn {
         pub expires_at: u64,
     }
     
    -/// Run the sign-in against `server_url`. `announce` is told the URL to open
    -/// before Aster waits.
    +/// Whether this looks like a shell where a browser cannot reach Aster's
    +/// loopback port: an SSH session (`SSH_CONNECTION`, `SSH_CLIENT` or
    +/// `SSH_TTY` set), or Linux with neither `DISPLAY` nor `WAYLAND_DISPLAY`.
    +pub fn remote_session(env: &dyn Fn(&str) -> Option) -> bool {
    +    let set = |name: &str| env(name).is_some_and(|value| !value.is_empty());
    +    if ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"]
    +        .iter()
    +        .any(|name| set(name))
    +    {
    +        return true;
    +    }
    +    cfg!(target_os = "linux") && !set("DISPLAY") && !set("WAYLAND_DISPLAY")
    +}
    +
    +/// Run the sign-in against `server_url`. `announce` is told the URL before
    +/// Aster waits; `input` is where a pasted code is read from.
     pub fn login(
         server_url: &str,
         options: &LoginOptions,
    -    announce: &mut dyn FnMut(&str),
    +    announce: &mut dyn FnMut(&Announcement),
    +    input: &mut dyn BufRead,
     ) -> Result {
         let config_dir =
             config_dir().context("cannot find a config directory; set XDG_CONFIG_HOME or HOME")?;
    -    login_in(&config_dir, server_url, options, announce)
    +    login_in(&config_dir, server_url, options, announce, input)
    +}
    +
    +/// This machine's name for the sign-in page, so the person can tell whether
    +/// the request is theirs. Best effort; only characters the server accepts.
    +fn host_hint() -> Option {
    +    let name = std::env::var("HOSTNAME")
    +        .or_else(|_| std::env::var("COMPUTERNAME"))
    +        .ok()
    +        .or_else(|| fs::read_to_string("/proc/sys/kernel/hostname").ok())?;
    +    let name: String = name
    +        .trim()
    +        .chars()
    +        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
    +        .take(64)
    +        .collect();
    +    (!name.is_empty()).then_some(name)
     }
     
     fn login_in(
         config_dir: &Path,
         server_url: &str,
         options: &LoginOptions,
    -    announce: &mut dyn FnMut(&str),
    +    announce: &mut dyn FnMut(&Announcement),
    +    input: &mut dyn BufRead,
     ) -> Result {
    -    let origin = server_origin(server_url)
    -        .with_context(|| format!("ASTER_SRV_URL must be an http(s) URL, not '{server_url}'"))?;
    +    let origin = checked_origin(server_url)?;
         let verifier = random_token(32)?;
    +    let user_code = random_user_code()?;
    +    let shown_code = format!("{}-{}", &user_code[..4], &user_code[4..]);
         let challenge = BASE64URL.encode(Sha256::digest(verifier.as_bytes()));
         let state = random_token(16)?;
    +    let request_id = &state[..8];
     
    -    let listener = TcpListener::bind(("127.0.0.1", 0)).context("cannot listen on 127.0.0.1")?;
    -    let port = listener.local_addr()?.port();
    -    let url = format!("{origin}/cli-auth?port={port}&challenge={challenge}&state={state}");
    -    announce(&url);
    -    if options.open_browser {
    -        open_browser(&url);
    -    }
    +    let mut reason = match options.mode {
    +        LoginMode::Copy => Some(CodeReason::Requested),
    +        LoginMode::Auto if options.open_browser && remote_session(&|n| std::env::var(n).ok()) => {
    +            Some(CodeReason::RemoteSession)
    +        }
    +        LoginMode::Auto if !options.open_browser => Some(CodeReason::Requested),
    +        _ => None,
    +    };
    +    let listener = if reason.is_none() {
    +        let listener = TcpListener::bind(("127.0.0.1", 0)).context("cannot listen on 127.0.0.1")?;
    +        let port = listener.local_addr()?.port();
    +        let url = format!(
    +            "{origin}/cli-auth?port={port}&challenge={challenge}&state={state}&challenge_method=S256&user_code={user_code}"
    +        );
    +        let opened = options.open_browser && open_browser(&url);
    +        if options.open_browser && !opened && options.mode == LoginMode::Auto {
    +            reason = Some(CodeReason::BrowserFailed);
    +            None
    +        } else {
    +            let flow = if opened {
    +                Flow::OpenedBrowser
    +            } else {
    +                Flow::LoopbackUrl
    +            };
    +            announce(&Announcement {
    +                url: &url,
    +                flow,
    +                request_id,
    +                user_code: &shown_code,
    +            });
    +            Some((listener, port, url))
    +        }
    +    } else {
    +        None
    +    };
     
    -    let code = wait_for_callback(&listener, &state, options.timeout)?;
    -    let reply = exchange(&origin, &code, &verifier, port)?;
    +    let (code, port) = match (listener, reason) {
    +        (Some((listener, port, _)), _) => (
    +            wait_for_callback(&listener, &state, options.timeout)?,
    +            Some(port),
    +        ),
    +        (None, Some(reason)) => {
    +            let mut url = format!(
    +                "{origin}/cli-auth?mode=copy&challenge={challenge}&state={state}&challenge_method=S256&user_code={user_code}"
    +            );
    +            if let Some(host) = host_hint() {
    +                url.push_str(&format!("&host={host}"));
    +            }
    +            announce(&Announcement {
    +                url: &url,
    +                flow: Flow::Code(reason),
    +                request_id,
    +                user_code: &shown_code,
    +            });
    +            (read_pasted_code(input)?, None)
    +        }
    +        (None, None) => unreachable!("a loopback listener or a copy reason is always set"),
    +    };
    +    let reply = exchange(&origin, &code, &verifier, &user_code, port)?;
         let token = StoredToken {
             server: origin,
             token: reply.token,
    @@ -230,14 +455,116 @@ fn login_in(
         })
     }
     
    +/// What `aster cache logout` did.
    +#[derive(Debug, PartialEq, Eq)]
    +pub struct SignedOut {
    +    /// The token file that was deleted, if there was one.
    +    pub deleted: Option,
    +    /// Whether the server now refuses every earlier token. `Err` holds why
    +    /// not; the local file is deleted either way.
    +    pub revoked: std::result::Result<(), String>,
    +}
    +
    +/// Sign out of `server_url`: ask the server to refuse every token issued to
    +/// this person so far, then delete the local token file.
    +pub fn logout(server_url: &str) -> Result {
    +    let config_dir =
    +        config_dir().context("cannot find a config directory; set XDG_CONFIG_HOME or HOME")?;
    +    logout_in(&config_dir, server_url)
    +}
    +
    +fn logout_in(config_dir: &Path, server_url: &str) -> Result {
    +    let origin = checked_origin(server_url)?;
    +    let path = token_path(config_dir, &origin).context("the server URL is not valid")?;
    +    if !path.exists() {
    +        return Ok(SignedOut {
    +            deleted: None,
    +            revoked: Err("this machine was not signed in".into()),
    +        });
    +    }
    +    // A file other users can read is not trusted or sent anywhere.
    +    let revoked = match load_token(config_dir, &origin, SystemTime::UNIX_EPOCH) {
    +        Some(stored) => revoke(&origin, &stored.token),
    +        None => Err("no usable token on this machine to revoke".into()),
    +    };
    +    fs::remove_file(&path).with_context(|| format!("cannot delete {}", path.display()))?;
    +    Ok(SignedOut {
    +        deleted: Some(path),
    +        revoked,
    +    })
    +}
    +
    +fn revoke(origin: &str, token: &str) -> std::result::Result<(), String> {
    +    let agent = super::aster_srv::build_agent(Duration::from_secs(15));
    +    let response = agent
    +        .post(&format!("{origin}/v1/auth/revoke"))
    +        .header("Authorization", &format!("Bearer {token}"))
    +        .send(&[][..])
    +        .map_err(|error| format!("cannot reach the server: {error}"))?;
    +    match response.status().as_u16() {
    +        200 => Ok(()),
    +        // Already expired or revoked: nothing is left to refuse.
    +        401 => Ok(()),
    +        status => Err(format!("the server answered {status}")),
    +    }
    +}
    +
    +/// The code the person pasted: one line, trimmed.
    +fn read_pasted_code(input: &mut dyn BufRead) -> Result {
    +    let mut line = String::new();
    +    let read = (&mut *input)
    +        .take(1024)
    +        .read_line(&mut line)
    +        .context("cannot read the code")?;
    +    let code = line.trim();
    +    if read == 0 {
    +        bail!("no code was entered; run aster cache login again");
    +    }
    +    if code.is_empty()
    +        || code.len() > 128
    +        || !code
    +            .bytes()
    +            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
    +    {
    +        bail!("that is not a sign-in code; copy the code shown on the page and run aster cache login again");
    +    }
    +    Ok(code.to_string())
    +}
    +
    +/// A normal origin for `server_url`, or why it cannot be used.
    +fn checked_origin(server_url: &str) -> Result {
    +    let origin = server_origin(server_url)
    +        .with_context(|| format!("ASTER_SRV_URL must be an http(s) URL, not '{server_url}'"))?;
    +    if !is_secure_origin(&origin) {
    +        bail!(
    +            "ASTER_SRV_URL must be https (plain http is only for this machine); \
    +             '{origin}' would send the sign-in in the clear"
    +        );
    +    }
    +    Ok(origin)
    +}
    +
    +/// Eight characters of the server's user-code alphabet (5 bits each).
    +fn random_user_code() -> Result {
    +    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    +    let mut bytes = [0u8; 8];
    +    getrandom::fill(&mut bytes).map_err(|e| anyhow::anyhow!("no randomness available: {e}"))?;
    +    // 32 symbols divide 256 evenly, so there is no modulo bias.
    +    Ok(bytes
    +        .iter()
    +        .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
    +        .collect())
    +}
    +
     fn random_token(bytes: usize) -> Result {
         let mut buffer = vec![0u8; bytes];
         getrandom::fill(&mut buffer).map_err(|e| anyhow::anyhow!("no randomness available: {e}"))?;
         Ok(BASE64URL.encode(buffer))
     }
     
    -/// Best effort: a missing opener is not an error, the URL is printed anyway.
    -fn open_browser(url: &str) {
    +/// Start the system browser. False when it cannot be started, which sends
    +/// an automatic sign-in to the pasted-code flow.
    +fn open_browser(url: &str) -> bool {
         let mut command = if cfg!(target_os = "macos") {
             std::process::Command::new("open")
         } else if cfg!(windows) {
    @@ -248,12 +575,13 @@ fn open_browser(url: &str) {
         } else {
             std::process::Command::new("xdg-open")
         };
    -    let _ = command
    +    command
             .arg(url)
             .stdin(std::process::Stdio::null())
             .stdout(std::process::Stdio::null())
             .stderr(std::process::Stdio::null())
    -        .spawn();
    +        .spawn()
    +        .is_ok()
     }
     
     /// Serve `GET /callback` until one arrives with the right `state`, and
    @@ -356,15 +684,27 @@ struct Exchanged {
         email: Option,
     }
     
    -fn exchange(origin: &str, code: &str, verifier: &str, port: u16) -> Result {
    +fn exchange(
    +    origin: &str,
    +    code: &str,
    +    verifier: &str,
    +    user_code: &str,
    +    port: Option,
    +) -> Result {
         let agent = super::aster_srv::build_agent(Duration::from_secs(15));
         let mut response = agent
             .post(&format!("{origin}/v1/auth/token"))
             .header("Content-Type", "application/json")
             .send(
    -            json!({"code": code, "verifier": verifier, "port": port})
    -                .to_string()
    -                .as_bytes(),
    +            // A copy-mode code has no port; the server requires the mode to match.
    +            match port {
    +                Some(port) => {
    +                    json!({"code": code, "verifier": verifier, "user_code": user_code, "port": port})
    +                }
    +                None => json!({"code": code, "verifier": verifier, "user_code": user_code}),
    +            }
    +            .to_string()
    +            .as_bytes(),
             )
             .context("cannot reach the server to finish signing in")?;
         let status = response.status().as_u16();
    @@ -564,10 +904,12 @@ mod tests {
                     config,
                     server,
                     &LoginOptions {
    +                    mode: LoginMode::Loopback,
                         open_browser: false,
                         timeout: Duration::from_secs(20),
                     },
    -                &mut |url| announce_tx.send(url.to_string()).unwrap(),
    +                &mut |announcement| announce_tx.send(announcement.url.to_string()).unwrap(),
    +                &mut &b""[..],
                 );
                 (result, browser.join().unwrap())
             })
    @@ -598,6 +940,8 @@ mod tests {
             // The challenge is the S256 of the verifier sent at exchange.
             let sent = seen.lock().unwrap().clone().unwrap();
             assert_eq!(sent["code"], "good-code");
    +        // The user code the page shows is the one the exchange presents.
    +        assert_eq!(sent["user_code"], query_param(&url, "user_code").as_str());
             let challenge = BASE64URL.encode(Sha256::digest(sent["verifier"].as_str().unwrap()));
             assert_eq!(query_param(&url, "challenge"), challenge);
             assert_eq!(
    @@ -617,10 +961,12 @@ mod tests {
                 dir.path(),
                 "http://127.0.0.1:9",
                 &LoginOptions {
    +                mode: LoginMode::Loopback,
                     open_browser: false,
                     timeout: Duration::from_millis(200),
                 },
                 &mut |_| {},
    +            &mut &b""[..],
             )
             .unwrap_err();
             assert!(format!("{error:#}").contains("timed out"), "{error:#}");
    @@ -639,4 +985,210 @@ mod tests {
             assert!(text.contains("aster cache login"), "{text}");
             assert!(load_token(dir.path(), &server, at(0)).is_none());
         }
    +
    +    #[test]
    +    fn a_pasted_code_signs_in_with_no_port_and_the_user_code() {
    +        let (server, seen) = token_server(
    +            r#"{"token":"copy-token","expires_at":1893456000,"email":"ada@example.com"}"#,
    +        );
    +        let dir = tempfile::tempdir().unwrap();
    +        let mut announced = None;
    +        let signed = login_in(
    +            dir.path(),
    +            &server,
    +            &LoginOptions {
    +                mode: LoginMode::Copy,
    +                open_browser: false,
    +                timeout: Duration::from_secs(1),
    +            },
    +            &mut |a| {
    +                announced = Some((a.url.to_string(), a.flow, a.user_code.to_string()));
    +            },
    +            &mut &b"  pasted-code_123 \n"[..],
    +        )
    +        .unwrap();
    +        let (url, flow, shown) = announced.unwrap();
    +        assert_eq!(flow, Flow::Code(CodeReason::Requested));
    +        assert!(url.contains("/cli-auth?mode=copy&challenge="), "{url}");
    +        assert!(!url.contains("port="), "copy mode has no port: {url}");
    +        assert!(url.contains("challenge_method=S256"), "{url}");
    +        let user_code = query_param(&url, "user_code");
    +        assert_eq!(shown, format!("{}-{}", &user_code[..4], &user_code[4..]));
    +        let sent = seen.lock().unwrap().clone().unwrap();
    +        assert_eq!(sent["code"], "pasted-code_123");
    +        assert_eq!(sent["user_code"], user_code.as_str());
    +        assert!(sent.get("port").is_none(), "{sent}");
    +        let challenge = BASE64URL.encode(Sha256::digest(sent["verifier"].as_str().unwrap()));
    +        assert_eq!(query_param(&url, "challenge"), challenge);
    +        assert_eq!(signed.email.as_deref(), Some("ada@example.com"));
    +        assert_eq!(
    +            load_token(dir.path(), &server, at(1_700_000_000))
    +                .unwrap()
    +                .token,
    +            "copy-token"
    +        );
    +    }
    +
    +    #[test]
    +    fn a_missing_or_garbled_paste_stores_nothing() {
    +        for (input, expect) in [
    +            (&b""[..], "no code was entered"),
    +            (&b"\n"[..], "not a sign-in code"),
    +            (&b"