diff --git a/crates/didbot-operator/src/bin/didbot-operator.rs b/crates/didbot-operator/src/bin/didbot-operator.rs index 175a3ddf..60f6425a 100644 --- a/crates/didbot-operator/src/bin/didbot-operator.rs +++ b/crates/didbot-operator/src/bin/didbot-operator.rs @@ -71,12 +71,13 @@ enum Verb { A server's own hostname is the top edge: the command waits for the \ server to answer over TLS and checks its identity first. Any other name \ is an account beneath a server your repository already names, chosen by \ - the name's suffix or with --server: your own PDS mints a service-auth \ - token for one call, and the server creates the account. A key `didbot \ - register` parked under the name is shown for you to confirm, or named by \ - --fingerprint, and becomes the account's; otherwise say what it \ - is with --kind, and --oidc if a token from an issuer is how it will \ - sign in. The record names both as what logs in. --creates lets the \ + the name's suffix or with --server. The server creates the account \ + when asked with the operator session `didbot login` kept for it; with \ + none kept, the command signs in the way `didbot login` does first. A \ + key `didbot register` parked under the name is shown for you to \ + confirm, or named by --fingerprint, and becomes the account's; \ + otherwise say what it is with --kind, and --oidc if a token from an \ + issuer is how it will sign in. The record names both as what logs in. --creates lets the \ account create beneath itself and --creates-beneath lets everything \ beneath it create; each names the kinds allowed, or any kind with \ none named.\n\n\ @@ -118,7 +119,8 @@ enum Verb { #[command( long_about = "Prints a URL to open. The sign-in happens on your own atproto account and \ comes back to a loopback address this command listens on; the deployment \ - mints the session, and it is kept for estop and announce." + mints the session, and it is kept for operate, estop, account and \ + announce." )] Login, /// Read or set the deployment's emergency stop diff --git a/crates/didbot-operator/src/lib.rs b/crates/didbot-operator/src/lib.rs index 476305b4..273fc351 100644 --- a/crates/didbot-operator/src/lib.rs +++ b/crates/didbot-operator/src/lib.rs @@ -4,7 +4,7 @@ //! Two credentials, two flows. [`operate`] is the claim: it authenticates to //! the operator's own atproto account and writes `bot.did.operator` there, //! which is what makes a deployment theirs. The rest of this crate is the -//! deployment's controls. `CLAUDE.md`'s rule for this project is that an +//! deployment's controls, and an admission's create at the deployment. `CLAUDE.md`'s rule for this project is that an //! operator command is available on the web dashboard *or* from a command //! line, using the same auth, and this is the second half of that: every //! control verb is one HTTP call to a `/dashboard/api/*` route, and the @@ -89,15 +89,34 @@ pub fn default_session_path() -> Option { /// Reads the session for `server`, if one is stored. pub fn load(path: &Path, server: &str) -> Result { + read_sessions(path)? + .into_iter() + .find(|held| held.server == server) + .ok_or_else(|| OperatorError::NotSignedIn(server.to_owned())) +} + +/// Reads the session for the deployment at `hostname`, stored under that +/// name or under it with the port `didbot login --server` was given. +pub fn load_for_host(path: &Path, hostname: &str) -> Result { + let sessions = read_sessions(path)?; + let exact = sessions.iter().position(|held| held.server == hostname); + let ported = || { + sessions + .iter() + .position(|held| didbot_identity::did::host_without_port(&held.server) == hostname) + }; + match exact.or_else(ported) { + Some(index) => Ok(sessions[index].clone()), + None => Err(OperatorError::NotSignedIn(hostname.to_owned())), + } +} + +fn read_sessions(path: &Path) -> Result, OperatorError> { let raw = std::fs::read_to_string(path).map_err(|source| OperatorError::Store { path: path.to_owned(), source, })?; - let sessions: Vec = serde_json::from_str(&raw).unwrap_or_default(); - sessions - .into_iter() - .find(|held| held.server == server) - .ok_or_else(|| OperatorError::NotSignedIn(server.to_owned())) + Ok(serde_json::from_str(&raw).unwrap_or_default()) } /// Stores `session`, replacing any for the same deployment. @@ -186,10 +205,19 @@ fn parse_handoff(server: &str, request_line: &str) -> Option { }) } -/// Calls one operator route, carrying `session`. -/// -/// `body` present means a mutating call, which also carries the CSRF token -/// the session was minted with — see `didbot_serve::operator`. +/// Carries `session` on `request`: the session cookie, and the CSRF token +/// a mutating call must echo — see `didbot_serve::operator`. +pub fn present(request: reqwest::RequestBuilder, session: &Session) -> reqwest::RequestBuilder { + request + .header( + reqwest::header::COOKIE, + format!("didbot_operator={}", session.token), + ) + .header("x-didbot-csrf", &session.csrf) +} + +/// Calls one operator route, carrying `session`. `body` present means a +/// `POST`. pub async fn call( session: &Session, path: &str, @@ -198,23 +226,17 @@ pub async fn call( let url = format!("https://{}{path}", session.server); let client = didbot_http::client(); let request = match &body { - Some(body) => client - .post(&url) - .header("x-didbot-csrf", &session.csrf) - .json(body), + Some(body) => client.post(&url).json(body), None => client.get(&url), }; - let response = request - .header( - reqwest::header::COOKIE, - format!("didbot_operator={}", session.token), - ) - .send() - .await - .map_err(|source| OperatorError::Unreachable { - server: session.server.clone(), - source, - })?; + let response = + present(request, session) + .send() + .await + .map_err(|source| OperatorError::Unreachable { + server: session.server.clone(), + source, + })?; let status = response.status(); let text = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY) .await @@ -308,6 +330,38 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + /// `didbot login --server` is typed with the port a development + /// deployment answers on, and an admission knows the server only by + /// its hostname: the session is found either way. + #[test] + fn a_session_is_found_by_its_hostname_with_or_without_a_port() { + let dir = std::env::temp_dir().join(format!("didbot-operator-host-{}", std::process::id())); + let path = dir.join("operator.json"); + for server in ["one.localhost:8443", "two.example"] { + store( + &path, + Session { + server: server.to_owned(), + token: server.to_owned(), + csrf: "beta".to_owned(), + }, + ) + .expect("stores"); + } + + let found = |host: &str| load_for_host(&path, host).map(|held| held.token); + assert_eq!( + found("one.localhost").ok().as_deref(), + Some("one.localhost:8443") + ); + assert_eq!(found("two.example").ok().as_deref(), Some("two.example")); + assert!(matches!( + found("localhost"), + Err(OperatorError::NotSignedIn(_)) + )); + std::fs::remove_dir_all(&dir).ok(); + } + /// The handoff is the whole point of the loopback listener, and a /// browser's request line is the only place the session arrives. /// diff --git a/crates/didbot-operator/src/operate.rs b/crates/didbot-operator/src/operate.rs index 848f0b77..951d12de 100644 --- a/crates/didbot-operator/src/operate.rs +++ b/crates/didbot-operator/src/operate.rs @@ -19,7 +19,8 @@ //! own account, kept in [`FileAuthStore`]'s file between runs so a second //! host is one command and no browser. It is not the deployment session //! [`crate::login`] mints: that one is checked by the deployment, this one by -//! the operator's own PDS, and neither verb can use the other's. +//! the operator's own PDS. An admission carries both, each to its own +//! server. //! //! [`check`] runs the read-only half alone: it resolves the hostname, opens //! HTTPS to it, reads its `did:web` document and cross-checks @@ -45,12 +46,14 @@ //! claimed: `didbot operate kestrel.pds.example --kind agent`. Which server //! is read from the operator's own repository ([`under`]): the name sits //! under a server they hold a `bot.did.operator` record for, and nothing -//! that answers at the name gets a say. The sign-in asks for the scope -//! [`scope::admit_scope`] builds for that one server, the operator's own -//! PDS mints a service-auth token for one call there, and the server -//! creates the account. [`account`] is that half; [`intent`] tells a claim -//! from an admission before anything signs in. [`walk`] is `--check` for an -//! account: every edge from it up to the operator, read with no credential. +//! that answers at the name gets a say. The sign-in to the operator's own +//! PDS asks for [`scope::operator_write_scope`], the same scope a claim +//! asks for. The create at the server carries the operator session +//! `didbot login` stored for it, and a run with none signs in the way +//! `didbot login` does first. [`account`] is that half; [`intent`] tells a +//! claim from an admission before anything signs in. [`walk`] is `--check` +//! for an account: every edge from it up to the operator, read with no +//! credential. //! //! # Idempotence //! @@ -108,7 +111,7 @@ use self::identify::HttpDocumentFetcher; use self::orchestrate::ClaimOutcome; use self::preflight::{preflight, Report}; use self::tls::ReqwestTlsProbe; -use self::write::{RecordDeleter, RecordReader, RecordWriter, ServiceAuthMinter}; +use self::write::{RecordDeleter, RecordReader, RecordWriter}; /// How long a claim waits for TLS before giving up and telling the operator /// to retry. A freshly applied deployment's first ACME DNS-01 order can take @@ -179,6 +182,10 @@ pub enum OperateError { /// No server the operator holds a record for has the name under it. #[error(transparent)] Under(#[from] under::UnderError), + /// The operator session for the deployment could not be read, or the + /// sign-in that would mint one did not finish. + #[error(transparent)] + Deployment(#[from] crate::OperatorError), /// Signed in, and the account was not admitted. #[error(transparent)] Admit(#[from] account::AdmitError), @@ -367,16 +374,17 @@ pub async fn admit( // read with no credential; the name itself is never asked. let (resolved_did, pds) = resolve_operator(&store, operator).await?; let held = under::operator_record_keys(&document_fetcher, &pds, &resolved_did).await?; - let server = under::server_for(name, &held, server)?; - let server = AccountServer::for_host(&server, http); + let hostname = under::server_for(name, &held, server)?; + let server = AccountServer::for_host(&hostname, http); progress(format!( "{name} is under {}, which {resolved_did} operates", server.did() )); + let deployment = deployment_session(&hostname, &mut progress).await?; let (operator_did, session) = sign_in( operator, - &scope::admit_scope(server.did()), + &scope::operator_write_scope(), &store, &mut progress, ) @@ -391,6 +399,7 @@ pub async fn admit( progress(format!("admitting {name} beneath {operator_did}")); Ok(account::admit( &server, + &deployment, &session, &operator_did, name, @@ -426,6 +435,34 @@ pub async fn revoke( Ok(account::revoke(&session, &operator_did, name).await?) } +/// The operator session for the deployment at `hostname`: the one `didbot +/// login` stored, or, with none stored, a sign-in the way `didbot login` +/// runs one, kept for the next run. +async fn deployment_session( + hostname: &str, + progress: &mut impl FnMut(String), +) -> Result { + let path = crate::default_session_path().ok_or(OperateError::NoHome)?; + match crate::load_for_host(&path, hostname) { + Ok(session) => return Ok(session), + Err(crate::OperatorError::NotSignedIn(_)) => {} + Err(crate::OperatorError::Store { source, .. }) + if source.kind() == std::io::ErrorKind::NotFound => {} + Err(other) => return Err(other.into()), + } + // `didbot login` is told the address a person types, port and all. + let origin = account::origin_of(hostname); + let server = origin.trim_start_matches("https://").to_owned(); + progress(format!( + "no operator session for {server} is kept; signing in as its operator" + )); + let session = tokio::task::spawn_blocking(move || crate::login(&server)) + .await + .map_err(|error| crate::OperatorError::Wait(error.to_string()))??; + crate::store(&path, session.clone())?; + Ok(session) +} + /// The file the operator's session with their own PDS is kept in. fn open_store() -> Result { let sessions_path = @@ -503,13 +540,7 @@ async fn sign_in( scope_string: &str, store: &FileAuthStore, progress: &mut impl FnMut(String), -) -> Result< - ( - String, - impl RecordWriter + RecordDeleter + RecordReader + ServiceAuthMinter, - ), - OperateError, -> { +) -> Result<(String, impl RecordWriter + RecordDeleter + RecordReader), OperateError> { // Authenticate to the operator's *own* atproto account, with a scope no // wider than this run needs. This is the one network exchange here that // is genuinely interactive -- a browser window, the operator's own PDS's diff --git a/crates/didbot-operator/src/operate/account.rs b/crates/didbot-operator/src/operate/account.rs index 28ec5193..e14fb1e4 100644 --- a/crates/didbot-operator/src/operate/account.rs +++ b/crates/didbot-operator/src/operate/account.rs @@ -1,24 +1,21 @@ //! Admitting an account beneath the operator: `didbot operate ` for //! a name that is not a server. //! -//! The human proves themself to the server without a credential of theirs -//! ever reaching it. Their own PDS mints a service-auth token for one call -//! at the server; the record naming the account goes into the human's own -//! repository; then the server is asked to create, and verifies the token -//! against the key the human's DID document names. What the server sees is -//! a token it can check and a record it can read, and nothing it could -//! replay. +//! No credential of the operator's own account reaches the server. The +//! record naming the account goes into the operator's own repository; then +//! the server is asked to create, carrying the operator session the server +//! minted when the operator ran `didbot login`. The session says who is +//! asking, and the record, which the server reads, is what lets them +//! create. //! -//! The token comes first because a PDS that will not mint it -- the -//! session's grant is too narrow, or it has aged out -- refuses before any -//! record exists. A server that then refuses the create has the record -//! taken back -- deleted, or put back as it stood before the run -- so a -//! refused admission leaves nothing behind and takes nothing away. +//! A server that refuses the create has the record taken back -- deleted, +//! or put back as it stood before the run -- so a refused admission leaves +//! nothing behind and takes nothing away. //! -//! [`admit`] is the whole flow after sign-in, over two seams: the human's -//! PDS as [`RecordWriter`], [`RecordReader`], [`RecordDeleter`] and -//! [`ServiceAuthMinter`], and the server as -//! [`AccountServer`], which is tested against an in-process router. +//! [`admit`] is the whole flow after sign-in, over two seams: the +//! operator's PDS as [`RecordWriter`], [`RecordReader`] and +//! [`RecordDeleter`], and the server as [`AccountServer`], which is tested +//! against an in-process router. use std::fmt; @@ -28,11 +25,10 @@ use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use crate::operate::record; -use crate::operate::write::{ - RecordDeleter, RecordReader, RecordWriter, ServiceAuthMinter, WriteError, -}; +use crate::operate::write::{RecordDeleter, RecordReader, RecordWriter, WriteError}; +use crate::Session; -/// The one method the service-auth token is for. +/// The method an admission calls at the server. pub const CREATE_ACCOUNT: &str = "bot.did.createAccount"; /// What `register` parked under a name, as the server describes it. @@ -141,7 +137,7 @@ impl AccountServer { ) } - /// The server's DID, which the service-auth token names as its audience. + /// The server's DID. pub fn did(&self) -> &str { &self.did } @@ -175,19 +171,15 @@ impl AccountServer { }) } - /// Creates the account `request` names, `credential` being the - /// service-auth token the human's PDS minted. + /// Creates the account `request` names, carrying the operator session + /// `didbot login` stored for this server. pub async fn create_account( &self, - credential: &str, + session: &Session, request: &CreateAccount, ) -> Result { let url = self.url(CREATE_ACCOUNT); - let response = self - .client - .post(&url) - .bearer_auth(credential) - .json(request) + let response = crate::present(self.client.post(&url).json(request), session) .send() .await .map_err(|err| ServerError::Unreachable { @@ -316,7 +308,7 @@ pub enum AdmitError { /// The server could not say what is parked; nothing was written. #[error("could not ask the server what is parked: {0}")] Park(#[source] ServerError), - /// The human's own PDS did not do its half; nothing was written. + /// The operator's own PDS did not do its half; nothing was created. #[error(transparent)] OwnPds(#[from] WriteError), /// The server did not create the account. @@ -356,8 +348,8 @@ pub struct Admitted { pub account_token: String, } -/// Admits `name` beneath `operator_did`: the service-auth token first, then -/// the record, then the create; a refused create takes the record back. +/// Admits `name` beneath `operator_did`: the record, then the create, +/// carrying `session`; a refused create takes the record back. /// A record that already stood at the key -- a second run for a name /// admitted before -- is put back as it was, so a refusal never costs an /// existing account its record. @@ -365,10 +357,11 @@ pub struct Admitted { /// A parked key is admitted by `fingerprint` -- `--fingerprint`, copied from /// what `register` printed -- whichever park under the name is newest, or, /// with none given, when `confirm` is shown the newest park and says yes. -/// Nothing is minted or written until one of those has happened. +/// Nothing is written until one of those has happened. #[allow(clippy::too_many_arguments)] -pub async fn admit( +pub async fn admit( server: &AccountServer, + session: &Session, own_pds: &W, operator_did: &str, name: &str, @@ -428,7 +421,6 @@ pub async fn admit created, Err(source) => { let taken_back = match &previous { @@ -597,12 +589,11 @@ mod tests { use super::*; - /// The human's own PDS, as this flow sees it: what was written, what - /// token was asked for, and in what order. + /// The operator's own PDS, as this flow sees it: what was written, and + /// in what order. #[derive(Default)] pub(super) struct OwnPds { written: Mutex>, - minted: Mutex>, deleted: Mutex>, /// Every call, in the order it came. log: Mutex>, @@ -664,17 +655,6 @@ mod tests { } } - impl ServiceAuthMinter for OwnPds { - async fn service_auth(&self, aud: &str, lxm: &str) -> Result { - self.log.lock().unwrap().push("mint"); - self.minted - .lock() - .unwrap() - .push((aud.to_owned(), lxm.to_owned())); - Ok("service-auth-token".to_owned()) - } - } - impl OwnPds { /// Every `(repo, rkey)` a delete reached. pub(super) fn deleted_records(&self) -> Vec<(String, String)> { @@ -738,14 +718,24 @@ mod tests { Json(json!({ "error": error, "message": "no" })), ); } - let bearer = headers - .get("authorization") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .unwrap_or_default() - .to_owned(); + let presented = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() + }; + let presented = (presented("cookie"), presented("x-didbot-csrf")); + if presented != ("didbot_operator=signed-in".to_owned(), "csrf".to_owned()) + || headers.contains_key("authorization") + { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ "error": "NotSignedIn", "message": "no" })), + ); + } let name = body["name"].as_str().unwrap_or_default().to_owned(); - served.created.lock().unwrap().push((bearer, body)); + served.created.lock().unwrap().push((presented.0, body)); ( StatusCode::OK, Json(json!({ @@ -782,12 +772,20 @@ mod tests { const NOW: OffsetDateTime = datetime!(2026-01-01 00:05:00 UTC); - /// The hand-off from `register`: the park is shown to the human, the - /// token is minted for this server and this one method before the - /// record goes in, and the create names the park and the park's kind - /// -- not the `--kind` typed. + /// The operator session `didbot login` stored for the server. + fn session() -> Session { + Session { + server: "pds.example".to_owned(), + token: "signed-in".to_owned(), + csrf: "csrf".to_owned(), + } + } + + /// The hand-off from `register`: the park is shown to the operator, the + /// record goes in, and the create carries the operator session and + /// names the park and the park's kind -- not the `--kind` typed. #[tokio::test] - async fn a_parked_key_is_confirmed_then_the_token_the_record_and_the_create_in_that_order() { + async fn a_parked_key_is_confirmed_then_the_record_and_the_create_in_that_order() { let served = Arc::new(Served { parked: Mutex::new(Some(park("h1.pds.example", "host"))), ..Default::default() @@ -798,6 +796,7 @@ mod tests { let admitted = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -823,7 +822,7 @@ mod tests { .map(|p| p.fingerprint.as_str()), Some("SHA256:abc") ); - assert_eq!(own.log.lock().unwrap().as_slice(), ["mint", "read", "put"]); + assert_eq!(own.log.lock().unwrap().as_slice(), ["read", "put"]); let written = own.written.lock().unwrap(); assert_eq!(written.len(), 1); let (operator, rkey, claim) = &written[0]; @@ -831,13 +830,9 @@ mod tests { assert_eq!(rkey, "h1.pds.example"); assert_eq!(claim.subject, "did:web:h1.pds.example"); assert!(claim.creates.is_empty(), "nothing was asked for"); - assert_eq!( - own.minted.lock().unwrap().as_slice(), - [("did:web:pds.example".to_owned(), CREATE_ACCOUNT.to_owned())] - ); let created = served.created.lock().unwrap(); - let (bearer, body) = &created[0]; - assert_eq!(bearer, "service-auth-token"); + let (cookie, body) = &created[0]; + assert_eq!(cookie, "didbot_operator=signed-in"); assert_eq!( body, &json!({ "name": "h1.pds.example", "kind": "host", "parkedKey": "SHA256:abc" }) @@ -863,6 +858,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -900,6 +896,7 @@ mod tests { ] { let admitted = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -931,6 +928,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "h1.pds.example", @@ -968,6 +966,7 @@ mod tests { let own = OwnPds::default(); let err = admit( &server, + &session(), &own, "did:plc:human", "kestrel.pds.example", @@ -980,7 +979,7 @@ mod tests { .unwrap_err(); assert_eq!( own.log.lock().unwrap().as_slice(), - ["mint", "read", "put", "delete"] + ["read", "put", "delete"] ); assert_eq!( own.deleted.lock().unwrap().as_slice(), @@ -1004,6 +1003,7 @@ mod tests { }; let err = admit( &server, + &session(), &own, "did:plc:human", "kestrel.pds.example", @@ -1030,6 +1030,50 @@ mod tests { assert!(own.deleted.lock().unwrap().is_empty()); } + /// A session the server does not accept -- it aged out, or the server + /// restarted since `didbot login` -- is a refused create like any + /// other: the record comes back out. + #[tokio::test] + async fn a_session_the_server_refuses_takes_the_record_back() { + let served = Arc::new(Served::default()); + let (server, _task) = serve(Arc::clone(&served)).await; + let own = OwnPds::default(); + let stale = Session { + token: "aged-out".to_owned(), + ..session() + }; + + let err = admit( + &server, + &stale, + &own, + "did:plc:human", + "kestrel.pds.example", + Ask { + kind: Some("agent".to_owned()), + ..Default::default() + }, + None, + |_| true, + NOW, + ) + .await + .unwrap_err(); + + assert!( + matches!( + &err, + AdmitError::Server { + source: ServerError::Refused { status: 401, .. }, + record: Rollback::Removed { .. }, + } + ), + "{err}" + ); + assert_eq!(own.calls(), ["read", "put", "delete"]); + assert!(served.created.lock().unwrap().is_empty()); + } + /// A second run for a name the repository already names: the server /// refuses because the account exists, and the record that stood /// before the run is put back rather than deleted, so the account it @@ -1058,6 +1102,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "kestrel.pds.example", @@ -1109,6 +1154,7 @@ mod tests { let err = admit( &server, + &session(), &own, "did:plc:human", "deploy.pds.example", @@ -1128,6 +1174,7 @@ mod tests { let admitted = admit( &server, + &session(), &own, "did:plc:human", "deploy.pds.example", @@ -1159,6 +1206,7 @@ mod tests { *served.parked.lock().unwrap() = Some(park("deploy.pds.example", "host")); let admitted = admit( &server, + &session(), &own, "did:plc:human", "deploy.pds.example", @@ -1201,6 +1249,7 @@ mod tests { let admitted = admit( &server, + &session(), &own, "did:plc:human", "laptop.pds.example", diff --git a/crates/didbot-operator/src/operate/scope.rs b/crates/didbot-operator/src/operate/scope.rs index eeab2544..4e59449a 100644 --- a/crates/didbot-operator/src/operate/scope.rs +++ b/crates/didbot-operator/src/operate/scope.rs @@ -19,35 +19,16 @@ //! server refuses, so a refused create leaves no record naming an account //! that does not exist. //! -//! Admitting an account also has the operator's PDS mint a service-auth -//! token for one call at the server. The atproto permission spec -//! (, "rpc") covers -//! `com.atproto.server.getServiceAuth` under the `rpc:` resource, and -//! requires the audience in the string form, so [`admit_scope`] binds the -//! token's one method to the one server it is for. +//! A claim, an admission and a revocation all ask for this one scope. The +//! create at the server carries the operator session `didbot login` mints +//! there, which the operator's own PDS plays no part in. use didbot_scope::{Action, ActionSet, NsidPattern, Scope, ScopeSet}; -/// The scope string a server claim requests: +/// The scope string every sign-in to the operator's own PDS requests: /// `"atproto repo:bot.did.operator?action=create&action=update&action=delete"`. pub fn operator_write_scope() -> String { - ScopeSet::new(write_atoms()).to_string() -} - -/// The scope string an account admission requests: [`operator_write_scope`] -/// plus `rpc:bot.did.createAccount?aud=`, so the PDS mints a -/// service-auth token for that one method at that one server. -pub fn admit_scope(server_did: &str) -> String { - let mut atoms = write_atoms(); - atoms.push(Scope::Rpc { - method: NsidPattern::Exact(crate::operate::account::CREATE_ACCOUNT.to_owned()), - aud: Some(server_did.to_owned()), - }); - ScopeSet::new(atoms).to_string() -} - -fn write_atoms() -> Vec { - vec![ + ScopeSet::new(vec![ Scope::Atproto, Scope::Repo { collection: NsidPattern::Exact(crate::operate::record::COLLECTION.to_owned()), @@ -57,7 +38,8 @@ fn write_atoms() -> Vec { .collect(), ), }, - ] + ]) + .to_string() } #[cfg(test)] @@ -73,30 +55,15 @@ mod tests { ); } - #[test] - fn an_admission_binds_the_service_auth_method_to_one_server() { - let scope = admit_scope("did:web:pds.example"); - assert_eq!( - scope, - "atproto repo:bot.did.operator?action=create&action=update&action=delete \ - rpc:bot.did.createAccount?aud=did:web:pds.example" - ); - assert!( - !admit_scope("did:web:other.example").contains("aud=did:web:pds.example"), - "the audience is the server asked for and no other" - ); - } - #[test] fn round_trips_through_the_servers_own_parser_and_jacquards() { // Not just well-formed by construction -- also accepted by the same // grammar a server enforcing a scope ceiling would parse it with, // and by the OAuth client that sends it. - for scope in [operator_write_scope(), admit_scope("did:web:pds.example")] { - let parsed = ScopeSet::parse(&scope).expect("the grammar we built it from parses it"); - assert_eq!(parsed.to_string(), scope); - jacquard_oauth::scopes::Scopes::new(smol_str::SmolStr::new(&scope)) - .expect("jacquard parses what it is asked to request"); - } + let scope = operator_write_scope(); + let parsed = ScopeSet::parse(&scope).expect("the grammar we built it from parses it"); + assert_eq!(parsed.to_string(), scope); + jacquard_oauth::scopes::Scopes::new(smol_str::SmolStr::new(&scope)) + .expect("jacquard parses what it is asked to request"); } } diff --git a/crates/didbot-operator/src/operate/write.rs b/crates/didbot-operator/src/operate/write.rs index 5a55cd90..c8e9179e 100644 --- a/crates/didbot-operator/src/operate/write.rs +++ b/crates/didbot-operator/src/operate/write.rs @@ -127,41 +127,6 @@ impl XrpcRequest for GetRecordParams { type Response = GetRecordResponse; } -/// `com.atproto.server.getServiceAuth`'s parameters: the audience, and the -/// one method the token is for. -#[derive(Debug, Serialize)] -struct GetServiceAuthParams { - aud: String, - lxm: String, -} - -/// The one field this command reads out of a minted service-auth token. -#[derive(Debug, Deserialize)] -pub struct ServiceAuthOutput { - /// The token. - pub token: String, -} - -/// The error shape `getServiceAuth` answers with; see [`PutRecordErr`]. -#[derive(Debug, Deserialize, Serialize, thiserror::Error)] -#[error("{0}")] -struct ServiceAuthErr(SmolStr); - -struct GetServiceAuthResponse; - -impl XrpcResp for GetServiceAuthResponse { - const NSID: &'static str = "com.atproto.server.getServiceAuth"; - const ENCODING: &'static str = "application/json"; - type Output = ServiceAuthOutput; - type Err = ServiceAuthErr; -} - -impl XrpcRequest for GetServiceAuthParams { - const NSID: &'static str = "com.atproto.server.getServiceAuth"; - const METHOD: XrpcMethod = XrpcMethod::Query; - type Response = GetServiceAuthResponse; -} - /// Why the write did not happen. #[derive(Debug, thiserror::Error)] pub enum WriteError { @@ -184,18 +149,6 @@ pub enum WriteError { /// What the server said. detail: String, }, - /// The operator's PDS would not mint a service-auth token for the - /// server. - #[error( - "the operator's PDS would not mint a service-auth token for {aud} ({detail}) -- the \ - session's `atproto` scope is what allows this; sign in again if it has aged out." - )] - ServiceAuth { - /// The server the token was for. - aud: String, - /// What the PDS said. - detail: String, - }, /// The record that may already stand at the key could not be read /// back, so nothing was written over it. #[error("could not read bot.did.operator/{rkey} back from the operator's PDS ({detail})")] @@ -311,17 +264,6 @@ pub trait RecordDeleter { ) -> Result<(), WriteError>; } -/// Mints a service-auth token at the operator's own PDS: `aud` is the -/// server it is for and `lxm` the one method it may call, and the PDS signs -/// it with the key the operator's DID document names. The other thing the -/// same session does, beside [`RecordWriter`], and the same fakes stand in -/// for both. -#[allow(async_fn_in_trait)] -pub trait ServiceAuthMinter { - /// The token, as the PDS minted it. - async fn service_auth(&self, aud: &str, lxm: &str) -> Result; -} - impl RecordWriter for jacquard_oauth::client::OAuthSession where T: jacquard_oauth::resolver::OAuthResolver @@ -392,33 +334,3 @@ where Ok(()) } } - -impl ServiceAuthMinter for jacquard_oauth::client::OAuthSession -where - T: jacquard_oauth::resolver::OAuthResolver - + jacquard_oauth::dpop::DpopExt - + jacquard_common::xrpc::XrpcExt - + Send - + Sync - + 'static, - S: jacquard_oauth::authstore::ClientAuthStore + Send + Sync + 'static, - W: Send + Sync, -{ - async fn service_auth(&self, aud: &str, lxm: &str) -> Result { - let refused = |error: &dyn std::fmt::Display| WriteError::ServiceAuth { - aud: aud.to_owned(), - detail: error.to_string(), - }; - let response = self - .send(GetServiceAuthParams { - aud: aud.to_owned(), - lxm: lxm.to_owned(), - }) - .await - .map_err(|error| refused(&error))?; - let output = response - .parse::() - .map_err(|error| refused(&error))?; - Ok(output.token) - } -}