A better Rust ATProto crate

deserialization fixes and service auth update

Orual 1864232b aae802b5

+15
Cargo.lock
···
"axum",
"axum-macros",
"axum-test",
+
"base64 0.22.1",
"bytes",
+
"chrono",
"jacquard",
"jacquard-common 0.5.1",
+
"jacquard-derive 0.5.1",
+
"jacquard-identity 0.5.1",
+
"k256",
"miette",
+
"multibase",
+
"rand 0.8.5",
+
"reqwest",
"serde",
"serde_html_form",
"serde_ipld_dagcbor",
···
"thiserror 2.0.17",
"tokio",
"tokio-test",
+
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
+
"urlencoding",
[[package]]
···
"serde_ipld_dagcbor",
"serde_json",
"serde_with",
+
"signature",
"smol_str",
"thiserror 2.0.17",
"tokio",
···
checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
dependencies = [
"cfg-if",
+
"ecdsa",
"elliptic-curve",
+
"once_cell",
+
"sha2",
+
"signature",
[[package]]
+15
crates/jacquard-axum/Cargo.toml
···
bytes.workspace = true
jacquard = { version = "0.5", path = "../jacquard" }
jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] }
+
jacquard-derive = { version = "0.5.1", path = "../jacquard-derive" }
+
jacquard-identity = { version = "0.5", path = "../jacquard-identity", optional = true }
miette.workspace = true
+
multibase = { version = "0.9.1", optional = true }
serde.workspace = true
serde_html_form.workspace = true
serde_ipld_dagcbor.workspace = true
···
tower-http = { version = "0.6.6", features = ["trace", "tracing"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter", "time"] }
+
urlencoding.workspace = true
+
+
[features]
+
default = ["service-auth"]
+
service-auth = ["jacquard-common/service-auth", "dep:jacquard-identity", "dep:multibase"]
[dev-dependencies]
axum-test = "18.1.0"
+
base64.workspace = true
+
chrono.workspace = true
+
k256 = { version = "0.13", features = ["ecdsa"] }
+
rand = "0.8"
+
reqwest.workspace = true
+
serde_json.workspace = true
tokio-test = "0.4.4"
+
tower = { version = "0.5", features = ["util"] }
+76
crates/jacquard-axum/src/did_web.rs
···
+
//! Helper for serving did:web DID documents
+
//!
+
//! did:web DIDs resolve to HTTPS endpoints serving DID documents. This module
+
//! provides a router that serves your service's DID document at `/.well-known/did.json`.
+
//!
+
//! # Example
+
//!
+
//! ```no_run
+
//! use axum::Router;
+
//! use jacquard_axum::did_web::did_web_router;
+
//! use jacquard_common::types::did_doc::DidDocument;
+
//!
+
//! #[tokio::main]
+
//! async fn main() {
+
//! // Your DID document (typically loaded from config or generated)
+
//! let did_doc: DidDocument = serde_json::from_str(r#"{
+
//! "id": "did:web:feedgen.example.com",
+
//! "verificationMethod": [{
+
//! "id": "did:web:feedgen.example.com#atproto",
+
//! "type": "Multikey",
+
//! "controller": "did:web:feedgen.example.com",
+
//! "publicKeyMultibase": "zQ3sh..."
+
//! }]
+
//! }"#).unwrap();
+
//!
+
//! let app = Router::new()
+
//! .merge(did_web_router(did_doc));
+
//!
+
//! let listener = tokio::net::TcpListener::bind("0.0.0.0:443")
+
//! .await
+
//! .unwrap();
+
//! axum::serve(listener, app).await.unwrap();
+
//! }
+
//! ```
+
+
use axum::{
+
Json, Router,
+
http::{HeaderValue, StatusCode, header},
+
response::IntoResponse,
+
routing::get,
+
};
+
use jacquard_common::types::did_doc::DidDocument;
+
+
/// Create a router that serves a DID document at `/.well-known/did.json`
+
///
+
/// Returns a Router that can be merged into your main application router.
+
/// The DID document is cloned on each request.
+
///
+
/// # Example
+
///
+
/// ```no_run
+
/// use axum::Router;
+
/// use jacquard_axum::did_web::did_web_router;
+
/// use jacquard_common::types::did_doc::DidDocument;
+
///
+
/// # async fn example(did_doc: DidDocument<'static>) {
+
/// let app = Router::new()
+
/// .merge(did_web_router(did_doc));
+
/// # }
+
/// ```
+
pub fn did_web_router(did_doc: DidDocument<'static>) -> Router {
+
Router::new().route(
+
"/.well-known/did.json",
+
get(move || async move {
+
(
+
StatusCode::OK,
+
[(
+
header::CONTENT_TYPE,
+
HeaderValue::from_static("application/did+json"),
+
)],
+
Json(did_doc.clone()),
+
)
+
.into_response()
+
}),
+
)
+
}
+24 -3
crates/jacquard-axum/src/lib.rs
···
//! The extractor deserializes to borrowed types first, then converts to `'static` via
//! [`IntoStatic`], avoiding the DeserializeOwned requirement of the Json axum extractor and similar.
+
pub mod did_web;
+
#[cfg(feature = "service-auth")]
+
pub mod service_auth;
+
use axum::{
Json, Router,
body::Bytes,
···
}
XrpcMethod::Query => {
if let Some(path_query) = req.uri().path_and_query() {
-
let query = path_query.query().unwrap_or("");
-
let value: R::Request<'_> =
-
serde_html_form::from_str::<R::Request<'_>>(query).map_err(|e| {
+
// TODO: see if we can eliminate this now that we've fixed the deserialize impls for string types
+
let query =
+
urlencoding::decode(path_query.query().unwrap_or("")).map_err(|e| {
(
StatusCode::BAD_REQUEST,
[(
···
)
.into_response()
})?;
+
let value: R::Request<'_> = serde_html_form::from_str::<R::Request<'_>>(
+
query.as_ref(),
+
)
+
.map_err(|e| {
+
(
+
StatusCode::BAD_REQUEST,
+
[(
+
header::CONTENT_TYPE,
+
HeaderValue::from_static("application/json"),
+
)],
+
Json(json!({
+
"error": "InvalidRequest",
+
"message": format!("failed to decode request: {}", e)
+
})),
+
)
+
.into_response()
+
})?;
Ok(ExtractXrpc(value.into_static()))
} else {
Err((
+516
crates/jacquard-axum/src/service_auth.rs
···
+
//! Service authentication extractor and middleware
+
//!
+
//! # Example
+
//!
+
//! ```no_run
+
//! use axum::{Router, routing::get};
+
//! use jacquard_axum::service_auth::{ServiceAuthConfig, ExtractServiceAuth};
+
//! use jacquard_identity::JacquardResolver;
+
//! use jacquard_identity::resolver::ResolverOptions;
+
//! use jacquard_common::types::string::Did;
+
//!
+
//! async fn handler(
+
//! ExtractServiceAuth(auth): ExtractServiceAuth,
+
//! ) -> String {
+
//! format!("Authenticated as {}", auth.did())
+
//! }
+
//!
+
//! #[tokio::main]
+
//! async fn main() {
+
//! let resolver = JacquardResolver::new(
+
//! reqwest::Client::new(),
+
//! ResolverOptions::default(),
+
//! );
+
//! let config = ServiceAuthConfig::new(
+
//! Did::new_static("did:web:feedgen.example.com").unwrap(),
+
//! resolver,
+
//! );
+
//!
+
//! let app = Router::new()
+
//! .route("/xrpc/app.bsky.feed.getFeedSkeleton", get(handler))
+
//! .with_state(config);
+
//!
+
//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
+
//! .await
+
//! .unwrap();
+
//! axum::serve(listener, app).await.unwrap();
+
//! }
+
//! ```
+
+
use axum::{
+
Json,
+
extract::FromRequestParts,
+
http::{HeaderValue, StatusCode, header, request::Parts},
+
middleware::Next,
+
response::{IntoResponse, Response},
+
};
+
use jacquard_common::{
+
CowStr, IntoStatic,
+
service_auth::{self, PublicKey},
+
types::{
+
did_doc::VerificationMethod,
+
string::{Did, Nsid},
+
},
+
};
+
use jacquard_identity::resolver::IdentityResolver;
+
use serde_json::json;
+
use std::sync::Arc;
+
use thiserror::Error;
+
+
/// Trait for providing service authentication configuration.
+
///
+
/// This trait allows custom state types to provide service auth configuration
+
/// without requiring `ServiceAuthConfig<R>` directly.
+
pub trait ServiceAuth {
+
/// The identity resolver type
+
type Resolver: IdentityResolver;
+
+
/// Get the service DID (expected audience)
+
fn service_did(&self) -> &Did<'_>;
+
+
/// Get a reference to the identity resolver
+
fn resolver(&self) -> &Self::Resolver;
+
+
/// Whether to require the `lxm` (method binding) field
+
fn require_lxm(&self) -> bool;
+
}
+
+
/// Configuration for service auth verification.
+
///
+
/// This should be stored in your Axum app state and will be extracted
+
/// by the `ExtractServiceAuth` extractor.
+
pub struct ServiceAuthConfig<R> {
+
/// The DID of your service (the expected audience)
+
service_did: Did<'static>,
+
/// Identity resolver for fetching DID documents
+
resolver: Arc<R>,
+
/// Whether to require the `lxm` (method binding) field
+
require_lxm: bool,
+
}
+
+
impl<R> Clone for ServiceAuthConfig<R> {
+
fn clone(&self) -> Self {
+
Self {
+
service_did: self.service_did.clone(),
+
resolver: Arc::clone(&self.resolver),
+
require_lxm: self.require_lxm,
+
}
+
}
+
}
+
+
impl<R: IdentityResolver> ServiceAuthConfig<R> {
+
/// 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<'static>, resolver: R) -> Self {
+
Self {
+
service_did,
+
resolver: Arc::new(resolver),
+
require_lxm: true,
+
}
+
}
+
+
/// Create a new service auth config.
+
///
+
/// `lxm` (method binding) is disabled for backwards compatibility
+
pub fn new_legacy(service_did: Did<'static>, resolver: R) -> Self {
+
Self {
+
service_did,
+
resolver: Arc::new(resolver),
+
require_lxm: false,
+
}
+
}
+
+
/// Set whether to require the `lxm` field (method binding).
+
///
+
/// When enabled, the JWT must contain an `lxm` field matching the requested endpoint.
+
/// This prevents token reuse across different methods.
+
pub fn require_lxm(mut self, require: bool) -> Self {
+
self.require_lxm = require;
+
self
+
}
+
+
/// Get the service DID.
+
pub fn service_did(&self) -> &Did<'static> {
+
&self.service_did
+
}
+
+
/// Get a reference to the identity resolver.
+
pub fn resolver(&self) -> &R {
+
&self.resolver
+
}
+
}
+
+
impl<R: IdentityResolver> ServiceAuth for ServiceAuthConfig<R> {
+
type Resolver = R;
+
+
fn service_did(&self) -> &Did<'_> {
+
&self.service_did
+
}
+
+
fn resolver(&self) -> &Self::Resolver {
+
&self.resolver
+
}
+
+
fn require_lxm(&self) -> bool {
+
self.require_lxm
+
}
+
}
+
+
/// Verified service authentication information.
+
///
+
/// This is the result of successfully verifying a service auth JWT.
+
/// This type is extracted by the `ExtractServiceAuth` extractor.
+
#[derive(Debug, Clone, jacquard_derive::IntoStatic)]
+
pub struct VerifiedServiceAuth<'a> {
+
/// The authenticated user's DID (from `iss` claim)
+
did: Did<'a>,
+
/// The audience (should match your service DID)
+
aud: Did<'a>,
+
/// The lexicon method NSID, if present
+
lxm: Option<Nsid<'a>>,
+
/// JWT ID (nonce), if present
+
jti: Option<CowStr<'a>>,
+
}
+
+
impl<'a> VerifiedServiceAuth<'a> {
+
/// Get the authenticated user's DID.
+
pub fn did(&self) -> &Did<'a> {
+
&self.did
+
}
+
+
/// Get the audience (your service DID).
+
pub fn aud(&self) -> &Did<'a> {
+
&self.aud
+
}
+
+
/// Get the lexicon method NSID, if present.
+
pub fn lxm(&self) -> Option<&Nsid<'a>> {
+
self.lxm.as_ref()
+
}
+
+
/// Get the JWT ID (nonce), if present.
+
///
+
/// You can use this for replay protection by tracking seen JTIs
+
/// until their expiration time.
+
pub fn jti(&self) -> Option<&str> {
+
self.jti.as_ref().map(|j| j.as_ref())
+
}
+
}
+
+
/// Axum extractor for service authentication.
+
///
+
/// This extracts and verifies a service auth JWT from the Authorization header,
+
/// resolving the issuer's DID to verify the signature.
+
///
+
/// # Example
+
///
+
/// ```no_run
+
/// use axum::{Router, routing::get};
+
/// use jacquard_axum::service_auth::{ServiceAuthConfig, ExtractServiceAuth};
+
/// use jacquard_identity::JacquardResolver;
+
/// use jacquard_identity::resolver::ResolverOptions;
+
/// use jacquard_common::types::string::Did;
+
///
+
/// async fn handler(
+
/// ExtractServiceAuth(auth): ExtractServiceAuth,
+
/// ) -> String {
+
/// format!("Authenticated as {}", auth.did())
+
/// }
+
///
+
/// #[tokio::main]
+
/// async fn main() {
+
/// let resolver = JacquardResolver::new(
+
/// reqwest::Client::new(),
+
/// ResolverOptions::default(),
+
/// );
+
/// let config = ServiceAuthConfig::new(
+
/// Did::new_static("did:web:feedgen.example.com").unwrap(),
+
/// resolver,
+
/// );
+
///
+
/// let app = Router::new()
+
/// .route("/xrpc/app.bsky.feed.getFeedSkeleton", get(handler))
+
/// .with_state(config);
+
///
+
/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
+
/// .await
+
/// .unwrap();
+
/// axum::serve(listener, app).await.unwrap();
+
/// }
+
/// ```
+
pub struct ExtractServiceAuth(pub VerifiedServiceAuth<'static>);
+
+
/// Errors that can occur during service auth verification.
+
#[derive(Debug, Error, miette::Diagnostic)]
+
pub enum ServiceAuthError {
+
/// Authorization header is missing
+
#[error("missing Authorization header")]
+
MissingAuthHeader,
+
+
/// Authorization header is malformed (not "Bearer <token>")
+
#[error("invalid Authorization header format")]
+
InvalidAuthHeader,
+
+
/// JWT parsing or verification failed
+
#[error("JWT verification failed: {0}")]
+
JwtError(#[from] service_auth::ServiceAuthError),
+
+
/// DID resolution failed
+
#[error("failed to resolve DID {did}: {source}")]
+
DidResolutionFailed {
+
did: Did<'static>,
+
#[source]
+
source: Box<dyn std::error::Error + Send + Sync>,
+
},
+
+
/// No valid signing key found in DID document
+
#[error("no valid signing key found in DID document for {0}")]
+
NoSigningKey(Did<'static>),
+
+
/// Method binding required but missing
+
#[error("lxm (method binding) is required but missing from token")]
+
MethodBindingRequired,
+
+
/// Invalid key format
+
#[error("invalid key format: {0}")]
+
InvalidKey(String),
+
}
+
+
impl IntoResponse for ServiceAuthError {
+
fn into_response(self) -> Response {
+
let (status, error_code, message) = match &self {
+
ServiceAuthError::MissingAuthHeader => {
+
(StatusCode::UNAUTHORIZED, "AuthMissing", self.to_string())
+
}
+
ServiceAuthError::InvalidAuthHeader => {
+
(StatusCode::UNAUTHORIZED, "AuthMissing", self.to_string())
+
}
+
ServiceAuthError::JwtError(_) => (
+
StatusCode::UNAUTHORIZED,
+
"AuthenticationRequired",
+
self.to_string(),
+
),
+
ServiceAuthError::DidResolutionFailed { .. } => (
+
StatusCode::UNAUTHORIZED,
+
"AuthenticationRequired",
+
self.to_string(),
+
),
+
ServiceAuthError::NoSigningKey(_) => (
+
StatusCode::UNAUTHORIZED,
+
"AuthenticationRequired",
+
self.to_string(),
+
),
+
ServiceAuthError::MethodBindingRequired => (
+
StatusCode::UNAUTHORIZED,
+
"AuthenticationRequired",
+
self.to_string(),
+
),
+
ServiceAuthError::InvalidKey(_) => (
+
StatusCode::UNAUTHORIZED,
+
"AuthenticationRequired",
+
self.to_string(),
+
),
+
};
+
+
tracing::warn!("Service auth failed: {}", message);
+
+
(
+
status,
+
[(
+
header::CONTENT_TYPE,
+
HeaderValue::from_static("application/json"),
+
)],
+
Json(json!({
+
"error": error_code,
+
"message": message,
+
})),
+
)
+
.into_response()
+
}
+
}
+
+
impl<S> FromRequestParts<S> for ExtractServiceAuth
+
where
+
S: ServiceAuth + Send + Sync,
+
S::Resolver: Send + Sync,
+
{
+
type Rejection = ServiceAuthError;
+
+
fn from_request_parts(
+
parts: &mut Parts,
+
state: &S,
+
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + 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()),
+
}))
+
}
+
}
+
}
+
+
/// Extract the signing key from a DID document's verification methods.
+
///
+
/// This looks for a key with type "atproto" or the first available key
+
/// if no atproto-specific key is found.
+
fn extract_signing_key(methods: &[VerificationMethod]) -> Option<PublicKey> {
+
// First try to find an atproto-specific key
+
let atproto_method = methods
+
.iter()
+
.find(|m| m.r#type.as_ref() == "Multikey" || m.r#type.as_ref() == "atproto");
+
+
let method = atproto_method.or_else(|| methods.first())?;
+
+
// Parse the multikey
+
let public_key_multibase = method.public_key_multibase.as_ref()?;
+
+
// Decode multibase
+
let (_, key_bytes) = multibase::decode(public_key_multibase.as_ref()).ok()?;
+
+
// First two bytes are the multicodec prefix
+
if key_bytes.len() < 2 {
+
return None;
+
}
+
+
let codec = &key_bytes[..2];
+
let key_material = &key_bytes[2..];
+
+
match codec {
+
// p256-pub (0x1200)
+
[0x80, 0x24] => PublicKey::from_p256_bytes(key_material).ok(),
+
// secp256k1-pub (0xe7)
+
[0xe7, 0x01] => PublicKey::from_k256_bytes(key_material).ok(),
+
_ => None,
+
}
+
}
+
+
/// Middleware for verifying service authentication on all requests.
+
///
+
/// This middleware extracts and verifies the service auth JWT, then adds the
+
/// `VerifiedServiceAuth` to request extensions for downstream handlers to access.
+
///
+
/// # Example
+
///
+
/// ```no_run
+
/// use axum::{Router, routing::get, middleware, Extension};
+
/// use jacquard_axum::service_auth::{ServiceAuthConfig, service_auth_middleware};
+
/// use jacquard_identity::JacquardResolver;
+
/// use jacquard_identity::resolver::ResolverOptions;
+
/// use jacquard_common::types::string::Did;
+
///
+
/// async fn handler(
+
/// Extension(auth): Extension<jacquard_axum::service_auth::VerifiedServiceAuth<'static>>,
+
/// ) -> String {
+
/// format!("Authenticated as {}", auth.did())
+
/// }
+
///
+
/// #[tokio::main]
+
/// async fn main() {
+
/// let resolver = JacquardResolver::new(
+
/// reqwest::Client::new(),
+
/// ResolverOptions::default(),
+
/// );
+
/// let config = ServiceAuthConfig::new(
+
/// Did::new_static("did:web:feedgen.example.com").unwrap(),
+
/// resolver,
+
/// );
+
///
+
/// let app = Router::new()
+
/// .route("/xrpc/app.bsky.feed.getFeedSkeleton", get(handler))
+
/// .layer(middleware::from_fn_with_state(
+
/// config.clone(),
+
/// service_auth_middleware::<ServiceAuthConfig<JacquardResolver>>,
+
/// ))
+
/// .with_state(config);
+
///
+
/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
+
/// .await
+
/// .unwrap();
+
/// axum::serve(listener, app).await.unwrap();
+
/// }
+
/// ```
+
pub async fn service_auth_middleware<S>(
+
state: axum::extract::State<S>,
+
mut req: axum::extract::Request,
+
next: Next,
+
) -> Result<Response, ServiceAuthError>
+
where
+
S: ServiceAuth + Send + Sync + Clone,
+
S::Resolver: Send + Sync,
+
{
+
// Extract auth from request parts
+
let (mut parts, body) = req.into_parts();
+
let ExtractServiceAuth(auth) =
+
ExtractServiceAuth::from_request_parts(&mut parts, &state.0).await?;
+
+
// Add auth to extensions
+
parts.extensions.insert(auth);
+
+
// Reconstruct request and continue
+
req = axum::extract::Request::from_parts(parts, body);
+
Ok(next.run(req).await)
+
}
+543
crates/jacquard-axum/tests/service_auth_tests.rs
···
+
use axum::{
+
Extension, Router,
+
body::Body,
+
extract::Request,
+
http::{StatusCode, header},
+
middleware,
+
routing::get,
+
};
+
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,
+
};
+
use jacquard_common::{
+
CowStr, IntoStatic,
+
service_auth::JwtHeader,
+
types::{
+
did::Did,
+
did_doc::{DidDocument, VerificationMethod},
+
},
+
};
+
use jacquard_identity::resolver::{
+
DidDocResponse, IdentityError, IdentityResolver, ResolverOptions,
+
};
+
use reqwest::StatusCode as ReqwestStatusCode;
+
use serde_json::json;
+
use std::future::Future;
+
use tower::ServiceExt;
+
+
// Test helper: create a signed JWT
+
fn create_test_jwt(
+
iss: &str,
+
aud: &str,
+
exp: i64,
+
lxm: 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"),
+
};
+
+
let mut claims_json = json!({
+
"iss": iss,
+
"aud": aud,
+
"exp": exp,
+
"iat": chrono::Utc::now().timestamp(),
+
});
+
+
if let Some(lxm_val) = lxm {
+
claims_json["lxm"] = json!(lxm_val);
+
}
+
+
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
+
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims_json).unwrap());
+
+
let signing_input = format!("{}.{}", header_b64, payload_b64);
+
+
let signature: k256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
+
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
+
+
format!("{}.{}", signing_input, signature_b64)
+
}
+
+
// Test helper: create DID document with k256 key
+
fn create_test_did_doc(did: &str, public_key: &k256::ecdsa::VerifyingKey) -> DidDocument<'static> {
+
use std::collections::BTreeMap;
+
+
// Encode as compressed SEC1
+
let encoded_point = public_key.to_encoded_point(true);
+
let key_bytes = encoded_point.as_bytes();
+
+
// Multicodec prefix for secp256k1-pub (0xe701)
+
let mut multicodec_bytes = vec![0xe7, 0x01];
+
multicodec_bytes.extend_from_slice(key_bytes);
+
+
// Multibase encode (base58btc = 'z')
+
let multibase_key = multibase::encode(multibase::Base::Base58Btc, &multicodec_bytes);
+
+
DidDocument {
+
id: Did::new_owned(did).unwrap().into_static(),
+
also_known_as: None,
+
verification_method: Some(vec![VerificationMethod {
+
id: CowStr::Owned(format!("{}#atproto", did).into()),
+
r#type: CowStr::new_static("Multikey"),
+
controller: Some(CowStr::Owned(did.into())),
+
public_key_multibase: Some(CowStr::Owned(multibase_key.into())),
+
extra_data: BTreeMap::new(),
+
}]),
+
service: None,
+
extra_data: BTreeMap::new(),
+
}
+
}
+
+
// Mock resolver for tests
+
#[derive(Clone)]
+
struct MockResolver {
+
did_doc: DidDocument<'static>,
+
options: ResolverOptions,
+
}
+
+
impl MockResolver {
+
fn new(did_doc: DidDocument<'static>) -> Self {
+
Self {
+
did_doc,
+
options: ResolverOptions::default(),
+
}
+
}
+
}
+
+
impl IdentityResolver for MockResolver {
+
fn options(&self) -> &ResolverOptions {
+
&self.options
+
}
+
+
fn resolve_handle(
+
&self,
+
_handle: &jacquard_common::types::string::Handle<'_>,
+
) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send {
+
async { Err(IdentityError::InvalidWellKnown) }
+
}
+
+
fn resolve_did_doc(
+
&self,
+
_did: &Did<'_>,
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send {
+
let doc = self.did_doc.clone();
+
async move {
+
let json = serde_json::to_vec(&doc).unwrap();
+
Ok(DidDocResponse {
+
buffer: Bytes::from(json),
+
status: ReqwestStatusCode::OK,
+
requested: Some(doc.id.clone()),
+
})
+
}
+
}
+
}
+
+
#[tokio::test]
+
async fn test_extractor_with_valid_jwt() {
+
// Generate keypair
+
let signing_key = k256::ecdsa::SigningKey::random(&mut rand::thread_rng());
+
let verifying_key = signing_key.verifying_key();
+
+
// Create test DID and JWT
+
let user_did = "did:plc:test123";
+
let service_did = "did:web:feedgen.example.com";
+
let exp = chrono::Utc::now().timestamp() + 300;
+
+
// JWT with lxm
+
let jwt = create_test_jwt(
+
user_did,
+
service_did,
+
exp,
+
Some("app.bsky.feed.getFeedSkeleton"),
+
&signing_key,
+
);
+
+
// Create mock resolver
+
let did_doc = create_test_did_doc(user_did, verifying_key);
+
let resolver = MockResolver::new(did_doc);
+
+
// Create config (default: require_lxm = true)
+
let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
+
+
// Create handler
+
async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
let app = Router::new()
+
.route("/test", get(handler))
+
.with_state(config);
+
+
// Create request with JWT
+
let request = Request::builder()
+
.uri("/test")
+
.header(header::AUTHORIZATION, format!("Bearer {}", jwt))
+
.body(Body::empty())
+
.unwrap();
+
+
// Send request
+
let response = app.oneshot(request).await.unwrap();
+
+
assert_eq!(response.status(), StatusCode::OK);
+
+
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
+
.await
+
.unwrap();
+
let body = String::from_utf8(body_bytes.to_vec()).unwrap();
+
+
assert_eq!(body, format!("Authenticated as {}", user_did));
+
}
+
+
#[tokio::test]
+
async fn test_extractor_with_expired_jwt() {
+
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; // Expired
+
+
let jwt = create_test_jwt(user_did, service_did, exp, 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) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
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_extractor_with_wrong_audience() {
+
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 wrong_aud = "did:web:other.example.com";
+
let exp = chrono::Utc::now().timestamp() + 300;
+
+
let jwt = create_test_jwt(user_did, wrong_aud, exp, 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) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
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_extractor_missing_auth_header() {
+
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 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) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
let app = Router::new()
+
.route("/test", get(handler))
+
.with_state(config);
+
+
let request = Request::builder().uri("/test").body(Body::empty()).unwrap();
+
+
let response = app.oneshot(request).await.unwrap();
+
+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
}
+
+
#[tokio::test]
+
async fn test_middleware_with_valid_jwt() {
+
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;
+
+
// JWT with lxm
+
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);
+
+
// Create config (default: require_lxm = true)
+
let config = ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver);
+
+
async fn handler(Extension(auth): Extension<VerifiedServiceAuth<'static>>) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
let app = Router::new()
+
.route("/test", get(handler))
+
.layer(middleware::from_fn_with_state(
+
config.clone(),
+
service_auth_middleware::<ServiceAuthConfig<MockResolver>>,
+
))
+
.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::OK);
+
+
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
+
.await
+
.unwrap();
+
let body = String::from_utf8(body_bytes.to_vec()).unwrap();
+
+
assert_eq!(body, format!("Authenticated as {}", user_did));
+
}
+
+
#[tokio::test]
+
async fn test_require_lxm() {
+
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;
+
+
// JWT without lxm
+
let jwt = create_test_jwt(user_did, service_did, exp, 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).require_lxm(true);
+
+
async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
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();
+
+
// Should fail because lxm is required but missing
+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
}
+
+
#[tokio::test]
+
async fn test_with_lxm_present() {
+
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;
+
+
// JWT with lxm
+
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).require_lxm(true);
+
+
async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
+
format!(
+
"Authenticated as {} for {}",
+
auth.did(),
+
auth.lxm().unwrap()
+
)
+
}
+
+
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::OK);
+
+
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
+
.await
+
.unwrap();
+
let body = String::from_utf8(body_bytes.to_vec()).unwrap();
+
+
assert_eq!(
+
body,
+
format!(
+
"Authenticated as {} for app.bsky.feed.getFeedSkeleton",
+
user_did
+
)
+
);
+
}
+
+
#[tokio::test]
+
async fn test_legacy_without_lxm() {
+
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;
+
+
// JWT without lxm
+
let jwt = create_test_jwt(user_did, service_did, exp, None, &signing_key);
+
+
let did_doc = create_test_did_doc(user_did, verifying_key);
+
let resolver = MockResolver::new(did_doc);
+
+
// Legacy config: lxm not required
+
let config =
+
ServiceAuthConfig::new(Did::new_static(service_did).unwrap(), resolver).require_lxm(false);
+
+
async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
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();
+
+
// Should succeed because lxm is not required
+
assert_eq!(response.status(), StatusCode::OK);
+
+
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
+
.await
+
.unwrap();
+
let body = String::from_utf8(body_bytes.to_vec()).unwrap();
+
+
assert_eq!(body, format!("Authenticated as {}", user_did));
+
}
+
+
#[tokio::test]
+
async fn test_invalid_signature() {
+
// Real JWT token from did:plc:uc7pehijmk5jrllip4cglxdd with bogus signature
+
let token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NjAzOTMyMzUsImlzcyI6ImRpZDpwbGM6dWM3cGVoaWptazVqcmxsaXA0Y2dseGRkIiwiYXVkIjoiZGlkOndlYjpkZXYucGRzbW9vdmVyLmNvbSIsImV4cCI6MTc2MDM5MzI5NSwibHhtIjoiY29tLnBkc21vb3Zlci5iYWNrdXAuc2lnblVwIiwianRpIjoiMTk0MDQzMzQyNmMyNTNlZjhmNmYxZDJjZWE1YzI0NGMifQ.h5BrgYE";
+
+
// Real DID document for did:plc:uc7pehijmk5jrllip4cglxdd
+
let did_doc_json = r##"{
+
"id": "did:plc:uc7pehijmk5jrllip4cglxdd",
+
"alsoKnownAs": ["at://bailey.skeetcentral.com"],
+
"verificationMethod": [{
+
"controller": "did:plc:uc7pehijmk5jrllip4cglxdd",
+
"id": "did:plc:uc7pehijmk5jrllip4cglxdd#atproto",
+
"publicKeyMultibase": "zQ3shNBS3N4EB3vX5G1HoxFkS8tDLFXUHaV85rHQZgVM88rM5",
+
"type": "Multikey"
+
}],
+
"service": [{
+
"id": "#atproto_pds",
+
"serviceEndpoint": "https://skeetcentral.com",
+
"type": "AtprotoPersonalDataServer"
+
}]
+
}"##;
+
+
let did_doc: DidDocument = serde_json::from_str(did_doc_json).unwrap();
+
let resolver = MockResolver::new(did_doc);
+
+
let config = ServiceAuthConfig::new(
+
Did::new_static("did:web:dev.pdsmoover.com").unwrap(),
+
resolver,
+
);
+
+
async fn handler(ExtractServiceAuth(auth): ExtractServiceAuth) -> String {
+
format!("Authenticated as {}", auth.did())
+
}
+
+
let app = Router::new()
+
.route("/test", get(handler))
+
.with_state(config);
+
+
let request = Request::builder()
+
.uri("/test")
+
.header(header::AUTHORIZATION, format!("Bearer {}", token))
+
.body(Body::empty())
+
.unwrap();
+
+
let response = app.oneshot(request).await.unwrap();
+
+
// Should fail due to invalid signature
+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
}
+5 -3
crates/jacquard-common/Cargo.toml
···
reqwest = { workspace = true, optional = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] }
serde_ipld_dagcbor.workspace = true
trait-variant.workspace = true
+
signature = { version = "2", optional = true }
[features]
-
default = []
+
default = ["service-auth", "reqwest-client", "crypto"]
crypto = []
crypto-ed25519 = ["crypto", "dep:ed25519-dalek"]
-
crypto-k256 = ["crypto", "dep:k256"]
-
crypto-p256 = ["crypto", "dep:p256"]
+
crypto-k256 = ["crypto", "dep:k256", "k256/ecdsa"]
+
crypto-p256 = ["crypto", "dep:p256", "p256/ecdsa"]
+
service-auth = ["crypto-k256", "crypto-p256", "dep:signature"]
reqwest-client = ["dep:reqwest"]
[dependencies.ed25519-dalek]
+47 -36
crates/jacquard-common/src/cowstr.rs
···
-
use serde::{Deserialize, Serialize};
+
use serde::{Deserialize, Deserializer, Serialize};
use smol_str::SmolStr;
use std::{
borrow::Cow,
···
}
}
+
/// Deserialization helper for things that wrap a CowStr
+
pub struct CowStrVisitor;
+
+
impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
+
type Value = CowStr<'de>;
+
+
#[inline]
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+
write!(formatter, "a string")
+
}
+
+
#[inline]
+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+
where
+
E: serde::de::Error,
+
{
+
Ok(CowStr::copy_from_str(v))
+
}
+
+
#[inline]
+
fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
+
where
+
E: serde::de::Error,
+
{
+
Ok(CowStr::Borrowed(v))
+
}
+
+
#[inline]
+
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
+
where
+
E: serde::de::Error,
+
{
+
Ok(v.into())
+
}
+
}
+
impl<'de, 'a> Deserialize<'de> for CowStr<'a>
where
'de: 'a,
···
where
D: serde::Deserializer<'de>,
{
-
struct CowStrVisitor;
-
-
impl<'de> serde::de::Visitor<'de> for CowStrVisitor {
-
type Value = CowStr<'de>;
-
-
#[inline]
-
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
-
write!(formatter, "a string")
-
}
-
-
#[inline]
-
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
-
where
-
E: serde::de::Error,
-
{
-
Ok(CowStr::copy_from_str(v))
-
}
-
-
#[inline]
-
fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
-
where
-
E: serde::de::Error,
-
{
-
Ok(CowStr::Borrowed(v))
-
}
-
-
#[inline]
-
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
-
where
-
E: serde::de::Error,
-
{
-
Ok(v.into())
-
}
-
}
-
deserializer.deserialize_str(CowStrVisitor)
}
+
}
+
+
/// Serde helper for deserializing stuff when you want an owned version
+
pub fn deserialize_owned<'de, T, D>(deserializer: D) -> Result<<T as IntoStatic>::Output, D::Error>
+
where
+
T: Deserialize<'de> + IntoStatic,
+
D: Deserializer<'de>,
+
{
+
let value = T::deserialize(deserializer)?;
+
Ok(value.into_static())
}
/// Convert to a CowStr.
+3
crates/jacquard-common/src/lib.rs
···
pub mod macros;
/// Generic session storage traits and utilities.
pub mod session;
+
/// Service authentication JWT parsing and verification.
+
#[cfg(feature = "service-auth")]
+
pub mod service_auth;
/// Baseline fundamental AT Protocol data types.
pub mod types;
// XRPC protocol types and traits
+480
crates/jacquard-common/src/service_auth.rs
···
+
//! Service authentication JWT parsing and verification for AT Protocol.
+
//!
+
//! Service auth is atproto's inter-service authentication mechanism. When a backend
+
//! service (feed generator, labeler, etc.) receives requests, the PDS signs a
+
//! short-lived JWT with the user's signing key and includes it as a Bearer token.
+
//!
+
//! # JWT Structure
+
//!
+
//! - Header: `alg` (ES256K for k256, ES256 for p256), `typ` ("JWT")
+
//! - Payload:
+
//! - `iss`: user's DID (issuer)
+
//! - `aud`: target service DID (audience)
+
//! - `exp`: expiration unix timestamp
+
//! - `iat`: issued at unix timestamp
+
//! - `jti`: random nonce (128-bit hex) for replay protection
+
//! - `lxm`: lexicon method NSID (method binding)
+
//! - Signature: signed with user's signing key from DID doc (ES256 or ES256K)
+
+
use crate::CowStr;
+
use crate::IntoStatic;
+
use crate::types::string::{Did, Nsid};
+
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;
+
use smol_str::format_smolstr;
+
use thiserror::Error;
+
+
#[cfg(feature = "crypto-p256")]
+
use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey};
+
+
#[cfg(feature = "crypto-k256")]
+
use k256::ecdsa::{Signature as K256Signature, VerifyingKey as K256VerifyingKey};
+
+
/// Errors that can occur during JWT parsing and verification.
+
#[derive(Debug, Error, miette::Diagnostic)]
+
pub enum ServiceAuthError {
+
/// JWT format is invalid (not three base64-encoded parts separated by dots)
+
#[error("malformed JWT: {0}")]
+
MalformedToken(CowStr<'static>),
+
+
/// Base64 decoding failed
+
#[error("base64 decode error: {0}")]
+
Base64Decode(#[from] base64::DecodeError),
+
+
/// JSON parsing failed
+
#[error("JSON parsing error: {0}")]
+
JsonParse(#[from] serde_json::Error),
+
+
/// Signature verification failed
+
#[error("invalid signature")]
+
InvalidSignature,
+
+
/// Unsupported algorithm
+
#[error("unsupported algorithm: {alg}")]
+
UnsupportedAlgorithm {
+
/// Algorithm name from JWT header
+
alg: SmolStr,
+
},
+
+
/// Token has expired
+
#[error("token expired at {exp} (current time: {now})")]
+
Expired {
+
/// Expiration timestamp from token
+
exp: i64,
+
/// Current timestamp
+
now: i64,
+
},
+
+
/// Audience mismatch
+
#[error("audience mismatch: expected {expected}, got {actual}")]
+
AudienceMismatch {
+
/// Expected audience DID
+
expected: Did<'static>,
+
/// Actual audience DID in token
+
actual: Did<'static>,
+
},
+
+
/// Method mismatch (lxm field)
+
#[error("method mismatch: expected {expected}, got {actual:?}")]
+
MethodMismatch {
+
/// Expected method NSID
+
expected: Nsid<'static>,
+
/// Actual method NSID in token (if any)
+
actual: Option<Nsid<'static>>,
+
},
+
+
/// Missing required field
+
#[error("missing required field: {0}")]
+
MissingField(&'static str),
+
+
/// Crypto error
+
#[error("crypto error: {0}")]
+
Crypto(CowStr<'static>),
+
}
+
+
/// 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>,
+
}
+
+
impl IntoStatic for JwtHeader<'_> {
+
type Output = JwtHeader<'static>;
+
+
fn into_static(self) -> Self::Output {
+
JwtHeader {
+
alg: self.alg.into_static(),
+
typ: self.typ.into_static(),
+
}
+
}
+
}
+
+
/// Service authentication claims.
+
///
+
/// These are the payload fields in a service auth JWT.
+
#[derive(Debug, Clone, Serialize, Deserialize)]
+
pub struct ServiceAuthClaims<'a> {
+
/// Issuer (user's DID)
+
#[serde(borrow)]
+
pub iss: Did<'a>,
+
+
/// Audience (target service DID)
+
#[serde(borrow)]
+
pub aud: Did<'a>,
+
+
/// Expiration time (unix timestamp)
+
pub exp: i64,
+
+
/// Issued at (unix timestamp)
+
pub iat: i64,
+
+
/// JWT ID (nonce for replay protection)
+
#[serde(borrow, skip_serializing_if = "Option::is_none")]
+
pub jti: Option<CowStr<'a>>,
+
+
/// Lexicon method NSID (method binding)
+
#[serde(borrow, skip_serializing_if = "Option::is_none")]
+
pub lxm: Option<Nsid<'a>>,
+
}
+
+
impl<'a> IntoStatic for ServiceAuthClaims<'a> {
+
type Output = ServiceAuthClaims<'static>;
+
+
fn into_static(self) -> Self::Output {
+
ServiceAuthClaims {
+
iss: self.iss.into_static(),
+
aud: self.aud.into_static(),
+
exp: self.exp,
+
iat: self.iat,
+
jti: self.jti.map(|j| j.into_static()),
+
lxm: self.lxm.map(|l| l.into_static()),
+
}
+
}
+
}
+
+
impl<'a> ServiceAuthClaims<'a> {
+
/// Validate the claims against expected values.
+
///
+
/// Checks:
+
/// - Audience matches expected DID
+
/// - Token is not expired
+
pub fn validate(&self, expected_aud: &Did) -> Result<(), ServiceAuthError> {
+
// Check audience
+
if self.aud.as_str() != expected_aud.as_str() {
+
return Err(ServiceAuthError::AudienceMismatch {
+
expected: expected_aud.clone().into_static(),
+
actual: self.aud.clone().into_static(),
+
});
+
}
+
+
// Check expiration
+
if self.is_expired() {
+
let now = chrono::Utc::now().timestamp();
+
return Err(ServiceAuthError::Expired { exp: self.exp, now });
+
}
+
+
Ok(())
+
}
+
+
/// Check if the token has expired.
+
pub fn is_expired(&self) -> bool {
+
let now = chrono::Utc::now().timestamp();
+
self.exp <= now
+
}
+
+
/// Check if the method (lxm) matches the expected NSID.
+
pub fn check_method(&self, nsid: &Nsid) -> bool {
+
self.lxm
+
.as_ref()
+
.map(|lxm| lxm.as_str() == nsid.as_str())
+
.unwrap_or(false)
+
}
+
+
/// Require that the method (lxm) matches the expected NSID.
+
pub fn require_method(&self, nsid: &Nsid) -> Result<(), ServiceAuthError> {
+
if !self.check_method(nsid) {
+
return Err(ServiceAuthError::MethodMismatch {
+
expected: nsid.clone().into_static(),
+
actual: self.lxm.as_ref().map(|l| l.clone().into_static()),
+
});
+
}
+
Ok(())
+
}
+
}
+
+
/// 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<u8>,
+
/// Decoded payload buffer (owned)
+
payload_buf: Vec<u8>,
+
/// Original token string for signing_input
+
token: String,
+
/// Signature bytes
+
signature: Vec<u8>,
+
/// 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 {
+
/// 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()
+
})
+
}
+
+
/// Get a reference to the header.
+
pub fn header(&self) -> &JwtHeader<'_> {
+
self.borrow_header()
+
}
+
+
/// Get a reference to the claims.
+
pub fn claims(&self) -> &ServiceAuthClaims<'_> {
+
self.borrow_claims()
+
}
+
+
/// Get a reference to the signature.
+
pub fn signature(&self) -> &[u8] {
+
self.borrow_signature()
+
}
+
+
/// Get owned header with 'static lifetime.
+
pub fn into_header(self) -> JwtHeader<'static> {
+
self.with_header(|header| header.clone().into_static())
+
}
+
+
/// Get owned claims with 'static lifetime.
+
pub fn into_claims(self) -> ServiceAuthClaims<'static> {
+
self.with_claims(|claims| claims.clone().into_static())
+
}
+
}
+
+
/// 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.
+
pub fn parse_jwt(token: &str) -> Result<ParsedJwt, ServiceAuthError> {
+
let parts: Vec<&str> = token.split('.').collect();
+
if parts.len() != 3 {
+
return Err(ServiceAuthError::MalformedToken(CowStr::new_static(
+
"JWT must have exactly 3 parts separated by dots",
+
)));
+
}
+
+
let header_b64 = parts[0];
+
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(),
+
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.
+
#[derive(Debug, Clone)]
+
pub enum PublicKey {
+
/// P-256 (ES256) public key
+
#[cfg(feature = "crypto-p256")]
+
P256(P256VerifyingKey),
+
+
/// secp256k1 (ES256K) public key
+
#[cfg(feature = "crypto-k256")]
+
K256(K256VerifyingKey),
+
}
+
+
impl PublicKey {
+
/// Create a P-256 public key from compressed or uncompressed bytes.
+
#[cfg(feature = "crypto-p256")]
+
pub fn from_p256_bytes(bytes: &[u8]) -> Result<Self, ServiceAuthError> {
+
let key = P256VerifyingKey::from_sec1_bytes(bytes).map_err(|e| {
+
ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!("invalid P-256 key: {}", e)))
+
})?;
+
Ok(PublicKey::P256(key))
+
}
+
+
/// Create a secp256k1 public key from compressed or uncompressed bytes.
+
#[cfg(feature = "crypto-k256")]
+
pub fn from_k256_bytes(bytes: &[u8]) -> Result<Self, ServiceAuthError> {
+
let key = K256VerifyingKey::from_sec1_bytes(bytes).map_err(|e| {
+
ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!("invalid K-256 key: {}", e)))
+
})?;
+
Ok(PublicKey::K256(key))
+
}
+
}
+
+
/// Verify a JWT signature using the provided public key.
+
///
+
/// The algorithm is determined by the JWT header and must match the public key type.
+
pub fn verify_signature(
+
parsed: &ParsedJwt,
+
public_key: &PublicKey,
+
) -> Result<(), ServiceAuthError> {
+
let alg = parsed.header().alg.as_str();
+
let signing_input = parsed.signing_input();
+
let signature = parsed.signature();
+
+
match (alg, public_key) {
+
#[cfg(feature = "crypto-p256")]
+
("ES256", PublicKey::P256(key)) => {
+
let sig = P256Signature::from_slice(signature).map_err(|e| {
+
ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!(
+
"invalid ES256 signature: {}",
+
e
+
)))
+
})?;
+
key.verify(signing_input, &sig)
+
.map_err(|_| ServiceAuthError::InvalidSignature)?;
+
Ok(())
+
}
+
+
#[cfg(feature = "crypto-k256")]
+
("ES256K", PublicKey::K256(key)) => {
+
let sig = K256Signature::from_slice(signature).map_err(|e| {
+
ServiceAuthError::Crypto(CowStr::Owned(format_smolstr!(
+
"invalid ES256K signature: {}",
+
e
+
)))
+
})?;
+
key.verify(signing_input, &sig)
+
.map_err(|_| ServiceAuthError::InvalidSignature)?;
+
Ok(())
+
}
+
+
_ => Err(ServiceAuthError::UnsupportedAlgorithm {
+
alg: SmolStr::new(alg),
+
}),
+
}
+
}
+
+
/// Parse and verify a service auth JWT in one step, returning owned claims.
+
///
+
/// This is a convenience function that combines parsing and signature verification.
+
pub fn verify_service_jwt(
+
token: &str,
+
public_key: &PublicKey,
+
) -> Result<ServiceAuthClaims<'static>, ServiceAuthError> {
+
let parsed = parse_jwt(token)?;
+
verify_signature(&parsed, public_key)?;
+
Ok(parsed.into_claims())
+
}
+
+
#[cfg(test)]
+
mod tests {
+
use super::*;
+
+
#[test]
+
fn test_parse_jwt_invalid_format() {
+
let result = parse_jwt("not.a.valid.jwt.with.too.many.parts");
+
assert!(matches!(result, Err(ServiceAuthError::MalformedToken(_))));
+
}
+
+
#[test]
+
fn test_claims_expiration() {
+
let now = chrono::Utc::now().timestamp();
+
let expired_claims = ServiceAuthClaims {
+
iss: Did::new("did:plc:test").unwrap(),
+
aud: Did::new("did:web:example.com").unwrap(),
+
exp: now - 100,
+
iat: now - 200,
+
jti: None,
+
lxm: None,
+
};
+
+
assert!(expired_claims.is_expired());
+
+
let valid_claims = ServiceAuthClaims {
+
iss: Did::new("did:plc:test").unwrap(),
+
aud: Did::new("did:web:example.com").unwrap(),
+
exp: now + 100,
+
iat: now,
+
jti: None,
+
lxm: None,
+
};
+
+
assert!(!valid_claims.is_expired());
+
}
+
+
#[test]
+
fn test_audience_validation() {
+
let now = chrono::Utc::now().timestamp();
+
let claims = ServiceAuthClaims {
+
iss: Did::new("did:plc:test").unwrap(),
+
aud: Did::new("did:web:example.com").unwrap(),
+
exp: now + 100,
+
iat: now,
+
jti: None,
+
lxm: None,
+
};
+
+
let expected_aud = Did::new("did:web:example.com").unwrap();
+
assert!(claims.validate(&expected_aud).is_ok());
+
+
let wrong_aud = Did::new("did:web:wrong.com").unwrap();
+
assert!(matches!(
+
claims.validate(&wrong_aud),
+
Err(ServiceAuthError::AudienceMismatch { .. })
+
));
+
}
+
+
#[test]
+
fn test_method_check() {
+
let claims = ServiceAuthClaims {
+
iss: Did::new("did:plc:test").unwrap(),
+
aud: Did::new("did:web:example.com").unwrap(),
+
exp: chrono::Utc::now().timestamp() + 100,
+
iat: chrono::Utc::now().timestamp(),
+
jti: None,
+
lxm: Some(Nsid::new("app.bsky.feed.getFeedSkeleton").unwrap()),
+
};
+
+
let expected = Nsid::new("app.bsky.feed.getFeedSkeleton").unwrap();
+
assert!(claims.check_method(&expected));
+
+
let wrong = Nsid::new("app.bsky.feed.getTimeline").unwrap();
+
assert!(!claims.check_method(&wrong));
+
}
+
}
-2
crates/jacquard-common/src/types.rs
···
pub mod integer;
/// Language tag types per BCP 47
pub mod language;
-
/// CID link wrapper for JSON serialization
-
pub mod link;
/// Namespaced Identifier (NSID) types and validation
pub mod nsid;
/// Record key types and validation
+6 -1
crates/jacquard-common/src/types/aturi.rs
···
.as_ref()
.and_then(|p| p.rkey.as_ref())
}
+
+
/// Fallible constructor, validates, borrows from input if possible
+
pub fn new_cow(uri: CowStr<'u>) -> Result<Self, AtStrError> {
+
Self::try_from(uri)
+
}
}
impl AtUri<'static> {
···
D: Deserializer<'de>,
{
let value = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+6 -1
crates/jacquard-common/src/types/blob.rs
···
Ok(Self(mime_type))
}
+
/// Fallible constructor, validates, borrows from input if possible
+
pub fn new_cow(mime_type: CowStr<'m>) -> Result<MimeType<'m>, &'static str> {
+
Self::from_cowstr(mime_type)
+
}
+
/// Infallible constructor for trusted MIME type strings
pub fn raw(mime_type: &'m str) -> Self {
Self(CowStr::Borrowed(mime_type))
···
D: Deserializer<'de>,
{
let value = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+32 -10
crates/jacquard-common/src/types/cid.rs
···
pub use cid::Cid as IpldCid;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Visitor};
use smol_str::ToSmolStr;
-
use std::{convert::Infallible, fmt, marker::PhantomData, ops::Deref, str::FromStr};
+
use std::{convert::Infallible, fmt, ops::Deref, str::FromStr};
/// CID codec for AT Protocol (raw)
pub const ATP_CID_CODEC: u64 = 0x55;
···
/// This type supports both string and parsed IPLD forms, with string caching
/// for the parsed form to optimize serialization.
///
-
/// Deserialization automatically detects the format (bytes trigger IPLD parsing).
+
/// # Validation
+
///
+
/// String deserialization does NOT validate CIDs. This is intentional for performance:
+
/// CID strings from AT Protocol endpoints are generally trustworthy, so validation
+
/// is deferred until needed. Use `to_ipld()` to parse and validate, or `is_valid()`
+
/// to check without parsing.
+
///
+
/// Byte deserialization (CBOR) parses immediately since the data is already in binary form.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Cid<'c> {
/// Parsed IPLD CID with cached string representation
···
Cid::Str(cow_str) => cow_str.as_ref(),
}
}
+
+
/// Check if the CID string is valid without parsing
+
///
+
/// Returns `true` if the CID is already parsed (`Ipld` variant) or if
+
/// the string can be successfully parsed as an IPLD CID.
+
pub fn is_valid(&self) -> bool {
+
match self {
+
Cid::Ipld { .. } => true,
+
Cid::Str(s) => IpldCid::try_from(s.as_ref()).is_ok(),
+
}
+
}
}
impl std::fmt::Display for Cid<'_> {
···
where
D: Deserializer<'de>,
{
-
struct StringOrBytes<T>(PhantomData<fn() -> T>);
+
struct CidVisitor;
-
impl<'de, T> Visitor<'de> for StringOrBytes<T>
-
where
-
T: Deserialize<'de> + FromStr<Err = Infallible> + From<IpldCid>,
-
{
-
type Value = T;
+
impl<'de> Visitor<'de> for CidVisitor {
+
type Value = Cid<'de>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("either valid IPLD CID bytes or a str")
}
+
fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
+
where
+
E: serde::de::Error,
+
{
+
Ok(Cid::str(v))
+
}
+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
···
E: serde::de::Error,
{
let hash = cid::multihash::Multihash::from_bytes(v).map_err(|e| E::custom(e))?;
-
Ok(T::from(IpldCid::new_v1(ATP_CID_CODEC, hash)))
+
Ok(Cid::ipld(IpldCid::new_v1(ATP_CID_CODEC, hash)))
}
}
-
deserializer.deserialize_any(StringOrBytes(PhantomData))
+
deserializer.deserialize_any(CidVisitor)
}
}
+23 -1
crates/jacquard-common/src/types/did.rs
···
}
}
+
/// Fallible constructor, validates, borrows from input if possible
+
///
+
/// May allocate for a long DID with an at:// prefix, otherwise borrows.
+
pub fn new_cow(did: CowStr<'d>) -> Result<Self, AtStrError> {
+
let did = if let Some(did) = did.strip_prefix("at://") {
+
CowStr::copy_from_str(did)
+
} else {
+
did
+
};
+
if did.len() > 2048 {
+
Err(AtStrError::too_long("did", &did, 2048, did.len()))
+
} else if !DID_REGEX.is_match(&did) {
+
Err(AtStrError::regex(
+
"did",
+
&did,
+
SmolStr::new_static("invalid"),
+
))
+
} else {
+
Ok(Self(did))
+
}
+
}
+
/// Fallible constructor, validates, takes ownership
pub fn new_owned(did: impl AsRef<str>) -> Result<Self, AtStrError> {
let did = did.as_ref();
···
D: Deserializer<'de>,
{
let value = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+29 -1
crates/jacquard-common/src/types/handle.rs
···
Ok(Self(CowStr::new_static(handle)))
}
}
+
+
/// Fallible constructor, validates, borrows from input if possible
+
///
+
/// May allocate for a long handle with an at:// or @ prefix, otherwise borrows.
+
/// Accepts (and strips) preceding '@' or 'at://' if present
+
pub fn new_cow(handle: CowStr<'h>) -> Result<Self, AtStrError> {
+
let handle = if let Some(stripped) = handle.strip_prefix("at://") {
+
CowStr::copy_from_str(stripped)
+
} else if let Some(stripped) = handle.strip_prefix('@') {
+
CowStr::copy_from_str(stripped)
+
} else {
+
handle
+
};
+
if handle.len() > 253 {
+
Err(AtStrError::too_long("handle", &handle, 253, handle.len()))
+
} else if !HANDLE_REGEX.is_match(&handle) {
+
Err(AtStrError::regex(
+
"handle",
+
&handle,
+
SmolStr::new_static("invalid"),
+
))
+
} else if ends_with(&handle, DISALLOWED_TLDS) {
+
Err(AtStrError::disallowed("handle", &handle, DISALLOWED_TLDS))
+
} else {
+
Ok(Self(handle))
+
}
+
}
+
/// Infallible constructor for when you *know* the string is a valid handle.
/// Will panic on invalid handles. If you're manually decoding atproto records
/// or API values you know are valid (rather than using serde), this is the one to use.
···
D: Deserializer<'de>,
{
let value = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+9
crates/jacquard-common/src/types/ident.rs
···
}
}
+
/// Fallible constructor, validates, borrows from input if possible
+
pub fn new_cow(ident: CowStr<'i>) -> Result<Self, AtStrError> {
+
if let Ok(did) = Did::new_cow(ident.clone()) {
+
Ok(AtIdentifier::Did(did))
+
} else {
+
Ok(AtIdentifier::Handle(Handle::new_cow(ident)?))
+
}
+
}
+
/// Infallible constructor for when you *know* the string is a valid identifier.
/// Will panic on invalid identifiers. If you're manually decoding atproto records
/// or API values you know are valid (rather than using serde), this is the one to use.
-1
crates/jacquard-common/src/types/link.rs
···
-
// strongref, blobref(s), cid links
+17 -2
crates/jacquard-common/src/types/nsid.rs
···
}
}
+
/// Fallible constructor, validates, borrows from input if possible
+
pub fn new_cow(nsid: CowStr<'n>) -> Result<Self, AtStrError> {
+
if nsid.len() > 317 {
+
Err(AtStrError::too_long("nsid", &nsid, 317, nsid.len()))
+
} else if !NSID_REGEX.is_match(&nsid) {
+
Err(AtStrError::regex(
+
"nsid",
+
&nsid,
+
SmolStr::new_static("invalid"),
+
))
+
} else {
+
Ok(Self(nsid))
+
}
+
}
+
/// Infallible constructor for when you *know* the string is a valid NSID.
/// Will panic on invalid NSIDs. If you're manually decoding atproto records
/// or API values you know are valid (rather than using serde), this is the one to use.
···
where
D: Deserializer<'de>,
{
-
let value: &str = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
let value = Deserialize::deserialize(deserializer)?;
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+17 -2
crates/jacquard-common/src/types/recordkey.rs
···
}
}
+
/// Fallible constructor, validates, borrows from input if possible
+
pub fn new_cow(rkey: CowStr<'r>) -> Result<Self, AtStrError> {
+
if [".", ".."].contains(&rkey.as_ref()) {
+
Err(AtStrError::disallowed("record-key", &rkey, &[".", ".."]))
+
} else if !RKEY_REGEX.is_match(&rkey) {
+
Err(AtStrError::regex(
+
"record-key",
+
&rkey,
+
SmolStr::new_static("doesn't match 'any' schema"),
+
))
+
} else {
+
Ok(Self(rkey))
+
}
+
}
+
/// Infallible constructor for when you *know* the string is a valid rkey.
/// Will panic on invalid rkeys. If you're manually decoding atproto records
/// or API values you know are valid (rather than using serde), this is the one to use.
···
where
D: Deserializer<'de>,
{
-
let value: &str = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
let value = Deserialize::deserialize(deserializer)?;
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+30 -7
crates/jacquard-common/src/types/uri.rs
···
-
use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
use smol_str::ToSmolStr;
-
use url::Url;
-
use crate::{
CowStr, IntoStatic,
types::{aturi::AtUri, cid::Cid, did::Did, string::AtStrError},
};
+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
use smol_str::ToSmolStr;
+
use std::str::FromStr;
+
use url::Url;
/// Generic URI with type-specific parsing
///
···
} else if uri.starts_with("wss://") {
Ok(Uri::Https(Url::parse(uri)?))
} else if uri.starts_with("ipld://") {
-
Ok(Uri::Cid(Cid::new(uri.as_bytes())?))
+
Ok(Uri::Cid(
+
Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(),
+
))
} else {
Ok(Uri::Any(CowStr::Borrowed(uri)))
}
···
} else if uri.starts_with("wss://") {
Ok(Uri::Https(Url::parse(uri)?))
} else if uri.starts_with("ipld://") {
-
Ok(Uri::Cid(Cid::new_owned(uri.as_bytes())?))
+
Ok(Uri::Cid(
+
Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_ref())).unwrap(),
+
))
} else {
Ok(Uri::Any(CowStr::Owned(uri.to_smolstr())))
}
}
+
/// Parse a URI from a CowStr, borrowing where possible
+
pub fn new_cow(uri: CowStr<'u>) -> Result<Self, UriParseError> {
+
if uri.starts_with("did:") {
+
Ok(Uri::Did(Did::new_cow(uri)?))
+
} else if uri.starts_with("at://") {
+
Ok(Uri::At(AtUri::new_cow(uri)?))
+
} else if uri.starts_with("https://") {
+
Ok(Uri::Https(Url::parse(uri.as_ref())?))
+
} else if uri.starts_with("wss://") {
+
Ok(Uri::Https(Url::parse(uri.as_ref())?))
+
} else if uri.starts_with("ipld://") {
+
Ok(Uri::Cid(
+
Cid::from_str(uri.strip_prefix("ipld://").unwrap_or(uri.as_str())).unwrap(),
+
))
+
} else {
+
Ok(Uri::Any(uri))
+
}
+
}
+
/// Get the URI as a string slice
pub fn as_str(&self) -> &str {
match self {
···
{
use serde::de::Error;
let value = Deserialize::deserialize(deserializer)?;
-
Self::new(value).map_err(D::Error::custom)
+
Self::new_cow(value).map_err(D::Error::custom)
}
}
+1 -1
crates/jacquard-common/src/xrpc.rs
···
fn send<R>(
&self,
request: R,
-
) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>>
+
) -> impl Future<Output = XrpcResult<Response<<R as XrpcRequest>::Response>>> + Send
where
R: XrpcRequest + Send + Sync,
<R as XrpcRequest>::Response: Send + Sync;
+7 -5
crates/jacquard-identity/src/lib.rs
···
use jacquard_common::{IntoStatic, types::string::Handle};
use percent_encoding::percent_decode_str;
use reqwest::StatusCode;
+
use std::sync::Arc;
use url::{ParseError, Url};
#[cfg(feature = "dns")]
use hickory_resolver::{TokioAsyncResolver, config::ResolverConfig};
/// Default resolver implementation with configurable fallback order.
+
#[derive(Clone)]
pub struct JacquardResolver {
http: reqwest::Client,
opts: ResolverOptions,
#[cfg(feature = "dns")]
-
dns: Option<TokioAsyncResolver>,
+
dns: Option<Arc<TokioAsyncResolver>>,
}
impl JacquardResolver {
···
Self {
http,
opts,
-
dns: Some(TokioAsyncResolver::tokio(
+
dns: Some(Arc::new(TokioAsyncResolver::tokio(
ResolverConfig::default(),
Default::default(),
-
)),
+
))),
}
}
#[cfg(feature = "dns")]
/// Add default DNS resolution to the resolver
pub fn with_system_dns(mut self) -> Self {
-
self.dns = Some(TokioAsyncResolver::tokio(
+
self.dns = Some(Arc::new(TokioAsyncResolver::tokio(
ResolverConfig::default(),
Default::default(),
-
));
+
)));
self
}
+28 -8
crates/jacquard-identity/src/resolver.rs
···
//! and optionally validate the document `id` against the requested DID.
use std::collections::BTreeMap;
+
use std::marker::Sync;
use std::str::FromStr;
use bon::Builder;
···
fn resolve_handle(
&self,
handle: &Handle<'_>,
-
) -> impl Future<Output = Result<Did<'static>, IdentityError>>;
+
) -> impl Future<Output = Result<Did<'static>, IdentityError>> + Send
+
where
+
Self: Sync;
/// Resolve DID document
fn resolve_did_doc(
&self,
did: &Did<'_>,
-
) -> impl Future<Output = Result<DidDocResponse, IdentityError>>;
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send
+
where
+
Self: Sync;
/// Resolve DID doc from an identifier
fn resolve_ident(
&self,
actor: &AtIdentifier<'_>,
-
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> {
+
) -> impl Future<Output = Result<DidDocResponse, IdentityError>> + Send
+
where
+
Self: Sync,
+
{
async move {
match actor {
AtIdentifier::Did(did) => self.resolve_did_doc(&did).await,
···
fn resolve_ident_owned(
&self,
actor: &AtIdentifier<'_>,
-
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> {
+
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send
+
where
+
Self: Sync,
+
{
async move {
match actor {
AtIdentifier::Did(did) => self.resolve_did_doc_owned(&did).await,
···
fn resolve_did_doc_owned(
&self,
did: &Did<'_>,
-
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> {
+
) -> impl Future<Output = Result<DidDocument<'static>, IdentityError>> + Send
+
where
+
Self: Sync,
+
{
async { self.resolve_did_doc(did).await?.into_owned() }
}
/// Return the PDS url for a DID
-
fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> {
+
fn pds_for_did(&self, did: &Did<'_>) -> impl Future<Output = Result<Url, IdentityError>> + Send
+
where
+
Self: Sync,
+
{
async {
let resp = self.resolve_did_doc(did).await?;
let doc = resp.parse()?;
···
fn pds_for_handle(
&self,
handle: &Handle<'_>,
-
) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> {
+
) -> impl Future<Output = Result<(Did<'static>, Url), IdentityError>> + Send
+
where
+
Self: Sync,
+
{
async {
let did = self.resolve_handle(handle).await?;
let pds = self.pds_for_did(&did).await?;
···
}
}
-
impl<T: IdentityResolver> IdentityResolver for std::sync::Arc<T> {
+
impl<T: IdentityResolver + Sync> IdentityResolver for std::sync::Arc<T> {
fn options(&self) -> &ResolverOptions {
self.as_ref().options()
}
+21 -6
crates/jacquard-oauth/src/resolver.rs
···
&self,
server_metadata: &OAuthAuthorizationServerMetadata<'_>,
sub: &Did<'_>,
-
) -> impl std::future::Future<Output = Result<Url, ResolverError>> {
+
) -> impl std::future::Future<Output = Result<Url, ResolverError>> + Send
+
where
+
Self: Sync,
+
{
async {
let (metadata, identity) = self.resolve_from_identity(sub).await?;
if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) {
···
),
ResolverError,
>,
-
> {
+
> + Send
+
where
+
Self: Sync,
+
{
// Allow using an entryway, or PDS url, directly as login input (e.g.
// when the user forgot their handle, or when the handle does not
// resolve to a DID)
···
fn resolve_from_service(
&self,
input: &Url,
-
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>>
+
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send
+
where
+
Self: Sync,
{
async {
// Assume first that input is a PDS URL (as required by ATPROTO)
···
),
ResolverError,
>,
-
> {
+
> + Send
+
where
+
Self: Sync,
+
{
async {
let actor = AtIdentifier::new(input)
.map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?;
···
fn get_authorization_server_metadata(
&self,
issuer: &Url,
-
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>>
+
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send
+
where
+
Self: Sync,
{
async {
let mut md = resolve_authorization_server(self, issuer).await?;
···
fn get_resource_server_metadata(
&self,
pds: &Url,
-
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>>
+
) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> + Send
+
where
+
Self: Sync,
{
async move {
let rs_metadata = resolve_protected_resource_info(self, pds).await?;
+1 -1
crates/jacquard/src/client/credential_session.rs
···
impl<S, T> CredentialSession<S, T>
where
S: SessionStore<SessionKey, AtpSession>,
-
T: HttpClient + IdentityResolver + XrpcExt,
+
T: HttpClient + IdentityResolver + XrpcExt + Sync + Send,
{
/// Resolve the user's PDS and create an app-password session.
///