diff --git a/Cargo.lock b/Cargo.lock index e37d4b79..357ee2f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -914,6 +914,7 @@ name = "bobbin-xrpc" version = "0.0.1" dependencies = [ "axum", + "base64", "bobbin-edge-index", "bobbin-knot-proxy", "bobbin-record-lru", @@ -928,6 +929,7 @@ dependencies = [ "jacquard-common", "jacquard-identity", "reqwest 0.13.1", + "scc", "serde", "serde_json", "thiserror 2.0.18", diff --git a/bobbin/crates/bobbin/src/main.rs b/bobbin/crates/bobbin/src/main.rs index 5f10bb3e..ab513ef2 100644 --- a/bobbin/crates/bobbin/src/main.rs +++ b/bobbin/crates/bobbin/src/main.rs @@ -11,17 +11,17 @@ use bobbin_ingest::{ IngestConfig, IngestRuntime, RepoIdResolver, WarmingBuffer, run as run_ingest, }; use bobbin_knot_ingest::{CapabilityGate, KnotClient, KnotRegistry, Orchestrator}; -use bobbin_knot_proxy::{KnotHttpConfig, KnotProxy, KnotProxyConfig, classify_ip}; +use bobbin_knot_proxy::{KnotHttpConfig, KnotProxy, KnotProxyConfig}; use bobbin_record_lru::{CacheCapacity, LruRecordStore, RecordStore}; use bobbin_runtime::{ Clock, GuardedWs, MemoryBudget, NetworkError, OsEntropy, ReqwestHttp, RuntimeHasher, - SystemClock, TungsteniteWs, WsTransport, + SystemClock, TungsteniteWs, WsTransport, classify_ip, }; use bobbin_search::{SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_slingshot_client::default_http_client; use bobbin_xrpc::{ - AppState, HeavyLimiter, MaxInFlight, PerRequestAnonBytes, ReservedFloor, router, + AppState, HeavyLimiter, MaxInFlight, PerRequestAnonBytes, ReservedFloor, ServiceAuth, router, }; use clap::{Parser, Subcommand}; use jacquard_common::deps::fluent_uri::Uri; @@ -219,8 +219,18 @@ async fn run(cfg: BobbinConfig) -> anyhow::Result<()> { }, KnotHttpConfig::default(), clock.clone(), - hasher, + hasher.clone(), )?); + let service_auth = Arc::new( + ServiceAuth::new( + cfg.server.hostname.trim(), + clock.clone(), + directory.clone(), + hasher.clone(), + ) + .map_err(|e| anyhow!("service auth: {e}"))?, + ); + tracing::info!(audience = %service_auth.audience().as_str(), "service auth enabled"); let search_heap = usize::try_from(search_heap_cap) .with_context(|| format!("search heap {search_heap_cap} exceeds usize"))?; let search = Arc::new(SearchIndex::new(search_heap, clock.clone())?); @@ -336,6 +346,7 @@ async fn run(cfg: BobbinConfig) -> anyhow::Result<()> { search as Arc, resolver, directory, + service_auth, ) .with_limiter(limiter); let app = router(state); diff --git a/bobbin/crates/knot-proxy/src/host.rs b/bobbin/crates/knot-proxy/src/host.rs index 7374c6a5..5ca2965d 100644 --- a/bobbin/crates/knot-proxy/src/host.rs +++ b/bobbin/crates/knot-proxy/src/host.rs @@ -1,10 +1,9 @@ -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - +pub use bobbin_runtime::{PrivateHostReason, classify_ip, private_host_reason}; use jacquard_common::BosStr; use jacquard_common::types::did::Did; use jacquard_common::types::nsid::Nsid; use thiserror::Error; -use url::{Host, Url}; +use url::Url; #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct KnotHost(Url); @@ -59,109 +58,10 @@ impl KnotHost { } pub fn private_literal_reason(&self) -> Option { - match self.0.host()? { - Host::Ipv4(ip) => classify_v4(ip), - Host::Ipv6(ip) => classify_v6(ip), - Host::Domain(name) if is_loopback_domain(name) => Some(PrivateHostReason::Loopback), - Host::Domain(_) => None, - } - } -} - -pub fn classify_ip(ip: &IpAddr) -> Option { - match ip { - IpAddr::V4(v4) => classify_v4(*v4), - IpAddr::V6(v6) => classify_v6(*v6), - } -} - -fn is_loopback_domain(name: &str) -> bool { - name.eq_ignore_ascii_case("localhost") - || name - .rsplit_once('.') - .is_some_and(|(_, label)| label.eq_ignore_ascii_case("localhost")) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum PrivateHostReason { - Loopback, - Private, - LinkLocal, - Unspecified, - Multicast, - Broadcast, - Documentation, - UniqueLocal, -} - -impl std::fmt::Display for PrivateHostReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Loopback => "loopback", - Self::Private => "private", - Self::LinkLocal => "link-local", - Self::Unspecified => "unspecified", - Self::Multicast => "multicast", - Self::Broadcast => "broadcast", - Self::Documentation => "documentation", - Self::UniqueLocal => "unique-local", - }) + self.0.host().and_then(private_host_reason) } } -fn classify_v4(ip: Ipv4Addr) -> Option { - if ip.is_loopback() { - Some(PrivateHostReason::Loopback) - } else if ip.is_private() { - Some(PrivateHostReason::Private) - } else if ip.is_link_local() { - Some(PrivateHostReason::LinkLocal) - } else if ip.is_unspecified() { - Some(PrivateHostReason::Unspecified) - } else if ip.is_broadcast() { - Some(PrivateHostReason::Broadcast) - } else if ip.is_multicast() { - Some(PrivateHostReason::Multicast) - } else if ip.is_documentation() { - Some(PrivateHostReason::Documentation) - } else if is_v4_carrier_grade_nat(ip) { - Some(PrivateHostReason::Private) - } else { - None - } -} - -fn is_v4_carrier_grade_nat(ip: Ipv4Addr) -> bool { - let [a, b, ..] = ip.octets(); - a == 100 && (0x40..=0x7f).contains(&b) -} - -fn classify_v6(ip: Ipv6Addr) -> Option { - if ip.is_loopback() { - Some(PrivateHostReason::Loopback) - } else if ip.is_unspecified() { - Some(PrivateHostReason::Unspecified) - } else if ip.is_multicast() { - Some(PrivateHostReason::Multicast) - } else if is_v6_link_local(ip) { - Some(PrivateHostReason::LinkLocal) - } else if is_v6_unique_local(ip) { - Some(PrivateHostReason::UniqueLocal) - } else if let Some(v4) = ip.to_ipv4_mapped() { - classify_v4(v4) - } else { - None - } -} - -fn is_v6_link_local(ip: Ipv6Addr) -> bool { - ip.segments()[0] & 0xffc0 == 0xfe80 -} - -fn is_v6_unique_local(ip: Ipv6Addr) -> bool { - ip.segments()[0] & 0xfe00 == 0xfc00 -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct RepoSlug(String); diff --git a/bobbin/crates/runtime/src/host.rs b/bobbin/crates/runtime/src/host.rs new file mode 100644 index 00000000..ae772aca --- /dev/null +++ b/bobbin/crates/runtime/src/host.rs @@ -0,0 +1,137 @@ +//! Whether a host is publicly routable. Shared by every outbound path that takes a host from +//! an untrusted source: the knot proxy, knot ingest, and did:web issuer verification. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use url::Host; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrivateHostReason { + Loopback, + Private, + LinkLocal, + Unspecified, + Multicast, + Broadcast, + Documentation, + UniqueLocal, +} + +impl std::fmt::Display for PrivateHostReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Loopback => "loopback", + Self::Private => "private", + Self::LinkLocal => "link-local", + Self::Unspecified => "unspecified", + Self::Multicast => "multicast", + Self::Broadcast => "broadcast", + Self::Documentation => "documentation", + Self::UniqueLocal => "unique-local", + }) + } +} + +/// Why this host isn't publicly routable, or `None` if it looks fine. +/// +/// Literal addresses and `localhost` only. A public name that resolves into private space is not +/// caught here — that needs a dial-time check, see [`crate::AddrGuard`] and [`classify_ip`]. +pub fn private_host_reason>(host: Host) -> Option { + match host { + Host::Ipv4(ip) => classify_v4(ip), + Host::Ipv6(ip) => classify_v6(ip), + Host::Domain(name) => { + is_loopback_domain(name.as_ref()).then_some(PrivateHostReason::Loopback) + } + } +} + +pub fn classify_ip(ip: &IpAddr) -> Option { + match ip { + IpAddr::V4(v4) => classify_v4(*v4), + IpAddr::V6(v6) => classify_v6(*v6), + } +} + +fn is_loopback_domain(name: &str) -> bool { + name.eq_ignore_ascii_case("localhost") + || name + .rsplit_once('.') + .is_some_and(|(_, label)| label.eq_ignore_ascii_case("localhost")) +} + +fn classify_v4(ip: Ipv4Addr) -> Option { + if ip.is_loopback() { + Some(PrivateHostReason::Loopback) + } else if ip.is_private() { + Some(PrivateHostReason::Private) + } else if ip.is_link_local() { + Some(PrivateHostReason::LinkLocal) + } else if ip.is_unspecified() { + Some(PrivateHostReason::Unspecified) + } else if ip.is_broadcast() { + Some(PrivateHostReason::Broadcast) + } else if ip.is_multicast() { + Some(PrivateHostReason::Multicast) + } else if ip.is_documentation() { + Some(PrivateHostReason::Documentation) + } else if is_v4_carrier_grade_nat(ip) { + Some(PrivateHostReason::Private) + } else { + None + } +} + +fn is_v4_carrier_grade_nat(ip: Ipv4Addr) -> bool { + let [a, b, ..] = ip.octets(); + a == 100 && (0x40..=0x7f).contains(&b) +} + +fn classify_v6(ip: Ipv6Addr) -> Option { + if ip.is_loopback() { + Some(PrivateHostReason::Loopback) + } else if ip.is_unspecified() { + Some(PrivateHostReason::Unspecified) + } else if ip.is_multicast() { + Some(PrivateHostReason::Multicast) + } else if is_v6_link_local(ip) { + Some(PrivateHostReason::LinkLocal) + } else if is_v6_unique_local(ip) { + Some(PrivateHostReason::UniqueLocal) + } else if let Some(v4) = ip.to_ipv4_mapped() { + classify_v4(v4) + } else { + None + } +} + +fn is_v6_link_local(ip: Ipv6Addr) -> bool { + ip.segments()[0] & 0xffc0 == 0xfe80 +} + +fn is_v6_unique_local(ip: Ipv6Addr) -> bool { + ip.segments()[0] & 0xfe00 == 0xfc00 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One case per arm of the match; the range classification itself is covered by the + /// `KnotHost::private_literal_reason` tests in bobbin-knot-proxy. + #[test] + fn classifies_literals_and_the_localhost_name() { + let reason = |host: &str| private_host_reason(Host::parse(host).expect("host parses")); + + assert_eq!(reason("127.0.0.1"), Some(PrivateHostReason::Loopback)); + assert_eq!(reason("[::1]"), Some(PrivateHostReason::Loopback)); + assert_eq!(reason("LOCALHOST"), Some(PrivateHostReason::Loopback)); + assert_eq!(reason("internal.localhost"), Some(PrivateHostReason::Loopback)); + assert_eq!(reason("bobbin.example"), None); + assert_eq!( + reason("localhost.attacker.example"), + None, + "only the last label counts" + ); + } +} diff --git a/bobbin/crates/runtime/src/lib.rs b/bobbin/crates/runtime/src/lib.rs index c8cb3809..a07074ef 100644 --- a/bobbin/crates/runtime/src/lib.rs +++ b/bobbin/crates/runtime/src/lib.rs @@ -1,6 +1,7 @@ mod clock; mod entropy; mod hasher; +mod host; mod mem; mod mem_network; mod network; @@ -8,6 +9,7 @@ mod network; pub use clock::{Clock, SimClock, SleepFuture, SystemClock, UnixMicros}; pub use entropy::{Entropy, OsEntropy, SeededEntropy}; pub use hasher::RuntimeHasher; +pub use host::{PrivateHostReason, classify_ip, private_host_reason}; pub use mem::MemoryBudget; pub use mem_network::{ DEFAULT_MEM_WS_CAPACITY, MemHttpBody, MemHttpResponder, MemHttpResponse, MemHttpTransport, diff --git a/bobbin/crates/xrpc/Cargo.toml b/bobbin/crates/xrpc/Cargo.toml index cadeb571..d32244d5 100644 --- a/bobbin/crates/xrpc/Cargo.toml +++ b/bobbin/crates/xrpc/Cargo.toml @@ -21,6 +21,7 @@ axum = { workspace = true } chrono = { workspace = true } futures = { workspace = true } http = { workspace = true } +scc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -31,6 +32,7 @@ url = { workspace = true } reqwest = { workspace = true } [dev-dependencies] +base64 = { workspace = true } bobbin-runtime = { workspace = true } http = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/bobbin/crates/xrpc/src/auth.rs b/bobbin/crates/xrpc/src/auth.rs new file mode 100644 index 00000000..acd0402e --- /dev/null +++ b/bobbin/crates/xrpc/src/auth.rs @@ -0,0 +1,425 @@ +use std::sync::Arc; + +use axum::extract::{FromRequestParts, MatchedPath}; +use axum::http::HeaderMap; +use axum::http::header::AUTHORIZATION; +use axum::http::request::Parts; +use bobbin_runtime::{Clock, RuntimeHasher, private_host_reason}; +use jacquard_common::DefaultStr; +use jacquard_common::service_auth::{self, ParsedJwt}; +use jacquard_common::types::crypto::{KeyCodec, PublicKey as CryptoKey}; +use jacquard_common::types::did::Did; +use jacquard_common::types::nsid::Nsid; +use jacquard_identity::resolver::{IdentityResolver, MiniDoc}; +use scc::HashMap as SccMap; +use url::Url; + +use crate::{AppState, Directory, XrpcError}; + +const CLOCK_SKEW_SECS: i64 = 60; +const MAX_TOKEN_LIFETIME_SECS: i64 = 300; +const MAX_NONCE_BYTES: usize = 256; +/// Replay store ceiling. Fails closed when hit, so it can't be used to disable replay checks. +const MAX_SEEN_JTI: usize = 8192; +/// No single issuer may occupy more than this share of the replay store. +const MAX_JTI_PER_ISSUER: usize = MAX_SEEN_JTI / 16; + +/// Verifies inbound service-auth tokens addressed to this instance. +pub struct ServiceAuth { + audience: Did, + clock: Arc, + directory: Arc, + /// (issuer, jti) -> unix seconds after which the entry may be pruned. + seen_jti: SccMap<(Did, String), i64, RuntimeHasher>, + issuer_jti_counts: SccMap, usize, RuntimeHasher>, +} + +impl ServiceAuth { + /// `hostname` is the public host clients reach us on; the audience is its `did:web`. + pub fn new( + hostname: &str, + clock: Arc, + directory: Arc, + hasher: RuntimeHasher, + ) -> Result { + Ok(Self { + audience: did_web(hostname)?, + clock, + directory, + seen_jti: SccMap::with_hasher(hasher.clone()), + issuer_jti_counts: SccMap::with_hasher(hasher), + }) + } + + pub fn audience(&self) -> &Did { + &self.audience + } + + /// Returns the verified issuer, or the error the caller should see. + async fn verify_service_jwt( + &self, + token: &str, + method: &Nsid, + ) -> Result, XrpcError> { + let parsed = service_auth::parse_jwt(token) + .map_err(|e| XrpcError::AuthRequired(format!("malformed token: {e}")))?; + if !parsed.header().typ.as_str().eq_ignore_ascii_case("JWT") { + return Err(XrpcError::AuthRequired("token is not a JWT".into())); + } + let issuer = parsed.claims().iss.clone(); + let now_micros = self.clock.now_unix_micros(); + let now = (now_micros.raw() / 1_000_000) as i64; + + jwt_check_claims(&parsed, &self.audience, method, now)?; + let jti = jwt_nonce(&parsed)?; + + self.guard_issuer_host(&issuer)?; + let key = issuer_key(&self.directory, &issuer).await?; + service_auth::verify_signature(&parsed, &key) + .map_err(|e| XrpcError::AuthRequired(format!("signature: {e}")))?; + + // always guard by single use + self.record_jti(&issuer, jti, parsed.claims().exp, now)?; + Ok(issuer) + } + + /// `iss` is attacker-controlled and `did:web` resolution fetches from it, so a private-host + /// issuer would turn every unauthenticated request into an ssrf probe. + fn guard_issuer_host(&self, issuer: &Did) -> Result<(), XrpcError> { + let Some(host) = did_web_host(issuer) else { + return Ok(()); + }; + // the url did:web resolution will fetch, so we classify the host it would dial + let url = Url::parse(&format!("https://{host}/")) + .map_err(|e| XrpcError::AuthRequired(format!("issuer host {host}: {e}")))?; + let reason = url + .host() + .ok_or_else(|| XrpcError::AuthRequired(format!("issuer host {host} has no authority"))) + .map(private_host_reason)?; + match reason { + None => Ok(()), + Some(reason) => Err(XrpcError::AuthRequired(format!( + "issuer host {host} is not publicly routable: {reason}" + ))), + } + } + + /// Single-use nonces, keyed by issuer so two issuers may legally pick the same one. + fn record_jti( + &self, + issuer: &Did, + jti: String, + exp: i64, + now: i64, + ) -> Result<(), XrpcError> { + let horizon = exp.saturating_add(CLOCK_SKEW_SECS); + if self.seen_jti.len() >= MAX_SEEN_JTI { + self.prune(now); + } + if self.seen_jti.len() >= MAX_SEEN_JTI { + return Err(XrpcError::Overloaded); + } + if self.issuer_share(issuer) >= MAX_JTI_PER_ISSUER { + self.prune(now); + if self.issuer_share(issuer) >= MAX_JTI_PER_ISSUER { + return Err(XrpcError::Overloaded); + } + } + self.seen_jti + .insert_sync((issuer.clone(), jti), horizon) + .map_err(|_| XrpcError::AuthRequired("token replayed".into()))?; + self.issuer_jti_counts + .entry_sync(issuer.clone()) + .and_modify(|count| *count += 1) + .or_insert(1); + Ok(()) + } + + fn prune(&self, now: i64) { + self.seen_jti.retain_sync(|_, horizon| *horizon > now); + self.issuer_jti_counts.clear_sync(); + let counts = &self.issuer_jti_counts; + self.seen_jti.iter_sync(|(issuer, _), _| { + counts + .entry_sync(issuer.clone()) + .and_modify(|count| *count += 1) + .or_insert(1); + true + }); + } + + fn issuer_share(&self, issuer: &Did) -> usize { + self.issuer_jti_counts + .read_sync(issuer, |_, count| *count) + .unwrap_or(0) + } +} + +/// `did:web` for a hostname, with the port colon percent-encoded per the did:web spec. +/// Matches `serviceauth.DidWeb` on the go side and `serviceDidForHost` in the web client. +fn did_web(hostname: &str) -> Result, XrpcError> { + let encoded = hostname.trim().to_ascii_lowercase().replace(':', "%3A"); + Did::new_owned(format!("did:web:{encoded}")) + .map_err(|e| XrpcError::Internal(format!("hostname {hostname:?} is not a did:web: {e}"))) +} + +/// The authority of a `did:web`, with `%3A` decoded back into a port separator. +fn did_web_host(did: &Did) -> Option { + let rest = did.as_str().strip_prefix("did:web:")?; + let authority = rest.split(':').next().unwrap_or(rest); + Some(authority.replace("%3A", ":").replace("%3a", ":")) +} + +/// Resolves the issuer's atproto signing key, via the shared cached identity resolver. +async fn issuer_key( + directory: &Directory, + issuer: &Did, +) -> Result { + let resp = directory.resolve_did_doc(issuer).await.map_err(|e| { + XrpcError::UpstreamUnavailable(format!("resolving issuer {}: {e}", issuer.as_str())) + })?; + // parse_validated enforces doc.id == issuer, which also covers the mini-doc branch below + let doc = resp + .parse_validated() + .map_err(|e| XrpcError::AuthRequired(format!("issuer document: {e}")))?; + + let key = match doc.atproto_public_key() { + Ok(Some(key)) => key, + // A slingshot mini-doc carries `signingKey` but no verification methods, and jacquard + // drops it when it synthesizes the document. That is the path every did:plc issuer + // takes here, so read the key off the raw response instead. + _ => serde_json::from_slice::(&resp.buffer) + .ok() + .and_then(|mini| CryptoKey::decode_owned(mini.signing_key.as_ref()).ok()) + .ok_or_else(|| { + XrpcError::AuthRequired(format!( + "issuer {} publishes no atproto signing key", + issuer.as_str(), + )) + })?, + }; + + match key.codec { + KeyCodec::Secp256k1 => service_auth::PublicKey::from_k256_bytes(&key.bytes), + KeyCodec::P256 => service_auth::PublicKey::from_p256_bytes(&key.bytes), + other => { + return Err(XrpcError::AuthRequired(format!( + "unsupported issuer key codec {other:?}" + ))); + } + } + .map_err(|e| XrpcError::AuthRequired(format!("issuer key: {e}"))) +} + +fn jwt_check_claims( + parsed: &ParsedJwt, + audience: &Did, + method: &Nsid, + now_unix: i64, +) -> Result<(), XrpcError> { + let claims = parsed.claims(); + if claims.aud.audience().as_str() != audience.as_str() { + return Err(XrpcError::AuthRequired(format!( + "token addressed to {}, expected {}", + claims.aud.as_str(), + audience.as_str(), + ))); + } + if claims.exp.saturating_add(CLOCK_SKEW_SECS) < now_unix { + return Err(XrpcError::AuthRequired(format!( + "token expired at {}, now {now_unix}", + claims.exp, + ))); + } + if claims.iat.saturating_sub(CLOCK_SKEW_SECS) > now_unix { + return Err(XrpcError::AuthRequired(format!( + "token issued at {}, now {now_unix}", + claims.iat, + ))); + } + if claims.exp < claims.iat { + return Err(XrpcError::AuthRequired("token expires before issue".into())); + } + if claims.exp.saturating_sub(claims.iat) > MAX_TOKEN_LIFETIME_SECS { + return Err(XrpcError::AuthRequired(format!( + "token lifetime exceeds {MAX_TOKEN_LIFETIME_SECS}s", + ))); + } + + let bound = claims.lxm.as_ref().map(|lxm| lxm.as_str()); + if bound != Some(method.as_str()) { + return Err(XrpcError::AuthRequired(format!( + "token bound to {}, expected {}", + bound.unwrap_or("nothing"), + method.as_str(), + ))); + } + + Ok(()) +} + +fn jwt_nonce(parsed: &ParsedJwt) -> Result { + let jti = parsed + .claims() + .jti + .as_ref() + .ok_or_else(|| XrpcError::AuthRequired("token has no jti".into()))?; + if jti.as_str().len() > MAX_NONCE_BYTES { + return Err(XrpcError::AuthRequired("token jti is oversized".into())); + } + Ok(jti.as_str().to_owned()) +} + +fn bearer(headers: &HeaderMap) -> Option<&str> { + let value = headers.get(AUTHORIZATION)?.to_str().ok()?; + let (scheme, token) = value.split_once(' ')?; + scheme + .eq_ignore_ascii_case("Bearer") + .then(|| token.trim()) + .filter(|token| !token.is_empty()) +} + +/// The route path is the lexicon method, so there is no per-handler nsid constant to drift. +fn matched_nsid(parts: &Parts) -> Result, XrpcError> { + let matched = parts.extensions.get::().ok_or_else(|| { + XrpcError::Internal("xrpc handler reached without a matched route".into()) + })?; + let nsid = matched + .as_str() + .strip_prefix("/xrpc/") + .ok_or_else(|| XrpcError::Internal("xrpc route paths are prefixed with /xrpc/".into()))?; + Nsid::new_owned(nsid).map_err(|e| XrpcError::Internal(format!("route nsid {nsid}: {e}"))) +} + +/// The verified caller, if it presented a token. `None` is anonymous, which stays a valid way to +/// read public data; a token that fails verification is a 401 rather than a silent downgrade. +pub(crate) struct Viewer(pub(crate) Option>); + +impl FromRequestParts for Viewer { + type Rejection = XrpcError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let Some(token) = bearer(&parts.headers) else { + return Ok(Self(None)); + }; + let method = matched_nsid(parts)?; + state + .service_auth + .verify_service_jwt(token, &method) + .await + .map(|did| { + tracing::debug!(viewer = %did.as_str(), method = %method.as_str(), "service auth ok"); + Self(Some(did)) + }) + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use bobbin_runtime::{SeededEntropy, SystemClock}; + use serde_json::json; + + use super::*; + + fn auth(allow_private: bool) -> ServiceAuth { + let hasher = RuntimeHasher::from_entropy(&SeededEntropy::new(7)); + ServiceAuth::new( + "bobbin.example", + Arc::new(SystemClock::new()), + Arc::new(crate::default_directory()), + hasher, + ) + .unwrap() + } + + /// `check_claims` never reaches the signature, so an unsigned token is enough for it. + fn addressed_to(aud: &str) -> ParsedJwt { + let b64 = |value: &serde_json::Value| { + URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).expect("test claims serialize")) + }; + let header = b64(&json!({ "alg": "ES256K", "typ": "JWT" })); + let claims = b64(&json!({ + "iss": "did:plc:issuer", + "aud": aud, + "iat": 1_000, + "exp": 1_060, + "jti": "nonce-1", + "lxm": "sh.tangled.feed.getTimeline", + })); + let signature = URL_SAFE_NO_PAD.encode([0u8; 64]); + service_auth::parse_jwt(&format!("{header}.{claims}.{signature}")) + .expect("hand-built jwt parses") + } + + #[test] + fn a_service_id_fragment_still_addresses_us() { + let auth = auth(false); + let method = Nsid::new_owned("sh.tangled.feed.getTimeline").unwrap(); + let check = |aud: &str| jwt_check_claims(&addressed_to(aud), &auth.audience, &method, 1_000); + + check("did:web:bobbin.example").unwrap(); + check("did:web:bobbin.example#bobbin_appview") + .expect("a fragment names which of our services, not a different audience"); + assert!(check("did:web:other.example").is_err()); + assert!( + check("did:web:other.example#bobbin_appview").is_err(), + "a fragment must not let a token addressed elsewhere through" + ); + } + + #[test] + fn private_issuer_hosts_are_refused() { + let strict = auth(false); + let guard = |host: &str| strict.guard_issuer_host(&did_web(host).unwrap()); + + guard("bobbin.example").unwrap(); + strict + .guard_issuer_host(&Did::new_owned("did:plc:abc").unwrap()) + .expect("a did:plc issuer has no host we fetch from"); + assert!(guard("localhost:8090").is_err()); + assert!(guard("127.0.0.1").is_err()); + assert!(guard("10.0.0.1").is_err()); + assert!(guard("169.254.169.254").is_err(), "cloud metadata endpoint"); + + let localinfra = auth(true); + localinfra + .guard_issuer_host(&did_web("localhost:8090").unwrap()) + .expect("localinfra issues did:web:localhost"); + } + + #[test] + fn did_web_encodes_ports_and_lowercases() { + assert_eq!( + did_web("Bobbin.Example").unwrap().as_str(), + "did:web:bobbin.example" + ); + assert_eq!( + did_web("localhost:8090").unwrap().as_str(), + "did:web:localhost%3A8090", + ); + } + + #[test] + fn did_web_host_round_trips_through_the_audience_encoding() { + let did = did_web("localhost:8090").unwrap(); + assert_eq!(did_web_host(&did).as_deref(), Some("localhost:8090")); + let plc = Did::new_owned("did:plc:abc").unwrap(); + assert_eq!(did_web_host(&plc), None); + } + + #[test] + fn bearer_is_scheme_insensitive_and_rejects_empties() { + let mut headers = HeaderMap::new(); + headers.insert(AUTHORIZATION, "bearer tok.en.sig ".parse().unwrap()); + assert_eq!(bearer(&headers), Some("tok.en.sig")); + headers.insert(AUTHORIZATION, "Bearer ".parse().unwrap()); + assert_eq!(bearer(&headers), None); + headers.insert(AUTHORIZATION, "Basic abc".parse().unwrap()); + assert_eq!(bearer(&headers), None); + } +} diff --git a/bobbin/crates/xrpc/src/feed.rs b/bobbin/crates/xrpc/src/feed.rs index 4a78c416..ac3dbb47 100644 --- a/bobbin/crates/xrpc/src/feed.rs +++ b/bobbin/crates/xrpc/src/feed.rs @@ -4,7 +4,7 @@ use futures::stream::{self, StreamExt}; use serde::Deserialize; use bobbin_edge_index::{EdgeItem, EdgePage, EdgeStore, PageCursor, PageLimit, PageToken, SortDir}; -use bobbin_types::ids::{nsid_static, owner_did_from_aturi, EdgeKey, SubjectRef}; +use bobbin_types::ids::{EdgeKey, SubjectRef, nsid_static, owner_did_from_aturi}; use bobbin_types::sh_tangled::actor::{ProfileViewBasic, ProfileViewDetailed, ViewerState}; use bobbin_types::sh_tangled::feed::get_timeline::{ FollowEvent, RepoEvent, StarEvent, TimelineItem, TimelineItemEvent, @@ -12,13 +12,14 @@ use bobbin_types::sh_tangled::feed::get_timeline::{ use bobbin_types::sh_tangled::feed::star::{Star, StarRecord, StarSubject}; use bobbin_types::sh_tangled::graph::follow::{Follow, FollowRecord}; use bobbin_types::sh_tangled::repo::{self, Repo, RepoRecord, RepoViewBasic}; +use jacquard_common::DefaultStr; use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue}; use jacquard_common::xrpc::XrpcResp; -use jacquard_common::DefaultStr; use jacquard_identity::resolver::IdentityResolver; +use crate::auth::Viewer; use crate::{ - fetch, json_stream, paged_tail, parse_cursor, parse_limit, AppState, XrpcError, XrpcQuery, + AppState, XrpcError, XrpcQuery, fetch, json_stream, paged_tail, parse_cursor, parse_limit, }; const REPO_NSID: &str = "sh.tangled.repo"; @@ -32,7 +33,6 @@ const HYDRATE_CONCURRENCY: usize = 8; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct GetTimelineQuery { - viewer: Option>, #[serde(default)] following_only: bool, limit: Option, @@ -41,18 +41,19 @@ pub(crate) struct GetTimelineQuery { pub(crate) async fn get_timeline( State(state): State, + Viewer(viewer): Viewer, XrpcQuery(q): XrpcQuery, ) -> Result { let limit = parse_limit(q.limit)?; let cursor = parse_cursor(q.cursor.as_deref())?; let permit = state.heavy_permit()?; - let viewer = q.viewer.as_ref(); + let viewer = viewer.as_ref(); // Prepare timeline skeleton let page = if q.following_only { // Following feed: the viewer's followed set, then a time-ordered k-way merge. let viewer = viewer.ok_or_else(|| { - XrpcError::InvalidParams("followingOnly requires a viewer did".into()) + XrpcError::AuthRequired("followingOnly requires an authenticated viewer".into()) })?; let followed = followed_dids(&state, viewer).await; following_skeleton(&state, &followed, cursor, limit) diff --git a/bobbin/crates/xrpc/src/lib.rs b/bobbin/crates/xrpc/src/lib.rs index db1e9643..ada97c58 100644 --- a/bobbin/crates/xrpc/src/lib.rs +++ b/bobbin/crates/xrpc/src/lib.rs @@ -99,12 +99,14 @@ use tower_http::classify::ServerErrorsFailureClass; use tower_http::trace::{DefaultMakeSpan, OnFailure, OnResponse, TraceLayer}; use tracing::{Level, Span}; +mod auth; mod backpressure; mod enrich; mod feed; mod filter; mod recordpath; +pub use auth::ServiceAuth; pub use backpressure::{ HeavyLimiter, HeavyPermit, MaxInFlight, PerRequestAnonBytes, PressureVerdict, ReservedFloor, }; @@ -132,6 +134,7 @@ pub struct AppState { pub resolver: Arc, pub directory: Arc, pub limiter: Option>, + pub service_auth: Arc, enrich_router: Arc>, } @@ -148,6 +151,7 @@ impl AppState { search: Arc, resolver: Arc, directory: Arc, + service_auth: Arc, ) -> Self { Self { records, @@ -161,6 +165,7 @@ impl AppState { resolver, directory, limiter: None, + service_auth, enrich_router: Arc::new(std::sync::OnceLock::new()), } } @@ -740,6 +745,8 @@ where pub enum XrpcError { #[error("invalid request: {0}")] InvalidParams(String), + #[error("authentication required: {0}")] + AuthRequired(String), #[error("record not found")] NotFound, #[error("upstream unavailable: {0}")] @@ -770,6 +777,7 @@ impl IntoResponse for XrpcError { fn into_response(self) -> Response { let (status, error) = match &self { Self::InvalidParams(_) => (StatusCode::BAD_REQUEST, "InvalidRequest"), + Self::AuthRequired(_) => (StatusCode::UNAUTHORIZED, "AuthenticationRequired"), Self::NotFound => (StatusCode::NOT_FOUND, "RecordNotFound"), Self::UpstreamUnavailable(_) => (StatusCode::BAD_GATEWAY, "UpstreamFailed"), Self::UpstreamGone(_) => (StatusCode::BAD_GATEWAY, "UpstreamGone"), @@ -1626,7 +1634,9 @@ fn drop_unhydratable( Ok(None) } }, - Err(err @ (XrpcError::Internal(_) | XrpcError::Overloaded)) => Err(err), + Err( + err @ (XrpcError::Internal(_) | XrpcError::Overloaded | XrpcError::AuthRequired(_)), + ) => Err(err), } } diff --git a/bobbin/crates/xrpc/tests/aggregation.rs b/bobbin/crates/xrpc/tests/aggregation.rs index 51cf9865..791e45ae 100644 --- a/bobbin/crates/xrpc/tests/aggregation.rs +++ b/bobbin/crates/xrpc/tests/aggregation.rs @@ -13,7 +13,7 @@ use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_types::edges::Edge; use bobbin_types::ids::SubjectRef; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use futures::stream::{self, StreamExt}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; @@ -72,6 +72,7 @@ impl Harness { let issue_states = Arc::new(StateIndex::new(RuntimeHasher::default())); let pull_statuses = Arc::new(StateIndex::new(RuntimeHasher::default())); let coverage = Arc::new(CoverageWatch::new()); + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), @@ -92,7 +93,16 @@ impl Harness { SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { server, diff --git a/bobbin/crates/xrpc/tests/bulk.rs b/bobbin/crates/xrpc/tests/bulk.rs index 7e57e906..2363814b 100644 --- a/bobbin/crates/xrpc/tests/bulk.rs +++ b/bobbin/crates/xrpc/tests/bulk.rs @@ -8,7 +8,7 @@ use bobbin_resolver::RepoIdResolver; use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; @@ -49,6 +49,7 @@ impl Harness { async fn new() -> Self { let server = MockServer::start().await; let coverage = Arc::new(CoverageWatch::new()); + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), @@ -69,7 +70,16 @@ impl Harness { SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { server, state } } diff --git a/bobbin/crates/xrpc/tests/cold_start.rs b/bobbin/crates/xrpc/tests/cold_start.rs index 73191213..0e6a0334 100644 --- a/bobbin/crates/xrpc/tests/cold_start.rs +++ b/bobbin/crates/xrpc/tests/cold_start.rs @@ -8,7 +8,7 @@ use bobbin_resolver::RepoIdResolver; use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use futures::stream::{self, StreamExt}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; @@ -37,6 +37,7 @@ fn nsid(s: &'static str) -> Nsid { } async fn fresh_app(server_uri: &Url) -> AppState { + let directory = Arc::new(bobbin_xrpc::default_directory()); AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(server_uri.clone()).unwrap(), @@ -56,7 +57,16 @@ async fn fresh_app(server_uri: &Url) -> AppState { Arc::new(SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap()) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ) } diff --git a/bobbin/crates/xrpc/tests/coverage.rs b/bobbin/crates/xrpc/tests/coverage.rs index 7a2cedc8..c71e2aa7 100644 --- a/bobbin/crates/xrpc/tests/coverage.rs +++ b/bobbin/crates/xrpc/tests/coverage.rs @@ -8,7 +8,7 @@ use bobbin_resolver::RepoIdResolver; use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use http::{Request, StatusCode}; use serde_json::{Value, json}; use tower::ServiceExt; @@ -24,6 +24,7 @@ impl Harness { async fn new() -> Self { let server = MockServer::start().await; let coverage = Arc::new(CoverageWatch::new()); + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), @@ -44,7 +45,16 @@ impl Harness { SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { coverage, state } } diff --git a/bobbin/crates/xrpc/tests/enrich.rs b/bobbin/crates/xrpc/tests/enrich.rs index 57cf0770..45f7dc53 100644 --- a/bobbin/crates/xrpc/tests/enrich.rs +++ b/bobbin/crates/xrpc/tests/enrich.rs @@ -10,7 +10,7 @@ use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_types::edges::Edge; use bobbin_types::ids::SubjectRef; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; @@ -53,6 +53,7 @@ impl Harness { let server = MockServer::start().await; let edges = Arc::new(EdgeStore::new(RuntimeHasher::default())); let coverage = Arc::new(CoverageWatch::new()); + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), @@ -73,7 +74,16 @@ impl Harness { SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { server, diff --git a/bobbin/crates/xrpc/tests/extended.rs b/bobbin/crates/xrpc/tests/extended.rs index 49536fd0..75b00343 100644 --- a/bobbin/crates/xrpc/tests/extended.rs +++ b/bobbin/crates/xrpc/tests/extended.rs @@ -10,7 +10,7 @@ use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_types::edges::Edge; use bobbin_types::ids::SubjectRef; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; @@ -61,6 +61,7 @@ impl Harness { let server = MockServer::start().await; let edges = Arc::new(EdgeStore::new(RuntimeHasher::default())); let coverage = Arc::new(CoverageWatch::new()); + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), @@ -81,7 +82,16 @@ impl Harness { SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { server, diff --git a/bobbin/crates/xrpc/tests/knot_proxy.rs b/bobbin/crates/xrpc/tests/knot_proxy.rs index e0105694..d5c75005 100644 --- a/bobbin/crates/xrpc/tests/knot_proxy.rs +++ b/bobbin/crates/xrpc/tests/knot_proxy.rs @@ -9,7 +9,7 @@ use bobbin_resolver::RepoIdResolver; use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; use jacquard_common::types::did::Did; @@ -61,6 +61,7 @@ impl Harness { async fn with_config(config: KnotProxyConfig) -> Self { let slingshot_server = MockServer::start().await; let knot_server = MockServer::start().await; + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&slingshot_server.uri()).unwrap()) @@ -82,7 +83,16 @@ impl Harness { SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ) as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { slingshot: slingshot_server, diff --git a/bobbin/crates/xrpc/tests/search.rs b/bobbin/crates/xrpc/tests/search.rs index 513c8caa..0037f73b 100644 --- a/bobbin/crates/xrpc/tests/search.rs +++ b/bobbin/crates/xrpc/tests/search.rs @@ -9,7 +9,7 @@ use bobbin_runtime::{RuntimeHasher, SystemClock}; use bobbin_search::{DEFAULT_WRITER_HEAP_BYTES, SearchIndex, SearchReader}; use bobbin_slingshot_client::SlingshotClient; use bobbin_types::search::{SearchDoc, SearchSink}; -use bobbin_xrpc::{AppState, router}; +use bobbin_xrpc::{AppState, ServiceAuth, router}; use http::{Request, StatusCode}; use jacquard_common::DefaultStr; use jacquard_common::types::nsid::Nsid; @@ -58,6 +58,7 @@ impl Harness { let search = Arc::new( SearchIndex::new(DEFAULT_WRITER_HEAP_BYTES, Arc::new(SystemClock::new())).unwrap(), ); + let directory = Arc::new(bobbin_xrpc::default_directory()); let state = AppState::new( Arc::new(LruRecordStore::new(CacheCapacity::from_bytes(64 * 1024))), SlingshotClient::with_default_http(Url::parse(&server.uri()).unwrap()).unwrap(), @@ -76,7 +77,16 @@ impl Harness { ), search.clone() as Arc, Arc::new(RepoIdResolver::detached(RuntimeHasher::default())), - Arc::new(bobbin_xrpc::default_directory()), + directory.clone(), + Arc::new( + ServiceAuth::new( + "bobbin.test", + Arc::new(SystemClock::new()), + directory, + RuntimeHasher::default(), + ) + .unwrap(), + ), ); Self { server,