webfinger_rs/axum.rs
1//! Axum integration for WebFinger request extraction and JRD responses.
2//!
3//! Enable the `axum` feature to:
4//!
5//! - extract [`WebFingerRequest`] from handlers mounted for `GET` requests to
6//! [`crate::WELL_KNOWN_PATH`]; and
7//! - return [`WebFingerResponse`] directly from Axum handlers as `application/jrd+json` with the
8//! WebFinger CORS header.
9//!
10//! The extractor expects the standard WebFinger query shape from [RFC 7033 section 4.1]:
11//!
12//! - a required `resource` query parameter; and
13//! - zero or more `rel` query parameters, encoded as repeated keys rather than a list.
14//!
15//! The `resource` value must be an absolute URI such as `acct:carol@example.com` or
16//! `https://example.com/users/carol`; relative references are rejected as malformed requests.
17//!
18//! In practice, route handlers should usually be mounted like this:
19//!
20//! ```rust
21//! use axum::{Router, routing::get};
22//! use webfinger_rs::{WELL_KNOWN_PATH, WebFingerRequest, WebFingerResponse};
23//!
24//! async fn webfinger(_request: WebFingerRequest) -> WebFingerResponse {
25//! WebFingerResponse::new("acct:carol@example.com")
26//! }
27//!
28//! let app = Router::<()>::new().route(WELL_KNOWN_PATH, get(webfinger));
29//! # let _ = app;
30//! ```
31//!
32//! The Axum router owns path and method matching. Mounting the handler with `get` at
33//! [`crate::WELL_KNOWN_PATH`] rejects other paths and non-`GET` methods before this extractor runs.
34//! The extractor itself validates the WebFinger request metadata available inside the handler:
35//! host, query parameters, percent encoding, and the `resource` URI.
36//!
37//! RFC 7033 requires HTTPS for WebFinger. Axum request parts do not reliably identify the
38//! externally visible scheme when the application runs behind TLS termination or a reverse proxy, so
39//! this extractor does not enforce scheme. Configure TLS and forwarded-proto handling at your
40//! server or proxy boundary.
41//!
42//! If extraction fails, Axum receives [`Rejection`], which returns `400 Bad Request` with a plain
43//! text message for missing or duplicated `resource`, missing host values, invalid percent
44//! encoding, relative resource references, or invalid resource URIs.
45//!
46//! See also [`WebFingerRequest`] for the extractor impl, [`WebFingerResponse`] for the responder
47//! impl, and the [Axum example] for a runnable server.
48//!
49//! [RFC 7033 section 4.1]: https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1
50//! [Axum example]:
51//! https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
52
53use axum::Json;
54use axum::extract::FromRequestParts;
55use axum::response::{IntoResponse, Response as AxumResponse};
56use http::header::{self, HOST};
57use http::request::Parts;
58use http::{HeaderValue, StatusCode};
59use tracing::trace;
60
61use crate::http::CORS_ALLOW_ORIGIN;
62use crate::query::{RequestParams, RequestParamsError};
63use crate::{Rel, ResourceError, WebFingerRequest, WebFingerResponse};
64
65const JRD_CONTENT_TYPE: HeaderValue = HeaderValue::from_static("application/jrd+json");
66const CORS_ALLOW_ORIGIN_HEADER: HeaderValue = HeaderValue::from_static(CORS_ALLOW_ORIGIN);
67
68impl IntoResponse for WebFingerResponse {
69 /// Converts a [`WebFingerResponse`] into an Axum response.
70 ///
71 /// This serializes the body as JSON, sets the `Content-Type` header to
72 /// `application/jrd+json`, and allows cross-origin browser requests with
73 /// `Access-Control-Allow-Origin: *` as recommended by RFC 7033 section 5.
74 ///
75 /// Handlers can therefore return [`WebFingerResponse`] directly without manually wrapping it in
76 /// [`axum::Json`] or setting the response header themselves.
77 ///
78 /// Mount the route at [`crate::WELL_KNOWN_PATH`] so the handler matches the standard WebFinger
79 /// endpoint path.
80 ///
81 /// See also the [`crate::axum`] module docs and the [Axum example].
82 ///
83 /// # Example
84 ///
85 /// ```rust
86 /// use axum::{Router, routing::get};
87 /// use http::StatusCode;
88 /// use webfinger_rs::{Link, Rel, WELL_KNOWN_PATH, WebFingerRequest, WebFingerResponse};
89 ///
90 /// async fn webfinger(request: WebFingerRequest) -> axum::response::Result<WebFingerResponse> {
91 /// let subject = request.resource.to_string();
92 /// if subject != "acct:carol@example.com" {
93 /// return Err((StatusCode::NOT_FOUND, "not found").into());
94 /// }
95 ///
96 /// let rel = Rel::new("http://webfinger.net/rel/profile-page");
97 /// let response = if request.rels.is_empty() || request.rels.contains(&rel) {
98 /// let link = Link::builder(rel).href("https://example.com/users/carol");
99 /// WebFingerResponse::builder(subject).link(link).build()
100 /// } else {
101 /// WebFingerResponse::builder(subject).build()
102 /// };
103 /// Ok(response)
104 /// }
105 ///
106 /// let app = Router::<()>::new().route(WELL_KNOWN_PATH, get(webfinger));
107 /// # let _ = app;
108 /// ```
109 ///
110 /// [Axum example]:
111 /// https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
112 fn into_response(self) -> AxumResponse {
113 (
114 [
115 (header::CONTENT_TYPE, JRD_CONTENT_TYPE),
116 (
117 header::ACCESS_CONTROL_ALLOW_ORIGIN,
118 CORS_ALLOW_ORIGIN_HEADER,
119 ),
120 ],
121 Json(self),
122 )
123 .into_response()
124 }
125}
126
127/// Rejection type for WebFinger requests.
128///
129/// This represents errors that can occur while extracting [`WebFingerRequest`] from Axum request
130/// parts.
131///
132/// Each variant maps to `400 Bad Request` when converted into an Axum response:
133///
134/// - [`Rejection::MissingHost`] when neither the request URI nor the `Host` header provides an
135/// authority;
136/// - [`Rejection::InvalidQueryString`] when the query string is missing `resource`, contains more
137/// than one `resource`, or contains malformed percent encoding;
138/// - [`Rejection::InvalidResource`] when the `resource` value is not an absolute URI.
139#[derive(Debug)]
140pub enum Rejection {
141 /// The WebFinger query string is missing required data or is malformed.
142 InvalidQueryString(String),
143
144 /// The `resource` query parameter is not an absolute URI.
145 InvalidResource(ResourceError),
146
147 /// The `Host` header is missing.
148 MissingHost,
149
150 /// A `rel` query parameter is invalid.
151 InvalidRel(crate::Error),
152}
153
154impl IntoResponse for Rejection {
155 /// Converts the rejection into a `400 Bad Request` Axum response.
156 ///
157 /// The body is a plain text error message intended to make local debugging and simple server
158 /// implementations straightforward.
159 ///
160 /// See also the [`crate::axum`] module docs.
161 fn into_response(self) -> AxumResponse {
162 let message = match self {
163 Rejection::MissingHost => "missing host".to_string(),
164 Rejection::InvalidQueryString(error) => error,
165 Rejection::InvalidResource(error) => format!("invalid resource: {error}"),
166 Rejection::InvalidRel(error) => error.to_string(),
167 };
168 (StatusCode::BAD_REQUEST, message).into_response()
169 }
170}
171
172impl From<RequestParamsError> for Rejection {
173 fn from(error: RequestParamsError) -> Self {
174 match error {
175 RequestParamsError::InvalidResource(error) => Rejection::InvalidResource(error),
176 error => Rejection::InvalidQueryString(error.to_string()),
177 }
178 }
179}
180
181impl<S: Send + Sync> FromRequestParts<S> for WebFingerRequest {
182 type Rejection = Rejection;
183
184 /// Extracts a [`WebFingerRequest`] from Axum request parts.
185 ///
186 /// The extractor expects a request routed to [`crate::WELL_KNOWN_PATH`] with:
187 ///
188 /// - a `resource` query parameter containing the target resource URI; and
189 /// - zero or more repeated `rel` query parameters.
190 ///
191 /// Host resolution follows this order:
192 ///
193 /// 1. Use the authority from `parts.uri` when the request URI is absolute.
194 /// 1. Otherwise, fall back to the HTTP `Host` header.
195 ///
196 /// The extracted host, parsed resource, and collected relations are used to construct the
197 /// resulting [`WebFingerRequest`].
198 ///
199 /// # Errors
200 ///
201 /// - If the request has neither a URI authority nor a `Host` header, extraction fails with
202 /// `Rejection::MissingHost`.
203 /// - If the query string is missing `resource`, contains more than one `resource`, or contains
204 /// malformed percent encoding, extraction fails with `Rejection::InvalidQueryString`.
205 /// - If `resource` is present but cannot be parsed as a URI, extraction fails with
206 /// `Rejection::InvalidResource`.
207 ///
208 /// See also the [`crate::axum`] module docs and the [Axum example].
209 ///
210 /// # Example
211 ///
212 /// ```rust
213 /// use axum::{Router, routing::get};
214 /// use webfinger_rs::{WELL_KNOWN_PATH, WebFingerRequest, WebFingerResponse};
215 ///
216 /// async fn webfinger(request: WebFingerRequest) -> WebFingerResponse {
217 /// WebFingerResponse::new(request.resource.to_string())
218 /// }
219 ///
220 /// let app = Router::<()>::new().route(WELL_KNOWN_PATH, get(webfinger));
221 /// # let _ = app;
222 /// ```
223 ///
224 /// [Axum example]:
225 /// https://github.com/joshka/webfinger-rs/blob/main/webfinger-rs/examples/axum.rs
226 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
227 trace!("request parts: {:?}", parts);
228
229 let host = parts
230 .uri
231 .host()
232 .or_else(|| parts.headers.get(HOST).and_then(|host| host.to_str().ok()))
233 .map(str::to_string)
234 .ok_or(Rejection::MissingHost)?;
235
236 let query: RequestParams = parts.uri.query().unwrap_or("").parse()?;
237 let rels = query
238 .rel
239 .into_iter()
240 .map(Rel::try_new)
241 .collect::<Result<Vec<_>, _>>()
242 .map_err(Rejection::InvalidRel)?;
243
244 Ok(WebFingerRequest {
245 host,
246 resource: query.resource,
247 rels,
248 })
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use axum::body::Body;
255 use axum::routing::get;
256 use http::{Method, Request, Response};
257 use http_body_util::BodyExt;
258 use tower::ServiceExt;
259
260 use super::*;
261 use crate::WELL_KNOWN_PATH;
262
263 type Result<T = (), E = Box<dyn std::error::Error>> = std::result::Result<T, E>;
264
265 /// A small helper trait to convert a response body into a string.
266 trait IntoText {
267 /// Consumes the response body and decodes it as UTF-8 text.
268 async fn into_text(self) -> Result<String>;
269 }
270
271 impl IntoText for Response<Body> {
272 async fn into_text(self) -> Result<String> {
273 let body = self.into_body().collect().await?.to_bytes();
274 let string = String::from_utf8(body.to_vec())?;
275 Ok(string)
276 }
277 }
278
279 /// Builds a test router using the resource-echoing WebFinger handler.
280 fn app() -> axum::Router {
281 axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger))
282 }
283
284 /// Builds a test router using the relation-echoing WebFinger handler.
285 fn rels_app() -> axum::Router {
286 axum::Router::new().route(WELL_KNOWN_PATH, get(webfinger_rels))
287 }
288
289 /// Returns a minimal JRD response so tests can assert resource extraction through Axum.
290 async fn webfinger(request: WebFingerRequest) -> impl IntoResponse {
291 WebFingerResponse::builder(&request.resource).build()
292 }
293
294 /// Returns extracted relation filters so tests can assert RFC 7033 repeated `rel` handling.
295 ///
296 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
297 async fn webfinger_rels(request: WebFingerRequest) -> impl IntoResponse {
298 let rels = request
299 .rels
300 .iter()
301 .map(ToString::to_string)
302 .collect::<Vec<_>>();
303 Json(rels)
304 }
305
306 const VALID_RESOURCE: &str = "acct:carol@example.com";
307
308 /// Accepts an ordinary `acct:` resource from an absolute request URI.
309 ///
310 /// This covers the common Axum path where the request URI already contains the authority, so host
311 /// extraction should not depend on the `Host` header.
312 ///
313 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4>.
314 #[tokio::test]
315 async fn valid_request() -> Result {
316 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
317 let request = Request::builder().uri(uri).body(Body::empty())?;
318
319 let response = app().oneshot(request).await?;
320
321 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
322 let body = response.into_text().await?;
323 assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
324 Ok(())
325 }
326
327 /// Includes the RFC 7033 CORS header on successful JRD responses.
328 ///
329 /// WebFinger resources must be queryable from browsers, and RFC 7033 section 5 recommends the
330 /// least restrictive `Access-Control-Allow-Origin` value for public WebFinger resources.
331 ///
332 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-5>.
333 #[tokio::test]
334 async fn successful_response_sets_cors_header() -> Result {
335 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
336 let request = Request::builder().uri(uri).body(Body::empty())?;
337
338 let response = app().oneshot(request).await?;
339
340 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
341 assert_eq!(
342 response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
343 Some(&CORS_ALLOW_ORIGIN_HEADER),
344 );
345 Ok(())
346 }
347
348 /// Accepts an ordinary `acct:` resource when only the `Host` header carries the authority.
349 ///
350 /// Axum tests usually build origin-form request URIs, so this catches regressions where the
351 /// extractor ignores the fallback authority that HTTP/1.1 clients send in `Host`.
352 ///
353 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4>.
354 #[tokio::test]
355 async fn valid_request_with_host_header() -> Result {
356 let request = Request::builder()
357 .uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
358 .header(HOST, "example.com")
359 .body(Body::empty())?;
360
361 let response = app().oneshot(request).await?;
362
363 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
364 let body = response.into_text().await?;
365 assert_eq!(body, r#"{"subject":"acct:carol@example.com","links":[]}"#);
366 Ok(())
367 }
368
369 /// Relies on Axum routing to reject non-WebFinger paths before extraction.
370 ///
371 /// RFC 7033 sections 4 and 10.1 define `/.well-known/webfinger` as the WebFinger resource.
372 /// Path matching stays in the router so applications get normal Axum `404 Not Found` behavior.
373 ///
374 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4> and
375 /// <https://www.rfc-editor.org/rfc/rfc7033.html#section-10.1>.
376 #[tokio::test]
377 async fn wrong_path_is_not_routed() -> Result {
378 let request = Request::builder()
379 .uri(format!("/webfinger?resource={VALID_RESOURCE}"))
380 .header(HOST, "example.com")
381 .body(Body::empty())?;
382
383 let response = app().oneshot(request).await?;
384
385 assert_eq!(response.status(), StatusCode::NOT_FOUND, "{response:?}");
386 Ok(())
387 }
388
389 /// Relies on Axum routing to reject non-`GET` requests before extraction.
390 ///
391 /// RFC 7033 section 4.2 specifies a `GET` request. Method matching stays in the router so
392 /// applications get normal Axum `405 Method Not Allowed` behavior.
393 ///
394 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
395 #[tokio::test]
396 async fn wrong_method_is_not_routed() -> Result {
397 let request = Request::builder()
398 .method(Method::POST)
399 .uri(format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}"))
400 .header(HOST, "example.com")
401 .body(Body::empty())?;
402
403 let response = app().oneshot(request).await?;
404
405 assert_eq!(
406 response.status(),
407 StatusCode::METHOD_NOT_ALLOWED,
408 "{response:?}"
409 );
410 Ok(())
411 }
412
413 /// Rejects requests where neither the URI nor `Host` header provides an authority.
414 ///
415 /// The request host is significant to WebFinger query routing.
416 ///
417 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4>.
418 #[tokio::test]
419 async fn request_with_no_host() -> Result {
420 let uri = format!("{WELL_KNOWN_PATH}?resource={VALID_RESOURCE}");
421 let request = Request::builder().uri(uri).body(Body::empty())?;
422
423 let response = app().oneshot(request).await?;
424
425 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
426 let body = response.into_text().await?;
427 assert_eq!(body, "missing host");
428 Ok(())
429 }
430
431 /// Rejects requests that omit the required `resource` parameter.
432 ///
433 /// RFC 7033 section 4.2 treats absent `resource` parameters as bad requests. This prevents the
434 /// Axum adapter from relying on framework deserialization wording or accepting an empty target.
435 ///
436 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
437 #[tokio::test]
438 async fn request_with_missing_resource() -> Result {
439 let request = Request::builder()
440 .uri(WELL_KNOWN_PATH)
441 .header(HOST, "example.com")
442 .body(Body::empty())?;
443
444 let response = app().oneshot(request).await?;
445
446 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
447 let body = response.into_text().await?;
448 assert_eq!(body, "missing resource parameter");
449 Ok(())
450 }
451
452 /// Converts malformed resource values into Axum bad-request responses.
453 ///
454 /// RFC 7033 section 4.2 requires malformed `resource` parameters to be treated as bad requests
455 /// instead of panicking inside extraction.
456 ///
457 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
458 #[tokio::test]
459 async fn request_with_invalid_resource() -> Result {
460 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=http%3A%2F%2F%5B%3A%3A1");
461 let request = Request::builder().uri(uri).body(Body::empty())?;
462
463 let response = app().oneshot(request).await?;
464
465 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
466 let body = response.into_text().await?;
467 assert_eq!(body, "invalid resource: invalid authority");
468 Ok(())
469 }
470
471 /// Preserves the typed resource parse error until Axum renders the rejection.
472 #[test]
473 fn invalid_resource_rejection_preserves_resource_error() {
474 let error = "resource=/relative".parse::<RequestParams>().unwrap_err();
475 let rejection = Rejection::from(error);
476
477 assert!(matches!(
478 rejection,
479 Rejection::InvalidResource(ResourceError::RelativeReference)
480 ));
481 }
482
483 /// Rejects relative resource references at the Axum extractor boundary.
484 ///
485 /// RFC 7033 identifies the WebFinger query target as a URI, not a relative reference. Axum
486 /// handlers should not receive ambiguous targets such as local paths or bare names.
487 ///
488 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1> and
489 /// <https://www.rfc-editor.org/rfc/rfc3986.html#section-4.1>.
490 #[tokio::test]
491 async fn relative_resource_is_bad_request() -> Result {
492 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=/relative");
493 let request = Request::builder().uri(uri).body(Body::empty())?;
494
495 let response = app().oneshot(request).await?;
496
497 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
498 let body = response.into_text().await?;
499 assert_eq!(body, "invalid resource: resource must be an absolute URI");
500 Ok(())
501 }
502
503 /// Accepts a percent-encoded `acct:` resource without panicking.
504 ///
505 /// The resource query value is percent-encoded under RFC 7033 section 4.1, then parsed as a URI
506 /// query target under RFC 7033 section 4.2.
507 ///
508 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1> and
509 /// <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
510 #[tokio::test]
511 async fn valid_percent_encoded_resource() -> Result {
512 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource=acct%3Abad%40example.org");
513 let request = Request::builder().uri(uri).body(Body::empty())?;
514
515 let response = app().oneshot(request).await?;
516
517 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
518 let body = response.into_text().await?;
519 assert_eq!(body, r#"{"subject":"acct:bad@example.org","links":[]}"#);
520 Ok(())
521 }
522
523 /// Preserves repeated `rel` parameters instead of collapsing them.
524 ///
525 /// WebFinger clients use repeated `rel` keys to request multiple relation filters. A generic
526 /// map-shaped query parser can easily keep only one value, which would make handlers see an
527 /// incomplete request.
528 ///
529 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
530 #[tokio::test]
531 async fn valid_request_with_repeated_rel_params() -> Result {
532 let resource = "acct%3Acarol%40example.org";
533 let uri = format!(
534 "https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=profile&rel=avatar"
535 );
536 let request = Request::builder().uri(uri).body(Body::empty())?;
537
538 let response = rels_app().oneshot(request).await?;
539
540 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
541 let body = response.into_text().await?;
542 assert_eq!(body, r#"["profile","avatar"]"#);
543 Ok(())
544 }
545
546 /// Exposes decoded relation URIs to Axum handlers.
547 ///
548 /// The shared parser owns the RFC 3986 percent-decoding rule; this adapter test proves Axum
549 /// handlers receive decoded `Rel` values rather than raw query text.
550 ///
551 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1> and
552 /// <https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1>.
553 #[tokio::test]
554 async fn rel_params_are_percent_decoded() -> Result {
555 let resource = "acct%3Acarol%40example.org";
556 let rel = "http%3A%2F%2Fwebfinger.example%2Frel%2Fprofile-page";
557 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel={rel}");
558 let request = Request::builder().uri(uri).body(Body::empty())?;
559
560 let response = rels_app().oneshot(request).await?;
561
562 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
563 let body = response.into_text().await?;
564 assert_eq!(body, r#"["http://webfinger.example/rel/profile-page"]"#);
565 Ok(())
566 }
567
568 /// Rejects relation values that are neither one registered relation type nor one URI.
569 ///
570 /// RFC 7033 section 4.4.4.1 allows one relation type per `rel` member. Multiple relation
571 /// filters should be encoded as repeated `rel` parameters, not as whitespace-separated values.
572 #[tokio::test]
573 async fn invalid_rel_is_bad_request() -> Result {
574 let resource = "acct%3Acarol%40example.org";
575 let uri =
576 format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=author%20avatar");
577 let request = Request::builder().uri(uri).body(Body::empty())?;
578
579 let response = rels_app().oneshot(request).await?;
580
581 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
582 let body = response.into_text().await?;
583 assert_eq!(body, "invalid relation type: author avatar");
584 Ok(())
585 }
586
587 /// Converts invalid UTF-8 after percent decoding into an Axum bad-request response.
588 ///
589 /// The shared parser owns the byte-level validation; this adapter test proves malformed
590 /// percent-encoded bytes do not reach an Axum handler as relation strings.
591 ///
592 /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1>.
593 #[tokio::test]
594 async fn invalid_percent_encoded_rel_is_bad_request() -> Result {
595 let resource = "acct%3Acarol%40example.org";
596 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=%FF");
597 let request = Request::builder().uri(uri).body(Body::empty())?;
598
599 let response = rels_app().oneshot(request).await?;
600
601 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
602 let body = response.into_text().await?;
603 assert_eq!(body, "invalid percent-encoded query parameter");
604 Ok(())
605 }
606
607 /// Rejects malformed percent escape syntax instead of treating `%` literally.
608 ///
609 /// The shared query parser owns the RFC 3986 check; this Axum test proves that parser errors are
610 /// converted into `400 Bad Request` responses instead of escaping the extractor boundary.
611 ///
612 /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-2.1>.
613 #[tokio::test]
614 async fn malformed_percent_escape_is_bad_request() -> Result {
615 let resource = "acct%3Acarol%40example.org";
616 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}&rel=%GG");
617 let request = Request::builder().uri(uri).body(Body::empty())?;
618
619 let response = rels_app().oneshot(request).await?;
620
621 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
622 let body = response.into_text().await?;
623 assert_eq!(body, "invalid percent-encoded query parameter");
624 Ok(())
625 }
626
627 /// Accepts `resource` in any query parameter position through the Axum extractor.
628 ///
629 /// RFC 7033 section 4.1 does not make parameter order significant. This adapter test proves
630 /// Axum handlers still receive relation filters when `resource` appears after them.
631 ///
632 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
633 #[tokio::test]
634 async fn resource_parameter_order_does_not_matter() -> Result {
635 let resource = "acct%3Acarol%40example.org";
636 let uri = format!("https://example.com{WELL_KNOWN_PATH}?rel=profile&resource={resource}");
637 let request = Request::builder().uri(uri).body(Body::empty())?;
638
639 let response = rels_app().oneshot(request).await?;
640
641 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
642 let body = response.into_text().await?;
643 assert_eq!(body, r#"["profile"]"#);
644 Ok(())
645 }
646
647 /// Keeps encoded `=` and `&` inside handler-visible resource values.
648 ///
649 /// Resource URIs may contain query strings of their own. This adapter test proves Axum receives
650 /// the decoded target resource without splitting encoded inner delimiters into WebFinger
651 /// parameters.
652 ///
653 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.1>.
654 #[tokio::test]
655 async fn encoded_delimiters_stay_inside_resource() -> Result {
656 let resource = "https%3A%2F%2Fexample.org%2Fprofile%3Fa%3D1%26b%3D2";
657 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={resource}");
658 let request = Request::builder().uri(uri).body(Body::empty())?;
659
660 let response = app().oneshot(request).await?;
661
662 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
663 let body = response.into_text().await?;
664 assert_eq!(
665 body,
666 r#"{"subject":"https://example.org/profile?a=1&b=2","links":[]}"#,
667 );
668 Ok(())
669 }
670
671 /// Preserves literal `+` in Axum handler-visible resources.
672 ///
673 /// Framework form-query extractors are not used here because WebFinger follows RFC 3986 query
674 /// semantics, where `+` remains data instead of becoming a space.
675 ///
676 /// See <https://www.rfc-editor.org/rfc/rfc3986.html#section-3.4>.
677 #[tokio::test]
678 async fn plus_is_not_decoded_as_space() -> Result {
679 let uri =
680 format!("https://example.com{WELL_KNOWN_PATH}?resource=acct%3Acarol+tag%40example.org");
681 let request = Request::builder().uri(uri).body(Body::empty())?;
682
683 let response = app().oneshot(request).await?;
684
685 assert_eq!(response.status(), StatusCode::OK, "{response:?}");
686 let body = response.into_text().await?;
687 assert_eq!(
688 body,
689 r#"{"subject":"acct:carol+tag@example.org","links":[]}"#
690 );
691 Ok(())
692 }
693
694 /// Rejects duplicate `resource` parameters at the Axum extractor boundary.
695 ///
696 /// The parser owns the RFC 7033 section 4.2 rule that there is exactly one target. This adapter
697 /// test proves ambiguous requests become `400 Bad Request` responses rather than arbitrary
698 /// handler inputs.
699 ///
700 /// See <https://www.rfc-editor.org/rfc/rfc7033.html#section-4.2>.
701 #[tokio::test]
702 async fn request_with_multiple_resources() -> Result {
703 let carol = "acct%3Acarol%40example.org";
704 let alice = "acct%3Aalice%40example.org";
705 let uri = format!("https://example.com{WELL_KNOWN_PATH}?resource={carol}&resource={alice}");
706 let request = Request::builder().uri(uri).body(Body::empty())?;
707
708 let response = app().oneshot(request).await?;
709
710 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{response:?}");
711 let body = response.into_text().await?;
712 assert_eq!(body, "multiple resource parameters");
713 Ok(())
714 }
715}