mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-28 02:12:37 +00:00
fix(pds): do service identifier resolution for proxying correctly
This commit is contained in:
@@ -37,8 +37,9 @@ pub async fn list_controllers(
|
||||
async move {
|
||||
if c.handle.is_none() {
|
||||
c.handle = did_resolver
|
||||
.resolve_did_document(c.did.as_str())
|
||||
.fetch_did_document(c.did.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
|
||||
.map(Into::into);
|
||||
}
|
||||
@@ -64,7 +65,7 @@ pub async fn add_controller(
|
||||
) -> Result<Json<SuccessResponse>, ApiError> {
|
||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
|
||||
.await
|
||||
.ok_or(ApiError::ControllerNotFound)?;
|
||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
||||
|
||||
if !resolved.is_local
|
||||
&& let Some(ref pds_url) = resolved.pds_url
|
||||
@@ -476,7 +477,7 @@ pub async fn resolve_controller(
|
||||
|
||||
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
|
||||
.await
|
||||
.ok_or(ApiError::ControllerNotFound)?;
|
||||
.map_err(|_| ApiError::ControllerNotFound)?;
|
||||
|
||||
Ok(Json(resolved))
|
||||
}
|
||||
|
||||
@@ -153,7 +153,12 @@ async fn try_reactivate_migration(
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.to_string().into(),
|
||||
did: did_typed.clone(),
|
||||
did_doc: state.did_resolver.resolve_did_document(did).await,
|
||||
did_doc: state
|
||||
.did_resolver
|
||||
.fetch_did_document(did)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|f| Some((*f).clone())),
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
verification_required,
|
||||
@@ -580,7 +585,7 @@ pub async fn create_account(
|
||||
Err(e) => return e.into_response(),
|
||||
};
|
||||
|
||||
let did_doc = state.did_resolver.resolve_did_document(&did).await;
|
||||
let did_doc = state.did_resolver.fetch_did_document(&did).await.ok();
|
||||
|
||||
if is_migration {
|
||||
info!(
|
||||
@@ -594,7 +599,7 @@ pub async fn create_account(
|
||||
Json(CreateAccountOutput {
|
||||
handle: handle.clone().into(),
|
||||
did: did_for_commit,
|
||||
did_doc,
|
||||
did_doc: did_doc.and_then(|f| Some((*f).clone())),
|
||||
access_jwt: session.access_jwt,
|
||||
refresh_jwt: session.refresh_jwt,
|
||||
verification_required: !is_migration,
|
||||
|
||||
@@ -153,7 +153,7 @@ pub async fn submit_plc_operation(
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::plc_data_key(did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did).await.is_none() {
|
||||
if state.did_resolver.refresh_did(did).await.is_err() {
|
||||
warn!(did = %did, "Failed to refresh DID cache after PLC update");
|
||||
}
|
||||
info!(did = %did, "PLC operation submitted successfully");
|
||||
|
||||
@@ -365,7 +365,7 @@ pub async fn activate_account(
|
||||
.cache
|
||||
.delete(&tranquil_pds::cache_keys::plc_data_key(&did))
|
||||
.await;
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_none() {
|
||||
if state.did_resolver.refresh_did(did.as_str()).await.is_err() {
|
||||
warn!(
|
||||
"[MIGRATION] activateAccount: Failed to refresh DID cache for {}",
|
||||
did
|
||||
|
||||
@@ -69,7 +69,8 @@ pub async fn create_session(
|
||||
input.identifier, normalized_identifier
|
||||
);
|
||||
let row = match state
|
||||
.repos.user
|
||||
.repos
|
||||
.user
|
||||
.get_login_full_by_identifier(normalized_identifier.as_str())
|
||||
.await
|
||||
{
|
||||
@@ -130,7 +131,8 @@ pub async fn create_session(
|
||||
}
|
||||
let is_verified = row.channel_verification.has_any_verified();
|
||||
let is_delegated = state
|
||||
.repos.delegation
|
||||
.repos
|
||||
.delegation
|
||||
.is_delegated_account(&row.did)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@@ -270,7 +272,7 @@ pub async fn create_session(
|
||||
};
|
||||
let (insert_result, did_doc) = tokio::join!(
|
||||
state.repos.session.create_session(&session_data),
|
||||
did_resolver.resolve_did_document(&did_for_doc)
|
||||
did_resolver.fetch_did_document(&did_for_doc),
|
||||
);
|
||||
if let Err(e) = insert_result {
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
@@ -311,7 +313,7 @@ pub async fn create_session(
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle,
|
||||
did: row.did,
|
||||
did_doc,
|
||||
did_doc: did_doc.ok().and_then(|f| Some((*f).clone())),
|
||||
email: row.email,
|
||||
email_confirmed: Some(row.channel_verification.email),
|
||||
email_auth_factor: email_auth_factor_out,
|
||||
@@ -360,7 +362,7 @@ pub async fn get_session(
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
let (db_result, did_doc) = tokio::join!(
|
||||
state.repos.user.get_session_info_by_did(&auth.did),
|
||||
did_resolver.resolve_did_document(&did_for_doc)
|
||||
did_resolver.fetch_did_document(&did_for_doc)
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(row)) => {
|
||||
@@ -404,7 +406,7 @@ pub async fn get_session(
|
||||
status: account_state.status_for_session().map(String::from),
|
||||
migrated_to_pds,
|
||||
migrated_at,
|
||||
did_doc,
|
||||
did_doc: did_doc.ok().and_then(|f| Some((*f).clone())),
|
||||
}))
|
||||
}
|
||||
Ok(None) => Err(ApiError::AuthenticationFailed(None)),
|
||||
@@ -476,7 +478,8 @@ pub async fn refresh_session(
|
||||
}
|
||||
};
|
||||
if let Ok(Some(_)) = state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.check_refresh_token_used(&refresh_jti)
|
||||
.await
|
||||
{
|
||||
@@ -486,7 +489,8 @@ pub async fn refresh_session(
|
||||
)));
|
||||
}
|
||||
let session_row = match state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.get_session_for_refresh(&refresh_jti)
|
||||
.await
|
||||
{
|
||||
@@ -548,7 +552,8 @@ pub async fn refresh_session(
|
||||
new_refresh_expires_at: new_refresh_meta.expires_at,
|
||||
};
|
||||
match state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.refresh_session_atomic(&refresh_data)
|
||||
.await
|
||||
{
|
||||
@@ -577,7 +582,7 @@ pub async fn refresh_session(
|
||||
let did_resolver = state.did_resolver.clone();
|
||||
let (db_result, did_doc) = tokio::join!(
|
||||
state.repos.user.get_session_info_by_did(&session_row.did),
|
||||
did_resolver.resolve_did_document(&did_for_doc)
|
||||
did_resolver.fetch_did_document(&did_for_doc)
|
||||
);
|
||||
match db_result {
|
||||
Ok(Some(u)) => {
|
||||
@@ -599,7 +604,7 @@ pub async fn refresh_session(
|
||||
preferred_locale: u.preferred_locale,
|
||||
is_admin: u.is_admin,
|
||||
active: account_state.is_active(),
|
||||
did_doc,
|
||||
did_doc: did_doc.ok().and_then(|f| Some((*f).clone())),
|
||||
status: account_state.status_for_session().map(String::from),
|
||||
}))
|
||||
}
|
||||
@@ -702,7 +707,8 @@ pub async fn confirm_signup(
|
||||
};
|
||||
|
||||
if let Err(e) = state
|
||||
.repos.user
|
||||
.repos
|
||||
.user
|
||||
.set_channel_verified(&input.did, row.channel)
|
||||
.await
|
||||
{
|
||||
@@ -821,7 +827,8 @@ pub async fn resend_verification(
|
||||
) -> Result<Json<SuccessResponse>, ApiError> {
|
||||
info!("resend_verification called for DID: {}", input.did);
|
||||
let row = match state
|
||||
.repos.user
|
||||
.repos
|
||||
.user
|
||||
.get_resend_verification_by_did(&input.did)
|
||||
.await
|
||||
{
|
||||
@@ -895,13 +902,15 @@ pub async fn list_sessions(
|
||||
let current_jti = tranquil_pds::auth::extract_jti_from_headers(&headers);
|
||||
|
||||
let jwt_rows = state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.list_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("fetching JWT sessions")?;
|
||||
|
||||
let oauth_rows = state
|
||||
.repos.oauth
|
||||
.repos
|
||||
.oauth
|
||||
.list_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("fetching OAuth sessions")?;
|
||||
@@ -962,13 +971,15 @@ pub async fn revoke_session(
|
||||
.map(SessionId::new)
|
||||
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
|
||||
let access_jti = state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.get_session_access_jti_by_id(session_id, &auth.did)
|
||||
.await
|
||||
.log_db_err("in revoke_session")?
|
||||
.ok_or(ApiError::SessionNotFound)?;
|
||||
state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.delete_session_by_id(session_id)
|
||||
.await
|
||||
.log_db_err("deleting session")?;
|
||||
@@ -983,7 +994,8 @@ pub async fn revoke_session(
|
||||
.map(TokenFamilyId::new)
|
||||
.map_err(|_| ApiError::InvalidRequest("Invalid session ID".into()))?;
|
||||
let deleted = state
|
||||
.repos.oauth
|
||||
.repos
|
||||
.oauth
|
||||
.delete_session_by_id(session_id, &auth.did)
|
||||
.await
|
||||
.log_db_err("deleting OAuth session")?;
|
||||
@@ -1007,24 +1019,28 @@ pub async fn revoke_all_sessions(
|
||||
|
||||
if auth.is_oauth() {
|
||||
state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.delete_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("revoking JWT sessions")?;
|
||||
let jti_typed = TokenId::from(jti.clone());
|
||||
state
|
||||
.repos.oauth
|
||||
.repos
|
||||
.oauth
|
||||
.delete_sessions_by_did_except(&auth.did, &jti_typed)
|
||||
.await
|
||||
.log_db_err("revoking OAuth sessions")?;
|
||||
} else {
|
||||
state
|
||||
.repos.session
|
||||
.repos
|
||||
.session
|
||||
.delete_sessions_by_did_except_jti(&auth.did, &jti)
|
||||
.await
|
||||
.log_db_err("revoking JWT sessions")?;
|
||||
state
|
||||
.repos.oauth
|
||||
.repos
|
||||
.oauth
|
||||
.delete_sessions_by_did(&auth.did)
|
||||
.await
|
||||
.log_db_err("revoking OAuth sessions")?;
|
||||
@@ -1046,7 +1062,8 @@ pub async fn get_legacy_login_preference(
|
||||
auth: Auth<Active>,
|
||||
) -> Result<Json<LegacyLoginPreferenceOutput>, ApiError> {
|
||||
let pref = state
|
||||
.repos.user
|
||||
.repos
|
||||
.user
|
||||
.get_legacy_login_pref(&auth.did)
|
||||
.await
|
||||
.log_db_err("getting legacy login pref")?
|
||||
@@ -1079,7 +1096,8 @@ pub async fn update_legacy_login_preference(
|
||||
let reauth_mfa = require_reauth_window(&state, &auth).await?;
|
||||
|
||||
let updated = state
|
||||
.repos.user
|
||||
.repos
|
||||
.user
|
||||
.update_legacy_login(reauth_mfa.did(), input.allow_legacy_login)
|
||||
.await
|
||||
.log_db_err("updating legacy login")?;
|
||||
@@ -1117,7 +1135,8 @@ pub async fn update_locale(
|
||||
}
|
||||
|
||||
let updated = state
|
||||
.repos.user
|
||||
.repos
|
||||
.user
|
||||
.update_locale(&auth.did, &input.preferred_locale)
|
||||
.await
|
||||
.log_db_err("updating locale")?;
|
||||
|
||||
@@ -205,9 +205,14 @@ async fn proxy_handler(
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let did = proxy_header.split('#').next().unwrap_or(&proxy_header);
|
||||
let Some(resolved) = state.did_resolver.resolve_did(did).await else {
|
||||
error!(did = %did, "Could not resolve service DID");
|
||||
let Some((did, service_id)) = proxy_header.split_once("#") else {
|
||||
return ApiError::InvalidRequest(
|
||||
"Invalid atproto-proxy header. Missing service identifier.".into(),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Ok(resolved) = state.did_resolver.resolve_service(did, service_id).await else {
|
||||
error!(did = %did, service_id = %service_id, "Could not resolve service DID");
|
||||
return ApiError::UpstreamFailure.into_response();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DidResolutionError {
|
||||
#[error("Invalid did:web format")]
|
||||
InvalidDidWeb,
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpFailed(String),
|
||||
#[error("Invalid DID document: {0}")]
|
||||
InvalidDocument(String),
|
||||
#[error("DID not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DidDocument {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub service: Vec<DidService>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DidService {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: String,
|
||||
pub service_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedDid {
|
||||
url: String,
|
||||
did: String,
|
||||
resolved_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedDidDocument {
|
||||
document: serde_json::Value,
|
||||
resolved_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedService {
|
||||
pub url: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DidResolver {
|
||||
did_cache: Arc<RwLock<HashMap<String, CachedDid>>>,
|
||||
did_doc_cache: Arc<RwLock<HashMap<String, CachedDidDocument>>>,
|
||||
client: Client,
|
||||
cache_ttl: Duration,
|
||||
plc_directory_url: String,
|
||||
}
|
||||
|
||||
impl DidResolver {
|
||||
pub fn new() -> Self {
|
||||
let cfg = tranquil_config::get();
|
||||
let cache_ttl_secs = cfg.plc.did_cache_ttl_secs;
|
||||
|
||||
let plc_directory_url = cfg.plc.directory_url.clone();
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.pool_max_idle_per_host(10)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
info!("DID resolver initialized");
|
||||
|
||||
Self {
|
||||
did_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
did_doc_cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
client,
|
||||
cache_ttl: Duration::from_secs(cache_ttl_secs),
|
||||
plc_directory_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_did_web_url(did: &str) -> Result<String, DidResolutionError> {
|
||||
let host = did
|
||||
.strip_prefix("did:web:")
|
||||
.ok_or(DidResolutionError::InvalidDidWeb)?;
|
||||
|
||||
let (host, path) = if host.contains(':') {
|
||||
let decoded = host.replace("%3A", ":");
|
||||
let parts: Vec<&str> = decoded.splitn(2, '/').collect();
|
||||
if parts.len() > 1 {
|
||||
(parts[0].to_string(), format!("/{}", parts[1]))
|
||||
} else {
|
||||
(decoded, String::new())
|
||||
}
|
||||
} else {
|
||||
let parts: Vec<&str> = host.splitn(2, ':').collect();
|
||||
if parts.len() > 1 && parts[1].contains('/') {
|
||||
let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect();
|
||||
if path_parts.len() > 1 {
|
||||
(
|
||||
format!("{}:{}", parts[0], path_parts[0]),
|
||||
format!("/{}", path_parts[1]),
|
||||
)
|
||||
} else {
|
||||
(host.to_string(), String::new())
|
||||
}
|
||||
} else {
|
||||
(host.to_string(), String::new())
|
||||
}
|
||||
};
|
||||
|
||||
let scheme =
|
||||
if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':')
|
||||
{
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
|
||||
let url = if path.is_empty() {
|
||||
format!("{}://{}/.well-known/did.json", scheme, host)
|
||||
} else {
|
||||
format!("{}://{}{}/did.json", scheme, host, path)
|
||||
};
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub async fn resolve_did(&self, did: &str) -> Option<ResolvedService> {
|
||||
{
|
||||
let cache = self.did_cache.read().await;
|
||||
if let Some(cached) = cache.get(did)
|
||||
&& cached.resolved_at.elapsed() < self.cache_ttl
|
||||
{
|
||||
return Some(ResolvedService {
|
||||
url: cached.url.clone(),
|
||||
did: cached.did.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = self.resolve_did_internal(did).await?;
|
||||
|
||||
{
|
||||
let mut cache = self.did_cache.write().await;
|
||||
cache.insert(
|
||||
did.to_string(),
|
||||
CachedDid {
|
||||
url: resolved.url.clone(),
|
||||
did: resolved.did.clone(),
|
||||
resolved_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Some(resolved)
|
||||
}
|
||||
|
||||
pub async fn refresh_did(&self, did: &str) -> Option<ResolvedService> {
|
||||
{
|
||||
let mut cache = self.did_cache.write().await;
|
||||
cache.remove(did);
|
||||
}
|
||||
self.resolve_did(did).await
|
||||
}
|
||||
|
||||
async fn resolve_did_internal(&self, did: &str) -> Option<ResolvedService> {
|
||||
let did_doc = if did.starts_with("did:web:") {
|
||||
self.resolve_did_web(did).await
|
||||
} else if did.starts_with("did:plc:") {
|
||||
self.resolve_did_plc(did).await
|
||||
} else {
|
||||
warn!("Unsupported DID method: {}", did);
|
||||
return None;
|
||||
};
|
||||
|
||||
let doc = match did_doc {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
error!("Failed to resolve DID {}: {}", did, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
self.extract_service_endpoint(&doc)
|
||||
}
|
||||
|
||||
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
let url = Self::build_did_web_url(did)?;
|
||||
|
||||
debug!("Resolving did:web {} via {}", did, url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
debug!("Resolving did:plc {} via {}", did, url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(DidResolutionError::NotFound);
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
fn extract_service_endpoint(&self, doc: &DidDocument) -> Option<ResolvedService> {
|
||||
if let Some(service) = doc.service.iter().find(|s| {
|
||||
s.service_type == crate::plc::ServiceType::AppView.as_str()
|
||||
|| s.id.contains("atproto_appview")
|
||||
|| s.id.ends_with("#bsky_appview")
|
||||
}) {
|
||||
return Some(ResolvedService {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(service) = doc
|
||||
.service
|
||||
.iter()
|
||||
.find(|s| s.service_type.contains("AppView") || s.id.contains("appview"))
|
||||
{
|
||||
return Some(ResolvedService {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(service) = doc.service.first()
|
||||
&& service.service_endpoint.starts_with("http")
|
||||
{
|
||||
warn!(
|
||||
"No explicit AppView service found for {}, using first service: {}",
|
||||
doc.id, service.service_endpoint
|
||||
);
|
||||
return Some(ResolvedService {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if doc.id.starts_with("did:web:") {
|
||||
let host = doc.id.strip_prefix("did:web:")?;
|
||||
let decoded_host = host.replace("%3A", ":");
|
||||
let base_host = decoded_host.split('/').next()?;
|
||||
let scheme = if base_host.starts_with("localhost")
|
||||
|| base_host.starts_with("127.0.0.1")
|
||||
|| base_host.contains(':')
|
||||
{
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
warn!(
|
||||
"No service found for {}, deriving URL from DID: {}://{}",
|
||||
doc.id, scheme, base_host
|
||||
);
|
||||
return Some(ResolvedService {
|
||||
url: format!("{}://{}", scheme, base_host),
|
||||
did: doc.id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn resolve_did_document(&self, did: &str) -> Option<serde_json::Value> {
|
||||
{
|
||||
let cache = self.did_doc_cache.read().await;
|
||||
if let Some(cached) = cache.get(did)
|
||||
&& cached.resolved_at.elapsed() < self.cache_ttl
|
||||
{
|
||||
return Some(cached.document.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = if did.starts_with("did:web:") {
|
||||
self.fetch_did_document_web(did).await
|
||||
} else if did.starts_with("did:plc:") {
|
||||
self.fetch_did_document_plc(did).await
|
||||
} else {
|
||||
warn!("Unsupported DID method for document resolution: {}", did);
|
||||
return None;
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(doc) => {
|
||||
let mut cache = self.did_doc_cache.write().await;
|
||||
cache.insert(
|
||||
did.to_string(),
|
||||
CachedDidDocument {
|
||||
document: doc.clone(),
|
||||
resolved_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
Some(doc)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to resolve DID document for {}: {}", did, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_did_document_web(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
let url = Self::build_did_web_url(did)?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_did_document_plc(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(DidResolutionError::NotFound);
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn invalidate_cache(&self, did: &str) {
|
||||
let mut cache = self.did_cache.write().await;
|
||||
cache.remove(did);
|
||||
drop(cache);
|
||||
let mut doc_cache = self.did_doc_cache.write().await;
|
||||
doc_cache.remove(did);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DidResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_did_resolver() -> Arc<DidResolver> {
|
||||
Arc::new(DidResolver::new())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub use scopes::{
|
||||
};
|
||||
pub use tranquil_db_traits::DelegationActionType;
|
||||
|
||||
use crate::did::DidResolutionError;
|
||||
use crate::state::AppState;
|
||||
use crate::types::Did;
|
||||
|
||||
@@ -24,7 +25,10 @@ pub struct ResolvedIdentity {
|
||||
pub is_local: bool,
|
||||
}
|
||||
|
||||
pub async fn resolve_identity(state: &AppState, did: &Did) -> Option<ResolvedIdentity> {
|
||||
pub async fn resolve_identity(
|
||||
state: &AppState,
|
||||
did: &Did,
|
||||
) -> Result<ResolvedIdentity, DidResolutionError> {
|
||||
let is_local = state
|
||||
.repos.user
|
||||
.get_by_did(did)
|
||||
@@ -33,15 +37,24 @@ pub async fn resolve_identity(state: &AppState, did: &Did) -> Option<ResolvedIde
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
let did_doc = state
|
||||
.did_resolver
|
||||
.resolve_did_document(did.as_str())
|
||||
.await?;
|
||||
let did_doc = state.did_resolver.resolve_did(did.as_str()).await?;
|
||||
|
||||
let pds_url = tranquil_types::did_doc::extract_pds_endpoint(&did_doc);
|
||||
let handle = tranquil_types::did_doc::extract_handle(&did_doc);
|
||||
let pds_url = did_doc.services.iter().find_map(|svc| {
|
||||
if (svc.id == "#atproto_pds" || svc.id.ends_with("#atproto_pds"))
|
||||
&& svc.service_type == "AtprotoPersonalDataServer"
|
||||
{
|
||||
Some(svc.service_endpoint.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let handle = did_doc.also_known_as.iter().find_map(|alias| {
|
||||
alias
|
||||
.strip_prefix("at://")
|
||||
.and_then(|s| Some(s.to_string()))
|
||||
});
|
||||
|
||||
Some(ResolvedIdentity {
|
||||
Ok(ResolvedIdentity {
|
||||
did: did.clone(),
|
||||
handle,
|
||||
pds_url,
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DidResolutionError {
|
||||
#[error("Unsupported DID method: \"{0}\". Only did:web and did:plc are allowed in atproto")]
|
||||
UnsupportedDidMethod(String),
|
||||
#[error("Invalid did:web format")]
|
||||
InvalidDidWeb,
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpFailed(String),
|
||||
#[error("Invalid DID document: {0}")]
|
||||
InvalidDocument(String),
|
||||
#[error("DID not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ServiceResolutionError {
|
||||
#[error("DID resolution failed: {0}")]
|
||||
DidResolutionFailed(#[from] DidResolutionError),
|
||||
#[error("Service ID \"{0}\" not found in DID doc")]
|
||||
ServiceIdNotFound(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DidDocument {
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "service")]
|
||||
pub services: Vec<DidService>,
|
||||
#[serde(default)]
|
||||
#[serde(rename = "alsoKnownAs")]
|
||||
pub also_known_as: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DidService {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub service_type: String,
|
||||
pub service_endpoint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedService {
|
||||
pub url: String,
|
||||
pub did: String,
|
||||
pub service_id: String,
|
||||
}
|
||||
|
||||
pub struct DidResolver {
|
||||
did_doc_cache: RwLock<HashMap<Box<str>, (Instant, Arc<serde_json::Value>)>>,
|
||||
parsed_did_doc_cache: RwLock<HashMap<Box<str>, (Instant, Arc<DidDocument>)>>,
|
||||
service_cache: RwLock<HashMap<Box<str>, (Instant, Arc<ResolvedService>)>>,
|
||||
client: Client,
|
||||
cache_ttl: Duration,
|
||||
plc_directory_url: String,
|
||||
}
|
||||
|
||||
impl DidResolver {
|
||||
pub fn new() -> Self {
|
||||
let cfg = tranquil_config::get();
|
||||
let cache_ttl_secs = cfg.plc.did_cache_ttl_secs;
|
||||
|
||||
let plc_directory_url = cfg.plc.directory_url.clone();
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.pool_max_idle_per_host(10)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
info!("DID resolver initialized");
|
||||
|
||||
Self {
|
||||
did_doc_cache: RwLock::new(HashMap::new()),
|
||||
parsed_did_doc_cache: RwLock::new(HashMap::new()),
|
||||
service_cache: RwLock::new(HashMap::new()),
|
||||
client,
|
||||
cache_ttl: Duration::from_secs(cache_ttl_secs),
|
||||
plc_directory_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve_service(
|
||||
&self,
|
||||
did: &str,
|
||||
service_id: &str,
|
||||
) -> Result<Arc<ResolvedService>, ServiceResolutionError> {
|
||||
{
|
||||
let cache = self.service_cache.read().await;
|
||||
if let Some(cached) = cache.get(&*format!("{did}#{service_id}"))
|
||||
&& cached.0.elapsed() < self.cache_ttl
|
||||
{
|
||||
return Ok(cached.1.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let did_doc = self.resolve_did(did).await?;
|
||||
let Some(service) = did_doc
|
||||
.services
|
||||
.iter()
|
||||
.find(|s| s.id.ends_with(&format!("#{service_id}")))
|
||||
else {
|
||||
return Err(ServiceResolutionError::ServiceIdNotFound(service_id.into()));
|
||||
};
|
||||
|
||||
let resolved = Arc::new(ResolvedService {
|
||||
url: service.service_endpoint.clone(),
|
||||
did: did.into(),
|
||||
service_id: service_id.into(),
|
||||
});
|
||||
|
||||
{
|
||||
let mut cache = self.service_cache.write().await;
|
||||
cache.insert(
|
||||
format!("{did}#{service_id}").into(),
|
||||
(Instant::now(), resolved.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub async fn resolve_did(&self, did: &str) -> Result<Arc<DidDocument>, DidResolutionError> {
|
||||
{
|
||||
let cache = self.parsed_did_doc_cache.read().await;
|
||||
if let Some(cached) = cache.get(did)
|
||||
&& cached.0.elapsed() < self.cache_ttl
|
||||
{
|
||||
return Ok(cached.1.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = Arc::new(self.resolve_did_uncached(did).await?);
|
||||
|
||||
{
|
||||
let mut cache = self.parsed_did_doc_cache.write().await;
|
||||
cache.insert(did.into(), (Instant::now(), resolved.clone()));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub async fn refresh_did(&self, did: &str) -> Result<Arc<DidDocument>, DidResolutionError> {
|
||||
{
|
||||
let mut cache = self.parsed_did_doc_cache.write().await;
|
||||
cache.remove(did);
|
||||
let mut cache = self.service_cache.write().await;
|
||||
cache.retain(|k, _| !k.starts_with(did));
|
||||
}
|
||||
self.resolve_did(did).await
|
||||
}
|
||||
|
||||
async fn resolve_did_uncached(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
if did.starts_with("did:web:") {
|
||||
self.resolve_did_web(did).await
|
||||
} else if did.starts_with("did:plc:") {
|
||||
self.resolve_did_plc(did).await
|
||||
} else {
|
||||
warn!("Unsupported DID method: {}", did);
|
||||
Err(DidResolutionError::UnsupportedDidMethod(did.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_did_web(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
let url = build_did_web_url(did)?;
|
||||
|
||||
debug!("Resolving did:web {} via {}", did, url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_did_plc(&self, did: &str) -> Result<DidDocument, DidResolutionError> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
debug!("Resolving did:plc {} via {}", did, url);
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(DidResolutionError::NotFound);
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<DidDocument>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn fetch_did_document(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<Arc<serde_json::Value>, DidResolutionError> {
|
||||
{
|
||||
let cache = self.did_doc_cache.read().await;
|
||||
if let Some(cached) = cache.get(did)
|
||||
&& cached.0.elapsed() < self.cache_ttl
|
||||
{
|
||||
return Ok(cached.1.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let resolved = Arc::new(self.fetch_did_document_uncached(did).await?);
|
||||
|
||||
{
|
||||
let mut cache = self.did_doc_cache.write().await;
|
||||
cache.insert(did.into(), (Instant::now(), resolved.clone()));
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
// TODO: make cached version
|
||||
async fn fetch_did_document_uncached(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
if did.starts_with("did:web:") {
|
||||
self.fetch_did_document_web(did).await
|
||||
} else if did.starts_with("did:plc:") {
|
||||
self.fetch_did_document_plc(did).await
|
||||
} else {
|
||||
warn!("Unsupported DID method: {}", did);
|
||||
Err(DidResolutionError::UnsupportedDidMethod(did.into()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_did_document_web(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
let url = build_did_web_url(did)?;
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_did_document_plc(
|
||||
&self,
|
||||
did: &str,
|
||||
) -> Result<serde_json::Value, DidResolutionError> {
|
||||
let url = format!("{}/{}", self.plc_directory_url, urlencoding::encode(did));
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::HttpFailed(e.to_string()))?;
|
||||
|
||||
if resp.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(DidResolutionError::NotFound);
|
||||
}
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(DidResolutionError::HttpFailed(format!(
|
||||
"HTTP {}",
|
||||
resp.status()
|
||||
)));
|
||||
}
|
||||
|
||||
resp.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|e| DidResolutionError::InvalidDocument(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn invalidate_cache(&self, did: &str) {
|
||||
let mut doc_cache = self.parsed_did_doc_cache.write().await;
|
||||
doc_cache.remove(did);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DidResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_did_resolver() -> Arc<DidResolver> {
|
||||
Arc::new(DidResolver::new())
|
||||
}
|
||||
|
||||
fn build_did_web_url(did: &str) -> Result<String, DidResolutionError> {
|
||||
let host = did
|
||||
.strip_prefix("did:web:")
|
||||
.ok_or(DidResolutionError::InvalidDidWeb)?;
|
||||
|
||||
let (host, path) = if host.contains(':') {
|
||||
let decoded = host.replace("%3A", ":");
|
||||
let parts: Vec<&str> = decoded.splitn(2, '/').collect();
|
||||
if parts.len() > 1 {
|
||||
(parts[0].to_string(), format!("/{}", parts[1]))
|
||||
} else {
|
||||
(decoded, String::new())
|
||||
}
|
||||
} else {
|
||||
let parts: Vec<&str> = host.splitn(2, ':').collect();
|
||||
if parts.len() > 1 && parts[1].contains('/') {
|
||||
let path_parts: Vec<&str> = parts[1].splitn(2, '/').collect();
|
||||
if path_parts.len() > 1 {
|
||||
(
|
||||
format!("{}:{}", parts[0], path_parts[0]),
|
||||
format!("/{}", path_parts[1]),
|
||||
)
|
||||
} else {
|
||||
(host.to_string(), String::new())
|
||||
}
|
||||
} else {
|
||||
(host.to_string(), String::new())
|
||||
}
|
||||
};
|
||||
|
||||
let scheme =
|
||||
if host.starts_with("localhost") || host.starts_with("127.0.0.1") || host.contains(':') {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
|
||||
let url = if path.is_empty() {
|
||||
format!("{}://{}/.well-known/did.json", scheme, host)
|
||||
} else {
|
||||
format!("{}://{}{}/did.json", scheme, host, path)
|
||||
};
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod api;
|
||||
pub mod appview;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod cache_keys;
|
||||
@@ -9,6 +8,7 @@ pub mod comms;
|
||||
pub mod config;
|
||||
pub mod crawlers;
|
||||
pub mod delegation;
|
||||
pub mod did;
|
||||
pub mod handle;
|
||||
pub mod image;
|
||||
pub mod metrics;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::appview::DidResolver;
|
||||
use crate::auth::webauthn::WebAuthnConfig;
|
||||
use crate::cache::{Cache, DistributedRateLimiter, create_cache};
|
||||
use crate::circuit_breaker::CircuitBreakers;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::did::DidResolver;
|
||||
use crate::oauth::client::CrossPdsOAuthClient;
|
||||
use crate::plc::PlcClient;
|
||||
use crate::rate_limit::RateLimiters;
|
||||
|
||||
Reference in New Issue
Block a user