From 0617663b249f9ca488e5de652108b17d67fbaf45 Mon Sep 17 00:00:00 2001 From: Vika Date: Sat, 29 Jul 2023 21:59:56 +0300 Subject: Moved the entire Kittybox tree into the root --- src/indieauth/mod.rs | 883 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 883 insertions(+) create mode 100644 src/indieauth/mod.rs (limited to 'src/indieauth/mod.rs') 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(pub(crate) TokenData, pub(crate) PhantomData); +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("User").field(&self.0).finish() + } +} +impl std::ops::Deref for User { + 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 axum::extract::FromRequestParts for User { + type Rejection = IndieAuthResourceError; + + async fn from_request_parts(req: &mut axum::http::request::Parts, state: &S) -> Result { + let TypedHeader(Authorization(token)) = + TypedHeader::>::from_request_parts(req, state) + .await + .map_err(|_| IndieAuthResourceError::Unauthorized)?; + + let axum::Extension(auth) = axum::Extension::::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( + Host(host): Host, + Query(request): Query, + Extension(db): Extension, + Extension(http): Extension, + Extension(auth): Extension +) -> 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( + auth: &A, + website: &url::Url, + credential: Credential, + #[cfg_attr(not(feature = "webauthn"), allow(unused_variables))] + challenge_id: Option<&str> +) -> std::io::Result { + 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( + Host(host): Host, + Extension(backend): Extension, + cookies: CookieJar, + Json(confirmation): Json, +) -> 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( + Host(host): Host, + Extension(backend): Extension, + Extension(db): Extension, + Form(grant): Form, +) -> 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( + Host(host): Host, + Extension(backend): Extension, + Extension(db): Extension, + Form(grant): Form, +) -> 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( + Host(host): Host, + TypedHeader(Authorization(auth_token)): TypedHeader>, + Extension(backend): Extension, + Form(token_request): Form, +) -> 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( + Host(host): Host, + Extension(backend): Extension, + Form(revocation): Form, +) -> 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( + db: D, + url: &str, + email: bool +) -> crate::database::Result> { + 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( + Host(host): Host, + TypedHeader(Authorization(auth_token)): TypedHeader>, + Extension(backend): Extension, + Extension(db): Extension +) -> 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(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::) + .post(authorization_endpoint_post::)) + .route( + "/auth/confirm", + post(authorization_endpoint_confirm::)) + .route( + "/token", + post(token_endpoint_post::)) + .route( + "/token_status", + post(introspection_endpoint_post::)) + .route( + "/revoke_token", + post(revocation_endpoint_post::)) + .route( + "/userinfo", + get(userinfo_endpoint_get::)) + + .route("/webauthn/pre_register", + get( + #[cfg(feature = "webauthn")] webauthn::webauthn_pre_register::, + #[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::(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"); + } +} -- cgit 1.4.1