From 9a664d6f32d3d6542245e618c898962769605d12 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 6 Jun 2026 16:38:37 -0400 Subject: [PATCH] fixed service auth, incorporating a version of bailey's PR, adding replay protection. --- Cargo.lock | 69 +-- crates/jacquard-axum/Cargo.toml | 5 +- crates/jacquard-axum/src/service_auth.rs | 542 +++++++++++++----- .../jacquard-axum/tests/service_auth_tests.rs | 409 ++++++++++++- crates/jacquard-common/Cargo.toml | 1 - crates/jacquard-common/src/service_auth.rs | 264 +++++---- crates/jacquard-common/src/types.rs | 2 + .../jacquard-common/src/types/did_service.rs | 332 +++++++++++ crates/jacquard-common/src/types/string.rs | 1 + 9 files changed, 1300 insertions(+), 325 deletions(-) create mode 100644 crates/jacquard-common/src/types/did_service.rs diff --git a/Cargo.lock b/Cargo.lock index a49a1f80f..bf9931561 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,12 +38,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "aliasable" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" - [[package]] name = "aligned" version = "0.4.3" @@ -753,7 +747,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -1325,7 +1319,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn", @@ -1887,12 +1881,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -2453,6 +2441,7 @@ dependencies = [ "jacquard-identity", "k256", "miette", + "mini-moka-wasm", "multibase", "rand 0.8.5", "reqwest", @@ -2509,7 +2498,6 @@ dependencies = [ "multibase", "multihash", "n0-future", - "ouroboros", "oxilangtag", "p256", "phf", @@ -2542,7 +2530,7 @@ dependencies = [ name = "jacquard-derive" version = "0.12.0-beta.2" dependencies = [ - "heck 0.5.0", + "heck", "inventory", "jacquard-common", "jacquard-lexicon", @@ -2610,7 +2598,7 @@ dependencies = [ "bytes", "cid", "dashmap", - "heck 0.5.0", + "heck", "inventory", "jacquard-common", "miette", @@ -3518,30 +3506,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "ouroboros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" -dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", -] - [[package]] name = "owo-colors" version = "4.3.0" @@ -3854,19 +3818,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "version_check", - "yansi", -] - [[package]] name = "profiling" version = "1.0.17" @@ -4910,12 +4861,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "string_cache" version = "0.8.9" @@ -6401,7 +6346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -6412,7 +6357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap", "prettyplease", "syn", diff --git a/crates/jacquard-axum/Cargo.toml b/crates/jacquard-axum/Cargo.toml index dd6aca54b..bb7869433 100644 --- a/crates/jacquard-axum/Cargo.toml +++ b/crates/jacquard-axum/Cargo.toml @@ -22,12 +22,14 @@ path = "../../examples/axum_server.rs" [dependencies] axum = "0.8.6" bytes.workspace = true +chrono.workspace = true jacquard = { version = "0.12.0-beta.1", path = "../jacquard", default-features = false, features = ["api"] } jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = ["reqwest-client"] } jacquard-derive = { version = "0.12.0-beta.1", path = "../jacquard-derive" } jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity", optional = true } miette.workspace = true multibase = { version = "0.9.1", optional = true } +mini-moka = { package = "mini-moka-wasm", version = "0.10", path = "../mini-moka-wasm", optional = true } serde.workspace = true serde_html_form.workspace = true serde_json.workspace = true @@ -37,8 +39,9 @@ tower-http = { version = "0.6.6", features = ["trace", "tracing"] } tracing = "0.1.41" [features] -default = ["service-auth"] +default = ["service-auth", "service-auth-replay"] service-auth = ["jacquard-common/service-auth", "dep:jacquard-identity", "dep:multibase"] +service-auth-replay = ["service-auth", "dep:mini-moka"] tracing = [] [dev-dependencies] diff --git a/crates/jacquard-axum/src/service_auth.rs b/crates/jacquard-axum/src/service_auth.rs index cc557059c..7785fd4f2 100644 --- a/crates/jacquard-axum/src/service_auth.rs +++ b/crates/jacquard-axum/src/service_auth.rs @@ -1,4 +1,22 @@ -//! Service authentication extractor and middleware +//! Service authentication extractor and middleware. +//! +//! Service auth verifies AT Protocol inter-service JWTs. Normal +//! [`ServiceAuthConfig::new`] configurations require `lxm` method binding and, +//! when the `service-auth-replay` feature is enabled, reject missing or replayed +//! `jti` values by default. Use [`ServiceAuthConfig::disable_replay_protection`] +//! only for legacy compatibility. +//! +//! Global service-id allow-lists constrain present `aud` fragments but do not +//! require a fragment. Use [`require_service_id`] as a route layer for endpoints +//! that require a specific `did:web:example.com#service_id` audience fragment. +//! +//! [`ExtractOptionalServiceAuth`] treats only an absent Authorization header as +//! anonymous. Present malformed, invalid, or replayed credentials are rejected. +//! +//! The default replay store is in-memory and per process. Horizontally scaled +//! deployments should provide a shared [`ReplayStore`] implementation. +//! Legacy configs created with [`ServiceAuthConfig::new_legacy`] disable `lxm` +//! and replay requirements. //! //! # Example //! @@ -38,25 +56,138 @@ //! ``` use axum::{ - Json, + Extension, Json, extract::FromRequestParts, http::{HeaderValue, StatusCode, header, request::Parts}, middleware::Next, response::{IntoResponse, Response}, }; +use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::{ CowStr, IntoStatic, service_auth::{self, PublicKey}, types::{ did_doc::VerificationMethod, - string::{Did, Nsid}, + string::{Did, DidService, Nsid}, }, }; use jacquard_identity::resolver::IdentityResolver; use serde_json::json; -use std::sync::Arc; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; use thiserror::Error; +/// Replay key for service auth JWT `jti` replay protection. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ReplayKey { + /// Issuer DID from the JWT. + iss: Did, + /// Full audience, including any service-id fragment. + aud: DidService, + /// JWT ID nonce. + jti: SmolStr, +} + +impl ReplayKey { + /// Create a new replay key. + pub fn new(iss: Did, aud: DidService, jti: impl Into) -> Self { + Self { + iss, + aud, + jti: jti.into(), + } + } +} + +/// Errors returned by replay stores. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum ReplayStoreError { + /// The replay key has already been presented and has not expired. + #[error("service auth JWT replay detected")] + Replayed, + + /// The replay store failed. + #[error("replay store failed: {0}")] + Store(String), +} + +/// Store used to reject replayed service auth JWT IDs. +pub trait ReplayStore: Send + Sync + 'static { + /// Check whether `key` has been seen, and record it until `expires_at`. + fn check_and_insert( + &self, + key: ReplayKey, + expires_at: i64, + ) -> Pin> + Send + '_>>; +} + +/// Replay store that disables replay protection. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopReplayStore; + +impl ReplayStore for NoopReplayStore { + fn check_and_insert( + &self, + _key: ReplayKey, + _expires_at: i64, + ) -> Pin> + Send + '_>> { + Box::pin(async { Ok(()) }) + } +} + +/// Default in-memory replay store. +#[cfg(feature = "service-auth-replay")] +#[derive(Debug, Clone)] +pub struct InMemoryReplayStore { + cache: mini_moka::sync::Cache, + lock: Arc>, +} + +#[cfg(feature = "service-auth-replay")] +impl Default for InMemoryReplayStore { + fn default() -> Self { + Self::new(100_000) + } +} + +#[cfg(feature = "service-auth-replay")] +impl InMemoryReplayStore { + /// Create an in-memory replay store with a maximum key capacity. + pub fn new(max_capacity: u64) -> Self { + Self { + cache: mini_moka::sync::Cache::new(max_capacity), + lock: Arc::new(Mutex::new(())), + } + } +} + +#[cfg(feature = "service-auth-replay")] +impl ReplayStore for InMemoryReplayStore { + fn check_and_insert( + &self, + key: ReplayKey, + expires_at: i64, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let _guard = self + .lock + .lock() + .map_err(|_| ReplayStoreError::Store("replay store lock poisoned".to_string()))?; + let now = chrono::Utc::now().timestamp(); + if let Some(existing_expires_at) = self.cache.get(&key) { + if existing_expires_at > now { + return Err(ReplayStoreError::Replayed); + } + self.cache.invalidate(&key); + } + self.cache.insert(key, expires_at); + Ok(()) + }) + } +} + /// Trait for providing service authentication configuration. /// /// This trait allows custom state types to provide service auth configuration @@ -71,8 +202,17 @@ pub trait ServiceAuth { /// Get a reference to the identity resolver fn resolver(&self) -> &Self::Resolver; - /// Whether to require the `lxm` (method binding) field + /// Whether to require the `lxm` (method binding) field. fn require_lxm(&self) -> bool; + + /// Service-id fragments allowed by global validation. + fn allowed_services(&self) -> &[SmolStr]; + + /// Whether replay protection is enabled. + fn replay_protection_enabled(&self) -> bool; + + /// Replay store used by replay protection. + fn replay_store(&self) -> &dyn ReplayStore; } /// Configuration for service auth verification. @@ -80,12 +220,18 @@ pub trait ServiceAuth { /// This should be stored in your Axum app state and will be extracted /// by the `ExtractServiceAuth` extractor. pub struct ServiceAuthConfig { - /// The DID of your service (the expected audience) + /// The DID of your service (the expected audience). service_did: Did, - /// Identity resolver for fetching DID documents + /// Identity resolver for fetching DID documents. resolver: Arc, - /// Whether to require the `lxm` (method binding) field + /// Whether to require the `lxm` (method binding) field. require_lxm: bool, + /// Globally allowed service-id fragments. + allowed_services: Vec, + /// Replay store used when replay protection is enabled. + replay_store: Arc, + /// Whether replay protection is enabled. + replay_protection_enabled: bool, } impl Clone for ServiceAuthConfig { @@ -94,20 +240,38 @@ impl Clone for ServiceAuthConfig { service_did: self.service_did.clone(), resolver: Arc::clone(&self.resolver), require_lxm: self.require_lxm, + allowed_services: self.allowed_services.clone(), + replay_store: Arc::clone(&self.replay_store), + replay_protection_enabled: self.replay_protection_enabled, } } } +fn default_replay_store() -> (Arc, bool) { + #[cfg(feature = "service-auth-replay")] + { + (Arc::new(InMemoryReplayStore::default()), true) + } + #[cfg(not(feature = "service-auth-replay"))] + { + (Arc::new(NoopReplayStore), false) + } +} + impl ServiceAuthConfig { /// Create a new service auth config. /// /// This enables `lxm` (method binding). If you need backward compatibility, /// use `ServiceAuthConfig::new_legacy()` pub fn new(service_did: Did, resolver: R) -> Self { + let (replay_store, replay_protection_enabled) = default_replay_store(); Self { service_did, resolver: Arc::new(resolver), require_lxm: true, + allowed_services: Vec::new(), + replay_store, + replay_protection_enabled, } } @@ -119,6 +283,9 @@ impl ServiceAuthConfig { service_did, resolver: Arc::new(resolver), require_lxm: false, + allowed_services: Vec::new(), + replay_store: Arc::new(NoopReplayStore), + replay_protection_enabled: false, } } @@ -131,6 +298,41 @@ impl ServiceAuthConfig { self } + /// Replace the global allowed service-id fragments. + pub fn with_allowed_services(mut self, services: I) -> Self + where + I: IntoIterator, + Svc: Into, + { + self.allowed_services = services.into_iter().map(Into::into).collect(); + self + } + + /// Add a single global allowed service-id fragment. + pub fn allow_service(mut self, service: impl Into) -> Self { + self.allowed_services.push(service.into()); + self + } + + /// Replace the replay store and enable replay protection. + pub fn with_replay_store(mut self, store: impl ReplayStore) -> Self { + self.replay_store = Arc::new(store); + self.replay_protection_enabled = true; + self + } + + /// Disable replay protection for legacy compatibility. + pub fn disable_replay_protection(mut self) -> Self { + self.replay_store = Arc::new(NoopReplayStore); + self.replay_protection_enabled = false; + self + } + + /// Get the globally allowed service-id fragments. + pub fn allowed_services(&self) -> &[SmolStr] { + &self.allowed_services + } + /// Get the service DID. pub fn service_did(&self) -> Did<&str> { self.service_did.borrow() @@ -156,6 +358,44 @@ impl ServiceAuth for ServiceAuthConfig { fn require_lxm(&self) -> bool { self.require_lxm } + + fn allowed_services(&self) -> &[SmolStr] { + &self.allowed_services + } + + fn replay_protection_enabled(&self) -> bool { + self.replay_protection_enabled + } + + fn replay_store(&self) -> &dyn ReplayStore { + self.replay_store.as_ref() + } +} + +/// Route-scoped service auth policy. +#[derive(Debug, Clone, Default)] +pub struct ServiceAuthRoutePolicy { + /// Required service-id fragment for this route. + required_service_id: Option, +} + +impl ServiceAuthRoutePolicy { + /// Require a specific service-id fragment for this route. + pub fn require_service_id(service_id: impl Into) -> Self { + Self { + required_service_id: Some(service_id.into()), + } + } + + /// Get the required service-id fragment. + pub fn required_service_id(&self) -> Option<&str> { + self.required_service_id.as_deref() + } +} + +/// Create an axum route layer that requires a specific service-id fragment. +pub fn require_service_id(service_id: impl Into) -> Extension { + Extension(ServiceAuthRoutePolicy::require_service_id(service_id)) } /// Verified service authentication information. @@ -166,8 +406,8 @@ impl ServiceAuth for ServiceAuthConfig { pub struct VerifiedServiceAuth<'a> { /// The authenticated user's DID (from `iss` claim) did: Did, - /// The audience (should match your service DID) - aud: Did, + /// The audience (should match your service DID, with optional service fragment). + aud: DidService, /// The lexicon method NSID, if present lxm: Option, /// JWT ID (nonce), if present @@ -180,11 +420,21 @@ impl<'a> VerifiedServiceAuth<'a> { self.did.borrow() } - /// Get the audience (your service DID). - pub fn aud(&self) -> Did<&str> { + /// Get the full audience, including any service-id fragment. + pub fn aud(&self) -> DidService<&str> { self.aud.borrow() } + /// Get the fragmentless service DID audience. + pub fn audience(&self) -> Did<&str> { + self.aud.audience() + } + + /// Get the optional service-id fragment. + pub fn service(&self) -> Option<&str> { + self.aud.service() + } + /// Get the lexicon method NSID, if present. pub fn lxm(&self) -> Option> { self.lxm.as_ref().map(|l| l.borrow()) @@ -326,6 +576,30 @@ pub enum ServiceAuthError { /// Invalid key format #[error("invalid key format: {0}")] InvalidKey(String), + + /// Service-id fragment is required for this route. + #[error("service id {required} is required but missing from token audience")] + ServiceIdRequired { + /// Required service-id fragment. + required: SmolStr, + }, + + /// Service-id fragment does not match this route. + #[error("service id mismatch: required {required}, got {actual}")] + RouteServiceIdMismatch { + /// Required service-id fragment. + required: SmolStr, + /// Actual service-id fragment. + actual: SmolStr, + }, + + /// Replay protection is enabled but the token has no `jti`. + #[error("service auth JWT is missing required jti")] + MissingJti, + + /// Replay protection rejected the token. + #[error("replay protection failed: {0}")] + Replay(#[from] ReplayStoreError), } impl IntoResponse for ServiceAuthError { @@ -357,7 +631,11 @@ impl IntoResponse for ServiceAuthError { "AuthenticationRequired", self.to_string(), ), - ServiceAuthError::InvalidKey(_) => ( + ServiceAuthError::InvalidKey(_) + | ServiceAuthError::ServiceIdRequired { .. } + | ServiceAuthError::RouteServiceIdMismatch { .. } + | ServiceAuthError::MissingJti + | ServiceAuthError::Replay(_) => ( StatusCode::UNAUTHORIZED, "AuthenticationRequired", self.to_string(), @@ -381,6 +659,107 @@ impl IntoResponse for ServiceAuthError { } } +fn owned_did(did: &Did) -> Did { + Did::new_owned(did.as_str()).unwrap() +} + +fn bearer_token_from_parts(parts: &Parts) -> Result, ServiceAuthError> { + let Some(auth_header) = parts.headers.get(header::AUTHORIZATION) else { + return Ok(None); + }; + + let auth_str = auth_header + .to_str() + .map_err(|_| ServiceAuthError::InvalidAuthHeader)?; + let token = auth_str + .strip_prefix("Bearer ") + .ok_or(ServiceAuthError::InvalidAuthHeader)?; + Ok(Some(token)) +} + +async fn verify_service_auth( + parts: &Parts, + state: &S, + token: &str, +) -> Result, ServiceAuthError> +where + S: ServiceAuth + Send + Sync, + S::Resolver: Send + Sync, +{ + let parsed = service_auth::parse_jwt(token)?; + let claims = parsed.claims(); + + let did_doc = state + .resolver() + .resolve_did_doc(&claims.iss) + .await + .map_err(|e| ServiceAuthError::DidResolutionFailed { + did: owned_did(&claims.iss), + source: Box::new(e), + })?; + + let doc = did_doc + .parse() + .map_err(|e| ServiceAuthError::DidResolutionFailed { + did: owned_did(&claims.iss), + source: Box::new(e), + })?; + + let verification_methods = doc + .verification_method + .as_deref() + .ok_or_else(|| ServiceAuthError::NoSigningKey(owned_did(&claims.iss)))?; + + let signing_key = extract_signing_key(verification_methods) + .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; + + service_auth::verify_signature(&parsed, &signing_key)?; + claims.validate(&state.service_did(), state.allowed_services())?; + + if state.require_lxm() && claims.lxm.is_none() { + return Err(ServiceAuthError::MethodBindingRequired); + } + + if let Some(policy) = parts.extensions.get::() { + if let Some(required) = policy.required_service_id() { + match claims.aud.service() { + Some(actual) if actual == required => {} + Some(actual) => { + return Err(ServiceAuthError::RouteServiceIdMismatch { + required: SmolStr::new(required), + actual: SmolStr::new(actual), + }); + } + None => { + return Err(ServiceAuthError::ServiceIdRequired { + required: SmolStr::new(required), + }); + } + } + } + } + + if state.replay_protection_enabled() { + let jti = claims.jti.as_ref().ok_or(ServiceAuthError::MissingJti)?; + let key = ReplayKey::new( + claims.iss.clone().into_static(), + claims.aud.clone().into_static(), + jti.clone(), + ); + state + .replay_store() + .check_and_insert(key, claims.exp) + .await?; + } + + Ok(VerifiedServiceAuth { + did: claims.iss.clone().into_static(), + aud: claims.aud.clone().into_static(), + lxm: claims.lxm.as_ref().map(|l| l.clone().into_static()), + jti: claims.jti.as_ref().map(|j| CowStr::from(j.clone())), + }) +} + impl FromRequestParts for ExtractServiceAuth where S: ServiceAuth + Send + Sync, @@ -393,72 +772,9 @@ where state: &S, ) -> impl std::future::Future> + Send { async move { - // Extract Authorization header - let auth_header = parts - .headers - .get(header::AUTHORIZATION) - .ok_or(ServiceAuthError::MissingAuthHeader)?; - - // Parse Bearer token - let auth_str = auth_header - .to_str() - .map_err(|_| ServiceAuthError::InvalidAuthHeader)?; - - let token = auth_str - .strip_prefix("Bearer ") - .ok_or(ServiceAuthError::InvalidAuthHeader)?; - - // Parse JWT - let parsed = service_auth::parse_jwt(token)?; - - // Get claims for DID resolution - let claims = parsed.claims(); - - // Resolve DID to get signing key (do this before checking claims) - let did_doc = state - .resolver() - .resolve_did_doc(&claims.iss) - .await - .map_err(|e| ServiceAuthError::DidResolutionFailed { - did: claims.iss.clone().into_static(), - source: Box::new(e), - })?; - - // Parse the DID document response to get verification methods - let doc = did_doc - .parse() - .map_err(|e| ServiceAuthError::DidResolutionFailed { - did: claims.iss.clone().into_static(), - source: Box::new(e), - })?; - - // Extract signing key from DID document - let verification_methods = doc - .verification_method - .as_deref() - .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; - - let signing_key = extract_signing_key(verification_methods) - .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; - - // Verify signature FIRST - if this fails, nothing else matters - service_auth::verify_signature(&parsed, &signing_key)?; - - // Now validate claims (audience, expiration, etc.) - claims.validate(&state.service_did())?; - - // Check method binding if required - if state.require_lxm() && claims.lxm.is_none() { - return Err(ServiceAuthError::MethodBindingRequired); - } - - // All checks passed - return verified auth - Ok(ExtractServiceAuth(VerifiedServiceAuth { - did: claims.iss.clone().into_static(), - aud: claims.aud.clone().into_static(), - lxm: claims.lxm.as_ref().map(|l| l.clone().into_static()), - jti: claims.jti.as_ref().map(|j| j.clone().into_static()), - })) + let token = + bearer_token_from_parts(parts)?.ok_or(ServiceAuthError::MissingAuthHeader)?; + verify_service_auth(parts, state, token).await.map(Self) } } } @@ -475,72 +791,12 @@ where state: &S, ) -> impl std::future::Future> + Send { async move { - // Check for Authorization header - if missing, return None (not an error) - let auth_header = match parts.headers.get(header::AUTHORIZATION) { - Some(h) => h, - None => return Ok(ExtractOptionalServiceAuth(None)), + let Some(token) = bearer_token_from_parts(parts)? else { + return Ok(Self(None)); }; - - // Header is present - now we MUST validate it (bad auth = error) - let auth_str = auth_header - .to_str() - .map_err(|_| ServiceAuthError::InvalidAuthHeader)?; - - let token = auth_str - .strip_prefix("Bearer ") - .ok_or(ServiceAuthError::InvalidAuthHeader)?; - - // Parse JWT - let parsed = service_auth::parse_jwt(token)?; - - // Get claims for DID resolution - let claims = parsed.claims(); - - // Resolve DID to get signing key - let did_doc = state - .resolver() - .resolve_did_doc(&claims.iss) + verify_service_auth(parts, state, token) .await - .map_err(|e| ServiceAuthError::DidResolutionFailed { - did: claims.iss.clone().into_static(), - source: Box::new(e), - })?; - - // Parse the DID document response to get verification methods - let doc = did_doc - .parse() - .map_err(|e| ServiceAuthError::DidResolutionFailed { - did: claims.iss.clone().into_static(), - source: Box::new(e), - })?; - - // Extract signing key from DID document - let verification_methods = doc - .verification_method - .as_deref() - .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; - - let signing_key = extract_signing_key(verification_methods) - .ok_or_else(|| ServiceAuthError::NoSigningKey(claims.iss.clone().into_static()))?; - - // Verify signature FIRST - if this fails, nothing else matters - service_auth::verify_signature(&parsed, &signing_key)?; - - // Now validate claims (audience, expiration, etc.) - claims.validate(&state.service_did())?; - - // Check method binding if required - if state.require_lxm() && claims.lxm.is_none() { - return Err(ServiceAuthError::MethodBindingRequired); - } - - // All checks passed - return verified auth - Ok(ExtractOptionalServiceAuth(Some(VerifiedServiceAuth { - did: claims.iss.clone().into_static(), - aud: claims.aud.clone().into_static(), - lxm: claims.lxm.as_ref().map(|l| l.clone().into_static()), - jti: claims.jti.as_ref().map(|j| j.clone().into_static()), - }))) + .map(|auth| Self(Some(auth))) } } } diff --git a/crates/jacquard-axum/tests/service_auth_tests.rs b/crates/jacquard-axum/tests/service_auth_tests.rs index dafb5b10e..cddb97c03 100644 --- a/crates/jacquard-axum/tests/service_auth_tests.rs +++ b/crates/jacquard-axum/tests/service_auth_tests.rs @@ -10,10 +10,10 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use bytes::Bytes; use jacquard_axum::service_auth::{ - ExtractServiceAuth, ServiceAuthConfig, VerifiedServiceAuth, service_auth_middleware, + ExtractOptionalServiceAuth, ExtractServiceAuth, ReplayKey, ReplayStore, ReplayStoreError, + ServiceAuthConfig, VerifiedServiceAuth, require_service_id, service_auth_middleware, }; use jacquard_common::{ - CowStr, bos::BosStr, deps::smol_str::SmolStr, service_auth::JwtHeader, @@ -27,7 +27,12 @@ use jacquard_identity::resolver::{ }; use reqwest::StatusCode as ReqwestStatusCode; use serde_json::json; -use std::future::Future; +use std::{ + collections::HashMap, + future::Future, + pin::Pin, + sync::{Arc, Mutex}, +}; use tower::ServiceExt; // Test helper: create a signed JWT @@ -37,12 +42,30 @@ fn create_test_jwt( exp: i64, lxm: Option<&str>, signing_key: &k256::ecdsa::SigningKey, +) -> String { + create_test_jwt_with_jti( + iss, + aud, + exp, + lxm, + Some(&format!("test-jti-{}-{}-{}", iss, aud, exp)), + signing_key, + ) +} + +fn create_test_jwt_with_jti( + iss: &str, + aud: &str, + exp: i64, + lxm: Option<&str>, + jti: Option<&str>, + signing_key: &k256::ecdsa::SigningKey, ) -> String { use k256::ecdsa::signature::Signer; let header = JwtHeader { - alg: CowStr::new_static("ES256K"), - typ: CowStr::new_static("JWT"), + alg: SmolStr::new_static("ES256K"), + typ: SmolStr::new_static("JWT"), }; let mut claims_json = json!({ @@ -52,6 +75,10 @@ fn create_test_jwt( "iat": chrono::Utc::now().timestamp(), }); + if let Some(jti_val) = jti { + claims_json["jti"] = json!(jti_val); + } + if let Some(lxm_val) = lxm { claims_json["lxm"] = json!(lxm_val); } @@ -114,6 +141,38 @@ impl MockResolver { } } +#[derive(Clone, Default)] +struct DeterministicReplayStore { + entries: Arc>>, + now: Arc>, +} + +impl DeterministicReplayStore { + fn set_now(&self, now: i64) { + *self.now.lock().unwrap() = now; + } +} + +impl ReplayStore for DeterministicReplayStore { + fn check_and_insert( + &self, + key: ReplayKey, + expires_at: i64, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let now = *self.now.lock().unwrap(); + let mut entries = self.entries.lock().unwrap(); + if let Some(existing_expires_at) = entries.get(&key) { + if *existing_expires_at > now { + return Err(ReplayStoreError::Replayed); + } + } + entries.insert(key, expires_at); + Ok(()) + }) + } +} + impl IdentityResolver for MockResolver { fn options(&self) -> &ResolverOptions { &self.options @@ -495,6 +554,346 @@ async fn test_legacy_without_lxm() { assert_eq!(body, format!("Authenticated as {}", user_did)); } +#[tokio::test] +async fn test_optional_extractor_valid_and_missing_and_invalid() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let exp = chrono::Utc::now().timestamp() + 300; + let jwt = create_test_jwt( + user_did, + service_did, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + &signing_key, + ); + let did_doc = create_test_did_doc(user_did, verifying_key); + let resolver = MockResolver::new(did_doc); + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); + + async fn handler(ExtractOptionalServiceAuth(auth): ExtractOptionalServiceAuth) -> String { + auth.map(|auth| auth.did().to_string()) + .unwrap_or_else(|| "anonymous".to_string()) + } + + let app = Router::new() + .route("/test", get(handler)) + .with_state(config); + + let request = Request::builder().uri("/test").body(Body::empty()).unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(String::from_utf8(body.to_vec()).unwrap(), "anonymous"); + + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, "Basic bad") + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, "Bearer not-a-jwt") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_replay_rejects_second_presentation() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let exp = chrono::Utc::now().timestamp() + 300; + let jwt = create_test_jwt_with_jti( + user_did, + service_did, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + Some("fixed-jti"), + &signing_key, + ); + let did_doc = create_test_did_doc(user_did, verifying_key); + let resolver = MockResolver::new(did_doc); + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); + + async fn handler(ExtractServiceAuth(_auth): ExtractServiceAuth) -> &'static str { + "ok" + } + + let app = Router::new() + .route("/test", get(handler)) + .with_state(config); + + for expected in [StatusCode::OK, StatusCode::UNAUTHORIZED] { + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), expected); + } +} + +#[tokio::test] +async fn test_replay_accepts_again_after_expiration() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let now = chrono::Utc::now().timestamp(); + let exp = now + 300; + let jwt = create_test_jwt_with_jti( + user_did, + service_did, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + Some("expiring-jti"), + &signing_key, + ); + let did_doc = create_test_did_doc(user_did, verifying_key); + let resolver = MockResolver::new(did_doc); + let replay_store = DeterministicReplayStore::default(); + replay_store.set_now(now); + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver) + .with_replay_store(replay_store.clone()); + + async fn handler(ExtractServiceAuth(_auth): ExtractServiceAuth) -> &'static str { + "ok" + } + + let app = Router::new() + .route("/test", get(handler)) + .with_state(config); + + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + replay_store.set_now(exp + 1); + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn test_replay_disabled_allows_missing_and_repeated_jti() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let exp = chrono::Utc::now().timestamp() + 300; + let jwt = create_test_jwt_with_jti( + user_did, + service_did, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + None, + &signing_key, + ); + let did_doc = create_test_did_doc(user_did, verifying_key); + let resolver = MockResolver::new(did_doc); + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver) + .disable_replay_protection(); + + async fn handler(ExtractServiceAuth(_auth): ExtractServiceAuth) -> &'static str { + "ok" + } + + let app = Router::new() + .route("/test", get(handler)) + .with_state(config); + + for _ in 0..2 { + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } +} + +#[tokio::test] +async fn test_missing_jti_rejected_when_replay_enabled() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let exp = chrono::Utc::now().timestamp() + 300; + let jwt = create_test_jwt_with_jti( + user_did, + service_did, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + None, + &signing_key, + ); + let did_doc = create_test_did_doc(user_did, verifying_key); + let resolver = MockResolver::new(did_doc); + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver); + + async fn handler(ExtractServiceAuth(_auth): ExtractServiceAuth) -> &'static str { + "ok" + } + + let app = Router::new() + .route("/test", get(handler)) + .with_state(config); + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn test_global_service_id_allow_list() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let exp = chrono::Utc::now().timestamp() + 300; + let did_doc = create_test_did_doc(user_did, verifying_key); + + async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { + auth.aud().to_string() + } + + for (aud, allowed, expected) in [ + (service_did, false, StatusCode::OK), + ( + "did:web:feedgen.example.com#bsky_appview", + false, + StatusCode::OK, + ), + (service_did, true, StatusCode::OK), + ( + "did:web:feedgen.example.com#bsky_appview", + true, + StatusCode::OK, + ), + ( + "did:web:feedgen.example.com#other", + true, + StatusCode::UNAUTHORIZED, + ), + ( + "did:web:other.example.com#bsky_appview", + true, + StatusCode::UNAUTHORIZED, + ), + ] { + let resolver = MockResolver::new(did_doc.clone()); + let mut config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver) + .disable_replay_protection(); + if allowed { + config = config.allow_service("bsky_appview"); + } + let app = Router::new() + .route("/test", get(handler)) + .with_state(config); + let jwt = create_test_jwt( + user_did, + aud, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + &signing_key, + ); + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), expected, "aud={aud} allowed={allowed}"); + } +} + +#[tokio::test] +async fn test_route_scoped_service_id_policy() { + let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let verifying_key = signing_key.verifying_key(); + let user_did = "did:plc:test123"; + let service_did = "did:web:feedgen.example.com"; + let exp = chrono::Utc::now().timestamp() + 300; + let did_doc = create_test_did_doc(user_did, verifying_key); + + async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String { + auth.service().unwrap_or("bare").to_string() + } + + for (aud, strict, expected) in [ + (service_did, false, StatusCode::OK), + (service_did, true, StatusCode::UNAUTHORIZED), + ( + "did:web:feedgen.example.com#bsky_appview", + true, + StatusCode::OK, + ), + ( + "did:web:feedgen.example.com#other", + true, + StatusCode::UNAUTHORIZED, + ), + ] { + let resolver = MockResolver::new(did_doc.clone()); + let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver) + .disable_replay_protection(); + let route = get(handler); + let app = if strict { + Router::new() + .route("/test", route) + .route_layer(require_service_id("bsky_appview")) + .with_state(config) + } else { + Router::new().route("/test", route).with_state(config) + }; + let jwt = create_test_jwt( + user_did, + aud, + exp, + Some("app.bsky.feed.getFeedSkeleton"), + &signing_key, + ); + let request = Request::builder() + .uri("/test") + .header(header::AUTHORIZATION, format!("Bearer {}", jwt)) + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), expected, "aud={aud} strict={strict}"); + } +} + #[tokio::test] async fn test_invalid_signature() { // Real JWT token from did:plc:uc7pehijmk5jrllip4cglxdd with bogus signature diff --git a/crates/jacquard-common/Cargo.toml b/crates/jacquard-common/Cargo.toml index d0a5abe5e..1f51b93af 100644 --- a/crates/jacquard-common/Cargo.toml +++ b/crates/jacquard-common/Cargo.toml @@ -66,7 +66,6 @@ oxilangtag = { version = "0.1", default-features = false, features = ["alloc", " miette = { workspace = true, optional = true } # also need to gate this to std only multibase = { version = "0.9.1", default-features = false } multihash = { version = "0.19.3", default-features = false, features = ["alloc"] } -ouroboros = "0.18.5" rand = { version = "0.9.2", default-features = false, features = ["alloc"] } serde.workspace = true serde_html_form.workspace = true # need to check these at workspace level diff --git a/crates/jacquard-common/src/service_auth.rs b/crates/jacquard-common/src/service_auth.rs index 462588d00..39a0e27bf 100644 --- a/crates/jacquard-common/src/service_auth.rs +++ b/crates/jacquard-common/src/service_auth.rs @@ -18,13 +18,12 @@ use crate::CowStr; use crate::IntoStatic; -use crate::types::string::{Did, Nsid}; +use crate::bos::{BosStr, DefaultStr}; +use crate::types::string::{Did, DidService, Nsid}; use alloc::string::String; -use alloc::string::ToString; use alloc::vec::Vec; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use ouroboros::self_referencing; use serde::{Deserialize, Serialize}; use signature::Verifier; use smol_str::SmolStr; @@ -73,13 +72,22 @@ pub enum ServiceAuthError { now: i64, }, - /// Audience mismatch + /// Audience mismatch. #[error("audience mismatch: expected {expected}, got {actual}")] AudienceMismatch { - /// Expected audience DID + /// Expected audience DID. expected: Did, - /// Actual audience DID in token - actual: Did, + /// Actual audience DID service in token. + actual: DidService, + }, + + /// Service id mismatch. + #[error("service id mismatch: allowed {allowed:?}, got {actual:?}")] + ServiceIdMismatch { + /// Allowed service ids. + allowed: Vec, + /// Actual service id in token. + actual: Option, }, /// Method mismatch (lxm field) @@ -102,17 +110,19 @@ pub enum ServiceAuthError { /// JWT header for service auth tokens. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JwtHeader<'a> { - /// Algorithm used for signing - #[serde(borrow)] - pub alg: CowStr<'a>, - /// Type (always "JWT") - #[serde(borrow)] - pub typ: CowStr<'a>, +pub struct JwtHeader { + /// Algorithm used for signing. + pub alg: S, + /// Type (always "JWT"). + pub typ: S, } -impl IntoStatic for JwtHeader<'_> { - type Output = JwtHeader<'static>; +impl IntoStatic for JwtHeader +where + S: BosStr + IntoStatic, + S::Output: BosStr, +{ + type Output = JwtHeader; fn into_static(self) -> Self::Output { JwtHeader { @@ -126,30 +136,34 @@ impl IntoStatic for JwtHeader<'_> { /// /// These are the payload fields in a service auth JWT. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServiceAuthClaims<'a> { - /// Issuer (user's DID) - pub iss: Did, +pub struct ServiceAuthClaims { + /// Issuer (user's DID). + pub iss: Did, - /// Audience (target service DID) - pub aud: Did, + /// Audience (target service DID with optional service-id fragment). + pub aud: DidService, - /// Expiration time (unix timestamp) + /// Expiration time (unix timestamp). pub exp: i64, - /// Issued at (unix timestamp) + /// Issued at (unix timestamp). pub iat: i64, - /// JWT ID (nonce for replay protection) - #[serde(borrow, skip_serializing_if = "Option::is_none")] - pub jti: Option>, + /// JWT ID (nonce for replay protection). + #[serde(skip_serializing_if = "Option::is_none")] + pub jti: Option, - /// Lexicon method NSID (method binding) + /// Lexicon method NSID (method binding). #[serde(skip_serializing_if = "Option::is_none")] - pub lxm: Option, + pub lxm: Option>, } -impl<'a> IntoStatic for ServiceAuthClaims<'a> { - type Output = ServiceAuthClaims<'static>; +impl IntoStatic for ServiceAuthClaims +where + S: BosStr + IntoStatic, + S::Output: BosStr, +{ + type Output = ServiceAuthClaims; fn into_static(self) -> Self::Output { ServiceAuthClaims { @@ -163,22 +177,46 @@ impl<'a> IntoStatic for ServiceAuthClaims<'a> { } } -impl<'a> ServiceAuthClaims<'a> { +impl ServiceAuthClaims { /// Validate the claims against expected values. /// /// Checks: - /// - Audience matches expected DID - /// - Token is not expired - pub fn validate(&self, expected_aud: &Did<&str>) -> Result<(), ServiceAuthError> { - // Check audience - if self.aud.as_str() != expected_aud.as_str() { + /// - The fragmentless audience matches the expected DID. + /// - A present service-id fragment is in the allowed service list when configured. + /// - The token is not expired. + pub fn validate( + &self, + expected_aud: &Did, + allowed_services: &[Svc], + ) -> Result<(), ServiceAuthError> + where + B: BosStr, + Svc: AsRef, + { + if self.aud.audience().as_str() != expected_aud.as_str() { return Err(ServiceAuthError::AudienceMismatch { - expected: expected_aud.clone().into_static(), - actual: self.aud.clone().into_static(), + expected: expected_aud.borrow().into_static(), + actual: DidService::new_owned(self.aud.as_str()).unwrap(), }); } - // Check expiration + if !allowed_services.is_empty() { + if let Some(service) = self.aud.service() { + if !allowed_services + .iter() + .any(|allowed| allowed.as_ref() == service) + { + return Err(ServiceAuthError::ServiceIdMismatch { + allowed: allowed_services + .iter() + .map(|allowed| SmolStr::new(allowed.as_ref())) + .collect(), + actual: Some(SmolStr::new(service)), + }); + } + } + } + if self.is_expired() { let now = chrono::Utc::now().timestamp(); return Err(ServiceAuthError::Expired { exp: self.exp, now }); @@ -219,68 +257,55 @@ impl<'a> ServiceAuthClaims<'a> { /// Parsed JWT components. /// -/// This struct owns the decoded buffers and parsed components using ouroboros -/// self-referencing. The header and claims borrow from their respective buffers. -#[self_referencing] -pub struct ParsedJwt { - /// Decoded header buffer (owned) - header_buf: Vec, - /// Decoded payload buffer (owned) - payload_buf: Vec, - /// Original token string for signing_input - token: String, - /// Signature bytes +/// This struct owns decoded and parsed JWT data. `signing_input` stores the +/// original `header.payload` bytes used for signature verification. +pub struct ParsedJwt { + /// Parsed JWT header. + header: JwtHeader, + /// Parsed service-auth claims. + claims: ServiceAuthClaims, + /// Original `header.payload` signing input. + signing_input: String, + /// Decoded signature bytes. signature: Vec, - /// Parsed header borrowing from header_buf - #[borrows(header_buf)] - #[covariant] - header: JwtHeader<'this>, - /// Parsed claims borrowing from payload_buf - #[borrows(payload_buf)] - #[covariant] - claims: ServiceAuthClaims<'this>, } -impl ParsedJwt { +impl ParsedJwt { /// Get the signing input (header.payload) for signature verification. pub fn signing_input(&self) -> &[u8] { - self.with_token(|token| { - let dot_pos = token.find('.').unwrap(); - let second_dot_pos = token[dot_pos + 1..].find('.').unwrap() + dot_pos + 1; - token[..second_dot_pos].as_bytes() - }) + self.signing_input.as_bytes() } /// Get a reference to the header. - pub fn header(&self) -> &JwtHeader<'_> { - self.borrow_header() + pub fn header(&self) -> &JwtHeader { + &self.header } /// Get a reference to the claims. - pub fn claims(&self) -> &ServiceAuthClaims<'_> { - self.borrow_claims() + pub fn claims(&self) -> &ServiceAuthClaims { + &self.claims } /// Get a reference to the signature. pub fn signature(&self) -> &[u8] { - self.borrow_signature() + &self.signature } /// Get owned header with 'static lifetime. - pub fn into_header(self) -> JwtHeader<'static> { - self.with_header(|header| header.clone().into_static()) + pub fn into_header(self) -> JwtHeader { + self.header } - /// Get owned claims with 'static lifetime. - pub fn into_claims(self) -> ServiceAuthClaims<'static> { - self.with_claims(|claims| claims.clone().into_static()) + /// Get owned claims. + pub fn into_claims(self) -> ServiceAuthClaims { + self.claims } } /// Parse a JWT token into its components without verifying the signature. /// /// This extracts and decodes all JWT components. The header and claims are parsed -/// and borrow from their respective owned buffers using ouroboros self-referencing. +/// into their default owned backing types. pub fn parse_jwt(token: &str) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -293,31 +318,19 @@ pub fn parse_jwt(token: &str) -> Result { let payload_b64 = parts[1]; let signature_b64 = parts[2]; - // Decode all components let header_buf = URL_SAFE_NO_PAD.decode(header_b64)?; let payload_buf = URL_SAFE_NO_PAD.decode(payload_b64)?; let signature = URL_SAFE_NO_PAD.decode(signature_b64)?; - - // Validate that buffers contain valid JSON for their types - // We parse once here to validate, then again in the builder (unavoidable with ouroboros) - let _header: JwtHeader = serde_json::from_slice(&header_buf)?; - let _claims: ServiceAuthClaims = serde_json::from_slice(&payload_buf)?; - - Ok(ParsedJwtBuilder { - header_buf, - payload_buf, - token: token.to_string(), + let header: JwtHeader = serde_json::from_slice(&header_buf)?; + let claims: ServiceAuthClaims = serde_json::from_slice(&payload_buf)?; + let signing_input = format!("{}.{}", header_b64, payload_b64); + + Ok(ParsedJwt { + header, + claims, + signing_input, signature, - header_builder: |buf| { - // Safe: we validated this succeeds above - serde_json::from_slice(buf).expect("header was validated") - }, - claims_builder: |buf| { - // Safe: we validated this succeeds above - serde_json::from_slice(buf).expect("claims were validated") - }, - } - .build()) + }) } /// Public key types for signature verification. @@ -402,7 +415,7 @@ pub fn verify_signature( pub fn verify_service_jwt( token: &str, public_key: &PublicKey, -) -> Result, ServiceAuthError> { +) -> Result { let parsed = parse_jwt(token)?; verify_signature(&parsed, public_key)?; Ok(parsed.into_claims()) @@ -421,9 +434,9 @@ mod tests { #[test] fn test_claims_expiration() { let now = chrono::Utc::now().timestamp(); - let expired_claims = ServiceAuthClaims { + let expired_claims: ServiceAuthClaims = ServiceAuthClaims { iss: Did::new_static("did:plc:test").unwrap(), - aud: Did::new_static("did:web:example.com").unwrap(), + aud: DidService::new_static("did:web:example.com").unwrap(), exp: now - 100, iat: now - 200, jti: None, @@ -432,9 +445,9 @@ mod tests { assert!(expired_claims.is_expired()); - let valid_claims = ServiceAuthClaims { + let valid_claims: ServiceAuthClaims = ServiceAuthClaims { iss: Did::new_static("did:plc:test").unwrap(), - aud: Did::new_static("did:web:example.com").unwrap(), + aud: DidService::new_static("did:web:example.com").unwrap(), exp: now + 100, iat: now, jti: None, @@ -444,33 +457,58 @@ mod tests { assert!(!valid_claims.is_expired()); } - #[test] - fn test_audience_validation() { - let now = chrono::Utc::now().timestamp(); - let claims = ServiceAuthClaims { + fn claims_with_aud(aud: &str) -> ServiceAuthClaims { + ServiceAuthClaims { iss: Did::new_static("did:plc:test").unwrap(), - aud: Did::new_static("did:web:example.com").unwrap(), - exp: now + 100, - iat: now, + aud: DidService::new_owned(aud).unwrap(), + exp: chrono::Utc::now().timestamp() + 100, + iat: chrono::Utc::now().timestamp(), jti: None, lxm: None, - }; + } + } + #[test] + fn test_audience_validation() { let expected_aud = Did::new("did:web:example.com").unwrap(); - assert!(claims.validate(&expected_aud).is_ok()); + assert!( + claims_with_aud("did:web:example.com") + .validate(&expected_aud, &[] as &[&str]) + .is_ok() + ); + assert!( + claims_with_aud("did:web:example.com#bsky_appview") + .validate(&expected_aud, &[] as &[&str]) + .is_ok() + ); + assert!( + claims_with_aud("did:web:example.com") + .validate(&expected_aud, &["bsky_appview"]) + .is_ok() + ); + assert!( + claims_with_aud("did:web:example.com#bsky_appview") + .validate(&expected_aud, &["bsky_appview"]) + .is_ok() + ); + assert!(matches!( + claims_with_aud("did:web:example.com#other").validate(&expected_aud, &["bsky_appview"]), + Err(ServiceAuthError::ServiceIdMismatch { .. }) + )); let wrong_aud = Did::new("did:web:wrong.com").unwrap(); assert!(matches!( - claims.validate(&wrong_aud), + claims_with_aud("did:web:example.com#bsky_appview") + .validate(&wrong_aud, &["bsky_appview"]), Err(ServiceAuthError::AudienceMismatch { .. }) )); } #[test] fn test_method_check() { - let claims = ServiceAuthClaims { + let claims: ServiceAuthClaims = ServiceAuthClaims { iss: Did::new_static("did:plc:test").unwrap(), - aud: Did::new_static("did:web:example.com").unwrap(), + aud: DidService::new_static("did:web:example.com").unwrap(), exp: chrono::Utc::now().timestamp() + 100, iat: chrono::Utc::now().timestamp(), jti: None, diff --git a/crates/jacquard-common/src/types.rs b/crates/jacquard-common/src/types.rs index 126ad8cfa..41dfcd253 100644 --- a/crates/jacquard-common/src/types.rs +++ b/crates/jacquard-common/src/types.rs @@ -18,6 +18,8 @@ pub mod datetime; pub mod did; /// DID Document types and helpers pub mod did_doc; +/// DID service audience types and validation. +pub mod did_service; /// AT Protocol handle types and validation pub mod handle; /// AT Protocol identifier types (handle or DID) diff --git a/crates/jacquard-common/src/types/did_service.rs b/crates/jacquard-common/src/types/did_service.rs new file mode 100644 index 000000000..0ddfc59a0 --- /dev/null +++ b/crates/jacquard-common/src/types/did_service.rs @@ -0,0 +1,332 @@ +use crate::bos::{Bos, BosStr, DefaultStr}; +use crate::types::did::{Did, validate_did}; +use crate::types::string::{AtStrError, StrParseKind}; +use crate::{CowStr, IntoStatic}; +use alloc::string::{String, ToString}; +use core::fmt; +use core::ops::Deref; +use core::str::FromStr; +use serde::{Deserialize, Deserializer, Serialize}; +use smol_str::SmolStr; + +/// A DID audience with an optional service-id fragment. +/// +/// Service auth JWTs may target either a bare service DID such as +/// `did:web:example.com` or a DID plus service fragment such as +/// `did:web:example.com#bsky_appview`. +#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +#[repr(transparent)] +pub struct DidService = DefaultStr>(pub(crate) S); + +/// Validate a DID service audience string without constructing a [`DidService`]. +pub fn validate_did_service(value: &str) -> Result<(), AtStrError> { + if value.len() > 2048 { + return Err(AtStrError::too_long( + "did service audience", + value, + 2048, + value.len(), + )); + } + + let mut parts = value.split('#'); + let did = parts.next().unwrap_or_default(); + let service = parts.next(); + if parts.next().is_some() { + return Err(AtStrError::regex( + "did service audience", + value, + SmolStr::new_static("multiple fragments"), + )); + } + + validate_did(did)?; + + if let Some(service) = service { + validate_service_id(service, value)?; + } + + Ok(()) +} + +fn validate_service_id(service: &str, whole: &str) -> Result<(), AtStrError> { + let mut bytes = service.bytes(); + match bytes.next() { + Some(first) if first.is_ascii_alphabetic() => {} + _ => { + return Err(AtStrError::regex( + "did service audience", + whole, + SmolStr::new_static("invalid service id"), + )); + } + } + + if bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') { + Ok(()) + } else { + Err(AtStrError::regex( + "did service audience", + whole, + SmolStr::new_static("invalid service id"), + )) + } +} + +impl DidService { + /// Get the full DID service audience as a string slice. + pub fn as_str(&self) -> &str { + self.0.as_ref() + } + + /// Get the fragmentless DID audience. + pub fn audience(&self) -> Did<&str> { + let did = self + .as_str() + .split_once('#') + .map_or(self.as_str(), |(did, _)| did); + // SAFETY: self is already validated, and validation validates the DID portion. + unsafe { Did::unchecked(did) } + } + + /// Get the optional service-id fragment without the leading `#`. + pub fn service(&self) -> Option<&str> { + self.as_str().split_once('#').map(|(_, service)| service) + } +} + +impl> DidService { + /// Infallible unchecked constructor. + /// + /// # Safety + /// + /// The caller must ensure the DID service audience is valid. + pub unsafe fn unchecked(value: S) -> Self { + Self(value) + } + + /// Convert to a `DidService` with a different backing type. + pub fn convert + From>(self) -> DidService { + DidService(B::from(self.0)) + } + + /// Borrow as a `DidService<&str>`. + pub fn borrow(&self) -> DidService<&str> + where + S: AsRef, + { + // SAFETY: self is already validated. + unsafe { DidService::unchecked(self.0.as_ref()) } + } +} + +impl DidService { + /// Fallible constructor that validates and wraps the input directly. + pub fn new(s: S) -> Result { + validate_did_service(s.as_ref())?; + Ok(Self(s)) + } + + /// Infallible constructor. Panics on invalid DID service audiences. + pub fn raw(s: S) -> Self { + Self::new(s).expect("invalid DID service audience") + } +} + +impl DidService { + /// Fallible constructor that validates and takes ownership. + pub fn new_owned(value: impl AsRef) -> Result { + let value = value.as_ref(); + validate_did_service(value)?; + let s = S::from_str(value).map_err(|_| { + AtStrError::new( + "did service audience", + value.to_string(), + StrParseKind::Conversion, + ) + })?; + Ok(Self(s)) + } + + /// Fallible constructor for static strings. + pub fn new_static(value: &'static str) -> Result { + validate_did_service(value)?; + let s = S::from_str(value).map_err(|_| { + AtStrError::new( + "did service audience", + value.to_string(), + StrParseKind::Conversion, + ) + })?; + Ok(Self(s)) + } +} + +impl<'de, S> Deserialize<'de> for DidService +where + S: BosStr + Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + validate_did_service(s.as_ref()).map_err(serde::de::Error::custom)?; + Ok(Self(s)) + } +} + +impl IntoStatic for DidService +where + S: Bos + IntoStatic, + S::Output: Bos, +{ + type Output = DidService; + + fn into_static(self) -> Self::Output { + DidService(self.0.into_static()) + } +} + +impl FromStr for DidService { + type Err = AtStrError; + + fn from_str(s: &str) -> Result { + Self::new_owned(s) + } +} + +impl fmt::Display for DidService { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Debug for DidService { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl From> for String { + fn from(value: DidService) -> Self { + value.as_str().to_string() + } +} + +impl From> for CowStr<'static> { + fn from(value: DidService) -> Self { + CowStr::copy_from_str(value.as_str()) + } +} + +impl From for DidService { + fn from(value: String) -> Self { + Self::new_owned(value).unwrap() + } +} + +impl<'d> From> for DidService> { + fn from(value: CowStr<'d>) -> Self { + Self::new(value).unwrap() + } +} + +impl AsRef for DidService { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for DidService { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn valid_with_service_id() { + assert!(DidService::<&str>::new("did:web:example.com#bsky_appview").is_ok()); + assert!(DidService::<&str>::new("did:plc:abc123#atproto_labeler").is_ok()); + } + + #[test] + fn valid_bare_did() { + assert!(DidService::<&str>::new("did:web:example.com").is_ok()); + } + + #[test] + fn splits_audience_and_service() { + let value = DidService::<&str>::new("did:web:example.com#bsky_appview").unwrap(); + assert_eq!(value.audience().as_str(), "did:web:example.com"); + assert_eq!(value.service(), Some("bsky_appview")); + + let bare = DidService::<&str>::new("did:web:example.com").unwrap(); + assert_eq!(bare.audience().as_str(), "did:web:example.com"); + assert_eq!(bare.service(), None); + } + + #[test] + fn rejects_empty_fragment() { + assert!(DidService::<&str>::new("did:web:example.com#").is_err()); + } + + #[test] + fn rejects_invalid_service_chars() { + for value in [ + "did:web:example.com#1bad", + "did:web:example.com#-bad", + "did:web:example.com#bad.service", + "did:web:example.com#bad:service", + "did:web:example.com#bad service", + "did:web:example.com#bad#service", + ] { + assert!(DidService::<&str>::new(value).is_err(), "{value}"); + } + } + + #[test] + fn rejects_invalid_did_body() { + assert!(DidService::<&str>::new("not-a-did#service").is_err()); + } + + #[test] + fn enforces_max_length() { + let service = "a".repeat(2049 - "did:web:example.com#".len()); + let value = format!("did:web:example.com#{service}"); + assert!(DidService::<&str>::new(&value).is_err()); + } + + #[test] + fn serde_roundtrip() { + let value = DidService::new_static("did:web:example.com#bsky_appview").unwrap(); + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(json, json!("did:web:example.com#bsky_appview")); + let decoded: DidService = serde_json::from_value(json).unwrap(); + assert_eq!(decoded, value); + } + + #[test] + fn into_static_preserves_value() { + let value = DidService::>::new(CowStr::copy_from_str( + "did:web:example.com#bsky_appview", + )) + .unwrap(); + let static_value: DidService> = value.into_static(); + assert_eq!(static_value.as_str(), "did:web:example.com#bsky_appview"); + } + + #[test] + fn from_str_owns_value() { + let value: DidService = "did:web:example.com#bsky_appview".parse().unwrap(); + assert_eq!(value.as_str(), "did:web:example.com#bsky_appview"); + } +} diff --git a/crates/jacquard-common/src/types/string.rs b/crates/jacquard-common/src/types/string.rs index 2a8775b08..29f67e7bb 100644 --- a/crates/jacquard-common/src/types/string.rs +++ b/crates/jacquard-common/src/types/string.rs @@ -34,6 +34,7 @@ pub use crate::{ cid::{Cid, CidLink}, datetime::Datetime, did::Did, + did_service::DidService, handle::Handle, ident::AtIdentifier, language::Language, -- 2.54.0