about summary refs log tree commit diff
path: root/src/indieauth/mod.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2025-04-09 23:31:02 +0300
committerVika <vika@fireburn.ru>2025-04-09 23:31:57 +0300
commit8826d9446e6c492db2243b9921e59ce496027bef (patch)
tree63738aa9001cb73b11cb0e974e93129bcdf1adbb /src/indieauth/mod.rs
parent519cadfbb298f50cbf819dde757037ab56e2863e (diff)
downloadkittybox-8826d9446e6c492db2243b9921e59ce496027bef.tar.zst
cargo fmt
Change-Id: I80e81ebba3f0cdf8c094451c9fe3ee4126b8c888
Diffstat (limited to 'src/indieauth/mod.rs')
-rw-r--r--src/indieauth/mod.rs727
1 files changed, 437 insertions, 290 deletions
diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs
index 00ae393..2f90a19 100644
--- a/src/indieauth/mod.rs
+++ b/src/indieauth/mod.rs
@@ -1,18 +1,29 @@
-use std::marker::PhantomData;
-use microformats::types::Class;
-use tracing::error;
-use serde::Deserialize;
+use crate::database::Storage;
 use axum::{
-    extract::{Form, FromRef, Json, Query, State}, http::StatusCode, response::{Html, IntoResponse, Response}
+    extract::{Form, FromRef, Json, Query, State},
+    http::StatusCode,
+    response::{Html, IntoResponse, Response},
 };
 #[cfg_attr(not(feature = "webauthn"), allow(unused_imports))]
-use axum_extra::extract::{Host, cookie::{CookieJar, Cookie}};
-use axum_extra::{headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, TypedHeader};
-use crate::database::Storage;
+use axum_extra::extract::{
+    cookie::{Cookie, CookieJar},
+    Host,
+};
+use axum_extra::{
+    headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt},
+    TypedHeader,
+};
 use kittybox_indieauth::{
-    AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest
+    AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest,
+    GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile,
+    ProfileUrl, ResponseType, RevocationEndpointAuthMethod, Scope, Scopes, TokenData,
+    TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest,
 };
+use microformats::types::Class;
+use serde::Deserialize;
+use std::marker::PhantomData;
 use std::str::FromStr;
+use tracing::error;
 
 pub mod backend;
 #[cfg(feature = "webauthn")]
@@ -41,35 +52,42 @@ impl<A: AuthBackend> std::ops::Deref for User<A> {
 pub enum IndieAuthResourceError {
     InvalidRequest,
     Unauthorized,
-    InvalidToken
+    InvalidToken,
 }
 impl axum::response::IntoResponse for IndieAuthResourceError {
     fn into_response(self) -> axum::response::Response {
         use IndieAuthResourceError::*;
 
         match self {
-            Unauthorized => (
-                StatusCode::UNAUTHORIZED,
-                [("WWW-Authenticate", "Bearer")]
-            ).into_response(),
+            Unauthorized => {
+                (StatusCode::UNAUTHORIZED, [("WWW-Authenticate", "Bearer")]).into_response()
+            }
             InvalidRequest => (
                 StatusCode::BAD_REQUEST,
-                Json(&serde_json::json!({"error": "invalid_request"}))
-            ).into_response(),
+                Json(&serde_json::json!({"error": "invalid_request"})),
+            )
+                .into_response(),
             InvalidToken => (
                 StatusCode::UNAUTHORIZED,
                 [("WWW-Authenticate", "Bearer, error=\"invalid_token\"")],
-                Json(&serde_json::json!({"error": "not_authorized"}))
-            ).into_response()
+                Json(&serde_json::json!({"error": "not_authorized"})),
+            )
+                .into_response(),
         }
     }
 }
 
-impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::OptionalFromRequestParts<St> for User<A> {
+impl<A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static>
+    axum::extract::OptionalFromRequestParts<St> for User<A>
+{
     type Rejection = <Self as axum::extract::FromRequestParts<St>>::Rejection;
 
-    async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Option<Self>, Self::Rejection> {
-        let res = <Self as axum::extract::FromRequestParts<St>>::from_request_parts(req, state).await;
+    async fn from_request_parts(
+        req: &mut axum::http::request::Parts,
+        state: &St,
+    ) -> Result<Option<Self>, Self::Rejection> {
+        let res =
+            <Self as axum::extract::FromRequestParts<St>>::from_request_parts(req, state).await;
 
         match res {
             Ok(user) => Ok(Some(user)),
@@ -79,14 +97,19 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext
     }
 }
 
-impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::extract::FromRequestParts<St> for User<A> {
+impl<A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static>
+    axum::extract::FromRequestParts<St> for User<A>
+{
     type Rejection = IndieAuthResourceError;
 
-    async fn from_request_parts(req: &mut axum::http::request::Parts, state: &St) -> Result<Self, Self::Rejection> {
+    async fn from_request_parts(
+        req: &mut axum::http::request::Parts,
+        state: &St,
+    ) -> Result<Self, Self::Rejection> {
         let TypedHeader(Authorization(token)) =
             TypedHeader::<Authorization<Bearer>>::from_request_parts(req, state)
-            .await
-            .map_err(|_| IndieAuthResourceError::Unauthorized)?;
+                .await
+                .map_err(|_| IndieAuthResourceError::Unauthorized)?;
 
         let auth = A::from_ref(state);
 
@@ -94,10 +117,7 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext
             .await
             .map_err(|_| IndieAuthResourceError::InvalidRequest)?;
 
-        auth.get_token(
-            &format!("https://{host}/").parse().unwrap(),
-            token.token()
-        )
+        auth.get_token(&format!("https://{host}/").parse().unwrap(), token.token())
             .await
             .unwrap()
             .ok_or(IndieAuthResourceError::InvalidToken)
@@ -105,9 +125,7 @@ impl <A: AuthBackend + FromRef<St>, St: Clone + Send + Sync + 'static> axum::ext
     }
 }
 
-pub async fn metadata(
-    Host(host): Host
-) -> Metadata {
+pub async fn metadata(Host(host): Host) -> Metadata {
     let issuer: url::Url = format!("https://{}/", host).parse().unwrap();
 
     let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap();
@@ -117,18 +135,16 @@ pub async fn metadata(
         token_endpoint: indieauth.join("token").unwrap(),
         introspection_endpoint: indieauth.join("token_status").unwrap(),
         introspection_endpoint_auth_methods_supported: Some(vec![
-            IntrospectionEndpointAuthMethod::Bearer
+            IntrospectionEndpointAuthMethod::Bearer,
         ]),
         revocation_endpoint: Some(indieauth.join("revoke_token").unwrap()),
-        revocation_endpoint_auth_methods_supported: Some(vec![
-            RevocationEndpointAuthMethod::None
-        ]),
+        revocation_endpoint_auth_methods_supported: Some(vec![RevocationEndpointAuthMethod::None]),
         scopes_supported: Some(vec![
             Scope::Create,
             Scope::Update,
             Scope::Delete,
             Scope::Media,
-            Scope::Profile
+            Scope::Profile,
         ]),
         response_types_supported: Some(vec![ResponseType::Code]),
         grant_types_supported: Some(vec![GrantType::AuthorizationCode, GrantType::RefreshToken]),
@@ -145,27 +161,39 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
     Query(request): Query<AuthorizationRequest>,
     State(db): State<D>,
     State(http): State<reqwest_middleware::ClientWithMiddleware>,
-    State(auth): State<A>
+    State(auth): State<A>,
 ) -> Response {
     let me: url::Url = format!("https://{host}/").parse().unwrap();
     // XXX: attempt fetching OAuth application metadata
-    let h_app: ClientMetadata = if request.client_id.domain().unwrap() == "localhost" && me.domain().unwrap() != "localhost" {
+    let h_app: ClientMetadata = if request.client_id.domain().unwrap() == "localhost"
+        && me.domain().unwrap() != "localhost"
+    {
         // If client is localhost, but we aren't localhost, generate synthetic metadata.
         tracing::warn!("Client is localhost, not fetching metadata");
-        let mut metadata = ClientMetadata::new(request.client_id.clone(), request.client_id.clone()).unwrap();
+        let mut metadata =
+            ClientMetadata::new(request.client_id.clone(), request.client_id.clone()).unwrap();
 
         metadata.client_name = Some("Your locally hosted app".to_string());
 
         metadata
     } else {
         tracing::debug!("Sending request to {} to fetch metadata", request.client_id);
-        let metadata_request = http.get(request.client_id.clone())
+        let metadata_request = http
+            .get(request.client_id.clone())
             .header("Accept", "application/json, text/html");
-        match metadata_request.send().await
-            .and_then(|res| res.error_for_status()
-                .map_err(reqwest_middleware::Error::Reqwest))
-        {
-            Ok(response) if response.headers().typed_get::<ContentType>().to_owned().map(mime::Mime::from).map(|m| m.type_() == "text" && m.subtype() == "html").unwrap_or(false) => {
+        match metadata_request.send().await.and_then(|res| {
+            res.error_for_status()
+                .map_err(reqwest_middleware::Error::Reqwest)
+        }) {
+            Ok(response)
+                if response
+                    .headers()
+                    .typed_get::<ContentType>()
+                    .to_owned()
+                    .map(mime::Mime::from)
+                    .map(|m| m.type_() == "text" && m.subtype() == "html")
+                    .unwrap_or(false) =>
+            {
                 let url = response.url().clone();
                 let text = response.text().await.unwrap();
                 tracing::debug!("Received {} bytes in response", text.len());
@@ -173,76 +201,95 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
                     Ok(mf2) => {
                         if let Some(relation) = mf2.rels.items.get(&request.redirect_uri) {
                             if !relation.rels.iter().any(|i| i == "redirect_uri") {
-                                return (StatusCode::BAD_REQUEST,
-                                        [("Content-Type", "text/plain")],
-                                        "The redirect_uri provided was declared as \
-                                         something other than redirect_uri.")
-                                    .into_response()
+                                return (
+                                    StatusCode::BAD_REQUEST,
+                                    [("Content-Type", "text/plain")],
+                                    "The redirect_uri provided was declared as \
+                                         something other than redirect_uri.",
+                                )
+                                    .into_response();
                             }
                         } else if request.redirect_uri.origin() != request.client_id.origin() {
-                            return (StatusCode::BAD_REQUEST,
-                                    [("Content-Type", "text/plain")],
-                                    "The redirect_uri didn't match the origin \
-                                     and wasn't explicitly allowed. You were being tricked.")
-                                .into_response()
+                            return (
+                                StatusCode::BAD_REQUEST,
+                                [("Content-Type", "text/plain")],
+                                "The redirect_uri didn't match the origin \
+                                     and wasn't explicitly allowed. You were being tricked.",
+                            )
+                                .into_response();
                         }
 
-                        if let Some(app) = mf2.items
+                        if let Some(app) = mf2
+                            .items
                             .iter()
-                            .find(|&i| i.r#type.iter()
-                                .any(|i| {
+                            .find(|&i| {
+                                i.r#type.iter().any(|i| {
                                     *i == Class::from_str("h-app").unwrap()
                                         || *i == Class::from_str("h-x-app").unwrap()
                                 })
-                            )
+                            })
                             .cloned()
                         {
                             // Create a synthetic metadata document. Be forgiving.
                             let mut metadata = ClientMetadata::new(
                                 request.client_id.clone(),
-                                app.properties.get("url")
+                                app.properties
+                                    .get("url")
                                     .and_then(|v| v.first())
                                     .and_then(|i| match i {
-                                        microformats::types::PropertyValue::Url(url) => Some(url.clone()),
-                                        _ => None
+                                        microformats::types::PropertyValue::Url(url) => {
+                                            Some(url.clone())
+                                        }
+                                        _ => None,
                                     })
-                                    .unwrap_or_else(|| request.client_id.clone())
-                            ).unwrap();
+                                    .unwrap_or_else(|| request.client_id.clone()),
+                            )
+                            .unwrap();
 
-                            metadata.client_name = app.properties.get("name")
+                            metadata.client_name = app
+                                .properties
+                                .get("name")
                                 .and_then(|v| v.first())
                                 .and_then(|i| match i {
-                                    microformats::types::PropertyValue::Plain(name) => Some(name.to_owned()),
-                                    _ => None
+                                    microformats::types::PropertyValue::Plain(name) => {
+                                        Some(name.to_owned())
+                                    }
+                                    _ => None,
                                 });
 
                             metadata.redirect_uris = mf2.rels.by_rels().remove("redirect_uri");
 
                             metadata
                         } else {
-                            return (StatusCode::BAD_REQUEST, [("Content-Type", "text/plain")], "No h-app or JSON application metadata found.").into_response()
+                            return (
+                                StatusCode::BAD_REQUEST,
+                                [("Content-Type", "text/plain")],
+                                "No h-app or JSON application metadata found.",
+                            )
+                                .into_response();
                         }
-                    },
+                    }
                     Err(err) => {
                         tracing::error!("Error parsing application metadata: {}", err);
                         return (
                             StatusCode::BAD_REQUEST,
                             [("Content-Type", "text/plain")],
-                            "Parsing h-app metadata failed.").into_response()
+                            "Parsing h-app metadata failed.",
+                        )
+                            .into_response();
                     }
                 }
-            },
+            }
             Ok(response) => match response.json::<ClientMetadata>().await {
-                Ok(client_metadata) => {
-                    client_metadata
-                },
+                Ok(client_metadata) => client_metadata,
                 Err(err) => {
                     tracing::error!("Error parsing JSON application metadata: {}", err);
                     return (
                         StatusCode::BAD_REQUEST,
                         [("Content-Type", "text/plain")],
-                        format!("Parsing OAuth2 JSON app metadata failed: {}", err)
-                    ).into_response()
+                        format!("Parsing OAuth2 JSON app metadata failed: {}", err),
+                    )
+                        .into_response();
                 }
             },
             Err(err) => {
@@ -250,27 +297,32 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
                 return (
                     StatusCode::BAD_REQUEST,
                     [("Content-Type", "text/plain")],
-                    format!("Fetching app metadata failed: {}", err)
-                ).into_response()
+                    format!("Fetching app metadata failed: {}", err),
+                )
+                    .into_response();
             }
         }
     };
 
     tracing::debug!("Application metadata: {:#?}", h_app);
 
-    Html(kittybox_frontend_renderer::Template {
-        title: "Confirm sign-in via IndieAuth",
-        blog_name: "Kittybox",
-        feeds: vec![],
-        user: None,
-        content: kittybox_frontend_renderer::AuthorizationRequestPage {
-            request,
-            credentials: auth.list_user_credential_types(&me).await.unwrap(),
-            user: db.get_post(me.as_str()).await.unwrap().unwrap(),
-            app: h_app
-        }.to_string(),
-    }.to_string())
-        .into_response()
+    Html(
+        kittybox_frontend_renderer::Template {
+            title: "Confirm sign-in via IndieAuth",
+            blog_name: "Kittybox",
+            feeds: vec![],
+            user: None,
+            content: kittybox_frontend_renderer::AuthorizationRequestPage {
+                request,
+                credentials: auth.list_user_credential_types(&me).await.unwrap(),
+                user: db.get_post(me.as_str()).await.unwrap().unwrap(),
+                app: h_app,
+            }
+            .to_string(),
+        }
+        .to_string(),
+    )
+    .into_response()
 }
 
 #[derive(Deserialize, Debug)]
@@ -278,7 +330,7 @@ async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
 enum Credential {
     Password(String),
     #[cfg(feature = "webauthn")]
-    WebAuthn(::webauthn::prelude::PublicKeyCredential)
+    WebAuthn(::webauthn::prelude::PublicKeyCredential),
 }
 
 // The IndieAuth standard doesn't prescribe a format for confirming
@@ -291,7 +343,7 @@ enum Credential {
 #[derive(Deserialize, Debug)]
 struct AuthorizationConfirmation {
     authorization_method: Credential,
-    request: AuthorizationRequest
+    request: AuthorizationRequest,
 }
 
 #[tracing::instrument(skip(auth, credential))]
@@ -299,18 +351,14 @@ async fn verify_credential<A: AuthBackend>(
     auth: &A,
     website: &url::Url,
     credential: Credential,
-    #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))]
-    challenge_id: Option<&str>
+    #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] challenge_id: Option<&str>,
 ) -> std::io::Result<bool> {
     match credential {
         Credential::Password(password) => auth.verify_password(website, password).await,
         #[cfg(feature = "webauthn")]
-        Credential::WebAuthn(credential) => webauthn::verify(
-            auth,
-            website,
-            credential,
-            challenge_id.unwrap()
-        ).await
+        Credential::WebAuthn(credential) => {
+            webauthn::verify(auth, website, credential, challenge_id.unwrap()).await
+        }
     }
 }
 
@@ -323,7 +371,8 @@ async fn authorization_endpoint_confirm<A: AuthBackend>(
 ) -> Response {
     tracing::debug!("Received authorization confirmation from user");
     #[cfg(feature = "webauthn")]
-    let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE)
+    let challenge_id = cookies
+        .get(webauthn::CHALLENGE_ID_COOKIE)
         .map(|cookie| cookie.value());
     #[cfg(not(feature = "webauthn"))]
     let challenge_id = None;
@@ -331,14 +380,16 @@ async fn authorization_endpoint_confirm<A: AuthBackend>(
     let website = format!("https://{}/", host).parse().unwrap();
     let AuthorizationConfirmation {
         authorization_method: credential,
-        request: mut auth
+        request: mut auth,
     } = confirmation;
 
     match verify_credential(&backend, &website, credential, challenge_id).await {
-        Ok(verified) => if !verified {
-            error!("User failed verification, bailing out.");
-            return StatusCode::UNAUTHORIZED.into_response();
-        },
+        Ok(verified) => {
+            if !verified {
+                error!("User failed verification, bailing out.");
+                return StatusCode::UNAUTHORIZED.into_response();
+            }
+        }
         Err(err) => {
             error!("Error while verifying credential: {}", err);
             return StatusCode::INTERNAL_SERVER_ERROR.into_response();
@@ -365,9 +416,14 @@ async fn authorization_endpoint_confirm<A: AuthBackend>(
 
     let location = {
         let mut uri = redirect_uri;
-        uri.set_query(Some(&serde_urlencoded::to_string(
-            AuthorizationResponse { code, state, iss: website }
-        ).unwrap()));
+        uri.set_query(Some(
+            &serde_urlencoded::to_string(AuthorizationResponse {
+                code,
+                state,
+                iss: website,
+            })
+            .unwrap(),
+        ));
 
         uri
     };
@@ -375,10 +431,11 @@ async fn authorization_endpoint_confirm<A: AuthBackend>(
     // DO NOT SET `StatusCode::FOUND` here! `fetch()` cannot read from
     // redirects, it can only follow them or choose to receive an
     // opaque response instead that is completely useless
-    (StatusCode::NO_CONTENT,
-     [("Location", location.as_str())],
-     #[cfg(feature = "webauthn")]
-     cookies.remove(Cookie::from(webauthn::CHALLENGE_ID_COOKIE))
+    (
+        StatusCode::NO_CONTENT,
+        [("Location", location.as_str())],
+        #[cfg(feature = "webauthn")]
+        cookies.remove(Cookie::from(webauthn::CHALLENGE_ID_COOKIE)),
     )
         .into_response()
 }
@@ -396,15 +453,18 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>(
             code,
             client_id,
             redirect_uri,
-            code_verifier
+            code_verifier,
         } => {
             let request: AuthorizationRequest = match backend.get_code(&code).await {
                 Ok(Some(request)) => request,
-                Ok(None) => return Error {
-                    kind: ErrorKind::InvalidGrant,
-                    msg: Some("The provided authorization code is invalid.".to_string()),
-                    error_uri: None
-                }.into_response(),
+                Ok(None) => {
+                    return Error {
+                        kind: ErrorKind::InvalidGrant,
+                        msg: Some("The provided authorization code is invalid.".to_string()),
+                        error_uri: None,
+                    }
+                    .into_response()
+                }
                 Err(err) => {
                     tracing::error!("Error retrieving auth request: {}", err);
                     return StatusCode::INTERNAL_SERVER_ERROR.into_response();
@@ -414,51 +474,66 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("This authorization code isn't yours.".to_string()),
-                    error_uri: None
-                }.into_response()
+                    error_uri: None,
+                }
+                .into_response();
             }
             if redirect_uri != request.redirect_uri {
                 return Error {
                     kind: ErrorKind::InvalidGrant,
-                    msg: Some("This redirect_uri doesn't match the one the code has been sent to.".to_string()),
-                    error_uri: None
-                }.into_response()
+                    msg: Some(
+                        "This redirect_uri doesn't match the one the code has been sent to."
+                            .to_string(),
+                    ),
+                    error_uri: None,
+                }
+                .into_response();
             }
             if !request.code_challenge.verify(code_verifier) {
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("The PKCE challenge failed.".to_string()),
                     // are RFCs considered human-readable? 😝
-                    error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok()
-                }.into_response()
+                    error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6"
+                        .parse()
+                        .ok(),
+                }
+                .into_response();
             }
             let me: url::Url = format!("https://{}/", host).parse().unwrap();
             if request.me.unwrap() != me {
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("This authorization endpoint does not serve this user.".to_string()),
-                    error_uri: None
-                }.into_response()
+                    error_uri: None,
+                }
+                .into_response();
             }
-            let profile = if request.scope.as_ref()
-                                  .map(|s| s.has(&Scope::Profile))
-                                  .unwrap_or_default()
+            let profile = if request
+                .scope
+                .as_ref()
+                .map(|s| s.has(&Scope::Profile))
+                .unwrap_or_default()
             {
                 match get_profile(
                     db,
                     me.as_str(),
-                    request.scope.as_ref()
+                    request
+                        .scope
+                        .as_ref()
                         .map(|s| s.has(&Scope::Email))
-                        .unwrap_or_default()
-                ).await {
+                        .unwrap_or_default(),
+                )
+                .await
+                {
                     Ok(profile) => {
                         tracing::debug!("Retrieved profile: {:?}", profile);
                         profile
-                    },
+                    }
                     Err(err) => {
                         tracing::error!("Error retrieving profile from database: {}", err);
 
-                        return StatusCode::INTERNAL_SERVER_ERROR.into_response()
+                        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
                     }
                 }
             } else {
@@ -466,12 +541,15 @@ async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>(
             };
 
             GrantResponse::ProfileUrl(ProfileUrl { me, profile }).into_response()
-        },
+        }
         _ => Error {
             kind: ErrorKind::InvalidGrant,
             msg: Some("The provided grant_type is unusable on this endpoint.".to_string()),
-            error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code".parse().ok()
-        }.into_response()
+            error_uri: "https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code"
+                .parse()
+                .ok(),
+        }
+        .into_response(),
     }
 }
 
