use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use k256::ecdsa::VerifyingKey;
use crate::did::{DidKeyResolver, DidResolveError};
use crate::service_jwt::{precheck_claims, verify_service_jwt};
pub fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[async_trait]
pub trait JtiCache: Send + Sync {
async fn check_and_record(&self, jti: &str, exp_unix: u64) -> bool;
}
#[derive(Default)]
pub struct InProcessJtiCache {
seen: Mutex<HashMap<String, u64>>,
}
impl InProcessJtiCache {
pub fn new() -> Self {
Self::default()
}
}
#[async_trait]
impl JtiCache for InProcessJtiCache {
async fn check_and_record(&self, jti: &str, exp_unix: u64) -> bool {
let now = now_unix();
let mut seen = self.seen.lock().unwrap();
seen.retain(|_, &mut exp| exp > now);
match seen.get(jti) {
Some(&exp) if exp > now => false,
_ => {
seen.insert(jti.to_string(), exp_unix);
true
}
}
}
}
pub const KEY_CACHE_TTL: Duration = Duration::from_secs(300);
pub const NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
#[error("authentication required")]
Missing,
#[error("invalid service-auth token")]
Invalid,
#[error("the caller's DID could not be resolved right now")]
Unavailable,
}
pub struct ServiceAuth {
service_did: String,
resolver: Arc<dyn DidKeyResolver>,
replay: Arc<dyn JtiCache>,
keys: Mutex<HashMap<String, (VerifyingKey, Instant)>>,
key_ttl: Duration,
unresolvable: Mutex<HashMap<String, Instant>>,
}
impl ServiceAuth {
pub fn new(service_did: impl Into<String>, resolver: Arc<dyn DidKeyResolver>, replay: Arc<dyn JtiCache>) -> Self {
Self {
service_did: service_did.into(),
resolver,
replay,
keys: Mutex::default(),
key_ttl: KEY_CACHE_TTL,
unresolvable: Mutex::default(),
}
}
pub fn with_key_ttl(mut self, ttl: Duration) -> Self {
self.key_ttl = ttl;
self
}
pub fn service_did(&self) -> &str {
&self.service_did
}
pub async fn caller(&self, authorization: Option<&str>, lxm: &str) -> Result<String, AuthError> {
let jwt = authorization
.and_then(|h| h.strip_prefix("Bearer ").or_else(|| h.strip_prefix("bearer ")))
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or(AuthError::Missing)?;
let now = now_unix();
let Some(iss) = precheck_claims(jwt, &self.service_did, lxm, now) else {
tracing::warn!(%lxm, "service-auth refused: malformed, expired, or not minted for this aud and lxm");
return Err(AuthError::Invalid);
};
let (key, cached) = match self.cached_key(&iss) {
Some(key) => (key, true),
None => (self.resolve_key(&iss, lxm).await?, false),
};
let mut verified = verify_service_jwt(jwt, &key, &self.service_did, lxm, now);
if verified.is_err() && cached {
match self.resolver.atproto_key(&iss).await {
Ok(fresh) if fresh != key => {
self.remember(&iss, fresh);
verified = verify_service_jwt(jwt, &fresh, &self.service_did, lxm, now);
}
Ok(_) => {}
Err(error) => tracing::warn!(%iss, %lxm, ?error, "service-auth: re-resolving a cached key failed"),
}
}
let claims = verified.map_err(|error| {
tracing::warn!(%iss, %lxm, ?error, "service-auth refused: verification failed");
AuthError::Invalid
})?;
if !self.replay.check_and_record(&claims.jti, claims.exp).await {
tracing::warn!(%iss, %lxm, "service-auth refused: replayed jti");
return Err(AuthError::Invalid);
}
Ok(claims.iss)
}
fn cached_key(&self, did: &str) -> Option<VerifyingKey> {
let keys = self.keys.lock().unwrap_or_else(|e| e.into_inner());
keys.get(did).filter(|(_, at)| at.elapsed() < self.key_ttl).map(|(key, _)| *key)
}
fn remember(&self, did: &str, key: VerifyingKey) {
let mut keys = self.keys.lock().unwrap_or_else(|e| e.into_inner());
keys.retain(|_, (_, at)| at.elapsed() < self.key_ttl);
keys.insert(did.to_owned(), (key, Instant::now()));
}
async fn resolve_key(&self, did: &str, lxm: &str) -> Result<VerifyingKey, AuthError> {
{
let mut unresolvable = self.unresolvable.lock().unwrap_or_else(|e| e.into_inner());
unresolvable.retain(|_, at| at.elapsed() < NEGATIVE_CACHE_TTL);
if unresolvable.contains_key(did) {
tracing::warn!(iss = %did, %lxm, "service-auth refused: the issuer's signing key did not resolve (cached)");
return Err(AuthError::Invalid);
}
}
match self.resolver.atproto_key(did).await {
Ok(key) => {
self.remember(did, key);
Ok(key)
}
Err(DidResolveError::Unavailable) => {
tracing::warn!(iss = %did, %lxm, "service-auth deferred: the issuer's DID directory did not answer");
Err(AuthError::Unavailable)
}
Err(error) => {
tracing::warn!(iss = %did, %lxm, ?error, "service-auth refused: the issuer's signing key did not resolve");
self.unresolvable.lock().unwrap_or_else(|e| e.into_inner()).insert(did.to_owned(), Instant::now());
Err(AuthError::Invalid)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::did::InMemoryDidKeyResolver;
use crate::labels::multibase;
use k256::ecdsa::SigningKey;
#[tokio::test]
async fn first_use_is_fresh_replay_is_rejected() {
let cache = InProcessJtiCache::new();
let exp = now_unix() + 60;
assert!(cache.check_and_record("jti-1", exp).await, "first use must be fresh");
assert!(!cache.check_and_record("jti-1", exp).await, "replay must be rejected");
assert!(cache.check_and_record("jti-2", exp).await, "a different jti is fresh");
}
#[tokio::test]
async fn an_expired_record_is_evicted_and_freshly_acceptable() {
let cache = InProcessJtiCache::new();
let past = now_unix().saturating_sub(10);
assert!(cache.check_and_record("old", past).await, "first insert reports fresh");
assert!(cache.check_and_record("old", now_unix() + 60).await, "expired record must not block");
}
const SERVICE: &str = "did:web:rules.example";
const CALLER: &str = "did:plc:caller000000000000000000";
const LXM: &str = "pub.techne.draft.commons.runChain";
fn hex_key(b: u8) -> String {
hex::encode([b; 32])
}
fn token(key: u8, aud: &str, lxm: &str) -> String {
crate::service_jwt::mint_service_jwt(&hex_key(key), CALLER, aud, lxm, 60, now_unix()).unwrap()
}
fn auth_with(resolver: Arc<dyn DidKeyResolver>) -> ServiceAuth {
ServiceAuth::new(SERVICE, resolver, Arc::new(InProcessJtiCache::new()))
}
fn seeded(key: u8) -> Arc<InMemoryDidKeyResolver> {
let r = InMemoryDidKeyResolver::new();
r.insert_key(CALLER, multibase(SigningKey::from_slice(&[key; 32]).unwrap().verifying_key()));
Arc::new(r)
}
#[tokio::test]
async fn a_token_for_this_service_names_its_caller_once() {
let auth = auth_with(seeded(7));
assert_eq!(auth.service_did(), SERVICE);
let jwt = token(7, SERVICE, LXM);
let bearer = format!("Bearer {jwt}");
assert_eq!(auth.caller(Some(&bearer), LXM).await, Ok(CALLER.to_owned()));
assert_eq!(auth.caller(Some(&bearer), LXM).await, Err(AuthError::Invalid), "a replayed jti");
assert_eq!(auth.caller(Some(&format!("bearer {}", token(7, SERVICE, LXM))), LXM).await, Ok(CALLER.to_owned()));
}
#[tokio::test]
async fn missing_and_wrong_tokens_are_refused() {
let auth = auth_with(seeded(7));
assert_eq!(auth.caller(None, LXM).await, Err(AuthError::Missing));
assert_eq!(auth.caller(Some("Bearer "), LXM).await, Err(AuthError::Missing));
assert_eq!(auth.caller(Some("Basic abc"), LXM).await, Err(AuthError::Missing));
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, "did:web:other.example", LXM))), LXM).await, Err(AuthError::Invalid));
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, SERVICE, "x.y.other"))), LXM).await, Err(AuthError::Invalid));
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(8, SERVICE, LXM))), LXM).await, Err(AuthError::Invalid), "another key");
assert_eq!(auth.caller(Some("Bearer junk"), LXM).await, Err(AuthError::Invalid));
}
struct Flaky {
inner: Arc<InMemoryDidKeyResolver>,
down: std::sync::atomic::AtomicBool,
lookups: std::sync::atomic::AtomicUsize,
}
#[async_trait]
impl DidKeyResolver for Flaky {
async fn atproto_key(&self, did: &str) -> Result<VerifyingKey, DidResolveError> {
self.lookups.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self.down.load(std::sync::atomic::Ordering::SeqCst) {
return Err(DidResolveError::Unavailable);
}
self.inner.atproto_key(did).await
}
async fn pds_endpoint(&self, _did: &str) -> Result<Option<String>, DidResolveError> {
Ok(None)
}
async fn handle(&self, _did: &str) -> Result<Option<String>, DidResolveError> {
Ok(None)
}
}
#[tokio::test]
async fn a_directory_that_does_not_answer_is_unavailable_not_a_refusal() {
let flaky = Arc::new(Flaky { inner: seeded(7), down: true.into(), lookups: 0.into() });
let auth = auth_with(flaky.clone());
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, SERVICE, LXM))), LXM).await, Err(AuthError::Unavailable));
flaky.down.store(false, std::sync::atomic::Ordering::SeqCst);
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, SERVICE, LXM))), LXM).await, Ok(CALLER.to_owned()), "not cached");
let before = flaky.lookups.load(std::sync::atomic::Ordering::SeqCst);
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, SERVICE, LXM))), LXM).await, Ok(CALLER.to_owned()));
assert_eq!(flaky.lookups.load(std::sync::atomic::Ordering::SeqCst), before);
}
#[tokio::test]
async fn an_unresolvable_issuer_is_refused_without_another_lookup() {
let flaky = Arc::new(Flaky { inner: Arc::new(InMemoryDidKeyResolver::new()), down: false.into(), lookups: 0.into() });
let auth = auth_with(flaky.clone());
for _ in 0..3 {
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, SERVICE, LXM))), LXM).await, Err(AuthError::Invalid));
}
assert_eq!(flaky.lookups.load(std::sync::atomic::Ordering::SeqCst), 1, "negative-cached");
}
#[tokio::test]
async fn a_rotated_key_works_at_once() {
let resolver = seeded(7);
let auth = auth_with(resolver.clone());
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(7, SERVICE, LXM))), LXM).await, Ok(CALLER.to_owned()));
resolver.insert_key(CALLER, multibase(SigningKey::from_slice(&[9; 32]).unwrap().verifying_key()));
assert_eq!(auth.caller(Some(&format!("Bearer {}", token(9, SERVICE, LXM))), LXM).await, Ok(CALLER.to_owned()));
}
}