use std::sync::Arc;
use proto_blue_crypto::{K256Keypair, Verifier as _, k256_compress_pubkey, parse_multikey};
use crate::auth::did::DidResolver;
use crate::auth::jwt;
use crate::xrpc_gateway::Nsid;
use crate::xrpc_gateway::config::XrpcGatewayConfig;
use crate::xrpc_gateway::error::XrpcAuthError;
const VERIFICATION_METHOD_FRAGMENT: &str = "#atproto";
const ACCEPTED_ALG: &str = "ES256K";
#[derive(Debug, Clone)]
pub struct XrpcAuthClaims {
pub iss: String,
pub aud: String,
pub lxm: Nsid,
pub exp: i64,
pub jti: String,
pub iat: Option<i64>,
}
pub struct XrpcAuthService {
config: XrpcGatewayConfig,
did_resolver: Arc<dyn DidResolver>,
clock: Arc<dyn Fn() -> i64 + Send + Sync>,
}
impl XrpcAuthService {
pub fn new(config: XrpcGatewayConfig, did_resolver: Arc<dyn DidResolver>) -> Self {
let clock: Arc<dyn Fn() -> i64 + Send + Sync> = Arc::new(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
});
Self {
config,
did_resolver,
clock,
}
}
#[cfg(test)]
pub(crate) fn with_clock(
config: XrpcGatewayConfig,
did_resolver: Arc<dyn DidResolver>,
clock: Arc<dyn Fn() -> i64 + Send + Sync>,
) -> Self {
Self {
config,
did_resolver,
clock,
}
}
pub async fn verify(&self, jwt: &str, nsid: Nsid) -> Result<XrpcAuthClaims, XrpcAuthError> {
let parsed =
jwt::parse(jwt).map_err(|e| XrpcAuthError::InvalidJwtStructure(e.to_string()))?;
if parsed.header.alg != ACCEPTED_ALG {
return Err(XrpcAuthError::UnsupportedAlgorithm(parsed.header.alg));
}
let doc = self
.did_resolver
.resolve(&parsed.payload.iss)
.await
.map_err(|e| XrpcAuthError::DidResolutionFailed {
iss: parsed.payload.iss.clone(),
reason: e.to_string(),
})?;
let vm = doc
.find_verification_method(VERIFICATION_METHOD_FRAGMENT)
.ok_or_else(|| XrpcAuthError::NoVerificationMethod {
iss: parsed.payload.iss.clone(),
reason: format!("no {VERIFICATION_METHOD_FRAGMENT} fragment in DID document"),
})?;
let parsed_key = parse_multikey(&vm.public_key_multibase).map_err(|e| {
XrpcAuthError::NoVerificationMethod {
iss: parsed.payload.iss.clone(),
reason: format!("multibase parse: {e}"),
}
})?;
if parsed_key.jwt_alg != ACCEPTED_ALG {
return Err(XrpcAuthError::NoVerificationMethod {
iss: parsed.payload.iss.clone(),
reason: format!(
"verification method has key type {} (expected {ACCEPTED_ALG})",
parsed_key.jwt_alg
),
});
}
let compressed = k256_compress_pubkey(&parsed_key.key_bytes).map_err(|e| {
XrpcAuthError::NoVerificationMethod {
iss: parsed.payload.iss.clone(),
reason: format!("compress pubkey: {e}"),
}
})?;
let verifier = K256Keypair::verifier_from_compressed(&compressed).map_err(|e| {
XrpcAuthError::NoVerificationMethod {
iss: parsed.payload.iss.clone(),
reason: format!("verifier from pubkey: {e}"),
}
})?;
let sig_ok = verifier
.verify(&parsed.signing_input, &parsed.signature)
.map_err(|_| XrpcAuthError::SignatureVerificationFailed {
iss: parsed.payload.iss.clone(),
})?;
if !sig_ok {
return Err(XrpcAuthError::SignatureVerificationFailed {
iss: parsed.payload.iss.clone(),
});
}
if parsed.payload.aud != self.config.service_did {
return Err(XrpcAuthError::AudienceMismatch {
expected: self.config.service_did.clone(),
actual: parsed.payload.aud,
});
}
let lxm_nsid = match Nsid::from_path_segment(&parsed.payload.lxm) {
Some(n) => n,
None => {
return Err(XrpcAuthError::LxmNotAllowlisted {
lxm: parsed.payload.lxm,
});
}
};
if lxm_nsid != nsid {
return Err(XrpcAuthError::MethodMismatch {
lxm: parsed.payload.lxm,
nsid: nsid.as_path_segment().to_string(),
});
}
let now = (self.clock)();
let skew = self.config.clock_skew_tolerance.as_secs() as i64;
if parsed.payload.exp < now.saturating_sub(skew) {
return Err(XrpcAuthError::Expired {
exp: parsed.payload.exp,
now,
});
}
Ok(XrpcAuthClaims {
iss: parsed.payload.iss,
aud: parsed.payload.aud,
lxm: lxm_nsid,
exp: parsed.payload.exp,
jti: parsed.payload.jti,
iat: Some(parsed.payload.iat),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use async_trait::async_trait;
use base64::Engine as _;
use proto_blue_crypto::{K256Keypair, Keypair as _, Signer as _, format_multikey};
use crate::auth::did::{DidDocument, DidResolver, ResolveError, VerificationMethod};
const TEST_PRIV_HEX: &str = "b7e3f1c9a2d84ef50712436589bc1d8f023147b68cafed94a8b603c7159d4e2a";
const SERVICE_DID: &str = "did:web:cairn.example.com";
const ISSUER_DID: &str = "did:plc:issuer0000000000000000000";
const FIXED_NOW: i64 = 1_700_000_000;
fn test_keypair() -> K256Keypair {
K256Keypair::from_private_key(&hex::decode(TEST_PRIV_HEX).unwrap()).unwrap()
}
fn test_did_doc(did: &str) -> DidDocument {
DidDocument {
id: did.to_string(),
verification_method: vec![VerificationMethod {
id: format!("{did}#atproto"),
r#type: "Multikey".into(),
public_key_multibase: format_multikey(
"ES256K",
&test_keypair().public_key_compressed(),
),
}],
}
}
struct MockResolver(Mutex<HashMap<String, DidDocument>>);
impl MockResolver {
fn with_doc(did: &str, doc: DidDocument) -> Arc<Self> {
let mut m = HashMap::new();
m.insert(did.to_string(), doc);
Arc::new(Self(Mutex::new(m)))
}
}
#[async_trait]
impl DidResolver for MockResolver {
async fn resolve(&self, did: &str) -> Result<DidDocument, ResolveError> {
let snapshot = self.0.lock().unwrap().get(did).cloned();
snapshot.ok_or(ResolveError::BadStatus(404))
}
}
fn build_jwt(claims: &serde_json::Value, alg_header: &str) -> String {
let header = serde_json::json!({"alg": alg_header, "typ": "JWT"});
let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
let h = engine.encode(header.to_string());
let p = engine.encode(claims.to_string());
let signing_input = format!("{h}.{p}");
let sig = test_keypair().sign(signing_input.as_bytes()).unwrap();
format!("{h}.{p}.{}", engine.encode(sig))
}
fn valid_claims(lxm: &str) -> serde_json::Value {
serde_json::json!({
"iss": ISSUER_DID,
"aud": SERVICE_DID,
"exp": FIXED_NOW + 60,
"iat": FIXED_NOW - 5,
"jti": "jti-fixture-1",
"lxm": lxm,
})
}
fn fixed_clock() -> Arc<dyn Fn() -> i64 + Send + Sync> {
Arc::new(|| FIXED_NOW)
}
fn fixture_config() -> XrpcGatewayConfig {
XrpcGatewayConfig {
enabled: true,
service_did: SERVICE_DID.into(),
clock_skew_tolerance: Duration::from_secs(30),
replay_cache_ttl: Duration::from_secs(90),
}
}
fn build_service() -> XrpcAuthService {
let resolver = MockResolver::with_doc(ISSUER_DID, test_did_doc(ISSUER_DID));
XrpcAuthService::with_clock(fixture_config(), resolver, fixed_clock())
}
#[tokio::test]
async fn verify_succeeds_for_well_formed_jwt() {
let svc = build_service();
let jwt = build_jwt(&valid_claims("tools.ozone.moderation.emitEvent"), "ES256K");
let claims = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect("valid JWT should verify");
assert_eq!(claims.iss, ISSUER_DID);
assert_eq!(claims.aud, SERVICE_DID);
assert_eq!(claims.lxm, Nsid::ToolsOzoneModerationEmitEvent);
assert_eq!(claims.exp, FIXED_NOW + 60);
assert_eq!(claims.jti, "jti-fixture-1");
assert_eq!(claims.iat, Some(FIXED_NOW - 5));
}
#[tokio::test]
async fn verify_rejects_hs256_alg() {
let svc = build_service();
let jwt = build_jwt(&valid_claims("tools.ozone.moderation.emitEvent"), "HS256");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("HS256 must be rejected");
assert!(matches!(err, XrpcAuthError::UnsupportedAlgorithm(ref a) if a == "HS256"));
}
#[tokio::test]
async fn verify_rejects_rs256_alg() {
let svc = build_service();
let jwt = build_jwt(&valid_claims("tools.ozone.moderation.emitEvent"), "RS256");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("RS256 must be rejected");
assert!(matches!(err, XrpcAuthError::UnsupportedAlgorithm(_)));
}
#[tokio::test]
async fn verify_rejects_alg_none() {
let svc = build_service();
let jwt = build_jwt(&valid_claims("tools.ozone.moderation.emitEvent"), "none");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("alg=none must be rejected (the load-bearing check)");
assert!(matches!(err, XrpcAuthError::UnsupportedAlgorithm(_)));
}
#[tokio::test]
async fn verify_rejects_tampered_signature() {
let svc = build_service();
let claims = valid_claims("tools.ozone.moderation.emitEvent");
let valid = build_jwt(&claims, "ES256K");
let mut other_claims = claims.clone();
other_claims["jti"] = serde_json::json!("different-payload");
let other = build_jwt(&other_claims, "ES256K");
let valid_parts: Vec<&str> = valid.split('.').collect();
let other_parts: Vec<&str> = other.split('.').collect();
let tampered = format!("{}.{}.{}", valid_parts[0], valid_parts[1], other_parts[2]);
let err = svc
.verify(&tampered, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("tampered signature must fail verification");
assert!(matches!(
err,
XrpcAuthError::SignatureVerificationFailed { .. }
));
}
#[tokio::test]
async fn verify_rejects_audience_mismatch() {
let svc = build_service();
let mut claims = valid_claims("tools.ozone.moderation.emitEvent");
claims["aud"] = serde_json::json!("did:web:other.example.com");
let jwt = build_jwt(&claims, "ES256K");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("aud mismatch must be rejected");
match err {
XrpcAuthError::AudienceMismatch { expected, actual } => {
assert_eq!(expected, SERVICE_DID);
assert_eq!(actual, "did:web:other.example.com");
}
other => panic!("expected AudienceMismatch, got {other:?}"),
}
}
#[tokio::test]
async fn verify_rejects_method_mismatch_when_lxm_is_different_allowlisted_nsid() {
let svc = build_service();
let jwt = build_jwt(
&valid_claims("tools.ozone.moderation.queryStatuses"),
"ES256K",
);
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("lxm mismatch must be rejected");
assert!(matches!(err, XrpcAuthError::MethodMismatch { .. }));
}
#[tokio::test]
async fn verify_rejects_lxm_not_on_allowlist() {
let svc = build_service();
let jwt = build_jwt(
&valid_claims("tools.ozone.moderation.deleteRecord"),
"ES256K",
);
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("lxm not on v1.7 allowlist must be rejected");
match err {
XrpcAuthError::LxmNotAllowlisted { lxm } => {
assert_eq!(lxm, "tools.ozone.moderation.deleteRecord");
}
other => panic!("expected LxmNotAllowlisted, got {other:?}"),
}
}
#[tokio::test]
async fn verify_rejects_expired_jwt() {
let svc = build_service();
let mut claims = valid_claims("tools.ozone.moderation.emitEvent");
claims["exp"] = serde_json::json!(FIXED_NOW - 100);
let jwt = build_jwt(&claims, "ES256K");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("expired JWT must be rejected");
match err {
XrpcAuthError::Expired { exp, now } => {
assert_eq!(exp, FIXED_NOW - 100);
assert_eq!(now, FIXED_NOW);
}
other => panic!("expected Expired, got {other:?}"),
}
}
#[tokio::test]
async fn verify_accepts_jwt_within_clock_skew_tolerance() {
let svc = build_service();
let mut claims = valid_claims("tools.ozone.moderation.emitEvent");
claims["exp"] = serde_json::json!(FIXED_NOW - 10); let jwt = build_jwt(&claims, "ES256K");
let res = svc.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent).await;
assert!(
res.is_ok(),
"exp 10s ago should pass within 30s skew: got {res:?}"
);
}
#[tokio::test]
async fn verify_rejects_jwt_with_missing_required_claim() {
let svc = build_service();
let claims = serde_json::json!({
"iss": ISSUER_DID,
"exp": FIXED_NOW + 60,
"iat": FIXED_NOW - 5,
"jti": "jti-fixture-2",
"lxm": "tools.ozone.moderation.emitEvent",
});
let jwt = build_jwt(&claims, "ES256K");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("missing aud must be rejected");
assert!(matches!(err, XrpcAuthError::InvalidJwtStructure(_)));
}
#[tokio::test]
async fn verify_rejects_when_issuer_did_unresolvable() {
let svc = build_service();
let mut claims = valid_claims("tools.ozone.moderation.emitEvent");
claims["iss"] = serde_json::json!("did:plc:notinresolver000000000000");
let jwt = build_jwt(&claims, "ES256K");
let err = svc
.verify(&jwt, Nsid::ToolsOzoneModerationEmitEvent)
.await
.expect_err("unknown issuer DID must fail resolution");
assert!(matches!(err, XrpcAuthError::DidResolutionFailed { .. }));
}
}