@@ -485,36 +563,40 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
     #[inline]
     fn prepare_access_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData {
         TokenData {
-            me, client_id, scope,
+            me,
+            client_id,
+            scope,
             exp: (std::time::SystemTime::now()
-                  .duration_since(std::time::UNIX_EPOCH)
-                  .unwrap()
-                  + std::time::Duration::from_secs(ACCESS_TOKEN_VALIDITY))
-                .as_secs()
-                .into(),
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                + std::time::Duration::from_secs(ACCESS_TOKEN_VALIDITY))
+            .as_secs()
+            .into(),
             iat: std::time::SystemTime::now()
                 .duration_since(std::time::UNIX_EPOCH)
                 .unwrap()
                 .as_secs()
-                .into()
+                .into(),
         }
     }
 
     #[inline]
     fn prepare_refresh_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData {
         TokenData {
-            me, client_id, scope,
+            me,
+            client_id,
+            scope,
             exp: (std::time::SystemTime::now()
-                  .duration_since(std::time::UNIX_EPOCH)
-                  .unwrap()
-                  + std::time::Duration::from_secs(REFRESH_TOKEN_VALIDITY))
-                .as_secs()
-                .into(),
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                + std::time::Duration::from_secs(REFRESH_TOKEN_VALIDITY))
+            .as_secs()
+            .into(),
             iat: std::time::SystemTime::now()
                 .duration_since(std::time::UNIX_EPOCH)
                 .unwrap()
                 .as_secs()
-                .into()
+                .into(),
         }
     }
 
