about summary refs log tree commit diff
path: root/src/indieauth/mod.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
committerVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
commit0617663b249f9ca488e5de652108b17d67fbaf45 (patch)
tree11564b6c8fa37bf9203a0a4cc1c4e9cc088cb1a5 /src/indieauth/mod.rs
parent26c2b79f6a6380ae3224e9309b9f3352f5717bd7 (diff)
Moved the entire Kittybox tree into the root
Diffstat (limited to 'src/indieauth/mod.rs')
-rw-r--r--src/indieauth/mod.rs883
1 files changed, 883 insertions, 0 deletions
diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs
new file mode 100644
index 0000000..0ad2702
--- /dev/null
+++ b/src/indieauth/mod.rs
@@ -0,0 +1,883 @@
+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");
+    }
+}