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 --- kittybox-rs/src/indieauth/backend.rs | 105 ---- kittybox-rs/src/indieauth/backend/fs.rs | 420 --------------- kittybox-rs/src/indieauth/mod.rs | 883 -------------------------------- kittybox-rs/src/indieauth/webauthn.rs | 140 ----- 4 files changed, 1548 deletions(-) delete mode 100644 kittybox-rs/src/indieauth/backend.rs delete mode 100644 kittybox-rs/src/indieauth/backend/fs.rs delete mode 100644 kittybox-rs/src/indieauth/mod.rs delete mode 100644 kittybox-rs/src/indieauth/webauthn.rs (limited to 'kittybox-rs/src/indieauth') 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 = std::io::Result; - -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; - /// 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>; - // Token management. - async fn create_token(&self, data: TokenData) -> Result; - async fn get_token(&self, website: &url::Url, token: &str) -> Result>; - async fn list_tokens(&self, website: &url::Url) -> Result>; - async fn revoke_token(&self, website: &url::Url, token: &str) -> Result<()>; - // Refresh token management. - async fn create_refresh_token(&self, data: TokenData) -> Result; - async fn get_refresh_token(&self, website: &url::Url, token: &str) -> Result>; - async fn list_refresh_tokens(&self, website: &url::Url) -> Result>; - 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; - /// 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>; - // 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>; - #[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; - #[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; - #[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; - #[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; - -} 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>(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>>( - &self, - dir: &str, - basename: B, - length: usize, - data: T - ) -> Result { - 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::(), - 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> - where - T: serde::de::DeserializeOwned + Send, - B: Into> - { - 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> { - 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 { - self.serialize_to_file("codes", None, CODE_LENGTH, data).await - } - - async fn get_code(&self, code: &str) -> Result> { - 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 { - 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> { - let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); - match self.deserialize_from_file::( - &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> { - 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 { - 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> { - let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); - match self.deserialize_from_file::( - &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> { - 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 { - 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> { - // TODO stub! - Ok(vec![]) - } - - #[cfg(feature = "webauthn")] - async fn persist_registration_challenge( - &self, - website: &url::Url, - state: PasskeyRegistration - ) -> Result { - todo!() - } - - #[cfg(feature = "webauthn")] - async fn retrieve_registration_challenge( - &self, - website: &url::Url, - challenge_id: &str - ) -> Result { - todo!() - } - - #[cfg(feature = "webauthn")] - async fn persist_authentication_challenge( - &self, - website: &url::Url, - state: PasskeyAuthentication - ) -> Result { - todo!() - } - - #[cfg(feature = "webauthn")] - async fn retrieve_authentication_challenge( - &self, - website: &url::Url, - challenge_id: &str - ) -> Result { - todo!() - } - - async fn list_user_credential_types(&self, website: &url::Url) -> Result> { - 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(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"); - } -} 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( - Host(host): Host, - Extension(db): Extension, - Extension(auth): Extension, - 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( - Host(host): Host, - Json(credential): Json, - // TODO determine if we can use a cookie maybe? - user_credential: Option>>, - Extension(auth): Extension -) -> 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( - auth: &A, - website: &url::Url, - credential: webauthn::prelude::PublicKeyCredential, - challenge_id: &str -) -> std::io::Result { - 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) - } - } -} -- cgit 1.4.1