@@ -525,15 +607,18 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
             code,
             client_id,
             redirect_uri,
-            code_verifier
+            code_verifier,
         } => {
             let request: AuthorizationRequest = match backend.get_code(&code).await {
                 Ok(Some(request)) => request,
-                Ok(None) => return Error {
-                    kind: ErrorKind::InvalidGrant,
-                    msg: Some("The provided authorization code is invalid.".to_string()),
-                    error_uri: None
-                }.into_response(),
+                Ok(None) => {
+                    return Error {
+                        kind: ErrorKind::InvalidGrant,
+                        msg: Some("The provided authorization code is invalid.".to_string()),
+                        error_uri: None,
+                    }
+                    .into_response()
+                }
                 Err(err) => {
                     tracing::error!("Error retrieving auth request: {}", err);
                     return StatusCode::INTERNAL_SERVER_ERROR.into_response();
@@ -542,33 +627,46 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
 
             tracing::debug!("Retrieved authorization request: {:?}", request);
 
-            let scope = if let Some(scope) = request.scope { scope } else {
+            let scope = if let Some(scope) = request.scope {
+                scope
+            } else {
                 return Error {
                     kind: ErrorKind::InvalidScope,
                     msg: Some("Tokens cannot be issued if no scopes are requested.".to_string()),
-                    error_uri: "https://indieauth.spec.indieweb.org/#access-token-response".parse().ok()
-                }.into_response();
+                    error_uri: "https://indieauth.spec.indieweb.org/#access-token-response"
+                        .parse()
+                        .ok(),
+                }
+                .into_response();
             };
             if client_id != request.client_id {
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("This authorization code isn't yours.".to_string()),
-                    error_uri: None
-                }.into_response()
+                    error_uri: None,
+                }
+                .into_response();
             }
             if redirect_uri != request.redirect_uri {
                 return Error {
                     kind: ErrorKind::InvalidGrant,
-                    msg: Some("This redirect_uri doesn't match the one the code has been sent to.".to_string()),
-                    error_uri: None
-                }.into_response()
+                    msg: Some(
+                        "This redirect_uri doesn't match the one the code has been sent to."
+                            .to_string(),
+                    ),
+                    error_uri: None,
+                }
+                .into_response();
             }
             if !request.code_challenge.verify(code_verifier) {
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("The PKCE challenge failed.".to_string()),
-                    error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6".parse().ok()
-                }.into_response();
+                    error_uri: "https://datatracker.ietf.org/doc/html/rfc7636#section-4.6"
+                        .parse()
+                        .ok(),
+                }
+                .into_response();
             }
 
             // Note: we can trust the `request.me` value, since we set
