diff --git a/Cargo.lock b/Cargo.lock index c63b24fc..42590e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,6 +1243,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/crates/didbot-serve/Cargo.toml b/crates/didbot-serve/Cargo.toml index ff986c2c..0962259d 100644 --- a/crates/didbot-serve/Cargo.toml +++ b/crates/didbot-serve/Cargo.toml @@ -52,6 +52,7 @@ reqwest.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true +url.workspace = true sha2.workspace = true subtle.workspace = true thiserror.workspace = true diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index ea893805..7c0eb124 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -118,6 +118,9 @@ struct Args { /// the budget buys is stopping at a size somebody chose rather than at /// the size the volume turns out to be. See `plan/store-scale.md`. log_budget: Option, + /// Whether to admit every client, grant every scope and confirm every + /// consent. See the usage text. + oauth_open: bool, /// What names agents when the first namer will not answer. fallback: Option, /// Print the bundled word lists and exit. @@ -243,6 +246,7 @@ impl Default for Args { zones: vec![DEFAULT_ZONE.to_owned()], owner: DEFAULT_OWNER.to_owned(), demo: 0, + oauth_open: false, names: Names::default(), hold: None, log_budget: None, @@ -302,6 +306,10 @@ usage: didbot-pds [options] minted under today -- and every zone gets its own DNS backend and, under --tls acme, its own certificate pair. --demo provision n agents on startup, then delete and pin one + --oauth-open admit every OAuth client, grant every scope it asks for + and confirm every consent. For running a client against a + development server before the policies that would refuse + it exist. Never for a deployment anyone else can reach. --names how to name agents. `hostname` (the default) uses the account's own hostname. A spec of word lists joined with `+` draws a part from each, in order: `creature`, @@ -823,6 +831,7 @@ fn parse_args>(mut args: I) -> Result { close_disclosure(&mut parsed.disclosure, &raw)?; } "--trust-forwarded-headers" => parsed.trust_forwarded_headers = true, + "--oauth-open" => parsed.oauth_open = true, "--tls" => { let raw = value()?; parsed.tls = match raw.as_str() { @@ -1212,12 +1221,33 @@ async fn run(mut args: Args) -> Result<(), String> { didbot_serve::oauth::token::OAuthTokenStore::with_grants(durable.oauth_grants()) } }), - // Every other OAuth policy hook refuses by default; see - // `didbot_serve::oauth::OAuthState::default`. Nothing here - // configures a real one yet, so this dev server's OAuth surface - // answers PAR but issues no tokens. + // Each of these refuses by default -- see + // `didbot_serve::oauth::OAuthState::default` -- so a server that has + // not been told otherwise answers PAR and issues no tokens. + admission: if args.oauth_open { + Arc::new(didbot_serve::oauth::par::AdmitAnything) + } else { + Arc::new(didbot_serve::oauth::par::RefuseAllAdmission) + }, + scope_policy: if args.oauth_open { + Arc::new(didbot_serve::oauth::authorize::GrantAnyScope) + } else { + Arc::new(didbot_serve::oauth::authorize::RefuseAllScopePolicy) + }, + consent_policy: if args.oauth_open { + Arc::new(didbot_serve::oauth::consent::ConfirmAnything) + } else { + Arc::new(didbot_serve::oauth::consent::RefuseAllConsent) + }, ..didbot_serve::oauth::OAuthState::default() }; + if args.oauth_open { + // Loud, because a deployment that grants every client every agent + // should be one somebody meant to start. + tracing::warn!( + "--oauth-open: every client is admitted, every scope granted, every consent confirmed" + ); + } let auth = AuthState { nudge: nudge.clone(), lifecycle: lifecycle.clone(), diff --git a/crates/didbot-serve/src/oauth/authorize.rs b/crates/didbot-serve/src/oauth/authorize.rs index e53b8766..ae9c7026 100644 --- a/crates/didbot-serve/src/oauth/authorize.rs +++ b/crates/didbot-serve/src/oauth/authorize.rs @@ -24,6 +24,23 @@ pub trait ScopePolicy: Send + Sync { fn ceiling(&self, did: &str, client_id: &str) -> ScopeSet; } +/// Grants whatever was asked for. The counterpart to +/// [`crate::oauth::consent::ConfirmAnything`], behind the same flag. +/// +/// The ceiling is written with wildcards rather than computed, so what it +/// admits is legible: every collection, every method, and the base scope. +#[derive(Debug, Clone, Copy, Default)] +pub struct GrantAnyScope; + +/// What [`GrantAnyScope`] admits. +const EVERY_SCOPE: &str = "atproto repo:* rpc:*"; + +impl ScopePolicy for GrantAnyScope { + fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet { + ScopeSet::parse(EVERY_SCOPE).expect("a constant this crate's own parser accepts") + } +} + /// The only safe default while `plan/scope-policy.md` is unbuilt: no scope /// is ever inside the ceiling, so every request is refused rather than /// silently granted in full. @@ -122,6 +139,7 @@ pub fn finish_authorization( pushed.client_id.clone(), granted_scope.clone(), pushed.redirect_uri.clone(), + pushed.state.clone(), pushed.code_challenge.clone(), pushed.code_challenge_method.clone(), ); diff --git a/crates/didbot-serve/src/oauth/consent.rs b/crates/didbot-serve/src/oauth/consent.rs index 7b19c10b..8705d87c 100644 --- a/crates/didbot-serve/src/oauth/consent.rs +++ b/crates/didbot-serve/src/oauth/consent.rs @@ -83,6 +83,10 @@ pub struct PendingConsent { /// Where the authorization code must be delivered — checked again at /// `token`, per RFC 6749 §4.1.3. pub redirect_uri: String, + /// What the client sent as `state`, to be handed back on the redirect. + /// A client checks it against what it generated, which is how it tells + /// its own flow from one somebody else started in its name. + pub state: Option, /// PKCE fields, carried through so `token` can verify `code_verifier` /// without a second round trip to the pushed request (which no longer /// exists — [`crate::oauth::par::ParStore::take`] already consumed it). @@ -120,6 +124,22 @@ pub trait ConsentPolicy: Send + Sync { fn allow(&self, did: &str, client_id: &str) -> Result<(), String>; } +/// Confirms whatever asked, for a deployment running without policy. +/// +/// Not a default and not reachable by configuration alone: `didbot-pds` +/// takes an explicit flag for it, so a deployment that grants every client +/// every agent said so out loud. It exists because the flow it completes — +/// a client signing in as an agent with nobody at the consent screen — is +/// worth exercising before the policies that would refuse it exist. +#[derive(Debug, Clone, Copy, Default)] +pub struct ConfirmAnything; + +impl ConsentPolicy for ConfirmAnything { + fn allow(&self, _did: &str, _client_id: &str) -> Result<(), String> { + Ok(()) + } +} + /// The only safe default while scope and app policy are unbuilt. /// /// TODO(scope-policy, app-allowlist): replace with a real check once @@ -198,6 +218,7 @@ pub fn begin( client_id: String, granted_scope: ScopeSet, redirect_uri: String, + state: Option, code_challenge: String, code_challenge_method: String, ) -> ConsentReference { @@ -207,6 +228,7 @@ pub fn begin( client_id, granted_scope, redirect_uri, + state, code_challenge, code_challenge_method, expires_at: OffsetDateTime::now_utc(), @@ -267,6 +289,7 @@ mod tests { "https://c".to_owned(), ScopeSet::parse("atproto").unwrap(), "https://c/cb".to_owned(), + None, "challenge".to_owned(), "S256".to_owned(), ) diff --git a/crates/didbot-serve/src/oauth/par.rs b/crates/didbot-serve/src/oauth/par.rs index b1f095af..8d6c2fe4 100644 --- a/crates/didbot-serve/src/oauth/par.rs +++ b/crates/didbot-serve/src/oauth/par.rs @@ -106,6 +106,22 @@ pub trait AppAdmission: Send + Sync { ) -> Result<(), String>; } +/// Admits whatever asked. The counterpart to +/// [`crate::oauth::consent::ConfirmAnything`], behind the same flag. +#[derive(Debug, Clone, Copy, Default)] +pub struct AdmitAnything; + +impl AppAdmission for AdmitAnything { + fn check( + &self, + _client_id: &str, + _key: &ClientKey, + _document: &ClientMetadataDocument, + ) -> Result<(), String> { + Ok(()) + } +} + /// The only safe default while `plan/app-allowlist.md` is unbuilt: refuse /// every client. Approving because no allowlist has been wired in yet would /// be exactly the "approval because nobody was there to say no" failure diff --git a/crates/didbot-serve/src/routes.rs b/crates/didbot-serve/src/routes.rs index 1392211a..4afcd659 100644 --- a/crates/didbot-serve/src/routes.rs +++ b/crates/didbot-serve/src/routes.rs @@ -32,12 +32,12 @@ use crate::rate_limit::{caller_key, Peer, RateLimiter}; use crate::subscribe::Repos; use crate::wire::{ record_uri, refuse_skipped_validation, repeated, require_known_lexicon, AgentSummary, - ApplyWritesRequest, CreateRecordRequest, CreateRecordResponse, CreateSessionRequest, - DeleteRecordRequest, DescribeRepoQuery, DescribeRepoResponse, DescribeServerResponse, - DidRequest, EventsQuery, FirehoseQuery, GetRecordQuery, GetRecordResponse, GetRepoQuery, - GetSessionResponse, LedgerQuery, ListRecordsQuery, ListReposQuery, ProvisionAgentRequest, - PutRecordRequest, RecordView, RepoWrite, SessionResponse, SetPinnedRequest, StatsResponse, - SyncGetRecordQuery, WriteResult, + ApplyWritesRequest, ConfirmRequest, CreateRecordRequest, CreateRecordResponse, + CreateSessionRequest, DeleteRecordRequest, DescribeRepoQuery, DescribeRepoResponse, + DescribeServerResponse, DidRequest, EventsQuery, FirehoseQuery, GetRecordQuery, + GetRecordResponse, GetRepoQuery, GetSessionResponse, LedgerQuery, ListRecordsQuery, + ListReposQuery, ProvisionAgentRequest, PutRecordRequest, RecordView, RepoWrite, + SessionResponse, SetPinnedRequest, StatsResponse, SyncGetRecordQuery, WriteResult, }; /// Session storage, the e-stop latch and the operator credential, bundled so @@ -546,6 +546,7 @@ pub fn app_with_repos( .route("/oauth/par", post(oauth_par)) .route("/oauth/authorize", get(oauth_authorize)) .route("/oauth/token", post(oauth_token)) + .route("/oauth/confirm", post(oauth_confirm)) .route("/events", get(events_stream)) .route("/firehose", get(firehose_stream)) .merge(crate::dashboard::routes()) @@ -1055,6 +1056,65 @@ fn oauth_token_error_response(err: crate::oauth::token::TokenError) -> Response /// bound. Every exchange and every refresh runs through /// `crate::oauth::dpop_seam::DpopVerifier`, which [`crate::oauth::OAuthState::default`] /// wires to the real RFC 9449 verifier — see `crate::oauth::dpop`. +/// `POST /oauth/confirm` +/// +/// The confirming half of `plan/oauth.md`'s two tool calls. A client prints +/// its authorize URL instead of opening it; something that knows which agent +/// is acting reads the reference off that page and posts it here with the +/// account it attests to. +/// +/// The identity is an argument and it is checked here rather than trusted: +/// `consent::confirm` compares it against the account the pushed request +/// named, so a caller naming somebody else's DID gets no code, however +/// plausible the DID. What this route adds over the function is the redirect +/// — a caller that had to build it would need the pending consent's +/// `redirect_uri` and `state`, which is the whole of what it is not allowed +/// to see. +async fn oauth_confirm(State(state): State, XrpcBody(body): XrpcBody) -> Response { + if let Err(halted) = state.estop.check_issue(EstopRefusal::Token) { + return ApiError::from(halted).into_response(); + } + let request: ConfirmRequest = match parse_body(&body) { + Ok(request) => request, + Err(err) => return err.into_response(), + }; + + let reference = crate::oauth::consent::ConsentReference(request.reference); + let pending = match crate::oauth::consent::confirm( + state.oauth.consent_store.as_ref(), + state.oauth.consent_policy.as_ref(), + &reference, + &request.did, + ) { + Ok(pending) => pending, + Err(err) => { + info!(did = request.did, error = %err, "consent refused"); + return ApiError::bad_request(err.to_string()).into_response(); + } + }; + + let redirect_uri = pending.redirect_uri.clone(); + let state_param = pending.state.clone(); + let code = state.oauth.code_store.issue(pending.into()); + + // Built here because the client's `state` and `redirect_uri` are the + // pending consent's, and a confirming caller never sees either. Parsed + // rather than concatenated: `state` is whatever the client sent. + let mut redirect = match url::Url::parse(&redirect_uri) { + Ok(redirect) => redirect, + Err(err) => { + return ApiError::bad_request(format!("the client's redirect is unusable: {err}")) + .into_response() + } + }; + redirect.query_pairs_mut().append_pair("code", &code); + if let Some(value) = state_param { + redirect.query_pairs_mut().append_pair("state", &value); + } + + Json(json!({ "redirect": redirect.as_str() })).into_response() +} + async fn oauth_token( State(state): State, headers: HeaderMap, diff --git a/crates/didbot-serve/src/wire.rs b/crates/didbot-serve/src/wire.rs index c3770872..d225021a 100644 --- a/crates/didbot-serve/src/wire.rs +++ b/crates/didbot-serve/src/wire.rs @@ -81,6 +81,19 @@ impl ProvisionAgentRequest { } } +/// Body of `POST /oauth/confirm`. +/// +/// `did` is what the caller attests the acting agent to be. It is compared +/// against the account the pushed request named rather than believed, so +/// naming another agent's DID yields no code. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConfirmRequest { + /// The one-time reference read off the authorize page. + pub reference: String, + /// The agent the caller says is confirming. + pub did: String, +} + /// Body of `bot.did.deleteAgent`, and of the pin, freeze and soft-delete /// routes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]