diff --git a/crates/didbot/tests/scenarios.rs b/crates/didbot/tests/scenarios.rs index 16896255..42d8eff6 100644 --- a/crates/didbot/tests/scenarios.rs +++ b/crates/didbot/tests/scenarios.rs @@ -6,16 +6,18 @@ //! agents over its socket. Two parties are stood in for, each a loopback //! router answering the requests the stack makes of it: the human's own PDS //! — their `did:web` document, their repository over `com.atproto.repo.*`, -//! and `com.atproto.server.getServiceAuth` signing with the key that -//! document names — and an OpenID Connect issuer with a discovery document, -//! a JWKS, and ID tokens signed by the key in it. +//! and an authorization server that grants no `rpc:` scope — and an OpenID +//! Connect issuer with a discovery document, a JWKS, and ID tokens signed by +//! the key in it. //! //! `didbot operate` signs in to the human's PDS through OAuth, which is a //! browser. The session it would keep afterwards is written here through //! the same store and types the command reads it back with, so every run //! resumes it; the consent screen is the one exchange not exercised. The -//! record writes, the service-auth mint and every call at the server are -//! the command's own. +//! operator session `didbot login` would keep for each server comes from a +//! real sign-in at that server, walked through the human's authorization +//! server the way a browser walks it. The record writes and every call at +//! the server are the command's own. //! //! Every assertion is what a stranger could check: public reads of records //! and documents, plus each command's exit status and named refusal. The @@ -49,6 +51,7 @@ mod scenarios { use scenarios::human::*; use scenarios::issuer::*; use scenarios::machine::*; +use scenarios::operator::{create_as_operator, operator_session}; use scenarios::reads::*; use scenarios::server::*; use scenarios::stack::*; @@ -68,7 +71,7 @@ use base64::prelude::*; use didbot::data::{dag_cbor, Value as Dag}; use didbot_authstore::FileAuthStore; use didbot_key::SigningKey; -use didbot_operator::operate::scope::admit_scope; +use didbot_operator::operate::scope::operator_write_scope; use jacquard_oauth::authstore::ClientAuthStore; use p256::ecdsa::signature::Signer; use serde_json::{json, Value}; diff --git a/crates/didbot/tests/scenarios/human.rs b/crates/didbot/tests/scenarios/human.rs index f4765327..ddb2e905 100644 --- a/crates/didbot/tests/scenarios/human.rs +++ b/crates/didbot/tests/scenarios/human.rs @@ -158,10 +158,6 @@ impl Human { "/xrpc/com.atproto.repo.deleteRecord", post(Self::delete_record), ) - .route( - "/xrpc/com.atproto.server.getServiceAuth", - get(Self::get_service_auth), - ) .route( "/.well-known/oauth-protected-resource", get(Self::protected_resource), @@ -451,46 +447,6 @@ impl Human { Json(json!({})).into_response() } - /// The service-auth token the human's PDS mints for one call: signed - /// ES256K with the key the human's document names as `#atproto`. - pub(crate) fn mint_service_auth(state: &HumanState, aud: &str, lxm: &str) -> String { - let now = OffsetDateTime::now_utc(); - let payload = json!({ - "iss": state.did, - "aud": aud, - "lxm": lxm, - "iat": now.unix_timestamp(), - "exp": (now + time::Duration::minutes(5)).unix_timestamp(), - "jti": format!("{}", now.unix_timestamp_nanos()), - }); - let input = format!( - "{}.{}", - b64(br#"{"typ":"JWT","alg":"ES256K"}"#), - b64(payload.to_string().as_bytes()) - ); - let signature = state.key.sign(input.as_bytes()).to_bytes(); - format!("{input}.{}", b64(&signature)) - } - - pub(crate) async fn get_service_auth( - State(state): State>, - Query(query): Query>, - ) -> axum::response::Response { - if let Some(refusal) = Self::outage(&state).await { - return refusal; - } - let (Some(aud), Some(lxm)) = (query.get("aud"), query.get("lxm")) else { - return Self::refuse(StatusCode::BAD_REQUEST, "InvalidRequest", "aud and lxm"); - }; - Json(json!({ "token": Self::mint_service_auth(&state, aud, lxm) })).into_response() - } - - /// A service-auth token for one call at `aud`, as the human's PDS - /// mints it, held here so it can be presented more than once. - pub(crate) fn service_auth(&self, aud: &str, lxm: &str) -> String { - Self::mint_service_auth(&self.state, aud, lxm) - } - /// The record keys in one of the human's collections, as anyone reads /// them. pub(crate) async fn record_keys(&self, collection: &str) -> Vec { @@ -635,6 +591,18 @@ impl Human { &format!("{redirect_uri} is not a redirect {client_id} lists"), ); } + // The grant didbot asks the operator's own PDS for names no `rpc:` + // method, and a request that does is refused here. + if form + .get("scope") + .is_some_and(|scope| scope.split(' ').any(|atom| atom.starts_with("rpc:"))) + { + return Self::refuse( + StatusCode::BAD_REQUEST, + "invalid_scope", + "this server grants no rpc: scope", + ); + } let id = format!("urn:ietf:params:oauth:request_uri:{}", random_id()); state.pushed.lock().expect("pushed").insert( id.clone(), diff --git a/crates/didbot/tests/scenarios/laptop.rs b/crates/didbot/tests/scenarios/laptop.rs index bb3c9ae8..beb5550a 100644 --- a/crates/didbot/tests/scenarios/laptop.rs +++ b/crates/didbot/tests/scenarios/laptop.rs @@ -5,8 +5,10 @@ use crate::*; /// `didbot operate kestrel. --kind agent`: one record in the human's -/// repository, one create proved by their PDS's service-auth token, and a -/// walk of one edge. +/// repository, written under the one scope their PDS granted, one create +/// carrying the operator session the server minted, and a walk of one +/// edge. The human's PDS here grants no `rpc:` scope and mints no +/// service-auth token. #[tokio::test(flavor = "multi_thread")] async fn an_agent_by_hand() { let stack = Stack::start(&["hand"]).await; diff --git a/crates/didbot/tests/scenarios/operator.rs b/crates/didbot/tests/scenarios/operator.rs index c43b735f..62f3e97e 100644 --- a/crates/didbot/tests/scenarios/operator.rs +++ b/crates/didbot/tests/scenarios/operator.rs @@ -74,6 +74,40 @@ async fn a_second_human_cannot_rotate_in_as_operator() { // The operator signs in to the dashboard // --------------------------------------------------------------------------- +/// Signs in as `server`'s operator the way a browser does, and returns the +/// session as `didbot login` keeps it. +pub(crate) async fn operator_session(server: &Server) -> didbot_operator::Session { + let mut browser = Browser::new(); + browser.sign_in(server).await; + let token = browser + .cookie + .as_deref() + .and_then(|cookie| cookie.strip_prefix("didbot_operator=")) + .expect("the callback set the operator cookie") + .to_owned(); + didbot_operator::Session { + server: server.host.clone(), + token, + csrf: browser.csrf.expect("the callback handed back a csrf token"), + } +} + +/// `bot.did.createAccount` carrying the operator session, as `didbot +/// operate` presents it. +pub(crate) async fn create_as_operator( + server: &Server, + session: &didbot_operator::Session, + body: Value, +) -> (u16, Value) { + let url = format!("{}/xrpc/bot.did.createAccount", server.origin); + let response = didbot_operator::present(http().post(&url).json(&body), session) + .send() + .await + .unwrap_or_else(|err| panic!("POST {url}: {err}")); + let status = response.status().as_u16(); + (status, response.json().await.unwrap_or(Value::Null)) +} + /// The browser half of a sign-in: one redirect at a time, keeping whatever /// cookie it is handed. struct Browser { @@ -222,6 +256,7 @@ async fn the_operator_signs_in_to_a_server_on_this_machine() { "the e-stop answered somebody who had not signed in" ); + let revoked_before = stack.human.revoked().len(); browser.sign_in(server).await; let status: Value = browser @@ -322,7 +357,7 @@ async fn the_operator_signs_in_to_a_server_on_this_machine() { // session at their server as soon as it had read the `sub`. assert_eq!( stack.human.revoked().len(), - 1, + revoked_before + 1, "the sign-in left a grant behind at the human's server" ); diff --git a/crates/didbot/tests/scenarios/outage.rs b/crates/didbot/tests/scenarios/outage.rs index 3b858c81..b7e8bf75 100644 --- a/crates/didbot/tests/scenarios/outage.rs +++ b/crates/didbot/tests/scenarios/outage.rs @@ -20,7 +20,7 @@ async fn the_humans_pds_is_down() { Server::start_with(&work, "outage", &human, PATIENT, port, Some(data.clone())).await; let laptop = Machine::new(&work, "human"); human - .seed_session(&laptop.config, &admit_scope(&server.did)) + .seed_session(&laptop.config, &operator_write_scope()) .await; let stack = Stack { work, @@ -40,6 +40,7 @@ async fn the_humans_pds_is_down() { assert_eq!(claimed["subject"], stack.server().did, "{claimed}"); stack.server().nudge().await; stack.server().wait_claimed().await; + stack.sign_in_as_operator().await; let server = stack.server(); let kestrel = format!("kestrel.{}", server.zone()); @@ -104,17 +105,21 @@ async fn the_humans_pds_is_down() { let (status, _) = write_as_kestrel().await; assert_eq!(status, 200, "writes stay open in grace"); - // A create the human authenticates, with a token their PDS minted - // before it went down: named, and not "invalid token". - let service_auth = - Human::mint_service_auth(&stack.human.state, &server.did, "bot.did.createAccount"); + // A create the human authenticates, with the operator session they + // signed in for before it went down: named, and not "invalid token". let c2 = format!("c2.{}", server.zone()); stack.human.write( "bot.did.operator", &rkey_of(&c2), json!({ "$type": "bot.did.operator", "subject": did_of(&c2), "createdAt": now_rfc3339() }), ); - let (status, body) = create_beneath(c2.clone(), service_auth.clone()).await; + let session = didbot_operator::load( + &stack.laptop.config.join("didbot").join("operator.json"), + &server.host, + ) + .expect("the operator session is kept"); + let (status, body) = + create_as_operator(server, &session, json!({ "name": c2, "kind": "agent" })).await; eprintln!("human create during a 500 outage: {status} {body}"); assert_eq!(body["error"], "OperatorUnreachable", "{status} {body}"); @@ -249,7 +254,7 @@ async fn a_restart_while_the_humans_pds_is_down() { Server::start_with(&work, "restart", &human, PATIENT, port, Some(data.clone())).await; let laptop = Machine::new(&work, "human"); human - .seed_session(&laptop.config, &admit_scope(&server.did)) + .seed_session(&laptop.config, &operator_write_scope()) .await; let mut stack = Stack { work, @@ -269,6 +274,7 @@ async fn a_restart_while_the_humans_pds_is_down() { assert_eq!(claimed["subject"], stack.server().did, "{claimed}"); stack.server().nudge().await; stack.server().wait_claimed().await; + stack.sign_in_as_operator().await; let kestrel = format!("kestrel.{}", stack.server().zone()); stack .laptop diff --git a/crates/didbot/tests/scenarios/restart.rs b/crates/didbot/tests/scenarios/restart.rs index 192b3e4b..6e38076e 100644 --- a/crates/didbot/tests/scenarios/restart.rs +++ b/crates/didbot/tests/scenarios/restart.rs @@ -46,8 +46,8 @@ async fn session_with(server: &Server, proof: &str) -> (u16, Value) { /// agent, an agent by hand beneath the host, a pipeline with a session — /// then `didbot-pds` killed and started again on the same `--data`. Every /// account, session and edge is where it was; what the log does not hold -/// is measured and printed: a key-signed proof and an OpenID Connect -/// token, each spent before the restart, presented again after it. +/// is measured and printed: an OpenID Connect token spent before the +/// restart, presented again after it. #[tokio::test(flavor = "multi_thread")] async fn a_restart_keeps_the_tree() { let mut stack = Stack::start_with(&["restart"], Server::CONFIG, true).await; @@ -90,22 +90,16 @@ async fn a_restart_keeps_the_tree() { let (status, _) = session_with(server, &oidc_token).await; assert_eq!(status, 401, "the token is spent while the server runs"); - // A key-signed proof from the human, spent on one create; presenting - // it for a second name is a replay while the server runs. + // An agent the human creates with their operator session. let j1 = format!("j1.{}", server.zone()); - let j2 = format!("j2.{}", server.zone()); vouch(&stack.human, &j1); - vouch(&stack.human, &j2); - let key_proof = stack - .human - .service_auth(&server.did, "bot.did.createAccount"); - let (status, body) = create_with(server, &key_proof, &j1, "agent").await; + let (status, body) = create_as_operator( + server, + &operator_session(server).await, + json!({ "name": j1, "kind": "agent" }), + ) + .await; assert_eq!(status, 200, "{body}"); - let (status, body) = create_with(server, &key_proof, &j2, "agent").await; - assert_eq!( - status, 401, - "the proof is spent while the server runs: {body}" - ); let human_records = stack.human.record_keys("bot.did.operator").await; let host_records = list_record_keys(&server.origin, &laptop.did, "bot.did.operator").await; @@ -161,9 +155,7 @@ async fn a_restart_keeps_the_tree() { "the host names its two agents from before and two from after" ); - // Measured: the proofs spent before the restart, presented again. - let (status, body) = create_with(server, &key_proof, &j2, "agent").await; - eprintln!("restart: a key-signed proof spent before the restart answers {status} {body}"); + // Measured: the token spent before the restart, presented again. let (status, body) = session_with(server, &oidc_token).await; eprintln!("restart: an OpenID Connect token spent before the restart answers {status} {body}"); } @@ -544,11 +536,14 @@ async fn a_kill_partway_through_a_create_leaves_no_torn_account() { // How long a create takes here, so the kills land inside it. let warm = format!("warm.{}", stack.server().zone()); vouch(&stack.human, &warm); + let mut session = operator_session(stack.server()).await; let timed = Instant::now(); - let proof = stack - .human - .service_auth(&stack.server().did, "bot.did.createAccount"); - let (status, _) = create_with(stack.server(), &proof, &warm, "agent").await; + let (status, _) = create_as_operator( + stack.server(), + &session, + json!({ "name": warm, "kind": "agent" }), + ) + .await; assert_eq!(status, 200); let takes = timed.elapsed(); eprintln!("torn: one create takes {takes:?}"); @@ -559,19 +554,21 @@ async fn a_kill_partway_through_a_create_leaves_no_torn_account() { let did = did_of(&name); vouch(&stack.human, &name); let at = takes.mul_f64(f64::from(step) / 9.0); - let proof = stack - .human - .service_auth(&stack.server().did, "bot.did.createAccount"); let origin = stack.server().origin.clone(); let creating = { let name = name.clone(); + let session = session.clone(); tokio::spawn(async move { - post_json( - &format!("{origin}/xrpc/bot.did.createAccount"), - Some(&proof), - json!({ "name": name, "kind": kind }), + let url = format!("{origin}/xrpc/bot.did.createAccount"); + let response = didbot_operator::present( + http() + .post(&url) + .json(&json!({ "name": name, "kind": kind })), + &session, ) - .await + .send() + .await; + response.map(|response| response.status().as_u16()) }) }; tokio::time::sleep(at).await; @@ -581,6 +578,8 @@ async fn a_kill_partway_through_a_create_leaves_no_torn_account() { let server = stack.server(); server.nudge().await; server.wait_claimed().await; + // The operator session lived in the process that was killed. + session = operator_session(server).await; let listed = listed_accounts(server).await.contains(&did); let (document, _) = get_record(&server.origin, &did, "bot.did.registration", "self").await; @@ -594,15 +593,8 @@ async fn a_kill_partway_through_a_create_leaves_no_torn_account() { .await; // Whether the name can be taken again, which is the only honest // answer for an account that is not there. - let retry = create_with( - server, - &stack - .human - .service_auth(&server.did, "bot.did.createAccount"), - &name, - kind, - ) - .await; + let retry = + create_as_operator(server, &session, json!({ "name": name, "kind": kind })).await; // What a sweep would ever do about a row nobody finished: one that // is not pinned is reaped once it is a week old. let retention = get_json(&format!("{}/xrpc/bot.did.listAccounts", server.origin)).await @@ -619,7 +611,7 @@ async fn a_kill_partway_through_a_create_leaves_no_torn_account() { "torn: a {kind}, killed {at:?} in; the call answered {:?}; listed={listed} \ registration={registered} operator_record={operator_record}; \ creating it again: {} {}; the row: {retention}", - answered.map(|(status, _)| status), + answered.and_then(Result::ok), retry.0, retry.1["error"] ); diff --git a/crates/didbot/tests/scenarios/stack.rs b/crates/didbot/tests/scenarios/stack.rs index a792ae45..cb810595 100644 --- a/crates/didbot/tests/scenarios/stack.rs +++ b/crates/didbot/tests/scenarios/stack.rs @@ -37,15 +37,10 @@ impl Stack { let laptop = Machine::new(&work, "human"); // The grant the human's sign-in would have left: the operator - // collection, and one service-auth call at each server. - let mut scope = admit_scope(&servers[0].did); - for server in &servers[1..] { - let rpc = admit_scope(&server.did); - let rpc = rpc.rsplit(' ').next().expect("an rpc atom"); - scope.push(' '); - scope.push_str(rpc); - } - human.seed_session(&laptop.config, &scope).await; + // collection, and nothing else. + human + .seed_session(&laptop.config, &operator_write_scope()) + .await; let stack = Self { work, @@ -77,6 +72,7 @@ impl Stack { for server in &stack.servers { server.wait_claimed().await; } + stack.sign_in_as_operator().await; eprintln!( "{}: up and claimed in {:?} (the poll found the claim after {:?})", labels.join("+"), @@ -90,6 +86,20 @@ impl Stack { &self.servers[0] } + /// Signs in as each server's operator and keeps the session on the + /// human's machine, where `didbot login` keeps it and `didbot operate` + /// reads it. + pub(crate) async fn sign_in_as_operator(&self) { + for server in &self.servers { + let session = operator_session(server).await; + didbot_operator::store( + &self.laptop.config.join("didbot").join("operator.json"), + session, + ) + .expect("the operator session is kept"); + } + } + /// `didbot operate --check ` from a machine holding nothing: the /// walk a stranger runs. Returns the hops on success and the refusal /// line otherwise. diff --git a/crates/didbot/tests/scenarios/standins.rs b/crates/didbot/tests/scenarios/standins.rs index 4ab749bf..dcc7795a 100644 --- a/crates/didbot/tests/scenarios/standins.rs +++ b/crates/didbot/tests/scenarios/standins.rs @@ -6,8 +6,8 @@ use crate::*; /// Starts the human's PDS and the issuer on fixed ports, seeds the session /// `didbot operate` resumes under `NEWCOMER_CONFIG`, and stays up until -/// killed. `NEWCOMER_SERVERS` is the comma-separated DIDs of the servers the -/// session may admit accounts at; `NEWCOMER_HUMAN_PORT`, `NEWCOMER_ISSUER_PORT` +/// killed. `didbot login --server` signs in as a server's operator against +/// it. `NEWCOMER_HUMAN_PORT`, `NEWCOMER_ISSUER_PORT` /// and `NEWCOMER_MINT_PORT` move the three listeners (3415, 3416, 3417), and /// `NEWCOMER_HUMAN_HOST` names the human `did:web:` instead of /// `did:web:localhost%3A`; see [`Human::start_as`]. @@ -25,20 +25,10 @@ async fn the_stand_ins_by_hand() { .unwrap_or(default) }; let config = PathBuf::from(std::env::var("NEWCOMER_CONFIG").expect("NEWCOMER_CONFIG is set")); - let servers = std::env::var("NEWCOMER_SERVERS").unwrap_or_default(); let host = std::env::var("NEWCOMER_HUMAN_HOST").ok(); let human = Human::start_as(host.as_deref(), port("NEWCOMER_HUMAN_PORT", 3415)).await; let issuer = Issuer::start_on(port("NEWCOMER_ISSUER_PORT", 3416)).await; - let mut scope = String::new(); - for (index, server) in servers.split(',').filter(|s| !s.is_empty()).enumerate() { - let atoms = admit_scope(server); - if index == 0 { - scope = atoms; - } else { - scope.push(' '); - scope.push_str(atoms.rsplit(' ').next().expect("an rpc atom")); - } - } + let scope = operator_write_scope(); human.seed_session(&config, &scope).await; let key = issuer.key.clone(); let base = issuer.base.clone(); diff --git a/docs/first-hour.md b/docs/first-hour.md index 412715a1..dd0afe95 100644 --- a/docs/first-hour.md +++ b/docs/first-hour.md @@ -28,8 +28,7 @@ are the harness's, left running on fixed ports with the session ```sh export NEWCOMER_CONFIG=$HOME/first-hour/human/config export DIDBOT_LOCAL_CA=$HOME/.local/state/didbot/local-ca -NEWCOMER_SERVERS='did:web:nc.localhost' \ - cargo test -p didbot --test scenarios the_stand_ins_by_hand -- --ignored --nocapture +cargo test -p didbot --test scenarios the_stand_ins_by_hand -- --ignored --nocapture ``` It prints the human's DID, `did:web:localhost%3A3415` — a bare loopback @@ -67,8 +66,10 @@ didbot operate kestrel.nc.localhost did:web:localhost%3A3415 --kind agent didbot operate --check kestrel.nc.localhost ``` -The first prints the token file for `didbot-oauth`; the second walks one -edge and exits 0. `--check` on the server's own name needs DNS delegation +The first run has no operator session for the server yet, so it prints a +URL to open, the way `didbot login --server nc.localhost:3413` does, and +keeps the session for every later run. It then prints the token file for +`didbot-oauth`; the second command walks one edge and exits 0. `--check` on the server's own name needs DNS delegation a `.localhost` zone has none of; `--check nc.localhost --server nc.localhost` walks the server's own edge instead.