@@ -577,30 +675,32 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("This authorization endpoint does not serve this user.".to_string()),
-                    error_uri: None
-                }.into_response()
+                    error_uri: None,
+                }
+                .into_response();
             }
 
             let profile = if dbg!(scope.has(&Scope::Profile)) {
-                match get_profile(
-                    db,
-                    me.as_str(),
-                    scope.has(&Scope::Email)
-                ).await {
+                match get_profile(db, me.as_str(), scope.has(&Scope::Email)).await {
                     Ok(profile) => dbg!(profile),
                     Err(err) => {
                         tracing::error!("Error retrieving profile from database: {}", err);
 
-                        return StatusCode::INTERNAL_SERVER_ERROR.into_response()
+                        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
                     }
                 }
             } else {
                 None
             };
 
-            let access_token = match backend.create_token(
-                prepare_access_token(me.clone(), client_id.clone(), scope.clone())
-            ).await {
+            let access_token = match backend
+                .create_token(prepare_access_token(
+                    me.clone(),
+                    client_id.clone(),
+                    scope.clone(),
+                ))
+                .await
+            {
                 Ok(token) => token,
                 Err(err) => {
                     tracing::error!("Error creating access token: {}", err);
@@ -608,9 +708,10 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 }
             };
             // TODO: only create refresh token if user allows it
-            let refresh_token = match backend.create_refresh_token(
-                prepare_refresh_token(me.clone(), client_id, scope.clone())
-            ).await {
+            let refresh_token = match backend
+                .create_refresh_token(prepare_refresh_token(me.clone(), client_id, scope.clone()))
+                .await
+            {
                 Ok(token) => token,
                 Err(err) => {
                     tracing::error!("Error creating refresh token: {}", err);
@@ -626,24 +727,28 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 scope: Some(scope),
                 expires_in: Some(ACCESS_TOKEN_VALIDITY),
                 refresh_token: Some(refresh_token),
-                state: None
-            }.into_response()
-        },
+                state: None,
+            }
+            .into_response()
+        }
         GrantRequest::RefreshToken {
             refresh_token,
             client_id,
-            scope
+            scope,
         } => {
             let data = match backend.get_refresh_token(&me, &refresh_token).await {
                 Ok(Some(token)) => token,
-                Ok(None) => return Error {
-                    kind: ErrorKind::InvalidGrant,
-                    msg: Some("This refresh token is not valid.".to_string()),
-                    error_uri: None
-                }.into_response(),
+                Ok(None) => {
+                    return Error {
+                        kind: ErrorKind::InvalidGrant,
+                        msg: Some("This refresh token is not valid.".to_string()),
+                        error_uri: None,
+                    }
+                    .into_response()
+                }
                 Err(err) => {
                     tracing::error!("Error retrieving refresh token: {}", err);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response()
+                    return StatusCode::INTERNAL_SERVER_ERROR.into_response();
                 }
             };
 
@@ -651,17 +756,22 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 return Error {
                     kind: ErrorKind::InvalidGrant,
                     msg: Some("This refresh token is not yours.".to_string()),
-                    error_uri: None
-                }.into_response();
+                    error_uri: None,
+                }
+                .into_response();
             }
 
             let scope = if let Some(scope) = scope {
                 if !data.scope.has_all(scope.as_ref()) {
                     return Error {
                         kind: ErrorKind::InvalidScope,
-                        msg: Some("You can't request additional scopes through the refresh token grant.".to_string()),
-                        error_uri: None
-                    }.into_response();
+                        msg: Some(
+                            "You can't request additional scopes through the refresh token grant."
+                                .to_string(),
+                        ),
+                        error_uri: None,
+                    }
+                    .into_response();
                 }
 
                 scope
