From 5730405b602dd6c071bd6296f223e8c36d54e8e1 Mon Sep 17 00:00:00 2001
From: calvin-archastro Authorize the Confirm only if your terminal shows this code: {user_code} Authorize the It can read cache entries only, and the sign-in ends after a \
- few hours. If you did not just run {}{}{}{}
",
+ 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!(
- "{}aster command on this computer (port {port}) \
+ "aster command {place} \
to read the shared build cache as {email}?aster cache login, close this tab.aster cache login, or these details \
+ are not yours, close this tab.
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::ResultCODE`.
+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"