use axum::Json;
use axum::extract::FromRequestParts;
use axum::response::{IntoResponse, Response as AxumResponse};
use http::header::{self, HOST};
use http::request::Parts;
use http::{HeaderValue, StatusCode};
use tracing::trace;
use crate::http::CORS_ALLOW_ORIGIN;
use crate::query::{RequestParams, RequestParamsError};
use crate::{Rel, ResourceError, WebFingerRequest, WebFingerResponse};
const JRD_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/jrd+json");
const CORS_ALLOW_ORIGIN_HEADER: HeaderValue = HeaderValue::from_static(CORS_ALLOW_ORIGIN);
impl IntoResponse for WebFingerResponse {
fn into_response(self) -> AxumResponse {
(
[
(header::CONTENT_TYPE, JRD_CONTENT_TYPE),
(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
CORS_ALLOW_ORIGIN_HEADER,
),
],
Json(self),
)
.into_response()
}
}
#[derive(Debug)]
pub enum Rejection {
InvalidQueryString(String),
InvalidResource(ResourceError),
MissingHost,
InvalidRel(crate::Error),
}
impl IntoResponse for Rejection {
fn into_response(self) -> AxumResponse {
let message = match self {
Rejection::MissingHost => "missing host".to_string(),
Rejection::InvalidQueryString(error) => error,
Rejection::InvalidResource(error) => format!("invalid resource: {error}"),
Rejection::InvalidRel(error) => error.to_string(),
};
(StatusCode::BAD_REQUEST, message).into_response()
}
}
impl From<RequestParamsError> for Rejection {
fn from(error: RequestParamsError) -> Self {
match error {
RequestParamsError::InvalidResource(error) => Rejection::InvalidResource(error),
error => Rejection::InvalidQueryString(error.to_string()),
}
}
}
impl<S: Send + Sync> FromRequestParts<S> for WebFingerRequest {
type Rejection = Rejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
trace!("request parts: {:?}", parts);
let host = parts
.uri
.host()
.or_else(|| parts.headers.get(HOST).and_then(|host| host.to_str().ok()))
.map(str::to_string)
.ok_or(Rejection::MissingHost)?;
let query: RequestParams = parts.uri.query().unwrap_or("").parse()?;
let rels = query
.rel
.into_iter()
.map(Rel::try_new)
.collect::<Result<Vec<_>, _>>()
.map_err(Rejection::InvalidRel)?;
Ok(WebFingerRequest {
host,
resource: query.resource,
rels,
})
}
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::routing::get;
use http::{Method, Request, Response};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
use crate::WELL_KNOWN_PATH;
type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
trait IntoText {
async fn into_text(self) -> Result<String>;
}
impl IntoText for Response<Body> {
async fn into_text(self) -> Result<String> {
let body = self.into_body().collect().await?.to_bytes();
let string = String::from_utf8(body.to_vec())?;
Ok(string)
}
}
fn app() -> axum::Router {
axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger))
}
fn rels_app() -> axum::Router {
axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger_rels))
}
async fn webfinger(request: WebFingerRequest) -> impl IntoResponse {
WebFingerResponse::builder(&request.resource).build()
}
async fn webfinger_rels(request: WebFingerRequest) -> impl IntoResponse {
let rels = request
.rels
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
Json(rels)
}
const VALID_RESOURCE: &str = "acct:carol@example.com";
#[tokio::test]
async fn valid_request() -> Result {
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
Ok(())
}
#[tokio::test]
async fn successful_response_sets_cors_header() -> Result {
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
assert_eq!(
response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
Some(&CORS_ALLOW_ORIGIN_HEADER),
);
Ok(())
}
#[tokio::test]
async fn valid_request_with_host_header() -> Result {
let request = Request::builder()
.uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
.header(HOST, "example.com")
.body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
Ok(())
}
#[tokio::test]
async fn wrong_path_is_not_routed() -> Result {
let request = Request::builder()
.uri(format!("/webfinger?resource={VALID_RESOURCE}"))
.header(HOST, "example.com")
.body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{response:?}");
Ok(())
}
#[tokio::test]
async fn wrong_method_is_not_routed() -> Result {
let request = Request::builder()
.method(Method::POST)
.uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
.header(HOST, "example.com")
.body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(
response.status(),
StatusCode::METHOD_NOT_ALLOWED,
"{response:?}"
);
Ok(())
}
#[tokio::test]
async fn request_with_no_host() -> Result {
let uri = format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "missing host");
Ok(())
}
#[tokio::test]
async fn request_with_missing_resource() -> Result {
let request = Request::builder()
.uri(WELL_KNOWN_PATH)
.header(HOST, "example.com")
.body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "missing resource parameter");
Ok(())
}
#[tokio::test]
async fn request_with_invalid_resource() -> Result {
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=http%3A%2F%2F%5B%3A%3A1");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "invalid resource: invalid authority");
Ok(())
}
#[test]
fn invalid_resource_rejection_preserves_resource_error() {
let error = "resource=/relative".parse::<RequestParams>().unwrap_err();
let rejection = Rejection::from(error);
assert!(matches!(
rejection,
Rejection::InvalidResource(ResourceError::RelativeReference)
));
}
#[tokio::test]
async fn relative_resource_is_bad_request() -> Result {
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=/relative");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "invalid resource: resource must be an absolute URI");
Ok(())
}
#[tokio::test]
async fn valid_percent_encoded_resource() -> Result {
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=acct%3Abad%40example.org");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, r#"{"subject":"acct:bad@example.org","links":[]}"#);
Ok(())
}
#[tokio::test]
async fn valid_request_with_repeated_rel_params() -> Result {
let resource = "acct%3Acarol%40example.org";
let uri = format!(
"https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=profile&rel=avatar"
);
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = rels_app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, r#"["profile","avatar"]"#);
Ok(())
}
#[tokio::test]
async fn rel_params_are_percent_decoded() -> Result {
let resource = "acct%3Acarol%40example.org";
let rel = "http%3A%2F%2Fwebfinger.example%2Frel%2Fprofile-page";
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel={rel}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = rels_app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, r#"["http://webfinger.example/rel/profile-page"]"#);
Ok(())
}
#[tokio::test]
async fn invalid_rel_is_bad_request() -> Result {
let resource = "acct%3Acarol%40example.org";
let uri =
format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=author%20avatar");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = rels_app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "invalid relation type: author avatar");
Ok(())
}
#[tokio::test]
async fn invalid_percent_encoded_rel_is_bad_request() -> Result {
let resource = "acct%3Acarol%40example.org";
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=%FF");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = rels_app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "invalid percent-encoded query parameter");
Ok(())
}
#[tokio::test]
async fn malformed_percent_escape_is_bad_request() -> Result {
let resource = "acct%3Acarol%40example.org";
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=%GG");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = rels_app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "invalid percent-encoded query parameter");
Ok(())
}
#[tokio::test]
async fn resource_parameter_order_does_not_matter() -> Result {
let resource = "acct%3Acarol%40example.org";
let uri = format!("https://example.com{WELL_KNOWN_PATH}?rel=profile&resource={resource}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = rels_app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, r#"["profile"]"#);
Ok(())
}
#[tokio::test]
async fn encoded_delimiters_stay_inside_resource() -> Result {
let resource = "https%3A%2F%2Fexample.org%2Fprofile%3Fa%3D1%26b%3D2";
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(
body,
r#"{"subject":"https://example.org/profile?a=1&b=2","links":[]}"#,
);
Ok(())
}
#[tokio::test]
async fn plus_is_not_decoded_as_space() -> Result {
let uri =
format!("https://example.com{WELL_KNOWN_PATH}?resource=acct%3Acarol+tag%40example.org");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::OK, "{response:?}");
let body = response.into_text().await?;
assert_eq!(
body,
r#"{"subject":"acct:carol+tag@example.org","links":[]}"#
);
Ok(())
}
#[tokio::test]
async fn request_with_multiple_resources() -> Result {
let carol = "acct%3Acarol%40example.org";
let alice = "acct%3Aalice%40example.org";
let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={carol}&resource={alice}");
let request = Request::builder().uri(uri).body(Body::empty())?;
let response = app().oneshot(request).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
let body = response.into_text().await?;
assert_eq!(body, "multiple resource parameters");
Ok(())
}
}