about summary refs log tree commit diff
path: root/kittybox-rs/src/indieauth
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/src/indieauth')
-rw-r--r--kittybox-rs/src/indieauth/backend.rs105
-rw-r--r--kittybox-rs/src/indieauth/backend/fs.rs420
-rw-r--r--kittybox-rs/src/indieauth/mod.rs883
-rw-r--r--kittybox-rs/src/indieauth/webauthn.rs140
4 files changed, 0 insertions, 1548 deletions
diff --git a/kittybox-rs/src/indieauth/backend.rs b/kittybox-rs/src/indieauth/backend.rs
deleted file mode 100644
index 534bcfb..0000000
--- a/kittybox-rs/src/indieauth/backend.rs
+++ /dev/null
@@ -1,105 +0,0 @@
-use std::collections::HashMap;
-use kittybox_indieauth::{
-    AuthorizationRequest, TokenData
-};
-pub use kittybox_util::auth::EnrolledCredential;
-
-type Result<T> = std::io::Result<T>;
-
-pub mod fs;
-pub use fs::FileBackend;
-
-#[async_trait::async_trait]
-pub trait AuthBackend: Clone + Send + Sync + 'static {
-    // Authorization code management.
-    /// Create a one-time OAuth2 authorization code for the passed
-    /// authorization request, and save it for later retrieval.
-    ///
-    /// Note for implementors: the [`AuthorizationRequest::me`] value
-    /// is guaranteed to be [`Some(url::Url)`][Option::Some] and can
-    /// be trusted to be correct and non-malicious.
-    async fn create_code(&self, data: AuthorizationRequest) -> Result<String>;
-    /// Retreive an authorization request using the one-time
-    /// code. Implementations must sanitize the `code` field to
-    /// prevent exploits, and must check if the code should still be
-    /// valid at this point in time (validity interval is left up to
-    /// the implementation, but is recommended to be no more than 10
-    /// minutes).
-    async fn get_code(&self, code: &str) -> Result<Option<AuthorizationRequest>>;
-    // Token management.
-    async fn create_token(&self, data: TokenData) -> Result<String>;
-    async fn get_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>>;
-    async fn list_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>>;
-    async fn revoke_token(&self, website: &url::Url, token: &str) -> Result<()>;
-    // Refresh token management.
-    async fn create_refresh_token(&self, data: TokenData) -> Result<String>;
-    async fn get_refresh_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>>;
-    async fn list_refresh_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>>;
-    async fn revoke_refresh_token(&self, website: &url::Url, token: &str) -> Result<()>;
-    // Password management.
-    /// Verify a password.
-    #[must_use]
-    async fn verify_password(&self, website: &url::Url, password: String) -> Result<bool>;
-    /// Enroll a password credential for a user. Only one password
-    /// credential must exist for a given user.
-    async fn enroll_password(&self, website: &url::Url, password: String) -> Result<()>;
-    /// List currently enrolled credential types for a given user.
-    async fn list_user_credential_types(&self, website: &url::Url) -> Result<Vec<EnrolledCredential>>;
-    // WebAuthn credential management.
-    #[cfg(feature = "webauthn")]
-    /// Enroll a WebAuthn authenticator public key for this user.
-    /// Multiple public keys may be saved for one user, corresponding
-    /// to different authenticators used by them.
-    ///
-    /// This function can also be used to overwrite a passkey with an
-    /// updated version after using
-    /// [webauthn::prelude::Passkey::update_credential()].
-    async fn enroll_webauthn(&self, website: &url::Url, credential: webauthn::prelude::Passkey) -> Result<()>;
-    #[cfg(feature = "webauthn")]
-    /// List currently enrolled WebAuthn authenticators for a given user.
-    async fn list_webauthn_pubkeys(&self, website: &url::Url) -> Result<Vec<webauthn::prelude::Passkey>>;
-    #[cfg(feature = "webauthn")]
-    /// Persist registration challenge state for a little while so it
-    /// can be used later.
-    ///
-    /// Challenges saved in this manner MUST expire after a little
-    /// while. 10 minutes is recommended.
-    async fn persist_registration_challenge(
-        &self,
-        website: &url::Url,
-        state: webauthn::prelude::PasskeyRegistration
-    ) -> Result<String>;
-    #[cfg(feature = "webauthn")]
-    /// Retrieve a persisted registration challenge.
-    ///
-    /// The challenge should be deleted after retrieval.
-    async fn retrieve_registration_challenge(
-        &self,
-        website: &url::Url,
-        challenge_id: &str
-    ) -> Result<webauthn::prelude::PasskeyRegistration>;
-    #[cfg(feature = "webauthn")]
-    /// Persist authentication challenge state for a little while so
-    /// it can be used later.
-    ///
-    /// Challenges saved in this manner MUST expire after a little
-    /// while. 10 minutes is recommended.
-    ///
-    /// To support multiple authentication options, this can return an
-    /// opaque token that should be set as a cookie.
-    async fn persist_authentication_challenge(
-        &self,
-        website: &url::Url,
-        state: webauthn::prelude::PasskeyAuthentication
-    ) -> Result<String>;
-    #[cfg(feature = "webauthn")]
-    /// Retrieve a persisted authentication challenge.
-    ///
-    /// The challenge should be deleted after retrieval.
-    async fn retrieve_authentication_challenge(
-        &self,
-        website: &url::Url,
-        challenge_id: &str
-    ) -> Result<webauthn::prelude::PasskeyAuthentication>;
-
-}
diff --git a/kittybox-rs/src/indieauth/backend/fs.rs b/kittybox-rs/src/indieauth/backend/fs.rs
deleted file mode 100644
index 600e901..0000000
--- a/kittybox-rs/src/indieauth/backend/fs.rs
+++ /dev/null
@@ -1,420 +0,0 @@
-use std::{path::PathBuf, collections::HashMap, borrow::Cow, time::{SystemTime, Duration}};
-
-use super::{AuthBackend, Result, EnrolledCredential};
-use async_trait::async_trait;
-use kittybox_indieauth::{
-    AuthorizationRequest, TokenData
-};
-use serde::de::DeserializeOwned;
-use tokio::{task::spawn_blocking, io::AsyncReadExt};
-#[cfg(feature = "webauthn")]
-use webauthn::prelude::{Passkey, PasskeyRegistration, PasskeyAuthentication};
-
-const CODE_LENGTH: usize = 16;
-const TOKEN_LENGTH: usize = 128;
-const CODE_DURATION: std::time::Duration = std::time::Duration::from_secs(600);
-
-#[derive(Clone, Debug)]
-pub struct FileBackend {
-    path: PathBuf,
-}
-
-impl FileBackend {
-    pub fn new<T: Into<PathBuf>>(path: T) -> Self {
-        Self {
-            path: path.into()
-        }
-    }
-    
-    /// Sanitize a filename, leaving only alphanumeric characters.
-    ///
-    /// Doesn't allocate a new string unless non-alphanumeric
-    /// characters are encountered.
-    fn sanitize_for_path(filename: &'_ str) -> Cow<'_, str> {
-        if filename.chars().all(char::is_alphanumeric) {
-            Cow::Borrowed(filename)
-        } else {
-            let mut s = String::with_capacity(filename.len());
-
-            filename.chars()
-                .filter(|c| c.is_alphanumeric())
-                .for_each(|c| s.push(c));
-
-            Cow::Owned(s)
-        }
-    }
-
-    #[inline]
-    async fn serialize_to_file<T: 'static + serde::ser::Serialize + Send, B: Into<Option<&'static str>>>(
-        &self,
-        dir: &str,
-        basename: B,
-        length: usize,
-        data: T
-    ) -> Result<String> {
-        let basename = basename.into();
-        let has_ext = basename.is_some();
-        let (filename, mut file) = kittybox_util::fs::mktemp(
-            self.path.join(dir), basename, length
-        )
-            .await
-            .map(|(name, file)| (name, file.try_into_std().unwrap()))?;
-
-        spawn_blocking(move || serde_json::to_writer(&mut file, &data))
-            .await
-            .unwrap_or_else(|e| panic!(
-                "Panic while serializing {}: {}",
-                std::any::type_name::<T>(),
-                e
-            ))
-            .map(move |_| {
-                (if has_ext {
-                    filename
-                        .extension()
-                        
-                } else {
-                    filename
-                        .file_name()
-                })
-                    .unwrap()
-                    .to_str()
-                    .unwrap()
-                    .to_owned()
-            })
-            .map_err(|err| err.into())
-    }
-
-    #[inline]
-    async fn deserialize_from_file<'filename, 'this: 'filename, T, B>(
-        &'this self,
-        dir: &'filename str,
-        basename: B,
-        filename: &'filename str,
-    ) -> Result<Option<(PathBuf, SystemTime, T)>>
-    where
-        T: serde::de::DeserializeOwned + Send,
-        B: Into<Option<&'static str>>
-    {
-        let basename = basename.into();
-        let path = self.path
-            .join(dir)
-            .join(format!(
-                "{}{}{}",
-                basename.unwrap_or(""),
-                if basename.is_none() { "" } else { "." },
-                FileBackend::sanitize_for_path(filename)
-            ));
-
-        let data = match tokio::fs::File::open(&path).await {
-            Ok(mut file) => {
-                let mut buf = Vec::new();
-
-                file.read_to_end(&mut buf).await?;
-
-                match serde_json::from_slice::<'_, T>(buf.as_slice()) {
-                    Ok(data) => data,
-                    Err(err) => return Err(err.into())
-                }
-            },
-            Err(err) => if err.kind() == std::io::ErrorKind::NotFound {
-                return Ok(None)
-            } else {
-                return Err(err)
-            }
-        };
-
-        let ctime = tokio::fs::metadata(&path).await?.created()?;
-
-        Ok(Some((path, ctime, data)))
-    }
-
-    #[inline]
-    fn url_to_dir(url: &url::Url) -> String {
-        let host = url.host_str().unwrap();
-        let port = url.port()
-            .map(|port| Cow::Owned(format!(":{}", port)))
-            .unwrap_or(Cow::Borrowed(""));
-
-        format!("{}{}", host, port)
-    }
-
-    async fn list_files<'dir, 'this: 'dir, T: DeserializeOwned + Send>(
-        &'this self,
-        dir: &'dir str,
-        prefix: &'static str
-    ) -> Result<HashMap<String, T>> {
-        let dir = self.path.join(dir);
-
-        let mut hashmap = HashMap::new();
-        let mut readdir = match tokio::fs::read_dir(dir).await {
-            Ok(readdir) => readdir,
-            Err(err) => if err.kind() == std::io::ErrorKind::NotFound {
-                // empty hashmap
-                return Ok(hashmap);
-            } else {
-                return Err(err);
-            }
-        };
-        while let Some(entry) = readdir.next_entry().await? {
-            // safe to unwrap; filenames are alphanumeric
-            let filename = entry.file_name()
-                .into_string()
-                .expect("token filenames should be alphanumeric!");
-            if let Some(token) = filename.strip_prefix(&format!("{}.", prefix)) {
-                match tokio::fs::File::open(entry.path()).await {
-                    Ok(mut file) => {
-                        let mut buf = Vec::new();
-
-                        file.read_to_end(&mut buf).await?;
-
-                        match serde_json::from_slice::<'_, T>(buf.as_slice()) {
-                            Ok(data) => hashmap.insert(token.to_string(), data),
-                            Err(err) => {
-                                tracing::error!(
-                                    "Error decoding token data from file {}: {}",
-                                    entry.path().display(), err
-                                );
-                                continue;
-                            }
-                        };
-                    },
-                    Err(err) => if err.kind() == std::io::ErrorKind::NotFound {
-                        continue
-                    } else {
-                        return Err(err)
-                    }
-                }
-            }
-        }
-
-        Ok(hashmap)
-    }
-}
-
-#[async_trait]
-impl AuthBackend for FileBackend {
-    // Authorization code management.
-    async fn create_code(&self, data: AuthorizationRequest) -> Result<String> {
-        self.serialize_to_file("codes", None, CODE_LENGTH, data).await
-    }
-
-    async fn get_code(&self, code: &str) -> Result<Option<AuthorizationRequest>> {
-        match self.deserialize_from_file("codes", None, FileBackend::sanitize_for_path(code).as_ref()).await? {
-            Some((path, ctime, data)) => {
-                if let Err(err) = tokio::fs::remove_file(path).await {
-                    tracing::error!("Failed to clean up authorization code: {}", err);
-                }
-                // Err on the safe side in case of clock drift
-                if ctime.elapsed().unwrap_or(Duration::ZERO) > CODE_DURATION {
-                    Ok(None)
-                } else {
-                    Ok(Some(data))
-                }
-            },
-            None => Ok(None)
-        }
-    }
-
-    // Token management.
-    async fn create_token(&self, data: TokenData) -> Result<String> {
-        let dir = format!("{}/tokens", FileBackend::url_to_dir(&data.me));
-        self.serialize_to_file(&dir, "access", TOKEN_LENGTH, data).await
-    }
-
-    async fn get_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>> {
-        let dir = format!("{}/tokens", FileBackend::url_to_dir(website));
-        match self.deserialize_from_file::<TokenData, _>(
-            &dir, "access",
-            FileBackend::sanitize_for_path(token).as_ref()
-        ).await? {
-            Some((path, _, token)) => {
-                if token.expired() {
-                    if let Err(err) = tokio::fs::remove_file(path).await {
-                        tracing::error!("Failed to remove expired token: {}", err);
-                    }
-                    Ok(None)
-                } else {
-                    Ok(Some(token))
-                }
-            },
-            None => Ok(None)
-        }
-    }
-
-    async fn list_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>> {
-        let dir = format!("{}/tokens", FileBackend::url_to_dir(website));
-        self.list_files(&dir, "access").await
-    }
-
-    async fn revoke_token(&self, website: &url::Url, token: &str) -> Result<()> {
-        match tokio::fs::remove_file(
-            self.path
-                .join(FileBackend::url_to_dir(website))
-                .join("tokens")
-                .join(format!("access.{}", FileBackend::sanitize_for_path(token)))
-        ).await {
-            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
-            result => result
-        }
-    }
-
-    // Refresh token management.
-    async fn create_refresh_token(&self, data: TokenData) -> Result<String> {
-        let dir = format!("{}/tokens", FileBackend::url_to_dir(&data.me));
-        self.serialize_to_file(&dir, "refresh", TOKEN_LENGTH, data).await
-    }
-
-    async fn get_refresh_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>> {
-        let dir = format!("{}/tokens", FileBackend::url_to_dir(website));
-        match self.deserialize_from_file::<TokenData, _>(
-            &dir, "refresh",
-            FileBackend::sanitize_for_path(token).as_ref()
-        ).await? {
-            Some((path, _, token)) => {
-                if token.expired() {
-                    if let Err(err) = tokio::fs::remove_file(path).await {
-                        tracing::error!("Failed to remove expired token: {}", err);
-                    }
-                    Ok(None)
-                } else {
-                    Ok(Some(token))
-                }
-            },
-            None => Ok(None)
-        }
-    }
-
-    async fn list_refresh_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>> {
-        let dir = format!("{}/tokens", FileBackend::url_to_dir(website));
-        self.list_files(&dir, "refresh").await
-    }
-
-    async fn revoke_refresh_token(&self, website: &url::Url, token: &str) -> Result<()> {
-        match tokio::fs::remove_file(
-            self.path
-                .join(FileBackend::url_to_dir(website))
-                .join("tokens")
-                .join(format!("refresh.{}", FileBackend::sanitize_for_path(token)))
-        ).await {
-            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
-            result => result
-        }
-    }
-
-    // Password management.
-    #[tracing::instrument(skip(password))]
-    async fn verify_password(&self, website: &url::Url, password: String) -> Result<bool> {
-        use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}};
-
-        let password_filename = self.path
-            .join(FileBackend::url_to_dir(website))
-            .join("password");
-
-        tracing::debug!("Reading password for {} from {}", website, password_filename.display());
-
-        match tokio::fs::read_to_string(password_filename).await {
-            Ok(password_hash) => {
-                let parsed_hash = {
-                    let hash = password_hash.trim();
-                    #[cfg(debug_assertions)] tracing::debug!("Password hash: {}", hash);
-                    PasswordHash::new(hash)
-                        .expect("Password hash should be valid!")
-                };
-                Ok(Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok())
-            },
-            Err(err) => if err.kind() == std::io::ErrorKind::NotFound {
-                Ok(false)
-            } else {
-                Err(err)
-            }
-        }
-    }
-
-    #[tracing::instrument(skip(password))]
-    async fn enroll_password(&self, website: &url::Url, password: String) -> Result<()> {
-        use argon2::{Argon2, password_hash::{rand_core::OsRng, PasswordHasher, SaltString}};
-
-        let password_filename = self.path
-            .join(FileBackend::url_to_dir(website))
-            .join("password");
-        
-        let salt = SaltString::generate(&mut OsRng);
-        let argon2 = Argon2::default();
-        let password_hash = argon2.hash_password(password.as_bytes(), &salt)
-            .expect("Hashing a password should not error out")
-            .to_string();
-
-        tracing::debug!("Enrolling password for {} at {}", website, password_filename.display());
-        tokio::fs::write(password_filename, password_hash.as_bytes()).await
-    }
-
-    // WebAuthn credential management.
-    #[cfg(feature = "webauthn")]
-    async fn enroll_webauthn(&self, website: &url::Url, credential: Passkey) -> Result<()> {
-        todo!()
-    }
-
-    #[cfg(feature = "webauthn")]
-    async fn list_webauthn_pubkeys(&self, website: &url::Url) -> Result<Vec<Passkey>> {
-        // TODO stub!
-        Ok(vec![])
-    }
-
-    #[cfg(feature = "webauthn")]
-    async fn persist_registration_challenge(
-        &self,
-        website: &url::Url,
-        state: PasskeyRegistration
-    ) -> Result<String> {
-        todo!()
-    }
-
-    #[cfg(feature = "webauthn")]
-    async fn retrieve_registration_challenge(
-        &self,
-        website: &url::Url,
-        challenge_id: &str
-    ) -> Result<PasskeyRegistration> {
-        todo!()
-    }
-
-    #[cfg(feature = "webauthn")]
-    async fn persist_authentication_challenge(
-        &self,
-        website: &url::Url,
-        state: PasskeyAuthentication
-    ) -> Result<String> {
-        todo!()
-    }
-
-    #[cfg(feature = "webauthn")]
-    async fn retrieve_authentication_challenge(
-        &self,
-        website: &url::Url,
-        challenge_id: &str
-    ) -> Result<PasskeyAuthentication> {
-        todo!()
-    }
-
-    async fn list_user_credential_types(&self, website: &url::Url) -> Result<Vec<EnrolledCredential>> {
-        let mut creds = vec![];
-
-        match tokio::fs::metadata(self.path
-                                  .join(FileBackend::url_to_dir(website))
-                                  .join("password"))
-            .await
-        {
-            Ok(_) => creds.push(EnrolledCredential::Password),
-            Err(err) => if err.kind() != std::io::ErrorKind::NotFound {
-                return Err(err)
-            }
-        }
-
-        #[cfg(feature = "webauthn")]
-        if !self.list_webauthn_pubkeys(website).await?.is_empty() {
-            creds.push(EnrolledCredential::WebAuthn);
-        }
-
-        Ok(creds)
-    }
-}
diff --git a/kittybox-rs/src/indieauth/mod.rs b/kittybox-rs/src/indieauth/mod.rs
deleted file mode 100644
index 0ad2702..0000000
--- a/kittybox-rs/src/indieauth/mod.rs
+++ /dev/null
@@ -1,883 +0,0 @@
-use std::marker::PhantomData;
-
-use tracing::error;
-use serde::Deserialize;
-use axum::{
-    extract::{Query, Json, Host, Form},
-    response::{Html, IntoResponse, Response},
-    http::StatusCode, TypedHeader, headers::{Authorization, authorization::Bearer},
-    Extension
-};
-#[cfg_attr(not(feature = "webauthn"), allow(unused_imports))]
-use axum_extra::extract::cookie::{CookieJar, Cookie};
-use crate::database::Storage;
-use kittybox_indieauth::{
-    Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod,
-    Scope, Scopes, PKCEMethod, Error, ErrorKind, ResponseType,
-    AuthorizationRequest, AuthorizationResponse,
-    GrantType, GrantRequest, GrantResponse, Profile,
-    TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, TokenData
-};
-use std::str::FromStr;
-use std::ops::Deref;
-
-pub mod backend;
-#[cfg(feature = "webauthn")]
-mod webauthn;
-use backend::AuthBackend;
-
-const ACCESS_TOKEN_VALIDITY: u64 = 7 * 24 * 60 * 60; // 7 days
-const REFRESH_TOKEN_VALIDITY: u64 = ACCESS_TOKEN_VALIDITY / 7 * 60; // 60 days
-/// Internal scope for accessing the token introspection endpoint.
-const KITTYBOX_TOKEN_STATUS: &str = "kittybox:token_status";
-
-pub(crate) struct User<A: AuthBackend>(pub(crate) TokenData, pub(crate) PhantomData<A>);
-impl<A: AuthBackend> std::fmt::Debug for User<A> {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_tuple("User").field(&self.0).finish()
-    }
-}
-impl<A: AuthBackend> std::ops::Deref for User<A> {
-    type Target = TokenData;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-pub enum IndieAuthResourceError {
-    InvalidRequest,
-    Unauthorized,
-    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(),
-            InvalidRequest => (
-                StatusCode::BAD_REQUEST,
-                Json(&serde_json::json!({"error": "invalid_request"}))
-            ).into_response(),
-            InvalidToken => (
-                StatusCode::UNAUTHORIZED,
-                [("WWW-Authenticate", "Bearer, error=\"invalid_token\"")],
-                Json(&serde_json::json!({"error": "unauthorized"}))
-            ).into_response()
-        }
-    }
-}
-
-#[async_trait::async_trait]
-impl <S: Send + Sync, A: AuthBackend> axum::extract::FromRequestParts<S> for User<A> {
-    type Rejection = IndieAuthResourceError;
-
-    async fn from_request_parts(req: &mut axum::http::request::Parts, state: &S) -> Result<Self, Self::Rejection> {
-        let TypedHeader(Authorization(token)) =
-            TypedHeader::<Authorization<Bearer>>::from_request_parts(req, state)
-            .await
-            .map_err(|_| IndieAuthResourceError::Unauthorized)?;
-
-        let axum::Extension(auth) = axum::Extension::<A>::from_request_parts(req, state)
-            .await
-            .unwrap();
-
-        let Host(host) = Host::from_request_parts(req, state)
-            .await
-            .map_err(|_| IndieAuthResourceError::InvalidRequest)?;
-        
-        auth.get_token(
-            &format!("https://{host}/").parse().unwrap(),
-            token.token()
-        )
-            .await
-            .unwrap()
-            .ok_or(IndieAuthResourceError::InvalidToken)
-            .map(|t| User(t, PhantomData))
-    }
-}
-
-pub async fn metadata(
-    Host(host): Host
-) -> Metadata {
-    let issuer: url::Url = format!(
-        "{}://{}/",
-        if cfg!(debug_assertions) {
-            "http"
-        } else {
-            "https"
-        },
-        host
-    ).parse().unwrap();
-
-    let indieauth: url::Url = issuer.join("/.kittybox/indieauth/").unwrap();
-    Metadata {
-        issuer,
-        authorization_endpoint: indieauth.join("auth").unwrap(),
-        token_endpoint: indieauth.join("token").unwrap(),
-        introspection_endpoint: indieauth.join("token_status").unwrap(),
-        introspection_endpoint_auth_methods_supported: Some(vec![
-            IntrospectionEndpointAuthMethod::Bearer
-        ]),
-        revocation_endpoint: Some(indieauth.join("revoke_token").unwrap()),
-        revocation_endpoint_auth_methods_supported: Some(vec![
-            RevocationEndpointAuthMethod::None
-        ]),
-        scopes_supported: Some(vec![
-            Scope::Create,
-            Scope::Update,
-            Scope::Delete,
-            Scope::Media,
-            Scope::Profile
-        ]),
-        response_types_supported: Some(vec![ResponseType::Code]),
-        grant_types_supported: Some(vec![GrantType::AuthorizationCode, GrantType::RefreshToken]),
-        service_documentation: None,
-        code_challenge_methods_supported: vec![PKCEMethod::S256],
-        authorization_response_iss_parameter_supported: Some(true),
-        userinfo_endpoint: Some(indieauth.join("userinfo").unwrap()),
-    }
-}
-
-async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>(
-    Host(host): Host,
-    Query(request): Query<AuthorizationRequest>,
-    Extension(db): Extension<D>,
-    Extension(http): Extension<reqwest::Client>,
-    Extension(auth): Extension<A>
-) -> Response {
-    let me = format!("https://{host}/").parse().unwrap();
-    let h_app = {
-        tracing::debug!("Sending request to {} to fetch metadata", request.client_id);
-        match http.get(request.client_id.clone()).send().await {
-            Ok(response) => {
-                let url = response.url().clone();
-                let text = response.text().await.unwrap();
-                tracing::debug!("Received {} bytes in response", text.len());
-                match microformats::from_html(&text, url) {
-                    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()
-                            }
-                        } 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()
-                        }
-
-                        mf2.items.iter()
-                            .cloned()
-                            .find(|i| (**i).borrow().r#type.iter()
-                                  .any(|i| *i == microformats::types::Class::from_str("h-app").unwrap()
-                                       || *i == microformats::types::Class::from_str("h-x-app").unwrap()))
-                            .map(|i| serde_json::to_value(i.borrow().deref()).unwrap())
-                    },
-                    Err(err) => {
-                        tracing::error!("Error parsing application metadata: {}", err);
-                        return (StatusCode::BAD_REQUEST,
-                                [("Content-Type", "text/plain")],
-                                "Parsing application metadata failed.").into_response()
-                    }
-                }
-            },
-            Err(err) => {
-                tracing::error!("Error fetching application metadata: {}", err);
-                return (StatusCode::INTERNAL_SERVER_ERROR,
-                        [("Content-Type", "text/plain")],
-                        "Fetching application metadata failed.").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()
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(untagged)]
-enum Credential {
-    Password(String),
-    #[cfg(feature = "webauthn")]
-    WebAuthn(::webauthn::prelude::PublicKeyCredential)
-}
-
-#[derive(Deserialize, Debug)]
-struct AuthorizationConfirmation {
-    authorization_method: Credential,
-    request: AuthorizationRequest
-}
-
-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>
-) -> 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
-    }
-}
-
-#[tracing::instrument(skip(backend, confirmation))]
-async fn authorization_endpoint_confirm<A: AuthBackend>(
-    Host(host): Host,
-    Extension(backend): Extension<A>,
-    cookies: CookieJar,
-    Json(confirmation): Json<AuthorizationConfirmation>,
-) -> Response {
-    tracing::debug!("Received authorization confirmation from user");
-    #[cfg(feature = "webauthn")]
-    let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE)
-        .map(|cookie| cookie.value());
-    #[cfg(not(feature = "webauthn"))]
-    let challenge_id = None;
-
-    let website = format!("https://{}/", host).parse().unwrap();
-    let AuthorizationConfirmation {
-        authorization_method: credential,
-        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();
-        },
-        Err(err) => {
-            error!("Error while verifying credential: {}", err);
-            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-        }
-    }
-    // Insert the correct `me` value into the request
-    //
-    // From this point, the `me` value that hits the backend is
-    // guaranteed to be authoritative and correct, and can be safely
-    // unwrapped.
-    auth.me = Some(website.clone());
-    // Cloning these two values, because we can't destructure
-    // the AuthorizationRequest - we need it for the code
-    let state = auth.state.clone();
-    let redirect_uri = auth.redirect_uri.clone();
-
-    let code = match backend.create_code(auth).await {
-        Ok(code) => code,
-        Err(err) => {
-            error!("Error creating authorization code: {}", err);
-            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-        }
-    };
-
-    let location = {
-        let mut uri = redirect_uri;
-        uri.set_query(Some(&serde_urlencoded::to_string(
-            AuthorizationResponse { code, state, iss: website }
-        ).unwrap()));
-
-        uri
-    };
-
-    // 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::named(webauthn::CHALLENGE_ID_COOKIE))
-    )
-        .into_response()
-}
-
-#[tracing::instrument(skip(backend, db))]
-async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>(
-    Host(host): Host,
-    Extension(backend): Extension<A>,
-    Extension(db): Extension<D>,
-    Form(grant): Form<GrantRequest>,
-) -> Response {
-    match grant {
-        GrantRequest::AuthorizationCode {
-            code,
-            client_id,
-            redirect_uri,
-            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(),
-                Err(err) => {
-                    tracing::error!("Error retrieving auth request: {}", err);
-                    return StatusCode::INTERNAL_SERVER_ERROR.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()
-            }
-            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()
-            }
-            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()
-            }
-            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()
-            }
-            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()
-                        .map(|s| s.has(&Scope::Email))
-                        .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()
-                    }
-                }
-            } else {
-                None
-            };
-
-            GrantResponse::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()
-    }
-}
-
-#[tracing::instrument(skip(backend, db))]
-async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>(
-    Host(host): Host,
-    Extension(backend): Extension<A>,
-    Extension(db): Extension<D>,
-    Form(grant): Form<GrantRequest>,
-) -> Response {
-    #[inline]
-    fn prepare_access_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData {
-        TokenData {
-            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(),
-            iat: std::time::SystemTime::now()
-                .duration_since(std::time::UNIX_EPOCH)
-                .unwrap()
-                .as_secs()
-                .into()
-        }
-    }
-
-    #[inline]
-    fn prepare_refresh_token(me: url::Url, client_id: url::Url, scope: Scopes) -> TokenData {
-        TokenData {
-            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(),
-            iat: std::time::SystemTime::now()
-                .duration_since(std::time::UNIX_EPOCH)
-                .unwrap()
-                .as_secs()
-                .into()
-        }
-    }
-
-    let me: url::Url = format!("https://{}/", host).parse().unwrap();
-
-    match grant {
-        GrantRequest::AuthorizationCode {
-            code,
-            client_id,
-            redirect_uri,
-            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(),
-                Err(err) => {
-                    tracing::error!("Error retrieving auth request: {}", err);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-                }
-            };
-
-            tracing::debug!("Retrieved authorization request: {:?}", request);
-
-            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();
-            };
-            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()
-            }
-            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()
-            }
-            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();
-            }
-
-            // Note: we can trust the `request.me` value, since we set
-            // it earlier before generating the authorization code
-            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()
-            }
-
-            let profile = if dbg!(scope.has(&Scope::Profile)) {
-                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()
-                    }
-                }
-            } else {
-                None
-            };
-
-            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);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-                }
-            };
-            // 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 {
-                Ok(token) => token,
-                Err(err) => {
-                    tracing::error!("Error creating refresh token: {}", err);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-                }
-            };
-
-            GrantResponse::AccessToken {
-                me,
-                profile,
-                access_token,
-                token_type: kittybox_indieauth::TokenType::Bearer,
-                scope: Some(scope),
-                expires_in: Some(ACCESS_TOKEN_VALIDITY),
-                refresh_token: Some(refresh_token)
-            }.into_response()
-        },
-        GrantRequest::RefreshToken {
-            refresh_token,
-            client_id,
-            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(),
-                Err(err) => {
-                    tracing::error!("Error retrieving refresh token: {}", err);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response()
-                }
-            };
-
-            if data.client_id != client_id {
-                return Error {
-                    kind: ErrorKind::InvalidGrant,
-                    msg: Some("This refresh token is not yours.".to_string()),
-                    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();
-                }
-
-                scope
-            } else {
-                // Note: check skipped because of redundancy (comparing a scope list with itself)
-                data.scope
-            };
-
-
-            let profile = if scope.has(&Scope::Profile) {
-                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()
-                    }
-                }
-            } else {
-                None
-            };
-
-            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);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-                }
-            };
-
-            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 {
-                Ok(token) => token,
-                Err(err) => {
-                    tracing::error!("Error creating refresh token: {}", err);
-                    return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-                }
-            };
-            if let Err(err) = backend.revoke_refresh_token(&me, &old_refresh_token).await {
-                tracing::error!("Error revoking refresh token: {}", err);
-                return StatusCode::INTERNAL_SERVER_ERROR.into_response();
-            }
-
-            GrantResponse::AccessToken {
-                me: data.me,
-                profile,
-                access_token,
-                token_type: kittybox_indieauth::TokenType::Bearer,
-                scope: Some(scope),
-                expires_in: Some(ACCESS_TOKEN_VALIDITY),
-                refresh_token: Some(refresh_token)
-            }.into_response()
-        }
-    }
-}
-
-#[tracing::instrument(skip(backend, token_request))]
-async fn introspection_endpoint_post<A: AuthBackend>(
-    Host(host): Host,
-    TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>,
-    Extension(backend): Extension<A>,
-    Form(token_request): Form<TokenIntrospectionRequest>,
-) -> Response {
-    use serde_json::json;
-
-    let me: url::Url = format!("https://{}/", host).parse().unwrap();
-
-    // 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(),
-        Err(err) => {
-            tracing::error!("Error retrieving token data for introspection: {}", 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()
-}
-
-async fn revocation_endpoint_post<A: AuthBackend>(
-    Host(host): Host,
-    Extension(backend): Extension<A>,
-    Form(revocation): Form<TokenRevocationRequest>,
-) -> impl IntoResponse {
-    let me: url::Url = format!("https://{}/", host).parse().unwrap();
-
-    if let Err(err) = tokio::try_join!(
-        backend.revoke_token(&me, &revocation.token),
-        backend.revoke_refresh_token(&me, &revocation.token)
-    ) {
-        tracing::error!("Error revoking token: {}", err);
-
-        StatusCode::INTERNAL_SERVER_ERROR
-    } else {
-        StatusCode::OK
-    }
-}
-
-async fn get_profile<D: Storage + 'static>(
-    db: D,
-    url: &str,
-    email: bool
-) -> crate::database::Result<Option<Profile>> {
-    Ok(db.get_post(url).await?.map(|mut mf2| {
-        // Ruthlessly manually destructure the MF2 document to save memory
-        let name = match mf2["properties"]["name"][0].take() {
-            serde_json::Value::String(s) => Some(s),
-            _ => None
-        };
-        let url = match mf2["properties"]["uid"][0].take() {
-            serde_json::Value::String(s) => s.parse().ok(),
-            _ => None
-        };
-        let photo = match mf2["properties"]["photo"][0].take() {
-            serde_json::Value::String(s) => s.parse().ok(),
-            _ => None
-        };
-        let email = if email {
-            match mf2["properties"]["email"][0].take() {
-                serde_json::Value::String(s) => Some(s),
-                _ => None
-            }
-        } else {
-            None
-        };
-
-        Profile { name, url, photo, email }
-    }))
-}
-
-async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>(
-    Host(host): Host,
-    TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>,
-    Extension(backend): Extension<A>,
-    Extension(db): Extension<D>
-) -> Response {
-    use serde_json::json;
-
-    let me: url::Url = format!("https://{}/", host).parse().unwrap();
-
-    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();
-            }
-            if !token.scope.has(&Scope::Profile) {
-                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 {
-                Ok(Some(profile)) => profile.into_response(),
-                Ok(None) => Json(json!({
-                    // We do this because ResourceErrorKind is IndieAuth errors only
-                    "error": "invalid_request"
-                })).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(),
-        Err(err) => {
-            tracing::error!("Error reading token: {}", err);
-
-            StatusCode::INTERNAL_SERVER_ERROR.into_response()
-        }
-    }
-}
-
-#[must_use]
-pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D, http: reqwest::Client) -> axum::Router {
-    use axum::routing::{Router, get, post};
-
-    Router::new()
-        .nest(
-            "/.kittybox/indieauth",
-            Router::new()
-                .route("/metadata",
-                       get(metadata))
-                .route(
-                    "/auth",
-                    get(authorization_endpoint_get::<A, D>)
-                        .post(authorization_endpoint_post::<A, D>))
-                .route(
-                    "/auth/confirm",
-                    post(authorization_endpoint_confirm::<A>))
-                .route(
-                    "/token",
-                    post(token_endpoint_post::<A, D>))
-                .route(
-                    "/token_status",
-                    post(introspection_endpoint_post::<A>))
-                .route(
-                    "/revoke_token",
-                    post(revocation_endpoint_post::<A>))
-                .route(
-                    "/userinfo",
-                    get(userinfo_endpoint_get::<A, D>))
-
-                .route("/webauthn/pre_register",
-                       get(
-                           #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::<A, D>,
-                           #[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(Extension(backend))
-            // I don't really like the fact that I have to use the whole database
-            // If I could, I would've designed a separate trait for getting profiles
-            // And made databases implement it, for example
-                .layer(Extension(db))
-                .layer(Extension(http))
-        )
-        .route(
-            "/.well-known/oauth-authorization-server",
-            get(|| std::future::ready(
-                (StatusCode::FOUND,
-                 [("Location",
-                   "/.kittybox/indieauth/metadata")]
-                ).into_response()
-            ))
-        )
-}
-
-#[cfg(test)]
-mod tests {
-    #[test]
-    fn test_deserialize_authorization_confirmation() {
-        use super::{Credential, AuthorizationConfirmation};
-
-        let confirmation = serde_json::from_str::<AuthorizationConfirmation>(r#"{
-            "request":{
-                "response_type": "code",
-                "client_id": "https://quill.p3k.io/",
-                "redirect_uri": "https://quill.p3k.io/",
-                "state": "10101010",
-                "code_challenge": "awooooooooooo",
-                "code_challenge_method": "S256",
-                "scope": "create+media"
-            },
-            "authorization_method": "swordfish"
-        }"#).unwrap();
-
-        match confirmation.authorization_method {
-            Credential::Password(password) => assert_eq!(password.as_str(), "swordfish"),
-            #[allow(unreachable_patterns)]
-            other => panic!("Incorrect credential: {:?}", other)
-        }
-        assert_eq!(confirmation.request.state.as_ref(), "10101010");
-    }
-}
diff --git a/kittybox-rs/src/indieauth/webauthn.rs b/kittybox-rs/src/indieauth/webauthn.rs
deleted file mode 100644
index ea3ad3d..0000000
--- a/kittybox-rs/src/indieauth/webauthn.rs
+++ /dev/null
@@ -1,140 +0,0 @@
-use axum::{
-    extract::{Json, Host},
-    response::{IntoResponse, Response},
-    http::StatusCode, Extension, TypedHeader, headers::{authorization::Bearer, Authorization}
-};
-use axum_extra::extract::cookie::{CookieJar, Cookie};
-
-use super::backend::AuthBackend;
-use crate::database::Storage;
-
-pub(crate) const CHALLENGE_ID_COOKIE: &str = "kittybox_webauthn_challenge_id";
-
-macro_rules! bail {
-    ($msg:literal, $err:expr) => {
-        {
-            ::tracing::error!($msg, $err);
-            return ::axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
-        }
-    }
-}
-
-pub async fn webauthn_pre_register<A: AuthBackend, D: Storage + 'static>(
-    Host(host): Host,
-    Extension(db): Extension<D>,
-    Extension(auth): Extension<A>,
-    cookies: CookieJar
-) -> Response {
-    let uid = format!("https://{}/", host.clone());
-    let uid_url: url::Url = uid.parse().unwrap();
-    // This will not find an h-card in onboarding!
-    let display_name = match db.get_post(&uid).await {
-        Ok(hcard) => match hcard {
-            Some(mut hcard) => {
-                match hcard["properties"]["uid"][0].take() {
-                    serde_json::Value::String(name) => name,
-                    _ => String::default()
-                }
-            },
-            None => String::default()
-        },
-        Err(err) => bail!("Error retrieving h-card: {}", err)
-    };
-
-    let webauthn = webauthn::WebauthnBuilder::new(
-        &host,
-        &uid_url
-    )
-        .unwrap()
-        .rp_name("Kittybox")
-        .build()
-        .unwrap();
-
-    let (challenge, state) = match webauthn.start_passkey_registration(
-        // Note: using a nil uuid here is fine
-        // Because the user corresponds to a website anyway
-        // We do not track multiple users
-        webauthn::prelude::Uuid::nil(),
-        &uid,
-        &display_name,
-        Some(vec![])
-    ) {
-        Ok((challenge, state)) => (challenge, state),
-        Err(err) => bail!("Error generating WebAuthn registration data: {}", err)
-    };
-
-    match auth.persist_registration_challenge(&uid_url, state).await {
-        Ok(challenge_id) => (
-            cookies.add(
-                Cookie::build(CHALLENGE_ID_COOKIE, challenge_id)
-                    .secure(true)
-                    .finish()
-            ),
-            Json(challenge)
-        ).into_response(),
-        Err(err) => bail!("Failed to persist WebAuthn challenge: {}", err)
-    }
-}
-
-pub async fn webauthn_register<A: AuthBackend>(
-    Host(host): Host,
-    Json(credential): Json<webauthn::prelude::RegisterPublicKeyCredential>,
-    // TODO determine if we can use a cookie maybe?
-    user_credential: Option<TypedHeader<Authorization<Bearer>>>,
-    Extension(auth): Extension<A>
-) -> Response {
-    let uid = format!("https://{}/", host.clone());
-    let uid_url: url::Url = uid.parse().unwrap();
-
-    let pubkeys = match auth.list_webauthn_pubkeys(&uid_url).await {
-        Ok(pubkeys) => pubkeys,
-        Err(err) => bail!("Error enumerating existing WebAuthn credentials: {}", err)
-    };
-
-    if !pubkeys.is_empty() {
-        if let Some(TypedHeader(Authorization(token))) = user_credential {
-            // TODO check validity of the credential
-        } else {
-            return StatusCode::UNAUTHORIZED.into_response()
-        }
-    }
-
-    return StatusCode::OK.into_response()
-}
-
-pub(crate) async fn verify<A: AuthBackend>(
-    auth: &A,
-    website: &url::Url,
-    credential: webauthn::prelude::PublicKeyCredential,
-    challenge_id: &str
-) -> std::io::Result<bool> {
-    let host = website.host_str().unwrap();
-
-    let webauthn = webauthn::WebauthnBuilder::new(
-        host,
-        website
-    )
-        .unwrap()
-        .rp_name("Kittybox")
-        .build()
-        .unwrap();
-
-    match webauthn.finish_passkey_authentication(
-        &credential,
-        &auth.retrieve_authentication_challenge(&website, challenge_id).await?
-    ) {
-        Err(err) => {
-            tracing::error!("WebAuthn error: {}", err);
-            Ok(false)
-        },
-        Ok(authentication_result) => {
-            let counter = authentication_result.counter();
-            let cred_id = authentication_result.cred_id();
-
-            if authentication_result.needs_update() {
-                todo!()
-            }
-            Ok(true)
-        }
-    }
-}