didbot_serve/operator.rs
1//! Signing in as the operator, over atproto OAuth, to reach this server's
2//! operator controls.
3//!
4//! One deployment has one operator, and this server was told which DID that
5//! is: `--operator`, held as [`didbot_pds::OperatorRelation`]'s `operator_did` and
6//! documented there as configuration. So authenticating an operator is
7//! establishing that whoever is asking controls that one DID, and the
8//! comparison at the end is a string equality against a value already in
9//! this process.
10//!
11//! # Nothing is granted, and nothing is kept
12//!
13//! The scope requested is `atproto` and nothing else: no repository access,
14//! no write, nothing this server could use even if it wanted to. It is an
15//! identity sign-in, and the only thing taken from it is the `sub` the
16//! operator's own authorization server put in the token response. The OAuth
17//! session is dropped the moment the callback has read that — see
18//! [`OperatorAuth::finish`] — so no credential belonging to the operator is
19//! ever stored here. That is what keeps `plan/handshake.md`'s rule intact:
20//! a server reads who operates it out of configuration and a record in the
21//! operator's own repository, and is never handed a secret to check.
22//!
23//! What this server does issue is its own session, which says only "the
24//! browser holding this proved control of the operator DID at some point in
25//! the last [`OperatorAuth::session_ttl`]". It is minted here, it never leaves this
26//! process, and it is not a credential any other party accepts.
27//!
28//! # `bot.did.operator` is a different question
29//!
30//! The operator poll reads a `bot.did.operator` record out of the
31//! operator's repository to decide whether this server is *claimed* — the
32//! operator's vouch for the server, fetched over the network. Sign-in does
33//! not consult it. Putting a remote fetch in front of a button press would
34//! make the operator's controls unreachable exactly when their own server is
35//! unreachable, and an operator has business here — reading the onboarding
36//! surface, throwing the stop — before any claim has landed.
37
38use std::collections::HashMap;
39use std::sync::{Arc, Mutex};
40use std::time::{Duration, Instant};
41
42use bytes::Bytes;
43use jacquard_common::http_client::HttpClient;
44use jacquard_common::types::did::Did;
45use jacquard_common::types::string::Handle;
46use jacquard_common::BosStr;
47use jacquard_identity::resolver::{
48 DidDocResponse, IdentityError, IdentityResolver, ResolverOptions,
49};
50use jacquard_identity::JacquardResolver;
51use jacquard_oauth::atproto::{atproto_client_metadata, AtprotoClientMetadata};
52use jacquard_oauth::authstore::MemoryAuthStore;
53use jacquard_oauth::client::OAuthClient;
54use jacquard_oauth::dpop::DpopExt;
55use jacquard_oauth::resolver::OAuthResolver;
56use jacquard_oauth::scopes::Scopes;
57use jacquard_oauth::session::ClientData;
58use jacquard_oauth::types::{AuthorizeOptions, CallbackParams};
59use smol_str::SmolStr;
60
61use crate::auth::constant_time_eq;
62
63/// How long a signed-in browser stays signed in, absent
64/// `--operator-session-ttl` or an `[operator]` section.
65///
66/// One working day, so a sign-in in the morning has not expired by the
67/// evening. See `didbot_config::sections::OperatorSection` for the range
68/// worth staying inside and why.
69pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(12 * 60 * 60);
70
71/// The one scope this server ever asks an operator's server for.
72const SIGN_IN_SCOPE: &str = "atproto";
73
74/// The path the operator's authorization server redirects back to.
75pub const CALLBACK_PATH: &str = "/dashboard/callback";
76
77/// The path this server publishes its client metadata document at. This URL
78/// *is* the `client_id`: an atproto client is identified by a document its
79/// authorization server can fetch.
80pub const CLIENT_METADATA_PATH: &str = "/dashboard/oauth/client-metadata.json";
81
82/// How long a started sign-in has to come back before its pending state is
83/// dropped. Generous enough for a consent screen and a password manager,
84/// short enough that an abandoned one does not linger.
85const PENDING_TTL: Duration = Duration::from_secs(10 * 60);
86
87/// How many sign-ins may be in flight at once.
88///
89/// [`OperatorAuth::start`] is reachable without a credential — it has to be,
90/// since signing in is how a caller gets one — and every call it accepts
91/// holds an entry until it is used or ages out. One operator needs one at a
92/// time; this is high enough never to be reached by a person and low enough
93/// that a flood cannot grow this map without bound. Past it, the oldest
94/// entry is dropped rather than the newest refused, so an attacker filling
95/// it cannot lock the operator out.
96const MAX_PENDING: usize = 64;
97
98/// A browser that proved control of the operator DID.
99struct Session {
100 /// When this stops being accepted.
101 expires: Instant,
102 /// The token a mutating request must echo back in a header, so that a
103 /// cookie alone — which another origin can make a browser send — is not
104 /// enough to throw the stop. See [`OperatorAuth::authorize`].
105 csrf: String,
106}
107
108/// Every way signing in can fail before a session exists.
109#[derive(Debug, thiserror::Error)]
110pub enum SignInError {
111 /// The handle or DID could not be resolved, or its server would not
112 /// start an authorization request.
113 #[error("could not start sign-in: {0}")]
114 Start(String),
115 /// The redirect came back, but the exchange failed.
116 #[error("could not finish sign-in: {0}")]
117 Finish(String),
118 /// The sign-in completed and authenticated somebody who is not the
119 /// operator this deployment was started with.
120 #[error("signed in as {got}, which is not this deployment's --operator")]
121 NotTheOperator {
122 /// The DID that actually signed in.
123 got: String,
124 },
125 /// A caller asked to be sent somewhere that is not this machine.
126 #[error("{got} is not a loopback address, so this server will not send a session there")]
127 NotLoopback {
128 /// What was asked for.
129 got: String,
130 },
131}
132
133/// The operator sign-in for one deployment.
134pub struct OperatorAuth {
135 operator_did: String,
136 session_ttl: Duration,
137 client: OAuthClient<Resolver, MemoryAuthStore>,
138 sessions: Mutex<HashMap<String, Session>>,
139 /// Sign-ins started but not yet come back, keyed by the `state` this
140 /// server chose. The value is where to send the browser afterwards —
141 /// `None` for the dashboard's own flow, and a loopback URL for a CLI
142 /// waiting on one. See [`Self::start`].
143 pending: Mutex<HashMap<String, (Option<String>, Instant)>>,
144}
145
146impl OperatorAuth {
147 /// Builds the sign-in for `operator_did`, publishing client metadata at
148 /// `base_url` + [`CLIENT_METADATA_PATH`].
149 ///
150 /// `base_url` is this deployment's own public origin, with no trailing
151 /// slash — the authorization server fetches the metadata document from
152 /// it and checks the redirect against it, so it has to be the address a
153 /// browser actually reached.
154 pub fn new(
155 operator_did: impl Into<String>,
156 base_url: &str,
157 session_ttl: Duration,
158 ) -> Result<Arc<Self>, String> {
159 let operator_did = operator_did.into();
160 let client_id = format!("{base_url}{CLIENT_METADATA_PATH}")
161 .parse()
162 .map_err(|err| format!("{base_url} is not a usable client_id: {err:?}"))?;
163 let redirect = format!("{base_url}{CALLBACK_PATH}")
164 .parse()
165 .map_err(|err| format!("{base_url} is not a usable redirect_uri: {err:?}"))?;
166 let scopes: Scopes<SmolStr> = Scopes::new(SmolStr::new_static(SIGN_IN_SCOPE))
167 .map_err(|err| format!("the sign-in scope does not parse: {err:?}"))?;
168 let metadata = AtprotoClientMetadata::new(vec![redirect], client_id, Some(scopes));
169 let client = OAuthClient::new_from_resolver(
170 MemoryAuthStore::new(),
171 Resolver::new(didbot_http::client()),
172 ClientData::new_public(metadata),
173 );
174 Ok(Arc::new(Self {
175 operator_did,
176 session_ttl,
177 client,
178 sessions: Mutex::new(HashMap::new()),
179 pending: Mutex::new(HashMap::new()),
180 }))
181 }
182
183 /// The DID this deployment will accept a sign-in from, and nobody else.
184 pub fn operator_did(&self) -> &str {
185 &self.operator_did
186 }
187
188 /// How long a session this issues stays good for.
189 pub fn session_ttl(&self) -> Duration {
190 self.session_ttl
191 }
192
193 /// The client metadata document, as the JSON served at
194 /// [`CLIENT_METADATA_PATH`].
195 pub fn client_metadata_document(&self) -> Result<serde_json::Value, String> {
196 let metadata = atproto_client_metadata(
197 &self.client.registry.client_data.config,
198 &self.client.registry.client_data.keyset,
199 )
200 .map_err(|err| format!("{err:?}"))?;
201 serde_json::to_value(metadata).map_err(|err| err.to_string())
202 }
203
204 /// Resolves this deployment's operator and returns the URL to send the
205 /// browser to.
206 ///
207 /// The identity is the one `--operator` names and no other, so no
208 /// caller picks the host this server resolves and pushes an
209 /// authorization request to. What is checked at the far end is still
210 /// the `sub` that comes back.
211 ///
212 /// `cli_redirect` is where to send the browser once the sign-in lands,
213 /// for a command-line caller waiting on a loopback address. It is
214 /// **refused unless it is loopback**: this
215 /// server would otherwise bounce a browser anywhere a link asked it to,
216 /// carrying a freshly minted operator session.
217 pub async fn start(&self, cli_redirect: Option<&str>) -> Result<String, SignInError> {
218 if let Some(redirect) = cli_redirect {
219 if !is_loopback_redirect(redirect) {
220 return Err(SignInError::NotLoopback {
221 got: redirect.to_owned(),
222 });
223 }
224 }
225 let state = random_token();
226 let url = self
227 .client
228 .start_auth(
229 self.operator_did(),
230 AuthorizeOptions {
231 state: Some(state.clone().into()),
232 ..Default::default()
233 },
234 )
235 .await
236 .map_err(|err| SignInError::Start(format!("{err:?}")))?;
237 let mut pending = self.pending.lock().unwrap_or_else(|p| p.into_inner());
238 let now = Instant::now();
239 pending.retain(|_, (_, started)| now.duration_since(*started) < PENDING_TTL);
240 while pending.len() >= MAX_PENDING {
241 // Oldest first: a flood evicts its own earlier attempts before
242 // it can evict a sign-in the operator started a moment ago.
243 let oldest = pending
244 .iter()
245 .min_by_key(|(_, (_, started))| *started)
246 .map(|(key, _)| key.clone());
247 match oldest {
248 Some(key) => {
249 pending.remove(&key);
250 }
251 None => break,
252 }
253 }
254 pending.insert(state, (cli_redirect.map(str::to_owned), now));
255 Ok(url)
256 }
257
258 /// Completes the redirect and, if it authenticated the operator, mints a
259 /// session. Returns the session token and its CSRF token.
260 ///
261 /// The OAuth session itself is dropped here: this server wanted the
262 /// `sub` and has it.
263 pub async fn finish(&self, params: CallbackParams) -> Result<SignedIn, SignInError> {
264 // Claimed before the exchange, so a replayed callback cannot mint a
265 // second session off one sign-in.
266 let cli_redirect = params.state.as_ref().and_then(|state| {
267 self.pending
268 .lock()
269 .unwrap_or_else(|p| p.into_inner())
270 .remove(AsRef::<str>::as_ref(state))
271 .and_then(|(redirect, _)| redirect)
272 });
273 let session = self
274 .client
275 .callback(params)
276 .await
277 .map_err(|err| SignInError::Finish(format!("{err:?}")))?;
278 let (did, _) = session.session_info().await;
279 let did = did.to_string();
280 // Ends the OAuth session on the operator's own server rather than
281 // leaving a grant behind that nothing here will ever use again.
282 let _ = session.logout().await;
283 if did != self.operator_did {
284 return Err(SignInError::NotTheOperator { got: did });
285 }
286 let mut signed_in = self.mint();
287 signed_in.cli_redirect = cli_redirect;
288 Ok(signed_in)
289 }
290
291 /// Issues a session for a caller already established as the operator.
292 ///
293 /// `pub(crate)` and no wider: this is the one call that hands out an
294 /// operator session without authenticating anybody, so the only callers
295 /// are [`Self::finish`], which has just checked the `sub`, and tests:
296 /// the dashboard's own, and [`Self::mint_for_tests`]'s.
297 pub(crate) fn mint(&self) -> SignedIn {
298 let token = random_token();
299 let csrf = random_token();
300 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
301 let now = Instant::now();
302 sessions.retain(|_, held| held.expires > now);
303 sessions.insert(
304 token.clone(),
305 Session {
306 expires: now + self.session_ttl,
307 csrf: csrf.clone(),
308 },
309 );
310 SignedIn {
311 token,
312 csrf,
313 cli_redirect: None,
314 }
315 }
316
317 /// A session as if the operator had just signed in, for a test standing
318 /// a server up with no authorization server to sign in through.
319 #[cfg(feature = "test-support")]
320 pub fn mint_for_tests(&self) -> SignedIn {
321 self.mint()
322 }
323
324 /// Whether `token` is a live session, and if so what CSRF token it
325 /// expects. A read may stop here; see [`Self::authorize`].
326 pub fn lookup(&self, token: &str) -> Option<String> {
327 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
328 let now = Instant::now();
329 sessions.retain(|_, held| held.expires > now);
330 sessions.get(token).map(|held| held.csrf.clone())
331 }
332
333 /// Whether `token` may perform a *mutating* operator action.
334 ///
335 /// A cookie is sent by the browser on a request another origin caused,
336 /// so a live session is necessary and not sufficient: a request that
337 /// changes something must also echo the session's own CSRF token, which
338 /// only a page served from this origin can have read.
339 pub fn authorize(&self, token: &str, presented_csrf: Option<&str>) -> bool {
340 let Some(expected) = self.lookup(token) else {
341 return false;
342 };
343 presented_csrf
344 .is_some_and(|presented| constant_time_eq(presented.as_bytes(), expected.as_bytes()))
345 }
346
347 /// Ends one session.
348 pub fn sign_out(&self, token: &str) {
349 self.sessions
350 .lock()
351 .unwrap_or_else(|p| p.into_inner())
352 .remove(token);
353 }
354}
355
356/// What a completed sign-in hands the browser.
357pub struct SignedIn {
358 /// Goes in the session cookie.
359 pub token: String,
360 /// Must come back as a header on anything that changes state.
361 pub csrf: String,
362 /// Where to send the browser next, for a sign-in a command line
363 /// started. Always loopback; see [`OperatorAuth::start`].
364 pub cli_redirect: Option<String>,
365}
366
367/// The identity resolution behind [`OperatorAuth`].
368///
369/// jacquard-identity builds a `did:web` document URL by pasting the
370/// method-specific identifier after `https://`, so `did:web:localhost%3A3415`
371/// becomes a request to a host literally named `localhost%3A3415`. atproto
372/// permits exactly that shape — a port, with its colon percent-encoded —
373/// for `localhost` and nowhere else, "only in testing and development", and
374/// [`didbot_identity::did::AccountDid`] decodes it. This puts that answer in
375/// front of the library's.
376///
377/// Only a DID that carries a port takes this path, and only a bare loopback
378/// hostname may carry one, so a deployment on a public zone never reaches
379/// it. Every such fetch is `https://` like any other.
380struct Resolver {
381 inner: JacquardResolver<reqwest::Client>,
382}
383
384impl Resolver {
385 fn new(http: reqwest::Client) -> Self {
386 Self {
387 inner: JacquardResolver::new(http, ResolverOptions::default()),
388 }
389 }
390
391 /// Where `did`'s document is served, for a `did:web` this resolver reads
392 /// itself rather than handing to the library.
393 fn ported_document_url(&self, did: &str) -> Option<String> {
394 let parsed = didbot_identity::did::AccountDid::parse(did).ok()?;
395 parsed.port().is_some().then(|| parsed.did_json_url())
396 }
397}
398
399impl HttpClient for Resolver {
400 type Error = IdentityError;
401
402 async fn send_http(
403 &self,
404 request: http::Request<Vec<u8>>,
405 ) -> Result<http::Response<Vec<u8>>, Self::Error> {
406 self.inner.send_http(request).await
407 }
408}
409
410impl IdentityResolver for Resolver {
411 fn options(&self) -> &ResolverOptions {
412 self.inner.options()
413 }
414
415 async fn resolve_handle<S: BosStr + Sync>(
416 &self,
417 handle: &Handle<S>,
418 ) -> jacquard_identity::resolver::Result<Did> {
419 self.inner.resolve_handle(handle).await
420 }
421
422 async fn resolve_did_doc<S: BosStr + Sync>(
423 &self,
424 did: &Did<S>,
425 ) -> jacquard_identity::resolver::Result<DidDocResponse> {
426 let Some(url) = self.ported_document_url(did.as_str()) else {
427 return self.inner.resolve_did_doc(did).await;
428 };
429 let request = http::Request::get(&url)
430 .body(Vec::new())
431 .map_err(IdentityError::url)?;
432 let response = self.send_http(request).await?;
433 Ok(DidDocResponse {
434 status: response.status(),
435 buffer: Bytes::from(response.into_body()),
436 requested: Did::new_owned(did.as_str()).ok(),
437 })
438 }
439}
440
441impl DpopExt for Resolver {}
442
443impl OAuthResolver for Resolver {}
444
445/// 256 bits from the OS, hex-encoded. Not a password and never stored
446/// anywhere but this process's memory, so there is nothing to hash.
447fn random_token() -> String {
448 use rand::rngs::OsRng;
449 use rand::TryRngCore;
450 let mut bytes = [0u8; 32];
451 OsRng
452 .try_fill_bytes(&mut bytes)
453 .expect("the OS random source is available");
454 hex::encode(bytes)
455}
456
457/// Whether `redirect` names a loopback address on the machine running the
458/// command — the only place this server will hand a session it just minted.
459///
460/// A command line waiting on `http://127.0.0.1:<port>` is the case this
461/// exists for. Anything else is somebody else's address, and sending an
462/// operator session to it is the whole of an account takeover, so the test
463/// is a allowlist of the three spellings of "here" rather than a search for
464/// anything suspicious.
465fn is_loopback_redirect(redirect: &str) -> bool {
466 let Ok(url) = url::Url::parse(redirect) else {
467 return false;
468 };
469 if url.scheme() != "http" {
470 return false;
471 }
472 matches!(
473 url.host_str(),
474 Some("127.0.0.1") | Some("::1") | Some("[::1]") | Some("localhost")
475 )
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 /// A `did:web` carrying a port is read by this crate rather than by the
483 /// library, which would paste the percent-encoded colon into a
484 /// hostname. Everything else goes to the library.
485 #[test]
486 fn a_did_with_a_port_is_resolved_here_and_nothing_else_is() {
487 let resolver = Resolver::new(didbot_http::client());
488 assert_eq!(
489 resolver
490 .ported_document_url("did:web:localhost%3A3400")
491 .as_deref(),
492 Some("https://localhost:3400/.well-known/did.json")
493 );
494 assert_eq!(
495 resolver.ported_document_url("did:web:kestrel.pds.example"),
496 None
497 );
498 assert_eq!(
499 resolver.ported_document_url("did:plc:ewvi7nxzyoun6zhxrhs64oiz"),
500 None
501 );
502 }
503
504 /// The allowlist is the security property: a sign-in that can be
505 /// redirected to an attacker's address hands them an operator session,
506 /// which is every operator control at once.
507 ///
508 /// Fails if the scheme check is dropped (the `https://evil` case reads
509 /// as a host check alone), or if the host check is loosened to a
510 /// substring or suffix match — which is what `127.0.0.1.evil.example`
511 /// and `evil.example/127.0.0.1` are here to catch.
512 #[test]
513 fn only_a_loopback_address_may_receive_a_session() {
514 for ok in [
515 "http://127.0.0.1:1410/done",
516 "http://localhost:9/done",
517 "http://[::1]:1410/done",
518 ] {
519 assert!(is_loopback_redirect(ok), "{ok} is this machine");
520 }
521 for refused in [
522 "https://evil.example/done",
523 "http://evil.example/done",
524 "http://127.0.0.1.evil.example/done",
525 "http://evil.example/127.0.0.1",
526 "http://evil.example#127.0.0.1",
527 "http://user@evil.example/done",
528 "ftp://127.0.0.1/done",
529 "not a url at all",
530 "",
531 ] {
532 assert!(
533 !is_loopback_redirect(refused),
534 "{refused} is not this machine"
535 );
536 }
537 }
538}