about summary refs log tree commit diff
path: root/kittybox-rs/src
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-09-19 17:30:38 +0300
committerVika <vika@fireburn.ru>2022-09-19 17:30:38 +0300
commit66049566ae865e1a4bd049257d6afc0abded16e9 (patch)
tree6013a26fa98a149d103eb4402ca91d698ef02ac2 /kittybox-rs/src
parent696458657b26032e6e2a987c059fd69aaa10508d (diff)
feat: indieauth support
Working:
 - Tokens and codes
 - Authenticating with a password

Not working:
 - Setting the password (need to patch onboarding)
 - WebAuthn (the JavaScript is too complicated)
Diffstat (limited to 'kittybox-rs/src')
-rw-r--r--kittybox-rs/src/bin/kittybox-indieauth-helper.rs216
-rw-r--r--kittybox-rs/src/frontend/indieauth.js107
-rw-r--r--kittybox-rs/src/frontend/mod.rs1
-rw-r--r--kittybox-rs/src/frontend/style.css9
-rw-r--r--kittybox-rs/src/indieauth/backend.rs92
-rw-r--r--kittybox-rs/src/indieauth/backend/fs.rs407
-rw-r--r--kittybox-rs/src/indieauth/mod.rs416
-rw-r--r--kittybox-rs/src/indieauth/webauthn.rs140
-rw-r--r--kittybox-rs/src/main.rs14
-rw-r--r--kittybox-rs/src/media/storage/file.rs30
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)
     }
 }