feat: add WebFinger query endpoint with rel filtering and CORS

This commit is contained in:
Till Wegmueller 2026-04-03 19:29:40 +02:00
parent 4b04cf9b76
commit 697c84accf
No known key found for this signature in database
6 changed files with 282 additions and 1 deletions

View file

@ -38,5 +38,6 @@ urlencoding = "2"
async-trait = "0.1"
[dev-dependencies]
axum-test = "16"
axum-test = "20"
migration = { path = "migration" }
tempfile = "3"

13
src/handler/health.rs Normal file
View file

@ -0,0 +1,13 @@
use axum::http::StatusCode;
use axum::routing::get;
use axum::Router;
use crate::state::AppState;
async fn healthz() -> StatusCode {
StatusCode::OK
}
pub fn router() -> Router<AppState> {
Router::new().route("/healthz", get(healthz))
}

12
src/handler/mod.rs Normal file
View file

@ -0,0 +1,12 @@
mod health;
mod webfinger;
use axum::Router;
use crate::state::AppState;
pub fn router(state: AppState) -> Router {
Router::new()
.merge(webfinger::router())
.merge(health::router())
.with_state(state)
}

109
src/handler/webfinger.rs Normal file
View file

@ -0,0 +1,109 @@
use axum::extract::State;
use axum::http::{header, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::json;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
/// Parse resource and rel params from query string manually,
/// because serde_urlencoded can't handle repeated keys into Vec.
fn parse_webfinger_query(uri: &Uri) -> (Option<String>, Vec<String>) {
let query_str = uri.query().unwrap_or("");
let mut resource = None;
let mut rels = Vec::new();
for pair in query_str.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let value = urlencoding::decode(value)
.unwrap_or_default()
.into_owned();
match key {
"resource" => resource = Some(value),
"rel" => rels.push(value),
_ => {}
}
}
}
(resource, rels)
}
async fn webfinger(
State(state): State<AppState>,
uri: Uri,
) -> AppResult<Response> {
let (resource_opt, rels) = parse_webfinger_query(&uri);
let resource = resource_opt
.ok_or_else(|| AppError::BadRequest("missing resource parameter".into()))?;
let cached = state
.cache
.get(&resource)
.ok_or(AppError::NotFound)?;
let links: Vec<serde_json::Value> = cached
.links
.iter()
.filter(|link| {
if rels.is_empty() {
true
} else {
rels.iter().any(|r| r == &link.rel)
}
})
.map(|link| {
let mut obj = serde_json::Map::new();
obj.insert("rel".into(), json!(link.rel));
if let Some(href) = &link.href {
obj.insert("href".into(), json!(href));
}
if let Some(t) = &link.link_type {
obj.insert("type".into(), json!(t));
}
if let Some(titles) = &link.titles {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(titles) {
obj.insert("titles".into(), v);
}
}
if let Some(props) = &link.properties {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(props) {
obj.insert("properties".into(), v);
}
}
if let Some(template) = &link.template {
obj.insert("template".into(), json!(template));
}
serde_json::Value::Object(obj)
})
.collect();
let mut response_body = serde_json::Map::new();
response_body.insert("subject".into(), json!(cached.subject));
if let Some(aliases) = &cached.aliases {
response_body.insert("aliases".into(), json!(aliases));
}
if let Some(properties) = &cached.properties {
response_body.insert("properties".into(), properties.clone());
}
response_body.insert("links".into(), json!(links));
Ok((
[
(header::CONTENT_TYPE, "application/jrd+json"),
(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
],
Json(serde_json::Value::Object(response_body)),
)
.into_response())
}
pub fn router() -> Router<AppState> {
Router::new().route("/.well-known/webfinger", get(webfinger))
}

View file

@ -4,4 +4,5 @@ pub mod challenge;
pub mod config;
pub mod entity;
pub mod error;
pub mod handler;
pub mod state;

145
tests/test_webfinger.rs Normal file
View file

@ -0,0 +1,145 @@
mod common;
use axum_test::TestServer;
use webfingerd::handler;
#[tokio::test]
async fn test_webfinger_returns_404_for_unknown_resource() {
let state = common::test_state().await;
let app = handler::router(state);
let server = TestServer::new(app);
let response = server
.get("/.well-known/webfinger")
.add_query_param("resource", "acct:nobody@example.com")
.await;
response.assert_status_not_found();
}
#[tokio::test]
async fn test_webfinger_returns_400_without_resource_param() {
let state = common::test_state().await;
let app = handler::router(state);
let server = TestServer::new(app);
let response = server.get("/.well-known/webfinger").await;
response.assert_status_bad_request();
}
#[tokio::test]
async fn test_webfinger_returns_jrd_for_known_resource() {
let state = common::test_state().await;
// Seed cache directly for this test
state.cache.set(
"acct:alice@example.com".into(),
webfingerd::cache::CachedResource {
subject: "acct:alice@example.com".into(),
aliases: Some(vec!["https://example.com/@alice".into()]),
properties: None,
links: vec![webfingerd::cache::CachedLink {
rel: "self".into(),
href: Some("https://example.com/users/alice".into()),
link_type: Some("application/activity+json".into()),
titles: None,
properties: None,
template: None,
}],
},
);
let app = handler::router(state);
let server = TestServer::new(app);
let response = server
.get("/.well-known/webfinger")
.add_query_param("resource", "acct:alice@example.com")
.await;
response.assert_status_ok();
let body: serde_json::Value = response.json();
assert_eq!(body["subject"], "acct:alice@example.com");
assert_eq!(body["aliases"][0], "https://example.com/@alice");
assert_eq!(body["links"][0]["rel"], "self");
assert_eq!(
body["links"][0]["href"],
"https://example.com/users/alice"
);
}
#[tokio::test]
async fn test_webfinger_filters_by_rel() {
let state = common::test_state().await;
state.cache.set(
"acct:alice@example.com".into(),
webfingerd::cache::CachedResource {
subject: "acct:alice@example.com".into(),
aliases: None,
properties: None,
links: vec![
webfingerd::cache::CachedLink {
rel: "self".into(),
href: Some("https://example.com/users/alice".into()),
link_type: Some("application/activity+json".into()),
titles: None,
properties: None,
template: None,
},
webfingerd::cache::CachedLink {
rel: "http://openid.net/specs/connect/1.0/issuer".into(),
href: Some("https://auth.example.com".into()),
link_type: None,
titles: None,
properties: None,
template: None,
},
],
},
);
let app = handler::router(state);
let server = TestServer::new(app);
let response = server
.get("/.well-known/webfinger")
.add_query_param("resource", "acct:alice@example.com")
.add_query_param("rel", "self")
.await;
response.assert_status_ok();
let body: serde_json::Value = response.json();
let links = body["links"].as_array().unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0]["rel"], "self");
}
#[tokio::test]
async fn test_webfinger_cors_headers() {
let state = common::test_state().await;
state.cache.set(
"acct:alice@example.com".into(),
webfingerd::cache::CachedResource {
subject: "acct:alice@example.com".into(),
aliases: None,
properties: None,
links: vec![],
},
);
let app = handler::router(state);
let server = TestServer::new(app);
let response = server
.get("/.well-known/webfinger")
.add_query_param("resource", "acct:alice@example.com")
.await;
assert_eq!(
response.header("access-control-allow-origin"),
"*"
);
}