about summary refs log tree commit diff
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
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)
-rw-r--r--.gitignore1
-rw-r--r--README.md11
-rw-r--r--configuration.nix19
-rw-r--r--kittybox-rs/Cargo.lock669
-rw-r--r--kittybox-rs/Cargo.toml25
-rwxr-xr-xkittybox-rs/dev.sh3
-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
-rw-r--r--kittybox-rs/templates/src/indieauth.rs174
-rw-r--r--kittybox-rs/templates/src/templates.rs4
-rw-r--r--kittybox-rs/util/Cargo.toml26
-rw-r--r--kittybox-rs/util/src/error.rs2
-rw-r--r--kittybox-rs/util/src/lib.rs82
21 files changed, 2244 insertions, 204 deletions
diff --git a/.gitignore b/.gitignore
index ce9b63e..6744025 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,5 +10,6 @@ dump.rdb
 *~
 /kittybox-rs/test-dir
 /kittybox-rs/media-store
+/kittybox-rs/auth-store
 /kittybox-rs/fonts/*
 /token.txt
diff --git a/README.md b/README.md
index aa533b2..85f0e70 100644
--- a/README.md
+++ b/README.md
@@ -72,12 +72,7 @@ in {
     kittybox.nixosModules.default
   ];
   
-  services.kittybox = {
-    enable = true;
-    # These will not be required in future versions.
-    authorizationEndpoint = "https://indieauth.com/auth";
-    tokenEndpoint = "https://tokens.indieauth.com/token";
-  };
+  services.kittybox.enable = true;
 }
 ```
 
@@ -98,6 +93,10 @@ Set the following environment variables:
    files uploaded via the media endpoint.
    - To use flat files, use `file://` and append an absolute path to
      your folder like this: `file:///var/lib/kittybox/media`
+ - `AUTH_STORE_URI`: Storage for authentication-related data (tokens,
+   authorization codes etc.)
+   - To use flat files, use `file://` and append an absolute path to
+     your folder like this: `file:///var/lib/kittybox/auth`
 
 Additionally you can customize the `SERVE_AT` environment variable to
 customize where Kittybox will listen to requests.
diff --git a/configuration.nix b/configuration.nix
index 87759c8..239243f 100644
--- a/configuration.nix
+++ b/configuration.nix
@@ -50,9 +50,9 @@ in {
           Make sure that if you are using the file backend, the state
           directory is accessible by Kittybox. By default, the unit config
           uses DynamicUser=true, which prevents the unit from accessing
-          data outside of its directory. It is recommended to use a
-          bind-mount to /var/lib/private/kittybox if you require the state
-          directory to reside elsewhere.
+          data outside of its directory. It is recommended to reconfigure
+          the sandboxing or use a bind-mount to /var/lib/private/kittybox
+          if you require the state directory to reside elsewhere.
         '';
       };
       blobstoreUri = mkOption {
@@ -65,6 +65,15 @@ in {
           When using the file backend, check notes in the `backendUri` option too.
         '';
       };
+      authstoreUri = mkOption {
+        type = types.nullOr types.str;
+        default = "file:///var/lib/kittybox/auth";
+        description = ''
+          Set the backend used for persisting authentication data. Available options are:
+           - file:// - flat files. Codes are stored globally, tokens and
+             credentials are stored per-site.
+        '';
+      };
       microsubServer = mkOption {
         type = types.nullOr types.str;
         default = null;
@@ -112,7 +121,7 @@ in {
 
       restartTriggers = [
         cfg.package
-        cfg.backendUri cfg.blobstoreUri
+        cfg.backendUri cfg.blobstoreUri cfg.authstoreUri
         cfg.internalTokenFile
         cfg.bind cfg.port
         cfg.cookieSecretFile
@@ -122,9 +131,9 @@ in {
         SERVE_AT = "${cfg.bind}:${builtins.toString cfg.port}";
         MICROSUB_ENDPOINT = cfg.microsubServer;
         WEBMENTION_ENDPOINT = cfg.webmentionEndpoint;
-        #REDIS_URI = if (cfg.redisUri == null) then "redis://127.0.0.1:6379/" else cfg.redisUri;
         BACKEND_URI = cfg.backendUri;
         BLOBSTORE_URI = cfg.blobstoreUri;
+        AUTH_STORE_URI = cfg.authstoreUri;
         RUST_LOG = "${cfg.logLevel}";
         COOKIE_SECRET_FILE = "${cfg.cookieSecretFile}";
       };
diff --git a/kittybox-rs/Cargo.lock b/kittybox-rs/Cargo.lock
index eecd036..8815b41 100644
--- a/kittybox-rs/Cargo.lock
+++ b/kittybox-rs/Cargo.lock
@@ -48,6 +48,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc"
 
 [[package]]
+name = "argon2"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db4ce4441f99dbd377ca8a8f57b698c44d0d6e712d8329b5040da5a64aa1ce73"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "password-hash",
+]
+
+[[package]]
+name = "asn1-rs"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30ff05a702273012438132f449575dbc804e27b2f3cbe3069aa237d26c98fa33"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror",
+ "time 0.3.14",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db8b7511298d5b7784b40b092d9e9dcd3a627a5707e4b5e507931ab0d44eeebf"
+dependencies = [
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2777730b2039ac0f95f093556e61b6d26cebed5393ca6f152717777cec3a42ed"
+dependencies = [
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
+]
+
+[[package]]
 name = "assert-json-diff"
 version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -121,9 +171,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
 [[package]]
 name = "axum"
-version = "0.5.11"
+version = "0.5.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2cc6e8e8c993cb61a005fab8c1e5093a29199b7253b05a6883999312935c1ff"
+checksum = "9de18bc5f2e9df8f52da03856bf40e29b747de5a84e43aefff90e3dc4a21529b"
 dependencies = [
  "async-trait",
  "axum-core",
@@ -154,9 +204,9 @@ dependencies = [
 
 [[package]]
 name = "axum-core"
-version = "0.2.6"
+version = "0.2.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf4d047478b986f14a13edad31a009e2e05cb241f9805d0d75e4cba4e129ad4d"
+checksum = "e4f44a0e6200e9d11a1cdc989e4b358f6e3d354fbf48478f345a17f4e43f8635"
 dependencies = [
  "async-trait",
  "bytes",
@@ -167,18 +217,64 @@ dependencies = [
 ]
 
 [[package]]
+name = "axum-extra"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69034b3b0fd97923eee2ce8a47540edb21e07f48f87f67d44bb4271cec622bdb"
+dependencies = [
+ "axum",
+ "bytes",
+ "cookie",
+ "futures-util",
+ "http",
+ "mime",
+ "pin-project-lite",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
 name = "base64"
 version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
 
 [[package]]
+name = "base64ct"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea2b2456fd614d856680dcd9fcc660a51a820fa09daef2e49772b56a193c8474"
+
+[[package]]
+name = "base64urlsafedata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d02340c3f25c8422ba85d481123406dd7506505485bac1c694b26eb538da8daf"
+dependencies = [
+ "base64",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
 name = "bitflags"
 version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
 [[package]]
+name = "blake2"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cf849ee05b2ee5fba5e36f97ff8ec2533916700fc0758d40d92136a42f3388"
+dependencies = [
+ "digest 0.10.3",
+]
+
+[[package]]
 name = "block-buffer"
 version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -263,11 +359,50 @@ dependencies = [
  "num-integer",
  "num-traits",
  "serde",
- "time",
+ "time 0.1.44",
  "winapi",
 ]
 
 [[package]]
+name = "clap"
+version = "3.2.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750"
+dependencies = [
+ "atty",
+ "bitflags",
+ "clap_derive",
+ "clap_lex",
+ "indexmap",
+ "once_cell",
+ "strsim",
+ "termcolor",
+ "textwrap",
+]
+
+[[package]]
+name = "clap_derive"
+version = "3.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65"
+dependencies = [
+ "heck",
+ "proc-macro-error",
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
+dependencies = [
+ "os_str_bytes",
+]
+
+[[package]]
 name = "cloudabi"
 version = "0.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -302,6 +437,22 @@ dependencies = [
 ]
 
 [[package]]
+name = "compact_jwt"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9417bb4f581b7a5e08fabb4398b910064363bbfd7b75a10d1da3bfff3ef9b36"
+dependencies = [
+ "base64",
+ "base64urlsafedata",
+ "openssl",
+ "serde",
+ "serde_json",
+ "tracing",
+ "url",
+ "uuid 1.1.2",
+]
+
+[[package]]
 name = "concurrent-queue"
 version = "1.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -317,6 +468,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
 
 [[package]]
+name = "cookie"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05"
+dependencies = [
+ "percent-encoding",
+ "time 0.3.14",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
+
+[[package]]
 name = "cpufeatures"
 version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -397,6 +575,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "eaa37046cc0f6c3cc6090fbdbf73ef0b8ef4cfcc37f6befc0020f63e8cf121e1"
 
 [[package]]
+name = "der-parser"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe398ac75057914d7d07307bf67dc7f3f574a26783b4fc7805a20ffa9f506e82"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
 name = "derive_more"
 version = "0.99.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -438,6 +630,18 @@ checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
 dependencies = [
  "block-buffer 0.10.2",
  "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bf95dc3f046b9da4f2d51833c0d3547d8564ef6910f5c1ed130306a75b92886"
+dependencies = [
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
 ]
 
 [[package]]
@@ -535,6 +739,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
 
 [[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
 name = "form_urlencoded"
 version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -731,6 +950,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "half"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7"
+
+[[package]]
 name = "hashbrown"
 version = "0.11.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -762,6 +987,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "heck"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
+
+[[package]]
 name = "hermit-abi"
 version = "0.1.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -903,6 +1134,19 @@ dependencies = [
 ]
 
 [[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
 name = "idna"
 version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -970,10 +1214,13 @@ name = "kittybox"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "argon2",
  "async-trait",
  "axum",
+ "axum-extra",
  "bytes",
  "chrono",
+ "clap",
  "data-encoding",
  "easy-scraper",
  "either",
@@ -1002,6 +1249,7 @@ dependencies = [
  "serde_variant",
  "sha2",
  "tempdir",
+ "thiserror",
  "tokio",
  "tokio-stream",
  "tokio-util 0.7.3",
@@ -1012,6 +1260,7 @@ dependencies = [
  "tracing-test",
  "tracing-tree",
  "url",
+ "webauthn-rs",
  "wiremock",
 ]
 
@@ -1052,8 +1301,10 @@ version = "0.1.0"
 dependencies = [
  "axum-core",
  "http",
+ "rand 0.8.5",
  "serde",
  "serde_json",
+ "tokio",
 ]
 
 [[package]]
@@ -1087,7 +1338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c02b14f35d9f5f082fd0b1b34aa0ef32e3354c859c721d7f3325b3f79a42ba54"
 dependencies = [
  "libc",
- "uuid",
+ "uuid 0.8.2",
  "winapi",
 ]
 
@@ -1229,6 +1480,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
 
 [[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
 name = "miniz_oxide"
 version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1286,6 +1543,24 @@ dependencies = [
 ]
 
 [[package]]
+name = "native-tls"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd7e2f3618557f980e0b17e8856252eee3c97fa12c54dff0ca290fb6266ca4a9"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
 name = "new_debug_unreachable"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1304,6 +1579,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
 
 [[package]]
+name = "nom"
+version = "7.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f"
+dependencies = [
+ "autocfg 1.1.0",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
 name = "num-integer"
 version = "0.1.45"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1333,10 +1629,28 @@ dependencies = [
 ]
 
 [[package]]
+name = "num_threads"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "oid-registry"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e20717fa0541f39bd146692035c37bedfa532b3e5071b35761082407546b2a"
+dependencies = [
+ "asn1-rs",
+]
+
+[[package]]
 name = "once_cell"
-version = "1.10.0"
+version = "1.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9"
+checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0"
 
 [[package]]
 name = "opaque-debug"
@@ -1345,6 +1659,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
 
 [[package]]
+name = "openssl"
+version = "0.10.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "618febf65336490dfcf20b73f885f5651a0c89c64c2d4a8c3662585a70bf5bd0"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b501e44f11665960c7e7fcf062c7d96a14ade4aa98116c004b2e37b5be7d736c"
+dependencies = [
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-src"
+version = "111.22.0+1.1.1q"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f31f0d509d1c1ae9cada2f9539ff8f37933831fd5098879e482aa687d659853"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5f9bd0c2710541a3cda73d6f9ac4f1b240de4ae261065d309dbe73d9dceb42f"
+dependencies = [
+ "autocfg 1.1.0",
+ "cc",
+ "libc",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "os_str_bytes"
+version = "6.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff"
+
+[[package]]
 name = "parking"
 version = "2.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1399,6 +1774,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "password-hash"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.3",
+ "subtle",
+]
+
+[[package]]
 name = "percent-encoding"
 version = "2.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1554,6 +1940,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d15b6607fa632996eb8a17c9041cb6071cb75ac057abd45dece578723ea8c7c0"
 
 [[package]]
+name = "pkg-config"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
+
+[[package]]
 name = "ppv-lite86"
 version = "0.2.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1566,6 +1958,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
 
 [[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "version_check",
+]
+
+[[package]]
 name = "proc-macro-hack"
 version = "0.5.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1940,11 +2356,13 @@ dependencies = [
  "http-body",
  "hyper",
  "hyper-rustls",
+ "hyper-tls",
  "ipnet",
  "js-sys",
  "lazy_static",
  "log",
  "mime",
+ "native-tls",
  "percent-encoding",
  "pin-project-lite",
  "rustls",
@@ -1953,6 +2371,7 @@ dependencies = [
  "serde_json",
  "serde_urlencoded",
  "tokio",
+ "tokio-native-tls",
  "tokio-rustls",
  "tokio-util 0.6.9",
  "url",
@@ -1994,6 +2413,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
 name = "rustls"
 version = "0.20.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2021,6 +2449,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f"
 
 [[package]]
+name = "schannel"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88d6731146462ea25d9244b2ed5fd1d716d25c52e4d54aa4fb0f3c4e9854dbe2"
+dependencies = [
+ "lazy_static",
+ "windows-sys",
+]
+
+[[package]]
 name = "scopeguard"
 version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2037,6 +2475,29 @@ dependencies = [
 ]
 
 [[package]]
+name = "security-framework"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dc14f172faf8a0194a3aded622712b0de276821addc574fa54fc0a1167e10dc"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
 name = "selectors"
 version = "0.22.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2072,6 +2533,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "serde_cbor"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5"
+dependencies = [
+ "half",
+ "serde",
+]
+
+[[package]]
 name = "serde_derive"
 version = "1.0.137"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2305,6 +2776,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b1884d1bc09741d466d9b14e6d37ac89d6909cbcac41dd9ae982d4d063bbedfc"
 
 [[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "subtle"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
+
+[[package]]
 name = "syn"
 version = "0.15.44"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2333,6 +2816,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "20518fe4a4c9acf048008599e464deb21beeae3d3578418951a189c235a7a9a8"
 
 [[package]]
+name = "synstructure"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
+dependencies = [
+ "proc-macro2 1.0.38",
+ "quote 1.0.18",
+ "syn 1.0.93",
+ "unicode-xid 0.2.3",
+]
+
+[[package]]
 name = "tempdir"
 version = "0.3.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2343,6 +2838,20 @@ dependencies = [
 ]
 
 [[package]]
+name = "tempfile"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "libc",
+ "redox_syscall",
+ "remove_dir_all",
+ "winapi",
+]
+
+[[package]]
 name = "tendril"
 version = "0.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2354,6 +2863,21 @@ dependencies = [
 ]
 
 [[package]]
+name = "termcolor"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "949517c0cf1bf4ee812e2e07e08ab448e3ae0d23472aee8a06c985f0c8815b16"
+
+[[package]]
 name = "thin-slice"
 version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2361,18 +2885,18 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
 
 [[package]]
 name = "thiserror"
-version = "1.0.31"
+version = "1.0.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a"
+checksum = "c53f98874615aea268107765aa1ed8f6116782501d18e53d08b471733bea6c85"
 dependencies = [
  "thiserror-impl",
 ]
 
 [[package]]
 name = "thiserror-impl"
-version = "1.0.31"
+version = "1.0.35"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a"
+checksum = "f8b463991b4eab2d801e724172285ec4195c650e8ec79b149e6c2a8e6dd3f783"
 dependencies = [
  "proc-macro2 1.0.38",
  "quote 1.0.18",
@@ -2400,6 +2924,24 @@ dependencies = [
 ]
 
 [[package]]
+name = "time"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b"
+dependencies = [
+ "itoa 1.0.1",
+ "libc",
+ "num_threads",
+ "time-macros",
+]
+
+[[package]]
+name = "time-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792"
+
+[[package]]
 name = "tinyvec"
 version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2447,6 +2989,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "tokio-native-tls"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d995660bd2b7f8c1568414c1126076c13fbb725c40112dc0120b78eb9b717b"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
 name = "tokio-rustls"
 version = "0.23.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2733,12 +3285,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
 
 [[package]]
+name = "uuid"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd6469f4314d5f1ffec476e05f17cc9a78bc7a27a6a857842170bdf8d6f98d2f"
+dependencies = [
+ "getrandom 0.2.6",
+ "serde",
+]
+
+[[package]]
 name = "valuable"
 version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
 
 [[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
 name = "version_check"
 version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2855,6 +3423,56 @@ dependencies = [
 ]
 
 [[package]]
+name = "webauthn-rs"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b813b9663ddc0b5594b5c54dec399eba428c199a8bb75ed6fde757ec2deca82"
+dependencies = [
+ "base64urlsafedata",
+ "serde",
+ "tracing",
+ "url",
+ "uuid 1.1.2",
+ "webauthn-rs-core",
+]
+
+[[package]]
+name = "webauthn-rs-core"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b68452d453abbd5bb7101fa5c97698940dbdea5cdc7f49a4bea1d546f3dc0f46"
+dependencies = [
+ "base64",
+ "base64urlsafedata",
+ "compact_jwt",
+ "der-parser",
+ "nom",
+ "openssl",
+ "rand 0.8.5",
+ "serde",
+ "serde_cbor",
+ "serde_json",
+ "thiserror",
+ "tracing",
+ "url",
+ "uuid 1.1.2",
+ "webauthn-rs-proto",
+ "x509-parser",
+]
+
+[[package]]
+name = "webauthn-rs-proto"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00f44e65fa62541ef22540ab8050866604b1a6a9e73a80c8e85adc1036afb7a2"
+dependencies = [
+ "base64urlsafedata",
+ "serde",
+ "serde_json",
+ "url",
+]
+
+[[package]]
 name = "webpki"
 version = "0.22.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2890,6 +3508,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
 
 [[package]]
+name = "winapi-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
 name = "winapi-x86_64-pc-windows-gnu"
 version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2968,3 +3595,21 @@ dependencies = [
  "serde_json",
  "tokio",
 ]
+
+[[package]]
+name = "x509-parser"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9bace5b5589ffead1afb76e43e34cff39cd0f3ce7e170ae0c29e53b88eb1c"
+dependencies = [
+ "asn1-rs",
+ "base64",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "rusticata-macros",
+ "thiserror",
+ "time 0.3.14",
+]
diff --git a/kittybox-rs/Cargo.toml b/kittybox-rs/Cargo.toml
index b82b2a9..c9b98a2 100644
--- a/kittybox-rs/Cargo.toml
+++ b/kittybox-rs/Cargo.toml
@@ -7,9 +7,12 @@ default-run = "kittybox"
 autobins = false
 
 [features]
-default = []
+default = ["openssl"]
 #util = ["anyhow"]
 #migration = ["util"]
+openssl = ["reqwest/native-tls-vendored", "reqwest/native-tls-alpn"]
+rustls = ["reqwest/rustls-tls-webpki-roots"]
+cli = ["clap"]
 
 [[bin]]
 name = "kittybox"
@@ -26,12 +29,18 @@ required-features = []
 #path = "src/bin/kittybox_database_converter.rs"
 #required-features = ["migration", "redis"]
 
+[[bin]]
+name = "kittybox-indieauth-helper"
+path = "src/bin/kittybox-indieauth-helper.rs"
+required-features = ["cli"]
+
 [workspace]
 members = [".", "./util", "./templates", "./indieauth"]
 default-members = [".", "./util", "./templates", "./indieauth"]
 [dependencies.kittybox-util]
 version = "0.1.0"
 path = "./util"
+features = ["fs"]
 [dependencies.kittybox-templates]
 version = "0.1.0"
 path = "./templates"
@@ -51,6 +60,7 @@ rand = "^0.8.5"              # Utilities for random number generation
 tracing-test = "^0.2.2"
 
 [dependencies]
+argon2 = { version = "^0.4.1", features = ["std"] }
 async-trait = "^0.1.50"      # Type erasure for async trait methods
 bytes = "^1.1.0"
 data-encoding = "^2.3.2"     # Efficient and customizable data-encoding functions like base64, base32, and hex
@@ -75,6 +85,7 @@ tracing-tree = "0.2.1"
 tracing-subscriber = { version = "0.3.11", features = ["env-filter", "json"] }
 tower-http = { version = "0.3.3", features = ["trace", "cors", "catch-panic"] }
 tower = { version = "0.4.12", features = ["tracing"] }
+webauthn = { version = "0.4.5", package = "webauthn-rs", features = ["danger-allow-state-serialisation"] }
 [dependencies.tokio]
 version = "^1.16.1"
 features = ["full", "tracing"] # TODO determine if my app doesn't need some features
@@ -92,6 +103,9 @@ optional = true
 [dependencies.axum]
 version = "^0.5.11"
 features = ["multipart", "json", "headers", "form"]
+[dependencies.axum-extra]
+version = "^0.3.7"
+features = ["cookie"]
 [dependencies.chrono]        # Date and time library for Rust
 version = "^0.4.19"
 features = ["serde"]
@@ -114,7 +128,14 @@ features = ["stream", "runtime"]
 [dependencies.reqwest]
 version = "^0.11.10"
 default-features = false
-features = ["rustls-tls-webpki-roots", "gzip", "brotli", "json", "stream"]
+features = ["gzip", "brotli", "json", "stream"]
 [dependencies.microformats]
 version = "^0.2.0"
 #git = "https://gitlab.com/maxburon/microformats-parser"
+
+[dependencies.clap]
+version = "3.2.22"
+features = ["derive"]
+optional = true
+[dependencies.thiserror]
+version = "1.0.35"
diff --git a/kittybox-rs/dev.sh b/kittybox-rs/dev.sh
index 2b19161..e6a020c 100755
--- a/kittybox-rs/dev.sh
+++ b/kittybox-rs/dev.sh
@@ -2,8 +2,7 @@
 export RUST_LOG="kittybox=debug,retainer::cache=warn,h2=info,rustls=info,tokio=info,tower_http::trace=debug"
 export BACKEND_URI=file://./test-dir
 export BLOBSTORE_URI=file://./media-store
-export TOKEN_ENDPOINT=https://tokens.indieauth.com/token
-export AUTHORIZATION_ENDPOINT=https://indieauth.com/auth
+export AUTH_STORE_URI=file://./auth-store
 export COOKIE_SECRET=1234567890abcdefghijklmnopqrstuvwxyz
 #export COOKIE_SECRET_FILE=/dev/null
 if [[ "$1" == "watch" ]]; then
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)
     }
 }
 
diff --git a/kittybox-rs/templates/src/indieauth.rs b/kittybox-rs/templates/src/indieauth.rs
index 99e94c7..23f64e9 100644
--- a/kittybox-rs/templates/src/indieauth.rs
+++ b/kittybox-rs/templates/src/indieauth.rs
@@ -1,7 +1,175 @@
-use kittybox_indieauth::AuthorizationRequest;
+use kittybox_indieauth::{AuthorizationRequest, Scope};
+use kittybox_util::auth::EnrolledCredential;
 
 markup::define! {
-    AuthorizationRequestPage() {
-        
+    AuthorizationRequestPage(
+        request: AuthorizationRequest,
+        credentials: Vec<EnrolledCredential>,
+        app: serde_json::Value,
+        user: serde_json::Value
+    ) {
+        script[src="/.kittybox/static/indieauth.js", type="module"] {}
+        main {
+            form #indieauth_page[action="/.kittybox/indieauth/auth/confirm", method="POST"] {
+                noscript {
+                    p {"I know how annoyed you can be about scripts." }
+                    p { "But WebAuthn doesn't work without JavaScript. And passwords are horribly insecure, and everyone knows it deep inside their heart." }
+                    p { b { "Please enable JavaScript for this page to work properly 😭" } }
+                }
+                div #introduction {
+                    h1."mini-h-card" {
+                        "Hi, "
+                            @if let Some(photo) = user["properties"]["photo"][0].as_str() {
+                                img.user_avatar[src=photo];
+                            }
+                        @user["properties"]["name"][0].as_str().unwrap_or("administrator")
+                    }
+
+                    p."mini-h-card" {
+                        @if let Some(icon) = app["properties"]["logo"][0].as_str() {
+                            img.app_icon[src=icon];
+                        }
+                        span {
+                            a[href=app["properties"]["url"][0].as_str().unwrap()] {
+                                @app["properties"]["name"][0].as_str().unwrap()
+                            }
+                            " wants to confirm your identity."
+                        }
+                    }
+                }
+
+                @if request.scope.is_some() {
+                    p {
+                        "An application just requested access to your website. This can give access to your data, including private content."
+                    }
+
+                    p {
+                        "You can review the permissions the application requested below. You are free to not grant any permissions that the application requested if you don't trust it, at the cost of potentially reducing its functionality."
+                    }
+                }
+
+                fieldset #scopes {
+                    legend { "Permissions to grant the app:" }
+                    div {
+                        input[type="checkbox", disabled="true", checked="true"];
+                        label[for="identify"] {
+                            "Identify you as the owner of "
+                                @user["properties"]["uid"][0].as_str().unwrap()
+                        }
+                    }
+                    @if let Some(scopes) = &request.scope {
+                        @for scope in scopes.iter() {
+                            div {
+                                input[type="checkbox", name="scope", id=scope.as_ref(), value=scope.as_ref()];
+                                label[for=scope.as_ref()] {
+                                    @match scope {
+                                        Scope::Profile => {
+                                            "Access your publicly visible profile information"
+                                        }
+                                        Scope::Email => {
+                                            "Access your email address"
+                                        }
+                                        Scope::Create => {
+                                            "Create new content on your website"
+                                        }
+                                        Scope::Update => {
+                                            "Modify content on your website"
+                                        }
+                                        Scope::Delete => {
+                                            "Delete content on your website"
+                                        }
+                                        Scope::Media => {
+                                            "Interact with your media storage"
+                                        }
+                                        other => {
+                                            @markup::raw(format!(
+                                                "(custom or unknown scope) <code>{}</code>",
+                                                other.as_ref()
+                                            ))
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+
+                fieldset {
+                    legend { "Choose your preferred authentication method:" }
+                    div {
+                        input[type="radio",
+                              name="auth_method",
+                              id="auth_with_webauthn",
+                              disabled=!credentials.iter().any(|e| *e == EnrolledCredential::WebAuthn),
+                              checked=credentials.iter().any(|e| *e == EnrolledCredential::WebAuthn)
+                        ];
+                        label[for="auth_with_webauthn"] { "Use an authenticator device to log in" }
+                    }
+                    div {
+                        input[type="radio",
+                              name="auth_method", value="password",
+                              id="auth_with_password",
+                              disabled=!credentials.iter().any(|e| *e == EnrolledCredential::Password),
+                              checked=credentials.iter().all(|e| *e == EnrolledCredential::Password)
+                        ];
+                        label[for="auth_with_password"] { "Password" }
+                        br;
+                        input[type="password", name="user_password", id="user_password"];
+                    }
+                }
+                
+                input[type="submit", value="Authenticate"];
+                br;
+
+                details {
+                    summary { "View detailed data about this request" }
+
+                    p {
+                        "More info about meanings of these fields can be found in "
+                            a[href="https://indieauth.spec.indieweb.org/20220212/#authorization-request"] {
+                                "the IndieAuth specification"
+                            } ", which this webpage uses."
+                    }
+                    fieldset {
+                        div {
+                            label[for="response_type"] { "Response type (will most likely be \"code\")" }
+                            br;
+                            input[name="response_type", id="response_type", readonly,
+                                  value=request.response_type.as_str()];
+                        }
+                        div {
+                            label[for="state"] { "Request state" }
+                            br;
+                            input[name="state", id="state", readonly,
+                                  value=request.state.as_ref()];
+                        }
+                        div {
+                            label[for="client_id"] { "Client ID" }
+                            br;
+                            input[name="client_id", id="client_id", readonly,
+                                  value=request.client_id.as_str()];
+                        }
+                        div {
+                            label[for="redirect_uri"] { "Redirect URI" }
+                            br;
+                            input[name="redirect_uri", id="redirect_uri", readonly,
+                                  value=request.redirect_uri.as_str()];
+                        }
+                        div {
+                            label[for="code_challenge"] { "PKCE code challenge" }
+                            br;
+                            input[name="code_challenge", id="code_challenge", readonly,
+                                  value=request.code_challenge.as_str()];
+                        }
+                        div {
+                            label[for="code_challenge_method"] { "PKCE method (should be S256)" }
+                            br;
+                            input[name="code_challenge_method", id="code_challenge_method", readonly,
+                                  value=request.code_challenge.method().as_str()];
+                        }
+                    }
+                }
+            }
+        }
     }
 }
diff --git a/kittybox-rs/templates/src/templates.rs b/kittybox-rs/templates/src/templates.rs
index 60da6af..60daa55 100644
--- a/kittybox-rs/templates/src/templates.rs
+++ b/kittybox-rs/templates/src/templates.rs
@@ -16,7 +16,9 @@ markup::define! {
                 link[rel="micropub", href="/.kittybox/micropub"];
                 link[rel="micropub_media", href="/.kittybox/media"];
                 link[rel="indieauth_metadata", href="/.kittybox/indieauth/metadata"];
-
+                // legacy links for some dumb clients
+                link[rel="authorization_endpoint", href="/.kittybox/indieauth/auth"];
+                link[rel="token_endpoint", href="/.kittybox/indieauth/token"];
                 /*@if let Some(endpoints) = endpoints {
                     @if let Some(webmention) = &endpoints.webmention {
                         link[rel="webmention", href=&webmention];
diff --git a/kittybox-rs/util/Cargo.toml b/kittybox-rs/util/Cargo.toml
index cdad17f..f36b6d8 100644
--- a/kittybox-rs/util/Cargo.toml
+++ b/kittybox-rs/util/Cargo.toml
@@ -5,16 +5,18 @@ edition = "2021"
 
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
-[dependencies]
-[dependencies.serde]         # A generic serialization/deserialization framework
-version = "^1.0.125"
-features = ["derive"]
-
-[dependencies.serde_json]
-version = "^1.0.64"
+[features]
+fs = ["rand", "tokio", "tokio/fs"]
 
-[dependencies.axum-core]
-version = "^0.2.6"
-
-[dependencies.http]
-version = "^0.2.7"
\ No newline at end of file
+[dependencies]
+serde = { version = "^1.0.125", features = ["derive"] }
+serde_json = "^1.0.64"
+axum-core = "^0.2.6"
+http = "^0.2.7"
+[dependencies.rand]
+version = "^0.8.5"
+optional = true
+[dependencies.tokio]
+version = "^1.16.1"
+features = ["tracing"]
+optional = true
diff --git a/kittybox-rs/util/src/error.rs b/kittybox-rs/util/src/error.rs
index 79f43ef..7edf176 100644
--- a/kittybox-rs/util/src/error.rs
+++ b/kittybox-rs/util/src/error.rs
@@ -4,6 +4,7 @@ use axum_core::response::{Response, IntoResponse};
 
 #[derive(Serialize, Deserialize, PartialEq, Debug)]
 #[serde(rename_all = "snake_case")]
+/// Kinds of errors that can happen within a Micropub operation.
 pub enum ErrorType {
     /// An erroneous attempt to create something that already exists.
     AlreadyExists,
@@ -27,6 +28,7 @@ pub enum ErrorType {
 #[derive(Serialize, Deserialize, Debug)]
 pub struct MicropubError {
     pub error: ErrorType,
+    // TODO use Cow<'static, str> to save on heap allocations
     pub error_description: String,
 }
 
diff --git a/kittybox-rs/util/src/lib.rs b/kittybox-rs/util/src/lib.rs
index debe589..f30dc2d 100644
--- a/kittybox-rs/util/src/lib.rs
+++ b/kittybox-rs/util/src/lib.rs
@@ -1,3 +1,9 @@
+#![warn(missing_docs)]
+//! Small things that couldn't fit elsewhere in Kittybox, yet may be
+//! useful on their own or in multiple Kittybox crates.
+//!
+//! Some things are gated behind features, namely:
+//!  - `fs` - enables use of filesystem-related utilities
 use serde::{Deserialize, Serialize};
 
 #[derive(Clone, Serialize, Deserialize)]
@@ -17,5 +23,81 @@ pub struct MicropubChannel {
     pub name: String,
 }
 
+/// Common errors from the IndieWeb protocols that can be reused between modules.
 pub mod error;
 pub use error::{ErrorType, MicropubError};
+
+/// Common data-types useful in creating smart authentication systems.
+pub mod auth {
+    #[derive(PartialEq, Eq, Hash, Clone, Copy)]
+    pub enum EnrolledCredential {
+        /// An indicator that a password is enrolled. Passwords can be
+        /// used to recover from a lost token.
+        Password,
+        /// An indicator that one or more WebAuthn credentials were
+        /// enrolled.
+        WebAuthn
+    }
+}
+
+#[cfg(feature = "fs")]
+/// Commonly-used operations with the file system in Kittybox's
+/// underlying storage mechanisms.
+pub mod fs {
+    use std::io::{self, Result};
+    use std::path::{Path, PathBuf};
+    use rand::{Rng, distributions::Alphanumeric};
+    use tokio::fs;
+
+    /// Create a temporary file named `temp.[a-zA-Z0-9]{length}` in
+    /// the given location and immediately open it. Returns the
+    /// filename and the corresponding file handle. It is the caller's
+    /// responsibility to clean up the temporary file when it is no
+    /// longer needed.
+    ///
+    /// Uses [`OpenOptions::create_new`][fs::OpenOptions::create_new]
+    /// to detect filename collisions, in which case it will
+    /// automatically retry until the operation succeeds.
+    ///
+    /// # Errors
+    ///
+    /// Returns the underlying [`io::Error`] if the operation fails
+    /// due to reasons other than filename collision.
+    pub async fn mktemp<T, B>(dir: T, basename: B, length: usize) -> Result<(PathBuf, fs::File)>
+    where
+        T: AsRef<Path>,
+        B: Into<Option<&'static str>>
+    {
+        let dir = dir.as_ref();
+        let basename = basename.into().unwrap_or("");
+        fs::create_dir_all(dir).await?;
+
+        loop {
+            let filename = dir.join(format!(
+                "{}{}{}",
+                basename,
+                if basename.is_empty() { "" } else { "." },
+                {
+                    let string = rand::thread_rng()
+                        .sample_iter(&Alphanumeric)
+                        .take(length)
+                        .collect::<Vec<u8>>();
+                    String::from_utf8(string).unwrap()
+                }
+            ));
+
+            match fs::OpenOptions::new()
+                .create_new(true)
+                .write(true)
+                .open(&filename)
+                .await
+            {
+                Ok(file) => return Ok((filename, file)),
+                Err(err) => match err.kind() {
+                    io::ErrorKind::AlreadyExists => continue,
+                    _ => return Err(err)
+                }
+            }
+        }
+    }
+}