diff options
Diffstat (limited to 'kittybox-rs/src')
-rw-r--r-- | kittybox-rs/src/bin/kittybox-indieauth-helper.rs | 216 | ||||
-rw-r--r-- | kittybox-rs/src/frontend/indieauth.js | 107 | ||||
-rw-r--r-- | kittybox-rs/src/frontend/mod.rs | 1 | ||||
-rw-r--r-- | kittybox-rs/src/frontend/style.css | 9 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/backend.rs | 92 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/backend/fs.rs | 407 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/mod.rs | 416 | ||||
-rw-r--r-- | kittybox-rs/src/indieauth/webauthn.rs | 140 | ||||
-rw-r--r-- | kittybox-rs/src/main.rs | 14 | ||||
-rw-r--r-- | kittybox-rs/src/media/storage/file.rs | 30 |
10 files changed, 1271 insertions, 161 deletions
diff --git a/kittybox-rs/src/bin/kittybox-indieauth-helper.rs b/kittybox-rs/src/bin/kittybox-indieauth-helper.rs new file mode 100644 index 0000000..37eee5b --- /dev/null +++ b/kittybox-rs/src/bin/kittybox-indieauth-helper.rs @@ -0,0 +1,216 @@ +use kittybox_indieauth::{AuthorizationRequest, PKCEVerifier, PKCEChallenge, PKCEMethod, GrantRequest, Scope, AuthorizationResponse, TokenData, GrantResponse}; +use clap::Parser; +use std::{borrow::Cow, io::Write}; + +const DEFAULT_CLIENT_ID: &str = "https://kittybox.fireburn.ru/indieauth-helper"; + +#[derive(Debug, thiserror::Error)] +enum Error { + #[error("i/o error: {0}")] + IO(#[from] std::io::Error), + #[error("http request error: {0}")] + HTTP(#[from] reqwest::Error), + #[error("urlencoded encoding error: {0}")] + UrlencodedEncoding(#[from] serde_urlencoded::ser::Error), + #[error("url parsing error: {0}")] + UrlParse(#[from] url::ParseError), + #[error("indieauth flow error: {0}")] + IndieAuth(Cow<'static, str>) +} + +#[derive(Parser, Debug)] +#[clap( + name = "kittybox-indieauth-helper", + author = "Vika <vika@fireburn.ru>", + version = env!("CARGO_PKG_VERSION"), + about = "Retrieve an IndieAuth token for debugging", + long_about = None +)] +struct Args { + /// Profile URL to use for initiating IndieAuth metadata discovery. + #[clap(value_parser)] + me: url::Url, + /// Scopes to request for the token. + /// + /// All IndieAuth scopes are supported, including arbitrary custom scopes. + #[clap(short, long)] + scope: Vec<Scope>, + /// Client ID to use when requesting a token. + #[clap(short, long, value_parser, default_value = DEFAULT_CLIENT_ID)] + client_id: url::Url, +} + +fn append_query_string<T: serde::Serialize>( + url: &url::Url, + query: T +) -> Result<url::Url, Error> { + let mut new_url = url.clone(); + let mut query = serde_urlencoded::to_string(query)?; + if let Some(old_query) = url.query() { + query.push('&'); + query.push_str(old_query); + } + new_url.set_query(Some(&query)); + + Ok(new_url) +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + let args = Args::parse(); + + let http: reqwest::Client = { + #[allow(unused_mut)] + let mut builder = reqwest::Client::builder() + .user_agent(concat!( + env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION") + )); + + builder.build().unwrap() + }; + + let redirect_uri: url::Url = "http://localhost:60000/callback".parse().unwrap(); + + eprintln!("Checking .well-known for metadata..."); + let metadata = http.get(args.me.join("/.well-known/oauth-authorization-server")?) + .header("Accept", "application/json") + .send() + .await? + .json::<kittybox_indieauth::Metadata>() + .await?; + + let verifier = PKCEVerifier::new(); + + let authorization_request = AuthorizationRequest { + response_type: kittybox_indieauth::ResponseType::Code, + client_id: args.client_id.clone(), + redirect_uri: redirect_uri.clone(), + state: kittybox_indieauth::State::new(), + code_challenge: PKCEChallenge::new(verifier.clone(), PKCEMethod::default()), + scope: Some(kittybox_indieauth::Scopes::new(args.scope)), + me: Some(args.me) + }; + + let indieauth_url = append_query_string( + &metadata.authorization_endpoint, + authorization_request + )?; + + // Prepare a callback + let (tx, rx) = tokio::sync::oneshot::channel::<AuthorizationResponse>(); + let server = { + use axum::{routing::get, extract::Query, response::IntoResponse}; + + let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx))); + + let router = axum::Router::new() + .route("/callback", axum::routing::get( + move |query: Option<Query<AuthorizationResponse>>| async move { + if let Some(Query(response)) = query { + if let Some(tx) = tx.lock_owned().await.take() { + tx.send(response).unwrap(); + + (axum::http::StatusCode::OK, + [("Content-Type", "text/plain")], + "Thank you! This window can now be closed.") + .into_response() + } else { + (axum::http::StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + "Oops. The callback was already received. Did you click twice?") + .into_response() + } + } else { + axum::http::StatusCode::BAD_REQUEST.into_response() + } + } + )); + + use std::net::{SocketAddr, IpAddr, Ipv4Addr}; + + let server = hyper::server::Server::bind( + &SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST),60000) + ) + .serve(router.into_make_service()); + + tokio::task::spawn(server) + }; + + eprintln!("Please visit the following URL in your browser:\n\n {}\n", indieauth_url.as_str()); + + let authorization_response = rx.await.unwrap(); + + // Clean up after the server + tokio::task::spawn(async move { + // Wait for the server to settle -- it might need to send its response + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + // Abort the future -- this should kill the server + server.abort(); + }); + + eprintln!("Got authorization response: {:#?}", authorization_response); + eprint!("Checking issuer field..."); + std::io::stderr().lock().flush()?; + + if dbg!(authorization_response.iss.as_str()) == dbg!(metadata.issuer.as_str()) { + eprintln!(" Done"); + } else { + eprintln!(" Failed"); + #[cfg(not(debug_assertions))] + std::process::exit(1); + } + let grant_response: GrantResponse = http.post(metadata.token_endpoint) + .form(&GrantRequest::AuthorizationCode { + code: authorization_response.code, + client_id: args.client_id, + redirect_uri, + code_verifier: verifier + }) + .header("Accept", "application/json") + .send() + .await? + .json() + .await?; + + if let GrantResponse::AccessToken { + me, + profile, + access_token, + expires_in, + refresh_token + } = grant_response { + eprintln!("Congratulations, {}, access token is ready! {}", + me.as_str(), + if let Some(exp) = expires_in { + format!("It expires in {exp} seconds.") + } else { + format!("It seems to have unlimited duration.") + } + ); + println!("{}", access_token); + if let Some(refresh_token) = refresh_token { + eprintln!("Save this refresh token, it will come in handy:"); + println!("{}", refresh_token); + }; + + if let Some(profile) = profile { + eprintln!("\nThe token endpoint returned some profile information:"); + if let Some(name) = profile.name { + eprintln!(" - Name: {name}") + } + if let Some(url) = profile.url { + eprintln!(" - URL: {url}") + } + if let Some(photo) = profile.photo { + eprintln!(" - Photo: {photo}") + } + if let Some(email) = profile.email { + eprintln!(" - Email: {email}") + } + } + + Ok(()) + } else { + return Err(Error::IndieAuth(Cow::Borrowed("IndieAuth token endpoint did not return an access token grant."))); + } +} diff --git a/kittybox-rs/src/frontend/indieauth.js b/kittybox-rs/src/frontend/indieauth.js new file mode 100644 index 0000000..03626b8 --- /dev/null +++ b/kittybox-rs/src/frontend/indieauth.js @@ -0,0 +1,107 @@ +const WEBAUTHN_TIMEOUT = 60 * 1000; + +async function webauthn_create_credential() { + const response = await fetch("/.kittybox/webauthn/pre_register"); + const { challenge, rp, user } = await response.json(); + + return await navigator.credentials.create({ + publicKey: { + challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), + rp: rp, + user: { + id: Uint8Array.from(user.cred_id), + name: user.name, + displayName: user.displayName + }, + pubKeyCredParams: [{alg: -7, type: "public-key"}], + authenticatorSelection: {}, + timeout: WEBAUTHN_TIMEOUT, + attestation: "none" + } + }); +} + +async function webauthn_authenticate() { + const response = await fetch("/.kittybox/webauthn/pre_auth"); + const { challenge, credentials } = await response.json(); + + try { + return await navigator.credentials.get({ + publicKey: { + challenge: Uint8Array.from(challenge, c => c.charCodeAt(0)), + allowCredentials: credentials.map(cred => ({ + id: Uint8Array.from(cred.id, c => c.charCodeAt(0)), + type: cred.type + })), + timeout: WEBAUTHN_TIMEOUT + } + }) + } catch (e) { + console.error("WebAuthn authentication failed:", e); + alert("Using your authenticator failed. (Check the DevTools for details)"); + throw e; + } +} + +async function submit_handler(e) { + e.preventDefault(); + const form = e.target; + + const scopes = Array.from(form.elements.scope) + .filter(e => e.checked) + .map(e => e.value); + + const authorization_request = { + response_type: form.elements.response_type.value, + client_id: form.elements.client_id.value, + redirect_uri: form.elements.redirect_uri.value, + state: form.elements.state.value, + code_challenge: form.elements.code_challenge.value, + code_challenge_method: form.elements.code_challenge_method.value, + // I would love to leave that as a list, but such is the form of + // IndieAuth. application/x-www-form-urlencoded doesn't have + // lists, so scopes are space-separated instead. It is annoying. + scope: scopes.length > 0 ? scopes.join(" ") : undefined, + }; + + let credential = null; + switch (form.elements.auth_method.value) { + case "password": + credential = form.elements.user_password.value; + if (credential.length == 0) { + alert("Please enter a password.") + return + } + break; + case "webauthn": + credential = await webauthn_authenticate(); + break; + default: + alert("Please choose an authentication method.") + return; + } + + console.log("Authorization request:", authorization_request); + console.log("Authentication method:", credential); + + const body = JSON.stringify({ + request: authorization_request, + authorization_method: credential + }); + console.log(body); + + const response = await fetch(form.action, { + method: form.method, + body: body, + headers: { + "Content-Type": "application/json" + } + }); + + if (response.ok) { + window.location.href = response.headers.get("Location") + } +} + +document.getElementById("indieauth_page") + .addEventListener("submit", submit_handler); diff --git a/kittybox-rs/src/frontend/mod.rs b/kittybox-rs/src/frontend/mod.rs index 00d3ba6..0797ba6 100644 --- a/kittybox-rs/src/frontend/mod.rs +++ b/kittybox-rs/src/frontend/mod.rs @@ -282,6 +282,7 @@ pub async fn statics(Path(name): Path<String>) -> impl IntoResponse { "style.css" => (StatusCode::OK, [(CONTENT_TYPE, MIME_CSS)], STYLE_CSS), "onboarding.js" => (StatusCode::OK, [(CONTENT_TYPE, MIME_JS)], ONBOARDING_JS), "onboarding.css" => (StatusCode::OK, [(CONTENT_TYPE, MIME_CSS)], ONBOARDING_CSS), + "indieauth.js" => (StatusCode::OK, [(CONTENT_TYPE, MIME_JS)], INDIEAUTH_JS), _ => ( StatusCode::NOT_FOUND, [(CONTENT_TYPE, MIME_PLAIN)], diff --git a/kittybox-rs/src/frontend/style.css b/kittybox-rs/src/frontend/style.css index 109bba0..a8ef6e4 100644 --- a/kittybox-rs/src/frontend/style.css +++ b/kittybox-rs/src/frontend/style.css @@ -177,7 +177,7 @@ article.h-card img.u-photo { aspect-ratio: 1; } -.mini-h-card img { +.mini-h-card img, #indieauth_page img { height: 2em; display: inline-block; border: 2px solid gray; @@ -192,3 +192,10 @@ article.h-card img.u-photo { .mini-h-card a { text-decoration: none; } + +#indieauth_page > #introduction { + border: .125rem solid gray; + border-radius: .75rem; + margin: 1.25rem; + padding: .75rem; +} diff --git a/kittybox-rs/src/indieauth/backend.rs b/kittybox-rs/src/indieauth/backend.rs index f420db9..8b0c10a 100644 --- a/kittybox-rs/src/indieauth/backend.rs +++ b/kittybox-rs/src/indieauth/backend.rs @@ -1,21 +1,99 @@ use std::collections::HashMap; - use kittybox_indieauth::{ AuthorizationRequest, TokenData }; +pub use kittybox_util::auth::EnrolledCredential; type Result<T> = std::io::Result<T>; +pub mod fs; +pub use fs::FileBackend; + + #[async_trait::async_trait] pub trait AuthBackend: Clone + Send + Sync + 'static { + // Authorization code management. + /// Create a one-time OAuth2 authorization code for the passed + /// authorization request, and save it for later retrieval. + /// + /// Note for implementors: the [`AuthorizationRequest::me`] value + /// is guaranteed to be [`Some(url::Url)`][Option::Some] and can + /// be trusted to be correct and non-malicious. async fn create_code(&self, data: AuthorizationRequest) -> Result<String>; + /// Retreive an authorization request using the one-time + /// code. Implementations must sanitize the `code` field to + /// prevent exploits, and must check if the code should still be + /// valid at this point in time (validity interval is left up to + /// the implementation, but is recommended to be no more than 10 + /// minutes). async fn get_code(&self, code: &str) -> Result<Option<AuthorizationRequest>>; + // Token management. async fn create_token(&self, data: TokenData) -> Result<String>; - async fn get_token(&self, token: &str) -> Result<Option<TokenData>>; - async fn list_tokens(&self, website: url::Url) -> Result<HashMap<String, TokenData>>; - async fn revoke_token(&self, token: &str) -> Result<()>; + async fn get_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>>; + async fn list_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>>; + async fn revoke_token(&self, website: &url::Url, token: &str) -> Result<()>; + // Refresh token management. async fn create_refresh_token(&self, data: TokenData) -> Result<String>; - async fn get_refresh_token(&self, token: &str) -> Result<Option<TokenData>>; - async fn list_refresh_tokens(&self, website: url::Url) -> Result<HashMap<String, TokenData>>; - async fn revoke_refresh_token(&self, token: &str) -> Result<()>; + async fn get_refresh_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>>; + async fn list_refresh_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>>; + async fn revoke_refresh_token(&self, website: &url::Url, token: &str) -> Result<()>; + // Password management. + /// Verify a password. + #[must_use] + async fn verify_password(&self, website: &url::Url, password: String) -> Result<bool>; + /// Enroll a password credential for a user. Only one password + /// credential must exist for a given user. + async fn enroll_password(&self, website: &url::Url, password: String) -> Result<()>; + // WebAuthn credential management. + /// 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<()>; + /// List currently enrolled WebAuthn authenticators for a given user. + async fn list_webauthn_pubkeys(&self, website: &url::Url) -> Result<Vec<webauthn::prelude::Passkey>>; + /// Persist registration challenge state for a little while so it + /// can be used later. + /// + /// Challenges saved in this manner MUST expire after a little + /// while. 10 minutes is recommended. + async fn persist_registration_challenge( + &self, + website: &url::Url, + state: webauthn::prelude::PasskeyRegistration + ) -> Result<String>; + /// Retrieve a persisted registration challenge. + /// + /// The challenge should be deleted after retrieval. + async fn retrieve_registration_challenge( + &self, + website: &url::Url, + challenge_id: &str + ) -> Result<webauthn::prelude::PasskeyRegistration>; + /// Persist authentication challenge state for a little while so + /// it can be used later. + /// + /// Challenges saved in this manner MUST expire after a little + /// while. 10 minutes is recommended. + /// + /// To support multiple authentication options, this can return an + /// opaque token that should be set as a cookie. + async fn persist_authentication_challenge( + &self, + website: &url::Url, + state: webauthn::prelude::PasskeyAuthentication + ) -> Result<String>; + /// Retrieve a persisted authentication challenge. + /// + /// The challenge should be deleted after retrieval. + async fn retrieve_authentication_challenge( + &self, + website: &url::Url, + challenge_id: &str + ) -> Result<webauthn::prelude::PasskeyAuthentication>; + /// List currently enrolled credential types for a given user. + async fn list_user_credential_types(&self, website: &url::Url) -> Result<Vec<EnrolledCredential>>; } diff --git a/kittybox-rs/src/indieauth/backend/fs.rs b/kittybox-rs/src/indieauth/backend/fs.rs new file mode 100644 index 0000000..fbfa0f7 --- /dev/null +++ b/kittybox-rs/src/indieauth/backend/fs.rs @@ -0,0 +1,407 @@ +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}; +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)] +pub struct FileBackend { + path: PathBuf, +} + +impl FileBackend { + pub fn new<T: Into<PathBuf>>(path: T) -> Self { + Self { + path: path.into() + } + } + + /// Sanitize a filename, leaving only alphanumeric characters. + /// + /// Doesn't allocate a new string unless non-alphanumeric + /// characters are encountered. + fn sanitize_for_path(filename: &'_ str) -> Cow<'_, str> { + if filename.chars().all(char::is_alphanumeric) { + Cow::Borrowed(filename) + } else { + let mut s = String::with_capacity(filename.len()); + + filename.chars() + .filter(|c| c.is_alphanumeric()) + .for_each(|c| s.push(c)); + + Cow::Owned(s) + } + } + + #[inline] + async fn serialize_to_file<T: 'static + serde::ser::Serialize + Send, B: Into<Option<&'static str>>>( + &self, + dir: &str, + basename: B, + length: usize, + data: T + ) -> Result<String> { + let basename = basename.into(); + let has_ext = basename.is_some(); + let (filename, mut file) = kittybox_util::fs::mktemp( + self.path.join(dir), basename, length + ) + .await + .map(|(name, file)| (name, file.try_into_std().unwrap()))?; + + spawn_blocking(move || serde_json::to_writer(&mut file, &data)) + .await + .unwrap_or_else(|e| panic!( + "Panic while serializing {}: {}", + std::any::type_name::<T>(), + e + )) + .map(move |_| { + (if has_ext { + filename + .extension() + + } else { + filename + .file_name() + }) + .unwrap() + .to_str() + .unwrap() + .to_owned() + }) + .map_err(|err| err.into()) + } + + #[inline] + async fn deserialize_from_file<'filename, 'this: 'filename, T, B>( + &'this self, + dir: &'filename str, + basename: B, + filename: &'filename str, + ) -> Result<Option<(PathBuf, SystemTime, T)>> + where + T: serde::de::DeserializeOwned + Send, + B: Into<Option<&'static str>> + { + let basename = basename.into(); + let path = self.path + .join(dir) + .join(format!( + "{}{}{}", + basename.unwrap_or(""), + if basename.is_none() { "" } else { "." }, + FileBackend::sanitize_for_path(filename) + )); + + let data = match tokio::fs::File::open(&path).await { + Ok(mut file) => { + let mut buf = Vec::new(); + + file.read_to_end(&mut buf).await?; + + match serde_json::from_slice::<'_, T>(buf.as_slice()) { + Ok(data) => data, + Err(err) => return Err(err.into()) + } + }, + Err(err) => if err.kind() == std::io::ErrorKind::NotFound { + return Ok(None) + } else { + return Err(err) + } + }; + + let ctime = tokio::fs::metadata(&path).await?.created()?; + + Ok(Some((path, ctime, data))) + } + + #[inline] + fn url_to_dir(url: &url::Url) -> String { + let host = url.host_str().unwrap(); + let port = url.port() + .map(|port| Cow::Owned(format!(":{}", port))) + .unwrap_or(Cow::Borrowed("")); + + format!("{}{}", host, port) + } + + async fn list_files<'dir, 'this: 'dir, T: DeserializeOwned + Send>( + &'this self, + dir: &'dir str, + prefix: &'static str + ) -> Result<HashMap<String, T>> { + let dir = self.path.join(dir); + + let mut hashmap = HashMap::new(); + let mut readdir = match tokio::fs::read_dir(dir).await { + Ok(readdir) => readdir, + Err(err) => if err.kind() == std::io::ErrorKind::NotFound { + // empty hashmap + return Ok(hashmap); + } else { + return Err(err); + } + }; + while let Some(entry) = readdir.next_entry().await? { + // safe to unwrap; filenames are alphanumeric + let filename = entry.file_name() + .into_string() + .expect("token filenames should be alphanumeric!"); + if let Some(token) = filename.strip_prefix(&format!("{}.", prefix)) { + match tokio::fs::File::open(entry.path()).await { + Ok(mut file) => { + let mut buf = Vec::new(); + + file.read_to_end(&mut buf).await?; + + match serde_json::from_slice::<'_, T>(buf.as_slice()) { + Ok(data) => hashmap.insert(token.to_string(), data), + Err(err) => { + tracing::error!( + "Error decoding token data from file {}: {}", + entry.path().display(), err + ); + continue; + } + }; + }, + Err(err) => if err.kind() == std::io::ErrorKind::NotFound { + continue + } else { + return Err(err) + } + } + } + } + + Ok(hashmap) + } +} + +#[async_trait] +impl AuthBackend for FileBackend { + // Authorization code management. + async fn create_code(&self, data: AuthorizationRequest) -> Result<String> { + self.serialize_to_file("codes", None, CODE_LENGTH, data).await + } + + async fn get_code(&self, code: &str) -> Result<Option<AuthorizationRequest>> { + match self.deserialize_from_file("codes", None, FileBackend::sanitize_for_path(code).as_ref()).await? { + Some((path, ctime, data)) => { + if let Err(err) = tokio::fs::remove_file(path).await { + tracing::error!("Failed to clean up authorization code: {}", err); + } + // Err on the safe side in case of clock drift + if ctime.elapsed().unwrap_or(Duration::ZERO) > CODE_DURATION { + Ok(None) + } else { + Ok(Some(data)) + } + }, + None => Ok(None) + } + } + + // Token management. + async fn create_token(&self, data: TokenData) -> Result<String> { + let dir = format!("{}/tokens", FileBackend::url_to_dir(&data.me)); + self.serialize_to_file(&dir, "access", TOKEN_LENGTH, data).await + } + + async fn get_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>> { + let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); + match self.deserialize_from_file::<TokenData, _>( + &dir, "access", + FileBackend::sanitize_for_path(token).as_ref() + ).await? { + Some((path, _, token)) => { + if token.expired() { + if let Err(err) = tokio::fs::remove_file(path).await { + tracing::error!("Failed to remove expired token: {}", err); + } + Ok(None) + } else { + Ok(Some(token)) + } + }, + None => Ok(None) + } + } + + async fn list_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>> { + let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); + self.list_files(&dir, "access").await + } + + async fn revoke_token(&self, website: &url::Url, token: &str) -> Result<()> { + match tokio::fs::remove_file( + self.path + .join(FileBackend::url_to_dir(website)) + .join("tokens") + .join(format!("access.{}", FileBackend::sanitize_for_path(token))) + ).await { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + result => result + } + } + + // Refresh token management. + async fn create_refresh_token(&self, data: TokenData) -> Result<String> { + let dir = format!("{}/tokens", FileBackend::url_to_dir(&data.me)); + self.serialize_to_file(&dir, "refresh", TOKEN_LENGTH, data).await + } + + async fn get_refresh_token(&self, website: &url::Url, token: &str) -> Result<Option<TokenData>> { + let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); + match self.deserialize_from_file::<TokenData, _>( + &dir, "refresh", + FileBackend::sanitize_for_path(token).as_ref() + ).await? { + Some((path, _, token)) => { + if token.expired() { + if let Err(err) = tokio::fs::remove_file(path).await { + tracing::error!("Failed to remove expired token: {}", err); + } + Ok(None) + } else { + Ok(Some(token)) + } + }, + None => Ok(None) + } + } + + async fn list_refresh_tokens(&self, website: &url::Url) -> Result<HashMap<String, TokenData>> { + let dir = format!("{}/tokens", FileBackend::url_to_dir(website)); + self.list_files(&dir, "refresh").await + } + + async fn revoke_refresh_token(&self, website: &url::Url, token: &str) -> Result<()> { + match tokio::fs::remove_file( + self.path + .join(FileBackend::url_to_dir(website)) + .join("tokens") + .join(format!("refresh.{}", FileBackend::sanitize_for_path(token))) + ).await { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + result => result + } + } + + // Password management. + async fn verify_password(&self, website: &url::Url, password: String) -> Result<bool> { + use argon2::{Argon2, password_hash::{PasswordHash, PasswordVerifier}}; + + let password_filename = self.path + .join(FileBackend::url_to_dir(website)) + .join("password"); + + 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) + } + } + } + + 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(); + + tokio::fs::write(password_filename, password_hash.as_bytes()).await + } + + // WebAuthn credential management. + async fn enroll_webauthn(&self, website: &url::Url, credential: Passkey) -> Result<()> { + todo!() + } + + async fn list_webauthn_pubkeys(&self, website: &url::Url) -> Result<Vec<Passkey>> { + // TODO stub! + Ok(vec![]) + } + + async fn persist_registration_challenge( + &self, + website: &url::Url, + state: PasskeyRegistration + ) -> Result<String> { + todo!() + } + + async fn retrieve_registration_challenge( + &self, + website: &url::Url, + challenge_id: &str + ) -> Result<PasskeyRegistration> { + todo!() + } + + async fn persist_authentication_challenge( + &self, + website: &url::Url, + state: PasskeyAuthentication + ) -> Result<String> { + todo!() + } + + async fn retrieve_authentication_challenge( + &self, + website: &url::Url, + challenge_id: &str + ) -> Result<PasskeyAuthentication> { + todo!() + } + + async fn list_user_credential_types(&self, website: &url::Url) -> Result<Vec<EnrolledCredential>> { + let mut creds = vec![]; + + match tokio::fs::metadata(self.path + .join(FileBackend::url_to_dir(website)) + .join("password")) + .await + { + Ok(metadata) => creds.push(EnrolledCredential::Password), + Err(err) => if err.kind() != std::io::ErrorKind::NotFound { + return Err(err) + } + } + + 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 index 8a37959..adf669e 100644 --- a/kittybox-rs/src/indieauth/mod.rs +++ b/kittybox-rs/src/indieauth/mod.rs @@ -1,20 +1,23 @@ +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 }; +use axum_extra::extract::cookie::{CookieJar, Cookie}; use crate::database::Storage; use kittybox_indieauth::{ Metadata, IntrospectionEndpointAuthMethod, RevocationEndpointAuthMethod, - Scope, Scopes, PKCEMethod, Error, ErrorKind, - ResponseType, RequestMaybeAuthorizationEndpoint, + Scope, Scopes, PKCEMethod, Error, ErrorKind, ResponseType, AuthorizationRequest, AuthorizationResponse, GrantType, GrantRequest, GrantResponse, Profile, TokenIntrospectionRequest, TokenIntrospectionResponse, TokenRevocationRequest, TokenData }; pub mod backend; +mod webauthn; use backend::AuthBackend; const ACCESS_TOKEN_VALIDITY: u64 = 7 * 24 * 60 * 60; // 7 days @@ -24,10 +27,19 @@ const KITTYBOX_TOKEN_STATUS: &str = "kittybox:token_status"; pub async fn metadata( Host(host): Host -) -> Json<Metadata> { - let issuer: url::Url = format!("https://{}/", host).parse().unwrap(); +) -> 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(); - Json(Metadata { + Metadata { issuer, authorization_endpoint: indieauth.join("auth").unwrap(), token_endpoint: indieauth.join("token").unwrap(), @@ -52,136 +64,230 @@ pub async fn metadata( 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( +async fn authorization_endpoint_get<A: AuthBackend, D: Storage + 'static>( Host(host): Host, - Query(auth): Query<AuthorizationRequest>, + Query(request): Query<AuthorizationRequest>, + Extension(db): Extension<D>, + Extension(auth): Extension<A> ) -> Html<String> { + let me = format!("https://{}/", host).parse().unwrap(); // TODO fetch h-app from client_id // TODO verify redirect_uri registration - // TODO fetch user profile to display it in a pretty page - Html(kittybox_templates::Template { title: "Confirm sign-in via IndieAuth", blog_name: "Kittybox", feeds: vec![], - // TODO user: None, - content: todo!(), + content: kittybox_templates::AuthorizationRequestPage { + request, + credentials: auth.list_user_credential_types(&me).await.unwrap(), + user: db.get_post(me.as_str()).await.unwrap().unwrap(), + // XXX parse MF2 + app: serde_json::json!({ + "type": [ + "h-app", + "h-x-app" + ], + "properties": { + "name": [ + "Quill" + ], + "logo": [ + "https://quill.p3k.io/images/quill-logo-144.png" + ], + "url": [ + "https://quill.p3k.io/" + ] + } + }) + }.to_string(), }.to_string()) } +#[derive(Deserialize, Debug)] +#[serde(untagged)] +enum Credential { + Password(String), + 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, + challenge_id: Option<&str> +) -> std::io::Result<bool> { + match credential { + Credential::Password(password) => auth.verify_password(website, password).await, + 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, + Json(confirmation): Json<AuthorizationConfirmation>, + Extension(backend): Extension<A>, + cookies: CookieJar, +) -> Response { + tracing::debug!("Received authorization confirmation from user"); + let challenge_id = cookies.get(webauthn::CHALLENGE_ID_COOKIE) + .map(|cookie| cookie.value()); + 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())], + cookies.remove(Cookie::named(webauthn::CHALLENGE_ID_COOKIE)) + ) + .into_response() +} + async fn authorization_endpoint_post<A: AuthBackend, D: Storage + 'static>( Host(host): Host, - Form(auth): Form<RequestMaybeAuthorizationEndpoint>, + Form(grant): Form<GrantRequest>, Extension(backend): Extension<A>, Extension(db): Extension<D> ) -> Response { - use RequestMaybeAuthorizationEndpoint::*; - match auth { - Authorization(auth) => { - // 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, + 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 creating authorization code: {}", 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 dbg!(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) => dbg!(profile), + Err(err) => { + tracing::error!("Error retrieving profile from database: {}", err); - let location = { - let mut uri = redirect_uri; - uri.set_query(Some(&serde_urlencoded::to_string( - AuthorizationResponse { - code, state, - iss: format!("https://{}/", host).parse().unwrap() + return StatusCode::INTERNAL_SERVER_ERROR.into_response() } - ).unwrap())); - - uri + } + } else { + None }; - (StatusCode::FOUND, - [("Location", location.as_str())] - ) - .into_response() + GrantResponse::ProfileUrl { me, profile }.into_response() }, - Grant(grant) => 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(); - 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) => 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() - } + _ => 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, Form(grant): Form<GrantRequest>, @@ -224,9 +330,15 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( } } + let me: url::Url = format!("https://{}/", host).parse().unwrap(); + match grant { - GrantRequest::AuthorizationCode { code, client_id, redirect_uri, code_verifier } => { - // TODO load the information corresponding to the code + 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 { @@ -240,7 +352,7 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( } }; - let me: url::Url = format!("https://{}/", host).parse().unwrap(); + tracing::debug!("Retrieved authorization request: {:?}", request); let scope = if let Some(scope) = request.scope { scope } else { return Error { @@ -271,13 +383,23 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( }.into_response(); } - let profile = if scope.has(&Scope::Profile) { + // 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) => profile, + Ok(profile) => dbg!(profile), Err(err) => { tracing::error!("Error retrieving profile from database: {}", err); @@ -316,8 +438,12 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( refresh_token: Some(refresh_token) }.into_response() }, - GrantRequest::RefreshToken { refresh_token, client_id, scope } => { - let data = match backend.get_refresh_token(&refresh_token).await { + 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, @@ -391,7 +517,7 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( return StatusCode::INTERNAL_SERVER_ERROR.into_response(); } }; - if let Err(err) = backend.revoke_refresh_token(&old_refresh_token).await { + 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(); } @@ -408,13 +534,17 @@ async fn token_endpoint_post<A: AuthBackend, D: Storage + 'static>( } async fn introspection_endpoint_post<A: AuthBackend>( + Host(host): Host, Form(token_request): Form<TokenIntrospectionRequest>, TypedHeader(Authorization(auth_token)): TypedHeader<Authorization<Bearer>>, Extension(backend): Extension<A> ) -> Response { use serde_json::json; + + let me: url::Url = format!("https://{}/", host).parse().unwrap(); + // Check authentication first - match backend.get_token(auth_token.token()).await { + 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 @@ -428,7 +558,7 @@ async fn introspection_endpoint_post<A: AuthBackend>( return StatusCode::INTERNAL_SERVER_ERROR.into_response() } } - let response: TokenIntrospectionResponse = match backend.get_token(&token_request.token).await { + 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); @@ -440,12 +570,15 @@ async fn introspection_endpoint_post<A: AuthBackend>( } async fn revocation_endpoint_post<A: AuthBackend>( + Host(host): Host, Form(revocation): Form<TokenRevocationRequest>, Extension(backend): Extension<A> ) -> impl IntoResponse { + let me: url::Url = format!("https://{}/", host).parse().unwrap(); + if let Err(err) = tokio::try_join!( - backend.revoke_token(&revocation.token), - backend.revoke_refresh_token(&revocation.token) + backend.revoke_token(&me, &revocation.token), + backend.revoke_refresh_token(&me, &revocation.token) ) { tracing::error!("Error revoking token: {}", err); @@ -495,7 +628,9 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( ) -> Response { use serde_json::json; - match backend.get_token(auth_token.token()).await { + 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!({ @@ -508,7 +643,7 @@ async fn userinfo_endpoint_get<A: AuthBackend, D: Storage + 'static>( }))).into_response(); } - match get_profile(db, &format!("https://{}/", host), token.scope.has(&Scope::Email)).await { + 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 @@ -539,11 +674,16 @@ pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum:: .nest( "/.kittybox/indieauth", Router::new() + .route("/metadata", + get(metadata)) .route( "/auth", - get(authorization_endpoint_get) + 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( @@ -555,6 +695,8 @@ pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum:: .route( "/userinfo", get(userinfo_endpoint_get::<A, D>)) + .route("/webauthn/pre_register", + get(webauthn::webauthn_pre_register::<A, D>)) .layer(tower_http::cors::CorsLayer::new() .allow_methods([ axum::http::Method::GET, @@ -570,13 +712,37 @@ pub fn router<A: AuthBackend, D: Storage + 'static>(backend: A, db: D) -> axum:: .route( "/.well-known/oauth-authorization-server", get(|| std::future::ready( - ( - StatusCode::FOUND, - [ - ("Location", - "/.kittybox/indieauth/metadata") - ] + (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"), + 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 new file mode 100644 index 0000000..ea3ad3d --- /dev/null +++ b/kittybox-rs/src/indieauth/webauthn.rs @@ -0,0 +1,140 @@ +use axum::{ + extract::{Json, Host}, + response::{IntoResponse, Response}, + http::StatusCode, Extension, TypedHeader, headers::{authorization::Bearer, Authorization} +}; +use axum_extra::extract::cookie::{CookieJar, Cookie}; + +use super::backend::AuthBackend; +use crate::database::Storage; + +pub(crate) const CHALLENGE_ID_COOKIE: &str = "kittybox_webauthn_challenge_id"; + +macro_rules! bail { + ($msg:literal, $err:expr) => { + { + ::tracing::error!($msg, $err); + return ::axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn webauthn_pre_register<A: AuthBackend, D: Storage + 'static>( + Host(host): Host, + Extension(db): Extension<D>, + Extension(auth): Extension<A>, + cookies: CookieJar +) -> Response { + let uid = format!("https://{}/", host.clone()); + let uid_url: url::Url = uid.parse().unwrap(); + // This will not find an h-card in onboarding! + let display_name = match db.get_post(&uid).await { + Ok(hcard) => match hcard { + Some(mut hcard) => { + match hcard["properties"]["uid"][0].take() { + serde_json::Value::String(name) => name, + _ => String::default() + } + }, + None => String::default() + }, + Err(err) => bail!("Error retrieving h-card: {}", err) + }; + + let webauthn = webauthn::WebauthnBuilder::new( + &host, + &uid_url + ) + .unwrap() + .rp_name("Kittybox") + .build() + .unwrap(); + + let (challenge, state) = match webauthn.start_passkey_registration( + // Note: using a nil uuid here is fine + // Because the user corresponds to a website anyway + // We do not track multiple users + webauthn::prelude::Uuid::nil(), + &uid, + &display_name, + Some(vec![]) + ) { + Ok((challenge, state)) => (challenge, state), + Err(err) => bail!("Error generating WebAuthn registration data: {}", err) + }; + + match auth.persist_registration_challenge(&uid_url, state).await { + Ok(challenge_id) => ( + cookies.add( + Cookie::build(CHALLENGE_ID_COOKIE, challenge_id) + .secure(true) + .finish() + ), + Json(challenge) + ).into_response(), + Err(err) => bail!("Failed to persist WebAuthn challenge: {}", err) + } +} + +pub async fn webauthn_register<A: AuthBackend>( + Host(host): Host, + Json(credential): Json<webauthn::prelude::RegisterPublicKeyCredential>, + // TODO determine if we can use a cookie maybe? + user_credential: Option<TypedHeader<Authorization<Bearer>>>, + Extension(auth): Extension<A> +) -> Response { + let uid = format!("https://{}/", host.clone()); + let uid_url: url::Url = uid.parse().unwrap(); + + let pubkeys = match auth.list_webauthn_pubkeys(&uid_url).await { + Ok(pubkeys) => pubkeys, + Err(err) => bail!("Error enumerating existing WebAuthn credentials: {}", err) + }; + + if !pubkeys.is_empty() { + if let Some(TypedHeader(Authorization(token))) = user_credential { + // TODO check validity of the credential + } else { + return StatusCode::UNAUTHORIZED.into_response() + } + } + + return StatusCode::OK.into_response() +} + +pub(crate) async fn verify<A: AuthBackend>( + auth: &A, + website: &url::Url, + credential: webauthn::prelude::PublicKeyCredential, + challenge_id: &str +) -> std::io::Result<bool> { + let host = website.host_str().unwrap(); + + let webauthn = webauthn::WebauthnBuilder::new( + host, + website + ) + .unwrap() + .rp_name("Kittybox") + .build() + .unwrap(); + + match webauthn.finish_passkey_authentication( + &credential, + &auth.retrieve_authentication_challenge(&website, challenge_id).await? + ) { + Err(err) => { + tracing::error!("WebAuthn error: {}", err); + Ok(false) + }, + Ok(authentication_result) => { + let counter = authentication_result.counter(); + let cred_id = authentication_result.cred_id(); + + if authentication_result.needs_update() { + todo!() + } + Ok(true) + } + } +} diff --git a/kittybox-rs/src/main.rs b/kittybox-rs/src/main.rs index fcfc135..796903b 100644 --- a/kittybox-rs/src/main.rs +++ b/kittybox-rs/src/main.rs @@ -74,6 +74,16 @@ async fn main() { kittybox::media::storage::file::FileStore::new(path) }; + let auth_backend = { + let variable = std::env::var("AUTH_STORE_URI") + .unwrap(); + let folder = variable + .strip_prefix("file://") + .unwrap(); + kittybox::indieauth::backend::fs::FileBackend::new(folder) + }; + + // This code proves that different components of Kittybox can // be split up without hurting the app // @@ -119,7 +129,7 @@ async fn main() { let media = axum::Router::new() .nest("/.kittybox/media", kittybox::media::router(blobstore).layer(axum::Extension(http))); - //let indieauth = kittybox::indieauth::router(); + let indieauth = kittybox::indieauth::router(auth_backend, database.clone()); let technical = axum::Router::new() .route( @@ -153,7 +163,7 @@ async fn main() { .merge(onboarding) .merge(micropub) .merge(media) - //.merge(indieauth) + .merge(indieauth) .merge(technical) .layer( axum::Extension( diff --git a/kittybox-rs/src/media/storage/file.rs b/kittybox-rs/src/media/storage/file.rs index c554d9e..1e0ff0e 100644 --- a/kittybox-rs/src/media/storage/file.rs +++ b/kittybox-rs/src/media/storage/file.rs @@ -29,38 +29,16 @@ impl From<tokio::io::Error> for MediaStoreError { } } - impl FileStore { pub fn new<T: Into<PathBuf>>(base: T) -> Self { Self { base: base.into() } } async fn mktemp(&self) -> Result<(PathBuf, BufWriter<tokio::fs::File>)> { - use rand::{Rng, distributions::Alphanumeric}; - tokio::fs::create_dir_all(self.base.as_path()).await?; - loop { - let filename = self.base.join(format!("temp.{}", { - let string = rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(16) - .collect::<Vec<u8>>(); - String::from_utf8(string).unwrap() - })); - - match OpenOptions::new() - .create_new(true) - .write(true) - .open(&filename) - .await - { - // TODO: determine if BufWriter provides benefit here - Ok(file) => return Ok((filename, BufWriter::with_capacity(BUF_CAPACITY, file))), - Err(err) => match err.kind() { - std::io::ErrorKind::AlreadyExists => continue, - _ => return Err(err.into()) - } - } - } + kittybox_util::fs::mktemp(&self.base, "temp", 16) + .await + .map(|(name, file)| (name, BufWriter::new(file))) + .map_err(Into::into) } } |