@@ -670,27 +780,27 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 data.scope
             };
 
-
             let profile = if scope.has(&Scope::Profile) {
-                match get_profile(
-                    db,
-                    data.me.as_str(),
-                    scope.has(&Scope::Email)
-                ).await {
+                match get_profile(db, data.me.as_str(), scope.has(&Scope::Email)).await {
                     Ok(profile) => profile,
                     Err(err) => {
                         tracing::error!("Error retrieving profile from database: {}", err);
 
-                        return StatusCode::INTERNAL_SERVER_ERROR.into_response()
+                        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
                     }
                 }
             } else {
                 None
             };
 
-            let access_token = match backend.create_token(
-                prepare_access_token(data.me.clone(), client_id.clone(), scope.clone())
-            ).await {
+            let access_token = match backend
+                .create_token(prepare_access_token(
+                    data.me.clone(),
+                    client_id.clone(),
+                    scope.clone(),
+                ))
+                .await
+            {
                 Ok(token) => token,
                 Err(err) => {
                     tracing::error!("Error creating access token: {}", err);
@@ -699,9 +809,14 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
             };
 
             let old_refresh_token = refresh_token;
-            let refresh_token = match backend.create_refresh_token(
-                prepare_refresh_token(data.me.clone(), client_id, scope.clone())
-            ).await {
+            let refresh_token = match backend
+                .create_refresh_token(prepare_refresh_token(
+                    data.me.clone(),
+                    client_id,
+                    scope.clone(),
+                ))
+                .await
+            {
                 Ok(token) => token,
                 Err(err) => {
                     tracing::error!("Error creating refresh token: {}", err);
@@ -721,8 +836,9 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
                 scope: Some(scope),
                 expires_in: Some(ACCESS_TOKEN_VALIDITY),
                 refresh_token: Some(refresh_token),
-                state: None
-            }.into_response()
+                state: None,
+            }
+            .into_response()
         }
     }
 }
@@ -740,26 +856,39 @@ async fn introspection_endpoint_post<A: AuthBackend>(
 
     // Check authentication first
     match backend.get_token(&me, auth_token.token()).await {
-        Ok(Some(token)) => if !token.scope.has(&Scope::custom(KITTYBOX_TOKEN_STATUS)) {
-            return (StatusCode::UNAUTHORIZED, Json(json!({
-                "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope
-            }))).into_response();
-        },
-        Ok(None) => return (StatusCode::UNAUTHORIZED, Json(json!({
-            "error": kittybox_indieauth::ResourceErrorKind::InvalidToken
-        }))).into_response(),
+        Ok(Some(token)) => {
+            if !token.scope.has(&Scope::custom(KITTYBOX_TOKEN_STATUS)) {
+                return (
+                    StatusCode::UNAUTHORIZED,
+                    Json(json!({
+                        "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope
+                    })),
+                )
+                    .into_response();
+            }
+        }
+        Ok(None) => {
+            return (
+                StatusCode::UNAUTHORIZED,
+                Json(json!({
+                    "error": kittybox_indieauth::ResourceErrorKind::InvalidToken
+                })),
+            )
+                .into_response()
+        }
         Err(err) => {
             tracing::error!("Error retrieving token data for introspection: {}", err);
-            return StatusCode::INTERNAL_SERVER_ERROR.into_response()
+            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
         }
     }
-    let response: TokenIntrospectionResponse = match backend.get_token(&me, &token_request.token).await {
-        Ok(maybe_data) => maybe_data.into(),
-        Err(err) => {
-            tracing::error!("Error retrieving token data: {}", err);
-            return StatusCode::INTERNAL_SERVER_ERROR.into_response()
-        }
-    };
+    let response: TokenIntrospectionResponse =
+        match backend.get_token(&me, &token_request.token).await {
+            Ok(maybe_data) => maybe_data.into(),
+            Err(err) => {
+                tracing::error!("Error retrieving token data: {}", err);
+                return StatusCode::INTERNAL_SERVER_ERROR.into_response();
+            }
+        };
 
     response.into_response()
 }
@@ -787,7 +916,7 @@ async fn revocation_endpoint_post<A: AuthBackend>(
 async fn get_profile<D: Storage + 'static>(
     db: D,
     url: &str,
-    email: bool
+    email: bool,
 ) -> crate::database::Result<Option<Profile>> {
     fn get_first(v: serde_json::Value) -> Option<String> {
         match v {
@@ -796,10 +925,10 @@ async fn get_profile<D: Storage + 'static>(
                 match a.pop() {
                     Some(serde_json::Value::String(s)) => Some(s),
                     Some(serde_json::Value::Object(mut o)) => o.remove("value").and_then(get_first),
-                    _ => None
+                    _ => None,
                 }
-            },
-            _ => None
+            }
+            _ => None,
         }
     }
 
