From 697c84accf607532942b2ee55a29e563437807d7 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Fri, 3 Apr 2026 19:29:40 +0200 Subject: [PATCH] feat: add WebFinger query endpoint with rel filtering and CORS --- Cargo.toml | 3 +- src/handler/health.rs | 13 ++++ src/handler/mod.rs | 12 ++++ src/handler/webfinger.rs | 109 +++++++++++++++++++++++++++++ src/lib.rs | 1 + tests/test_webfinger.rs | 145 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/handler/health.rs create mode 100644 src/handler/mod.rs create mode 100644 src/handler/webfinger.rs create mode 100644 tests/test_webfinger.rs diff --git a/Cargo.toml b/Cargo.toml index ee34e99..52f0fbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,5 +38,6 @@ urlencoding = "2" async-trait = "0.1" [dev-dependencies] -axum-test = "16" +axum-test = "20" +migration = { path = "migration" } tempfile = "3" diff --git a/src/handler/health.rs b/src/handler/health.rs new file mode 100644 index 0000000..69dfde2 --- /dev/null +++ b/src/handler/health.rs @@ -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 { + Router::new().route("/healthz", get(healthz)) +} diff --git a/src/handler/mod.rs b/src/handler/mod.rs new file mode 100644 index 0000000..1c09ae5 --- /dev/null +++ b/src/handler/mod.rs @@ -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) +} diff --git a/src/handler/webfinger.rs b/src/handler/webfinger.rs new file mode 100644 index 0000000..7cf2c13 --- /dev/null +++ b/src/handler/webfinger.rs @@ -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, Vec) { + 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, + uri: Uri, +) -> AppResult { + 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 = 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::(titles) { + obj.insert("titles".into(), v); + } + } + if let Some(props) = &link.properties { + if let Ok(v) = serde_json::from_str::(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 { + Router::new().route("/.well-known/webfinger", get(webfinger)) +} diff --git a/src/lib.rs b/src/lib.rs index 2f1e909..2e575d0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,4 +4,5 @@ pub mod challenge; pub mod config; pub mod entity; pub mod error; +pub mod handler; pub mod state; diff --git a/tests/test_webfinger.rs b/tests/test_webfinger.rs new file mode 100644 index 0000000..85c9fe8 --- /dev/null +++ b/tests/test_webfinger.rs @@ -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"), + "*" + ); +}