@@ -807,15 +936,26 @@ async fn get_profile<D: Storage + 'static>(
         // Ruthlessly manually destructure the MF2 document to save memory
         let mut properties = match mf2.as_object_mut().unwrap().remove("properties") {
             Some(serde_json::Value::Object(props)) => props,
-            _ => unreachable!()
+            _ => unreachable!(),
         };
         drop(mf2);
         let name = properties.remove("name").and_then(get_first);
-        let url = properties.remove("uid").and_then(get_first).and_then(|u| u.parse().ok());
-        let photo = properties.remove("photo").and_then(get_first).and_then(|u| u.parse().ok());
+        let url = properties
+            .remove("uid")
+            .and_then(get_first)
+            .and_then(|u| u.parse().ok());
+        let photo = properties
+            .remove("photo")
+            .and_then(get_first)
+            .and_then(|u| u.parse().ok());
         let email = properties.remove("name").and_then(get_first);
 
-        Profile { name, url, photo, email }
+        Profile {
+            name,
+            url,
+            photo,
+            email,
+        }
     }))
 }
 
@@ -823,7 +963,7 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
     Host(host): Host,
     TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>,
     State(backend): State<A>,
-    State(db): State<D>
+    State(db): State<D>,
 ) -> Response {
     use serde_json::json;
 
@@ -832,14 +972,22 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
     match backend.get_token(&me, auth_token.token()).await {
         Ok(Some(token)) => {
             if token.expired() {
-                return (StatusCode::UNAUTHORIZED, Json(json!({
-                    "error": kittybox_indieauth::ResourceErrorKind::InvalidToken
-                }))).into_response();
+                return (
+                    StatusCode::UNAUTHORIZED,
+                    Json(json!({
+                        "error": kittybox_indieauth::ResourceErrorKind::InvalidToken
+                    })),
+                )
+                    .into_response();
             }
             if !token.scope.has(&Scope::Profile) {
-                return (StatusCode::UNAUTHORIZED, Json(json!({
-                    "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope
-                }))).into_response();
+                return (
+                    StatusCode::UNAUTHORIZED,
+                    Json(json!({
+                        "error": kittybox_indieauth::ResourceErrorKind::InsufficientScope
+                    })),
+                )
+                    .into_response();
             }
 
             match get_profile(db, me.as_str(), token.scope.has(&Scope::Email)).await {
@@ -847,17 +995,19 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
                 Ok(None) => Json(json!({
                     // We do this because ResourceErrorKind is IndieAuth errors only
                     "error": "invalid_request"
-                })).into_response(),
+                }))
+                .into_response(),
                 Err(err) => {
                     tracing::error!("Error retrieving profile from database: {}", err);
 
                     StatusCode::INTERNAL_SERVER_ERROR.into_response()
                 }
             }
-        },
+        }
         Ok(None) => Json(json!({
             "error": kittybox_indieauth::ResourceErrorKind::InvalidToken
-        })).into_response(),
+        }))
+        .into_response(),
         Err(err) => {
             tracing::error!("Error reading token: {}", err);
 
@@ -871,57 +1021,51 @@ where
     S: Storage + FromRef<St> + 'static,
     A: AuthBackend + FromRef<St>,
     reqwest_middleware::ClientWithMiddleware: FromRef<St>,
-    St: Clone + Send + Sync + 'static
+    St: Clone + Send + Sync + 'static,
 {
-    use axum::routing::{Router, get, post};
+    use axum::routing::{get, post, Router};
 
     Router::new()
         .nest(
             "/.kittybox/indieauth",
             Router::new()
-                .route("/metadata",
-                       get(metadata))
+                .route("/metadata", get(metadata))
                 .route(
                     "/auth",
                     get(authorization_endpoint_get::<A, S>)
-                        .post(authorization_endpoint_post::<A, S>))
-                .route(
-                    "/auth/confirm",
-                    post(authorization_endpoint_confirm::<A>))
-                .route(
-                    "/token",
-                    post(token_endpoint_post::<A, S>))
-                .route(
-                    "/token_status",
-                    post(introspection_endpoint_post::<A>))
-                .route(
-                    "/revoke_token",
-                    post(revocation_endpoint_post::<A>))
+                        .post(authorization_endpoint_post::<A, S>),
+                )
+                .route("/auth/confirm", post(authorization_endpoint_confirm::<A>))
+                .route("/token", post(token_endpoint_post::<A, S>))
+                .route("/token_status", post(introspection_endpoint_post::<A>))
+                .route("/revoke_token", post(revocation_endpoint_post::<A>))
+                .route("/userinfo", get(userinfo_endpoint_get::<A, S>))
                 .route(
-                    "/userinfo",
-                    get(userinfo_endpoint_get::<A, S>))
-
-                .route("/webauthn/pre_register",
-                       get(
-                           #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::<A, S>,
-                           #[cfg(not(feature = "webauthn"))] || std::future::ready(axum::http::StatusCode::NOT_FOUND)
-                       )
+                    "/webauthn/pre_register",
+                    get(
+                        #[cfg(feature = "webauthn")]
+                        webauthn::webauthn_pre_register::<A, S>,
+                        #[cfg(not(feature = "webauthn"))]
+                        || std::future::ready(axum::http::StatusCode::NOT_FOUND),
+                    ),
                 )
-                .layer(tower_http::cors::CorsLayer::new()
-                       .allow_methods([
-                           axum::http::Method::GET,
-                           axum::http::Method::POST
-                       ])
-                       .allow_origin(tower_http::cors::Any))
+                .layer(
+                    tower_http::cors::CorsLayer::new()
+                        .allow_methods([axum::http::Method::GET, axum::http::Method::POST])
+                        .allow_origin(tower_http::cors::Any),
+                ),
         )
         .route(
             "/.well-known/oauth-authorization-server",
-            get(|| std::future::ready(
-                (StatusCode::FOUND,
-                 [("Location",
-                   "/.kittybox/indieauth/metadata")]
-                ).into_response()
-            ))
+            get(|| {
+                std::future::ready(
+                    (
+                        StatusCode::FOUND,
+                        [("Location", "/.kittybox/indieauth/metadata")],
+                    )
+                        .into_response(),
+                )
+            }),
         )
 }
 
@@ -929,9 +1073,10 @@ where
 mod tests {
     #[test]
     fn test_deserialize_authorization_confirmation() {
-        use super::{Credential, AuthorizationConfirmation};
+        use super::{AuthorizationConfirmation, Credential};
 
-        let confirmation = serde_json::from_str::<AuthorizationConfirmation>(r#"{
+        let confirmation = serde_json::from_str::<AuthorizationConfirmation>(
+            r#"{
             "request":{
                 "response_type": "code",
                 "client_id": "https://quill.p3k.io/",
@@ -942,12 +1087,14 @@ mod tests {
                 "scope": "create+media"
             },
             "authorization_method": "swordfish"
-        }"#).unwrap();
+        }"#,
+        )
+        .unwrap();
 
         match confirmation.authorization_method {
             Credential::Password(password) => assert_eq!(password.as_str(), "swordfish"),
             #[allow(unreachable_patterns)]
-            other => panic!("Incorrect credential: {:?}", other)
+            other => panic!("Incorrect credential: {:?}", other),
         }
         assert_eq!(confirmation.request.state.as_ref(), "10101010");
     }