summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpennae <github@quasiparticle.net>2022-07-13 10:33:30 +0200
committerpennae <github@quasiparticle.net>2022-07-13 13:27:12 +0200
commit2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 (patch)
treecaff55807c5fc773a36aa773cfde9cd6ebbbb6c8
downloadminor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.tar.gz
minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.tar.xz
minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.zip
initial import
-rw-r--r--.gitignore7
-rw-r--r--Cargo.lock3269
-rw-r--r--Cargo.toml41
-rw-r--r--README.md26
-rw-r--r--Raven-Silhouette.svg140
-rw-r--r--Rocket.toml116
-rw-r--r--default.nix54
-rw-r--r--flake.lock27
-rw-r--r--flake.nix32
-rw-r--r--migrations/20220626163140_init.down.sql1
-rw-r--r--migrations/20220626163140_init.up.sql291
-rw-r--r--pytest.ini3
-rw-r--r--rustfmt.toml8
-rw-r--r--sqlx-data.json1801
-rw-r--r--src/api/auth/account.rs413
-rw-r--r--src/api/auth/device.rs455
-rw-r--r--src/api/auth/email.rs126
-rw-r--r--src/api/auth/invite.rs47
-rw-r--r--src/api/auth/mod.rs238
-rw-r--r--src/api/auth/oauth.rs433
-rw-r--r--src/api/auth/password.rs260
-rw-r--r--src/api/auth/session.rs107
-rw-r--r--src/api/mod.rs32
-rw-r--r--src/api/oauth.rs163
-rw-r--r--src/api/profile/mod.rs324
-rw-r--r--src/auth.rs241
-rw-r--r--src/bin/minorskulk.rs9
-rw-r--r--src/cache.rs42
-rw-r--r--src/crypto.rs408
-rw-r--r--src/db/mod.rs1026
-rw-r--r--src/js.rs53
-rw-r--r--src/lib.rs319
-rw-r--r--src/mailer.rs105
-rw-r--r--src/push.rs198
-rw-r--r--src/types.rs436
-rw-r--r--src/types/oauth.rs267
-rw-r--r--src/utils.rs124
-rw-r--r--tests/_utils.py421
-rw-r--r--tests/api.py252
-rw-r--r--tests/conftest.py115
-rw-r--r--tests/integration.rs73
-rw-r--r--tests/smtp.py27
-rw-r--r--tests/test_auth_account.py348
-rw-r--r--tests/test_auth_device.py434
-rw-r--r--tests/test_auth_email.py96
-rw-r--r--tests/test_auth_oauth.py369
-rw-r--r--tests/test_auth_password.py211
-rw-r--r--tests/test_auth_session.py69
-rw-r--r--tests/test_oauth.py97
-rw-r--r--tests/test_profile.py134
-rw-r--r--tests/test_push.py147
-rw-r--r--web/index.html392
-rw-r--r--web/js/browser/browser.js4
-rw-r--r--web/js/browser/lib/client.js792
-rw-r--r--web/js/browser/lib/crypto.js163
-rw-r--r--web/js/browser/lib/hawk.d.ts24
-rw-r--r--web/js/browser/lib/hawk.js145
-rw-r--r--web/js/browser/lib/recoveryKey.js38
-rw-r--r--web/js/browser/lib/utils.js26
-rw-r--r--web/js/crypto.js137
-rw-r--r--web/js/crypto.test.js49
-rw-r--r--web/js/main.js761
62 files changed, 16966 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..72cb6fa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/target
+**/.*.~undo-tree~
+**/.#*
+.env
+**/__pycache__
+/test.log
+/result
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..7936ce1
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,3269 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "aead"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8"
+dependencies = [
+ "cfg-if",
+ "cipher 0.3.0",
+ "cpufeatures",
+ "opaque-debug",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher 0.3.0",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "ahash"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
+dependencies = [
+ "getrandom",
+ "once_cell",
+ "version_check",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "ansi_term"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704"
+
+[[package]]
+name = "async-channel"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319"
+dependencies = [
+ "concurrent-queue",
+ "event-listener",
+ "futures-core",
+]
+
+[[package]]
+name = "async-stream"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atoi"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b88d82667eca772c4aa12f0f1348b3ae643424c8876448f3f7bd5787032e234c"
+dependencies = [
+ "autocfg 1.1.0",
+]
+
+[[package]]
+name = "atty"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "autocfg"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dde43e75fd43e8a1bf86103336bc699aa8d17ad1be60c76c0bdfd4828e19b78"
+dependencies = [
+ "autocfg 1.1.0",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "base16ct"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce"
+
+[[package]]
+name = "base64"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+
+[[package]]
+name = "base64ct"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6b4d9b1225d28d360ec6a231d65af1fd99a2a095154c8040689617290569c5c"
+
+[[package]]
+name = "binascii"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "block-buffer"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3"
+
+[[package]]
+name = "byteorder"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
+
+[[package]]
+name = "bytes"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
+
+[[package]]
+name = "cache-padded"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c"
+
+[[package]]
+name = "castaway"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
+
+[[package]]
+name = "cc"
+version = "1.0.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
+dependencies = [
+ "libc",
+ "num-integer",
+ "num-traits",
+ "time 0.1.44",
+ "winapi",
+]
+
+[[package]]
+name = "cipher"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
+[[package]]
+name = "coarsetime"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "454038500439e141804c655b4cd1bc6a70bcb95cd2bc9463af5661b6956f0e46"
+dependencies = [
+ "libc",
+ "once_cell",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3"
+dependencies = [
+ "cache-padded",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b"
+
+[[package]]
+name = "const-oid"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4c78c047431fee22c1a7bb92e00ad095a02a983affe4d8a72e2a2c62c1b94f3"
+
+[[package]]
+name = "cookie"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94d4706de1b0fa5b132270cddffa8585166037822e260a944fe161acd137ca05"
+dependencies = [
+ "aes-gcm",
+ "base64",
+ "hkdf",
+ "hmac 0.12.1",
+ "percent-encoding",
+ "rand",
+ "sha2 0.10.2",
+ "subtle",
+ "time 0.3.11",
+ "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"
+checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f83bd3bb4314701c568e340cd8cf78c975aa0ca79e03d3f6d1677d5b0c9c0c03"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c6a1d5fa1de37e071642dfa44ec552ca5b299adb128fab16138e24b548fd21"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ccfd8c0ee4cce11e45b3fd6f9d5e69e0cc62912aa6a0cb1bf4617b0eba5a12f"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-mac"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714"
+dependencies = [
+ "generic-array",
+ "subtle",
+]
+
+[[package]]
+name = "ct-codecs"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3b7eb4404b8195a9abb6356f4ac07d8ba267045c8d6d220ac4dc992e6cc75df"
+
+[[package]]
+name = "ctr"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea"
+dependencies = [
+ "cipher 0.3.0",
+]
+
+[[package]]
+name = "curl"
+version = "0.4.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37d855aeef205b43f65a5001e0997d81f8efca7badad4fad7d897aa7f0d0651f"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2",
+ "winapi",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.55+curl-7.83.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23734ec77368ec583c2e61dd3f0b0e5c98b93abe6d2a004ca06b91dd7e3e2762"
+dependencies = [
+ "cc",
+ "libc",
+ "libnghttp2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "winapi",
+]
+
+[[package]]
+name = "der"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4"
+dependencies = [
+ "const-oid 0.6.2",
+ "crypto-bigint 0.2.11",
+ "der_derive",
+]
+
+[[package]]
+name = "der"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6919815d73839e7ad218de758883aae3a257ba6759ce7a9992501efbb53d705c"
+dependencies = [
+ "const-oid 0.7.1",
+ "pem-rfc7468 0.3.1",
+]
+
+[[package]]
+name = "der_derive"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "devise"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50c7580b072f1c8476148f16e0a0d5dedddab787da98d86c5082c5e9ed8ab595"
+dependencies = [
+ "devise_codegen",
+ "devise_core",
+]
+
+[[package]]
+name = "devise_codegen"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "123c73e7a6e51b05c75fe1a1b2f4e241399ea5740ed810b0e3e6cacd9db5e7b2"
+dependencies = [
+ "devise_core",
+ "quote",
+]
+
+[[package]]
+name = "devise_core"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841ef46f4787d9097405cac4e70fb8644fc037b526e8c14054247c0263c400d0"
+dependencies = [
+ "bitflags",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
+dependencies = [
+ "block-buffer 0.10.2",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "dirs"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
+[[package]]
+name = "dotenv"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
+
+[[package]]
+name = "ecdsa"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0d69ae62e0ce582d56380743515fefaf1a8c70cec685d9677636d7e30ae9dc9"
+dependencies = [
+ "der 0.5.1",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+]
+
+[[package]]
+name = "ece"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dd5463ffecc0677adcd786c4481f73b215714d4757edf2eb37a573c03d00459"
+dependencies = [
+ "base64",
+ "byteorder",
+ "hex",
+ "hkdf",
+ "lazy_static",
+ "once_cell",
+ "openssl",
+ "serde",
+ "sha2 0.10.2",
+ "thiserror",
+]
+
+[[package]]
+name = "ed25519-compact"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24e1f30f0312ac83726c1197abeacd91c9557f8a623e904a009ae6bc529ae8d8"
+dependencies = [
+ "ct-codecs",
+ "getrandom",
+]
+
+[[package]]
+name = "either"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25b477563c2bfed38a3b7a60964c49e058b2510ad3f12ba3483fd8f62c2306d6"
+dependencies = [
+ "base16ct",
+ "crypto-bigint 0.3.2",
+ "der 0.5.1",
+ "ff",
+ "generic-array",
+ "group",
+ "pem-rfc7468 0.3.1",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "email-encoding"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34dd14c63662e0206599796cd5e1ad0268ab2b9d19b868d6050d688eba2bbf98"
+dependencies = [
+ "base64",
+ "memchr",
+]
+
+[[package]]
+name = "email_address"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8684b7c9cb4857dfa1e5b9629ef584ba618c9b93bae60f58cb23f4f271d0468e"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9852635589dc9f9ea1b6fe9f05b50ef208c85c834a562f0c6abb1c475736ec2b"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "event-listener"
+version = "2.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77f3309417938f28bf8228fcff79a4a37103981e3e186d2ccd19c74b38f4eb71"
+
+[[package]]
+name = "fastrand"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf"
+dependencies = [
+ "instant",
+]
+
+[[package]]
+name = "ff"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "131655483be284720a17d74ff97592b8e76576dc25563148601df2d7c9080924"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "figment"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790b4292c72618abbab50f787a477014fe15634f96291de45672ce46afe122df"
+dependencies = [
+ "atomic",
+ "pear",
+ "serde",
+ "toml",
+ "uncased",
+ "version_check",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+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"
+checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
+dependencies = [
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62007592ac46aa7c2b6416f7deb9a8a8f63a01e0f1d6e1787d5630170db2b63e"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot 0.11.2",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
+
+[[package]]
+name = "futures-lite"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "memchr",
+ "parking",
+ "pin-project-lite",
+ "waker-fn",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21163e139fa306126e6eedaf49ecdb4588f939600f0b1e770f4205ee4b7fa868"
+
+[[package]]
+name = "futures-task"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c66a976bf5909d801bbef33416c41372779507e7a6b3a5e25e4749c58f776a"
+
+[[package]]
+name = "futures-util"
+version = "0.3.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generator"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d9279ca822891c1a4dae06d185612cf8fc6acfe5dff37781b41297811b12ee"
+dependencies = [
+ "cc",
+ "libc",
+ "log",
+ "rustversion",
+ "winapi",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "ghash"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
+
+[[package]]
+name = "group"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc5ac374b108929de78460075f3dc439fa66df9d8fc77e8f12caa5165fcf0c89"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "h2"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37a82c6d637fc9515a4694bbf1cb2457b79d81ce52b3108bdeea58b07dd34a57"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util 0.7.3",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "607c8a29735385251a339424dd462993c0fed8fa09d378f259377df08c126022"
+dependencies = [
+ "ahash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "hawk"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f42afdd0e58859aa7b944db9125bdddb5437233161726c20578fbb73c776f440"
+dependencies = [
+ "anyhow",
+ "base64",
+ "log",
+ "once_cell",
+ "ring",
+ "thiserror",
+ "url",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hex-literal"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0"
+
+[[package]]
+name = "hkdf"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791a029f6b9fc27657f6f188ec6e5e43f6911f6f878e0dc5501396e09809d437"
+dependencies = [
+ "hmac 0.12.1",
+]
+
+[[package]]
+name = "hmac"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b"
+dependencies = [
+ "crypto-mac",
+ "digest 0.9.0",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.3",
+]
+
+[[package]]
+name = "hmac-sha1-compact"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d103cfecf6edf3f7d1dc7c5ab64e99488c0f8d11786e43b40873e66e8489d014"
+
+[[package]]
+name = "hmac-sha256"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd29dbba58ee5314f3ec570066d78a3f4772bf45b322efcf2ce2a43af69a4d85"
+dependencies = [
+ "digest 0.9.0",
+]
+
+[[package]]
+name = "hmac-sha512"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a928b002dff1780b7fa21056991d395770ab9359154b8c1724c4d0511dad0a65"
+dependencies = [
+ "digest 0.9.0",
+]
+
+[[package]]
+name = "hostname"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
+dependencies = [
+ "libc",
+ "match_cfg",
+ "winapi",
+]
+
+[[package]]
+name = "http"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
+dependencies = [
+ "bytes",
+ "http",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "496ce29bb5a52785b44e0f7ca2847ae0bb839c9bd28f69acac9b99d461c0c04c"
+
+[[package]]
+name = "httpdate"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421"
+
+[[package]]
+name = "humantime"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
+
+[[package]]
+name = "humantime-serde"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c"
+dependencies = [
+ "humantime",
+ "serde",
+]
+
+[[package]]
+name = "hyper"
+version = "0.14.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02c929dc5c39e335a03c405292728118860721b10190d98c2a0f0efd5baafbac"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "idna"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
+dependencies = [
+ "matches",
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "if_chain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
+
+[[package]]
+name = "indexmap"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
+dependencies = [
+ "autocfg 1.1.0",
+ "hashbrown",
+ "serde",
+]
+
+[[package]]
+name = "inlinable_string"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb"
+
+[[package]]
+name = "inout"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "instant"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "isahc"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
+dependencies = [
+ "async-channel",
+ "castaway",
+ "crossbeam-utils",
+ "curl",
+ "curl-sys",
+ "encoding_rs",
+ "event-listener",
+ "futures-lite",
+ "http",
+ "log",
+ "mime",
+ "once_cell",
+ "polling",
+ "slab",
+ "sluice",
+ "tracing",
+ "tracing-futures",
+ "url",
+ "waker-fn",
+]
+
+[[package]]
+name = "itertools"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d"
+
+[[package]]
+name = "js-sys"
+version = "0.3.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jwt-simple"
+version = "0.10.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ae17b3b351f55edf6c5d2f0776ad8a467677751362207b92f443bf0a348f67"
+dependencies = [
+ "anyhow",
+ "coarsetime",
+ "ct-codecs",
+ "ed25519-compact",
+ "hmac-sha1-compact",
+ "hmac-sha256",
+ "hmac-sha512",
+ "k256",
+ "p256",
+ "rand",
+ "rsa",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "zeroize",
+]
+
+[[package]]
+name = "k256"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19c3a5e0a0b8450278feda242592512e09f61c72e018b8cd5c859482802daf2d"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "sec1",
+ "sha2 0.9.9",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+dependencies = [
+ "spin 0.5.2",
+]
+
+[[package]]
+name = "lettre"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5677c78c7c7ede1dd68e8a7078012bc625449fb304e7b509b917eaaedfe6e849"
+dependencies = [
+ "async-trait",
+ "base64",
+ "email-encoding",
+ "email_address",
+ "fastrand",
+ "futures-io",
+ "futures-util",
+ "hostname",
+ "httpdate",
+ "idna",
+ "mime",
+ "native-tls",
+ "nom",
+ "once_cell",
+ "quoted_printable",
+ "serde",
+ "socket2",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
+
+[[package]]
+name = "libm"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33a33a362ce288760ec6a508b94caaec573ae7d3bbbd91b87aa0bad4456839db"
+
+[[package]]
+name = "libnghttp2-sys"
+version = "0.1.7+1.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ed28aba195b38d5ff02b9170cbff627e336a20925e43b4945390401c5dc93f"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9702761c3935f8cc2f101793272e202c72b99da8f4224a19ddcf1279a6450bbf"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "lock_api"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53"
+dependencies = [
+ "autocfg 1.1.0",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "loom"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5"
+dependencies = [
+ "cfg-if",
+ "generator",
+ "scoped-tls",
+ "serde",
+ "serde_json",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "match_cfg"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
+
+[[package]]
+name = "matchers"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matches"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
+
+[[package]]
+name = "md-5"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "658646b21e0b72f7866c7038ab086d3d5e1cd6271f060fd37defb241949d0582"
+dependencies = [
+ "digest 0.10.3",
+]
+
+[[package]]
+name = "memchr"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+
+[[package]]
+name = "mime"
+version = "0.3.16"
+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 = "minor-skulk"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "chrono",
+ "dotenv",
+ "either",
+ "futures",
+ "hawk",
+ "hex",
+ "hex-literal",
+ "hkdf",
+ "hmac 0.12.1",
+ "humantime-serde",
+ "lazy_static",
+ "lettre",
+ "password-hash",
+ "rand",
+ "rocket",
+ "scrypt",
+ "serde",
+ "serde_json",
+ "sha2 0.10.2",
+ "sqlx",
+ "subtle",
+ "url",
+ "validator",
+ "web-push",
+ "zeroize",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf"
+dependencies = [
+ "libc",
+ "log",
+ "wasi 0.11.0+wasi-snapshot-preview1",
+ "windows-sys",
+]
+
+[[package]]
+name = "multer"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f8f35e687561d5c1667590911e6698a8cb714a134a7505718a182e7bc9d3836"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "log",
+ "memchr",
+ "mime",
+ "spin 0.9.3",
+ "tokio",
+ "tokio-util 0.6.10",
+ "version_check",
+]
+
+[[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 = "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-dig"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4547ee5541c18742396ae2c895d0717d0f886d8823b8399cdaf7b07d63ad0480"
+dependencies = [
+ "autocfg 0.1.8",
+ "byteorder",
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9"
+dependencies = [
+ "autocfg 1.1.0",
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252"
+dependencies = [
+ "autocfg 1.1.0",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd"
+dependencies = [
+ "autocfg 1.1.0",
+ "libm",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a6dbe30758c9f83eb00cbea4ac95966305f5a7772f3f42ebfc7fc7eddbd8e1"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.0"
+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",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[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",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "p256"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19736d80675fbe9fe33426268150b951a3fb8f5cfca2a23a17c85ef3adb24e3b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "sec1",
+ "sha2 0.9.9",
+]
+
+[[package]]
+name = "parking"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
+
+[[package]]
+name = "parking_lot"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99"
+dependencies = [
+ "instant",
+ "lock_api",
+ "parking_lot_core 0.8.5",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+dependencies = [
+ "lock_api",
+ "parking_lot_core 0.9.3",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216"
+dependencies = [
+ "cfg-if",
+ "instant",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "winapi",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-sys",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c520e05135d6e763148b6426a837e239041653ba7becd2e538c076c738025fc"
+
+[[package]]
+name = "pbkdf2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
+dependencies = [
+ "digest 0.10.3",
+]
+
+[[package]]
+name = "pear"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15e44241c5e4c868e3eaa78b7c1848cadd6344ed4f54d029832d32b415a58702"
+dependencies = [
+ "inlinable_string",
+ "pear_codegen",
+ "yansi",
+]
+
+[[package]]
+name = "pear_codegen"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82a5ca643c2303ecb740d506539deba189e16f2754040a42901cd8105d0282d0"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pem"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb"
+dependencies = [
+ "base64",
+ "once_cell",
+ "regex",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84e93a3b1cc0510b03020f33f21e62acdde3dcaef432edc95bea377fbd4c2cd4"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01de5d978f34aa4b2296576379fcc416034702fd94117c56ffd8a1a767cefb30"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
+
+[[package]]
+name = "pin-project"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78203e83c48cffbe01e4a2d35d566ca4de445d79a85372fc64e378bfc812a260"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "710faf75e1b33345361201d36d04e98ac1ed8909151a017ed384700836104c74"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkcs1"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "116bee8279d783c0cf370efa1a94632f2108e5ef0bb32df31f051647810a4e2c"
+dependencies = [
+ "der 0.4.5",
+ "pem-rfc7468 0.2.4",
+ "zeroize",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee3ef9b64d26bad0536099c816c6734379e45bbd5f14798def6809e5cc350447"
+dependencies = [
+ "der 0.4.5",
+ "pem-rfc7468 0.2.4",
+ "pkcs1",
+ "spki 0.4.1",
+ "zeroize",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cabda3fb821068a9a4fab19a683eac3af12edf0f34b94a8be53c4972b8149d0"
+dependencies = [
+ "der 0.5.1",
+ "spki 0.5.4",
+ "zeroize",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
+
+[[package]]
+name = "polling"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685404d509889fade3e86fe3a5803bca2ec09b0c0778d5ada6ec8bf7a8de5259"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "log",
+ "wepoll-ffi",
+ "winapi",
+]
+
+[[package]]
+name = "polyval"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872"
+
+[[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",
+ "quote",
+ "syn",
+ "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",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4bf29726d67464d49fa6224a1d07936a8c08bb3fba727c7493f6cf1616fdaada"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+ "yansi",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "quoted_printable"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fee2dce59f7a43418e3382c766554c614e06a552d53a8f07ef499ea4b332c0f"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom",
+ "redox_syscall",
+ "thiserror",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685d58625b6c2b83e4cc88a27c4bf65adb7b6b16dbdc413e515c9405b47432ab"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a043824e29c94169374ac5183ac0ed43f5724dc4556b19568007486bd840fa1f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "regex"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
+dependencies = [
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.6.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244"
+
+[[package]]
+name = "remove_dir_all"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96ef608575f6392792f9ecf7890c00086591d29a83910939d430753f7c050525"
+dependencies = [
+ "crypto-bigint 0.3.2",
+ "hmac 0.11.0",
+ "zeroize",
+]
+
+[[package]]
+name = "ring"
+version = "0.16.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
+dependencies = [
+ "cc",
+ "libc",
+ "once_cell",
+ "spin 0.5.2",
+ "untrusted",
+ "web-sys",
+ "winapi",
+]
+
+[[package]]
+name = "rocket"
+version = "0.5.0-rc.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98ead083fce4a405feb349cf09abdf64471c6077f14e0ce59364aa90d4b99317"
+dependencies = [
+ "async-stream",
+ "async-trait",
+ "atomic",
+ "atty",
+ "binascii",
+ "bytes",
+ "either",
+ "figment",
+ "futures",
+ "indexmap",
+ "log",
+ "memchr",
+ "multer",
+ "num_cpus",
+ "parking_lot 0.12.1",
+ "pin-project-lite",
+ "rand",
+ "ref-cast",
+ "rocket_codegen",
+ "rocket_http",
+ "serde",
+ "serde_json",
+ "state",
+ "tempfile",
+ "time 0.3.11",
+ "tokio",
+ "tokio-stream",
+ "tokio-util 0.7.3",
+ "ubyte",
+ "version_check",
+ "yansi",
+]
+
+[[package]]
+name = "rocket_codegen"
+version = "0.5.0-rc.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6aeb6bb9c61e9cd2c00d70ea267bf36f76a4cc615e5908b349c2f9d93999b47"
+dependencies = [
+ "devise",
+ "glob",
+ "indexmap",
+ "proc-macro2",
+ "quote",
+ "rocket_http",
+ "syn",
+ "unicode-xid",
+]
+
+[[package]]
+name = "rocket_http"
+version = "0.5.0-rc.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ded65d127954de3c12471630bf4b81a2792f065984461e65b91d0fdaafc17a2"
+dependencies = [
+ "cookie",
+ "either",
+ "futures",
+ "http",
+ "hyper",
+ "indexmap",
+ "log",
+ "memchr",
+ "pear",
+ "percent-encoding",
+ "pin-project-lite",
+ "ref-cast",
+ "serde",
+ "smallvec",
+ "stable-pattern",
+ "state",
+ "time 0.3.11",
+ "tokio",
+ "uncased",
+]
+
+[[package]]
+name = "rsa"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c2603e2823634ab331437001b411b9ed11660fbc4066f3908c84a9439260d"
+dependencies = [
+ "byteorder",
+ "digest 0.9.0",
+ "lazy_static",
+ "num-bigint-dig",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "pkcs1",
+ "pkcs8 0.7.6",
+ "rand",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0a5f7c728f5d284929a1cccb5bc19884422bfe6ef4d6c409da2c41838983fcf"
+
+[[package]]
+name = "ryu"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695"
+
+[[package]]
+name = "salsa20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
+dependencies = [
+ "cipher 0.4.3",
+]
+
+[[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 = "scoped-tls"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2"
+
+[[package]]
+name = "scopeguard"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
+
+[[package]]
+name = "scrypt"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d"
+dependencies = [
+ "hmac 0.12.1",
+ "password-hash",
+ "pbkdf2",
+ "salsa20",
+ "sha2 0.10.2",
+]
+
+[[package]]
+name = "sec1"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08da66b8b0965a5555b6bd6639e68ccba85e1e2506f5fbb089e93f8a04e1a2d1"
+dependencies = [
+ "der 0.5.1",
+ "generic-array",
+ "pkcs8 0.8.0",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "sec1_decode"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23"
+dependencies = [
+ "der 0.4.5",
+ "pem",
+ "thiserror",
+]
+
+[[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 = "serde"
+version = "1.0.139"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0171ebb889e45aa68b44aee0859b3eede84c6f5f5c228e6f140c0b2a0a46cad6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.139"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1d3230c1de7932af58ad8ffbe1d784bd55efd5a9d84ac24f69c72d83543dfb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.82"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha-1"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "028f48d513f9678cda28f6e4064755b3fbb2af6acd672f2c209b62323f7aea0f"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest 0.10.3",
+]
+
+[[package]]
+name = "sha2"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
+dependencies = [
+ "block-buffer 0.9.0",
+ "cfg-if",
+ "cpufeatures",
+ "digest 0.9.0",
+ "opaque-debug",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest 0.10.3",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02658e48d89f2bec991f9a78e69cfa4c316f8d6a6c4ec12fae1aeb263d486788"
+dependencies = [
+ "digest 0.9.0",
+ "rand_core",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb703cfe953bccee95685111adeedb76fabe4e97549a58d16f03ea7b9367bb32"
+
+[[package]]
+name = "sluice"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
+dependencies = [
+ "async-channel",
+ "futures-core",
+ "futures-io",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1"
+
+[[package]]
+name = "socket2"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66d72b759436ae32898a2af0a14218dbf55efde3feeb170eb623637db85ee1e0"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
+[[package]]
+name = "spin"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
+[[package]]
+name = "spin"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c530c2b0d0bf8b69304b39fe2001993e267461948b890cd037d8ad4293fa1a0d"
+
+[[package]]
+name = "spki"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c01a0c15da1b0b0e1494112e7af814a678fec9bd157881b49beac661e9b6f32"
+dependencies = [
+ "der 0.4.5",
+]
+
+[[package]]
+name = "spki"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d01ac02a6ccf3e07db148d2be087da624fea0221a16152ed01f0496a6b0a27"
+dependencies = [
+ "base64ct",
+ "der 0.5.1",
+]
+
+[[package]]
+name = "sqlformat"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4b7922be017ee70900be125523f38bdd644f4f06a1b16e8fa5a8ee8c34bffd4"
+dependencies = [
+ "itertools",
+ "nom",
+ "unicode_categories",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f82cbe94f41641d6c410ded25bbf5097c240cefdf8e3b06d04198d0a96af6a4"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b69bf218860335ddda60d6ce85ee39f6cf6e5630e300e19757d1de15886a093"
+dependencies = [
+ "ahash",
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "dirs",
+ "either",
+ "event-listener",
+ "futures-channel",
+ "futures-core",
+ "futures-intrusive",
+ "futures-util",
+ "hashlink",
+ "hex",
+ "hkdf",
+ "hmac 0.12.1",
+ "indexmap",
+ "itoa",
+ "libc",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "paste",
+ "percent-encoding",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha-1",
+ "sha2 0.10.2",
+ "smallvec",
+ "sqlformat",
+ "sqlx-rt",
+ "stringprep",
+ "thiserror",
+ "tokio-stream",
+ "url",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40c63177cf23d356b159b60acd27c54af7423f1736988502e36bae9a712118f"
+dependencies = [
+ "dotenv",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2 0.10.2",
+ "sqlx-core",
+ "sqlx-rt",
+ "syn",
+ "url",
+]
+
+[[package]]
+name = "sqlx-rt"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "874e93a365a598dc3dadb197565952cb143ae4aa716f7bcc933a8d836f6bf89f"
+dependencies = [
+ "native-tls",
+ "once_cell",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
+name = "stable-pattern"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4564168c00635f88eaed410d5efa8131afa8d8699a612c80c455a0ba05c21045"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "state"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbe866e1e51e8260c9eed836a042a5e7f6726bb2b411dffeaa712e19c388f23b"
+dependencies = [
+ "loom",
+]
+
+[[package]]
+name = "stringprep"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee348cb74b87454fff4b551cbf727025810a004f88aeacae7f85b87f4e9a1c1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "subtle"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
+
+[[package]]
+name = "syn"
+version = "1.0.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "unicode-xid",
+]
+
+[[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 = "thiserror"
+version = "1.0.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "time"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
+dependencies = [
+ "libc",
+ "wasi 0.10.0+wasi-snapshot-preview1",
+ "winapi",
+]
+
+[[package]]
+name = "time"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217"
+dependencies = [
+ "itoa",
+ "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"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
+
+[[package]]
+name = "tokio"
+version = "1.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51a52ed6686dd62c320f9b89299e9dfb46f730c7a48e635c19f21d116cb1439"
+dependencies = [
+ "bytes",
+ "libc",
+ "memchr",
+ "mio",
+ "num_cpus",
+ "once_cell",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "winapi",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9724f9a975fb987ef7a3cd9be0350edcbe130698af5b8f7a631e23d42d052484"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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-stream"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df54d54117d6fdc4e4fea40fe1e4e566b3505700e148a6827e59b34b0d2600d9"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36943ee01a6d67977dd3f84a5a1d2efeb4ada3a1ae771cadfaa535d9d9fc6507"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "log",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc463cd8deddc3770d20f9852143d50bf6094e640b485cb2e189a2099085ff45"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "toml"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "tower-service"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+
+[[package]]
+name = "tracing"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160"
+dependencies = [
+ "cfg-if",
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11c75893af559bc8e10716548bdef5cb2b983f8e637db9d0e15126b61b484ee2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b7358be39f2f274f322d2aaed611acc57f382e8eb1e5b48cb9ae30933495ce7"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922"
+dependencies = [
+ "lazy_static",
+ "log",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a713421342a5a666b7577783721d3117f1b69a393df803ee17bb73b1e122a59"
+dependencies = [
+ "ansi_term",
+ "matchers",
+ "once_cell",
+ "regex",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
+
+[[package]]
+name = "typenum"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
+
+[[package]]
+name = "ubyte"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a58e29f263341a29bb79e14ad7fda5f63b1c7e48929bad4c685d7876b1d04e94"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "uncased"
+version = "0.9.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09b01702b0fd0b3fadcf98e098780badda8742d4f4a7676615cad90e8ac73622"
+dependencies = [
+ "serde",
+ "version_check",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "854cbdc4f7bc6ae19c820d44abdc3277ac3e1b2b93db20a636825d9322fb60e6"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"
+
+[[package]]
+name = "unicode_categories"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
+
+[[package]]
+name = "universal-hash"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05"
+dependencies = [
+ "generic-array",
+ "subtle",
+]
+
+[[package]]
+name = "untrusted"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
+
+[[package]]
+name = "url"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
+name = "validator"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f07b0a1390e01c0fc35ebb26b28ced33c9a3808f7f9fbe94d3cc01e233bfeed5"
+dependencies = [
+ "idna",
+ "lazy_static",
+ "regex",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "url",
+ "validator_derive",
+]
+
+[[package]]
+name = "validator_derive"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea7ed5e8cf2b6bdd64a6c4ce851da25388a89327b17b88424ceced6bd5017923"
+dependencies = [
+ "if_chain",
+ "lazy_static",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "syn",
+ "validator_types",
+]
+
+[[package]]
+name = "validator_types"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ddf34293296847abfc1493b15c6e2f5d3cd19f57ad7d22673bf4c6278da329"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[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"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "waker-fn"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
+
+[[package]]
+name = "want"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0"
+dependencies = [
+ "log",
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.10.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a"
+dependencies = [
+ "bumpalo",
+ "lazy_static",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be"
+
+[[package]]
+name = "web-push"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b1d99b17f0cd87b673d856db1f6c041ddc719c0fdeb59f97c770b8499996aba"
+dependencies = [
+ "base64",
+ "chrono",
+ "ece",
+ "futures-lite",
+ "http",
+ "isahc",
+ "jwt-simple",
+ "log",
+ "pem",
+ "pkcs8 0.7.6",
+ "sec1_decode",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wepoll-ffi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d743fdedc5c64377b5fc2bc036b01c7fd642205a0d96356034ae3404d49eb7fb"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "whoami"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524b58fa5a20a2fb3014dd6358b70e6579692a56ef6fce928834e488f42f65e8"
+dependencies = [
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2"
+dependencies = [
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.36.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680"
+
+[[package]]
+name = "yansi"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
+
+[[package]]
+name = "zeroize"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d68d9dcec5f9b43a30d38c49f91dfedfaac384cb8f085faca366c26207dd1619"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f8f187641dad4f680d25c4bfc4225b418165984179f26ca76ec4fb6441d3a17"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..da84734
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "minor-skulk"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0"
+base64 = "0.13"
+chrono = "0.4.19"
+dotenv = "0.15"
+either = "1.7"
+futures = "0.3.21"
+hawk = "4.0.0"
+hex = "0.4"
+hkdf = "0.12.3"
+hmac = "0.12.1"
+humantime-serde = "1.1.1"
+lazy_static = "1.4"
+lettre = { version = "0.10", features = [ "tokio1", "tokio1-native-tls", "serde" ] }
+password-hash = "0.4"
+rand = "0.8.5"
+rocket = { version = "0.5.0-rc.2", features = [ "json" ] }
+scrypt = { version = "0.10", features = [ "simple" ] }
+serde = { version = "1.0", features = [ "derive" ] }
+serde_json = "1.0"
+sha2 = "0.10.2"
+sqlx = { version = "0.6.0", features = [ "runtime-tokio-native-tls", "postgres", "chrono", "json", "offline" ] }
+subtle = "2.4.1"
+url = "2.2.2"
+validator = { version = "0.15", features = [ "derive" ] }
+web-push = "0.9.2"
+zeroize = { version = "1.4.3", features = [ "zeroize_derive" ] }
+
+[dev-dependencies]
+hex-literal = "0.3.4"
+
+[profile.dev.package.scrypt]
+# avoid tests taking a huge amount of time due to unoptimized scrypt
+opt-level = 2
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3da6105
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+# installation
+
+ * compile minor-skulk with `cargo build`.
+ * edit `Rocket.toml`, settings values for all the mandatory parameters
+ * for test deployments it's sufficient to use the http server provided by
+ minor-skulk. live deployments **must** use a reverse proxy to add TLS!
+ * install [syncstorage-rs](https://github.com/mozilla-services/syncstorage-rs)
+ * configure syncstorage-rs for a single-node setup as exemplified in
+ [this nixos module](https://github.com/NixOS/nixpkgs/pull/176835).
+ you will also have to set `tokenserver.fxa_oauth_server_url` as
+ noted in `Rocket.toml`, otherwise actual sync will not work.
+
+# configuring firefox desktop
+
+go to `about:config` and set `identity.fxaccounts.autoconfig.uri` to
+the address of your server, e.g. `http://localhost:8000` if you're just
+starting minor-skulk locally for testing. if you are not using https you
+must also create a `identity.fxaccounts.allowHttp` settings with value `true`,
+otherwise firefox will not accept the config. restart firefox for the
+changes to take effect, then create an account or log in as usual.
+
+# configuring firefox android
+
+this is a sufficiently involved process that the web interface has a
+dedicated guide. just open the url of your server in firefox on android
+and follow the guide. ("just". we're so sorry.)
diff --git a/Raven-Silhouette.svg b/Raven-Silhouette.svg
new file mode 100644
index 0000000..78ecff2
--- /dev/null
+++ b/Raven-Silhouette.svg
@@ -0,0 +1,140 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1856.4178 1669.4326" enable-background="new 0 0 1856.4178 1669.4326" xml:space="preserve">
+<path d="M1816.1461,1018.158c-1.8677-5.4106-5.0758-10.2363-8.6921-14.6192c-7.2214-8.6637-15.9307-15.9307-24.8782-22.7378
+ c-8.4534-6.3134-16.9979-12.6265-26.502-17.2934c-9.8901-5.0471-20.2341-9.1064-30.2719-13.8187
+ c-31.0948-14.2446-63.7117-24.8101-96.5325-34.2631c-50.2845-14.392-101.3468-25.8774-151.5746-40.4683
+ c-13.3533-3.8379-27.0356-6.4211-40.2924-10.5883c-20.45-6.1884-38.0269-18.707-55.9164-29.9539
+ c-10.1399-6.5403-20.9894-11.9225-32.1283-16.5325c-11.6954-4.928-23.6464-9.2428-35.1089-14.6987
+ c11.6443-2.5264,23.3396-5.6263,33.911-11.2639c-4.9396-7.6872-8.4026-16.1636-12.1555-24.4525
+ c-3.7753-8.3798-8.1809-16.7142-14.8804-23.141c0.8688-2.2198,4.2638-4.3262,2.4186-6.8867
+ c-6.1145-6.6766-10.3726-14.7214-14.9031-22.4824c-4.2751-7.5793-9.5664-14.5739-15.8967-20.552
+ c-15.7716-15.153-34.8988-26.241-54.4177-35.83c-20.7278-10.066-42.2452-18.3776-63.8136-26.4282
+ c-23.7144-8.7943-47.9283-16.5779-70.4222-28.2903c-19.0643-9.7481-37.3401-21.5457-58.0056-27.7341
+ c-14.5342-4.2977-29.7948-4.6781-44.7891-5.9157c-11.2808-0.9709-22.8342-1.8793-33.3884-6.3019
+ c-9.7312-4.0367-17.9178-10.9858-24.9803-18.6616c-6.989-7.3919-13.4725-15.3005-21.2507-21.8976
+ c-9.0837-7.8575-18.8884-14.8009-28.4833-22.0056c-8.8907-6.563-17.3898-13.6314-26.0477-20.4896
+ c-57.5062-45.9413-114.6435-92.3709-173.6313-136.4047c-7.5566-5.7966-15.5448-10.9914-23.3171-16.4814
+ c-6.9547-5.0131-14.1196-10.0546-19.3881-16.9129c-5.3311-7.5453-8.5672-16.2884-13.1092-24.2822
+ c-7.5396-12.8932-15.6241-25.4573-23.0898-38.396c-22.5618-38.538-42.762-78.5975-68.8666-114.9442
+ c-12.8763-17.8724-27.2628-34.7682-43.8635-49.2966c-7.2102-6.2338-14.3751-12.5584-22.2268-17.986
+ c-8.9476-6.1826-18.7751-10.9005-28.4494-15.8058c-10.5316-5.0642-21.3413-9.8502-32.8322-12.246
+ c-27.9896-5.3993-57.4494-4.5705-84.6667,4.2466c-21.7727,7.3523-42.0411,18.4571-61.6448,30.3058
+ c-4.7975,2.7365-9.6403,5.5752-15.0054,7.0513c-5.4049,1.4591-10.9914,0.3123-16.4757,0.1815
+ c-2.6515-0.1021-5.2857,0.1931-7.92,0.4145c-9.1576,0.8516-18.2528,2.339-27.2684,4.1388
+ c-22.6074,4.6442-44.7491,11.3207-66.4878,19.0193c-21.7898,7.6871-43.3467,16.8504-62.2354,30.3513
+ c-16.436,11.6895-30.618,27.2968-38.0611,46.2534c10.8608,2.3449,21.8522,4.0935,32.8834,5.4277c0,0,47.9737,4.3488,63.9499,5.4446
+ c28.3302,2.0325,56.8022,4.1956,84.5646,10.5201c1.1525,0.2555,2.3504,0.4941,3.3781,1.1299
+ c5.4275,3.0488,9.9069,7.5168,13.6653,12.4277c6.2055,8.2153,10.6566,17.6056,14.1652,27.2458
+ c4.1728,11.6159,6.9831,23.7144,8.4366,35.9718c3.4359,28.1916-4.0358,56.6843,1.1292,84.7678
+ c0.3048-0.0174,0.2173-0.0124,0.5221-0.0298c0.4145-6.1854,1.1176-12.6839,4.2644-18.1481c0.1476,3.0317-1.2774,5.9669-0.8346,8.993
+ c0.0966,1.3626,2.2029,2.2369,3.1736,1.2945c1.4081-6.881,2.5379-13.9948,5.8535-20.2796
+ c1.9586,35.5461-2.4639,71.0978-8.7602,106.025c0.6358-0.6756,1.5726-2.5661,2.6797-1.5726
+ c0.3918,1.1979-0.1022,2.4867-0.1703,3.7243c-2.674,20.6089-9.0781,41.019-7.2614,61.997
+ c2.4639-4.0423,1.8849-9.6515,5.5581-13.0864c1.0447,4.6725,1.1468,9.4812,1.6635,14.2276
+ c0.528,4.1728,1.1353,8.7943,4.1954,11.9622c0.9653-2.5037,1.0448-5.6944,3.418-7.3806c0.1761,4.0026-0.5848,7.9484-1.0332,11.9111
+ c-0.3975,3.6051-0.8064,7.4544,0.8571,10.8268c1.3171-1.2433,1.425-3.2928,2.606-4.6384c1.4988,0.1816,1.1071,3.185,2.5094,3.2531
+ c2.0211-2.6172,1.5101-6.9548,4.7974-8.5728c0.0625,7.8064-0.5337,15.6809,0.5337,23.4532c0.2951,1.919,0.9085,5.1778,3.5995,4.4
+ c2.2709,13.9493,8.4763,27.314,7.7838,41.689c-0.2158,5.5696-2.4697,10.8155-2.7535,16.3679
+ c-1.5557,24.6625,3.8152,49.467,13.8641,71.9495c7.4032,16.9696,18.656,31.9409,31.0666,45.561
+ c5.8192,6.2734,11.5251,12.7286,18.2982,18.02c10.0489,8.1867,21.5059,15.1699,28.9659,26.0478
+ c5.7172,8.1471,10.2477,17.0491,15.5618,25.4517c6.7333,10.5145,13.6826,20.8984,21.2675,30.8225
+ c7.7552,10.015,15.732,19.8595,23.561,29.8175c13.3192,17.0322,26.7973,34.1495,36.92,53.3446
+ c11.6614,21.8013,20.7849,44.8117,30.9816,67.294c14.3239,31.044,28.2676,62.4456,37.3969,95.4709
+ c14.2785,51.7037,25.673,104.2307,33.1899,157.354c1.6351,12.7515,4.2979,25.3381,6.37,38.0215
+ c1.2072,7.8705,2.3062,15.8937,2.4547,23.8947c0.1169,6.2915-2.1918,12.3933-6.5272,16.954
+ c-21.9421,23.0815-44.356,45.7445-68.0388,67.027c-5.9222,5.6853-13.5702,8.7988-20.2368,13.4344
+ c-6.3282,4.6365-6.7343,14.0439-13.0286,18.5784c-15.2622,4.264-31.3025,3.1814-46.9708,4.298
+ c-9.7461,0-20.0336,1.6243-29.2722-2.1992c-13.0285-4.8395-24.162-14.7549-38.3073-16.3114
+ c-16.8528-0.0679-31.2012,13.7054-33.1301,30.2194c9.2048-10.2533,21.5903-20.3378,36.4126-16.9537
+ c5.4144,0.0673,12.6563,6.0233,7.6141,11.3025c-9.577,11.5399-16.2095,25.7527-15.1266,41.1504
+ c8.0878-8.1558,13.7729-18.2402,21.8609-26.3281c2.132,4.8395,3.4179,10.3888,7.648,14.01
+ c10.1522,1.4889,20.406-5.9559,30.2196-0.9137c7.648,2.8763,15.8374-0.1355,23.587-0.8461c0,0,0.9055-0.1208,2.2899-0.2842
+ c6.7663-0.7982,13.5849,1.2021,18.8079,5.577c1.6437,1.377,3.3407,2.6841,5.1962,3.7762
+ c10.6599,0.7449,21.4889,0.1017,32.0133-1.7258c9.2384-1.9288,15.1944-12.521,25.2788-11.1672
+ c25.5498,2.7747,51.5055,7.4451,77.1567,3.4518c8.0427-5.3232,11.0646-14.2576,11.0321-23.3988
+ c-0.0101-2.8394,1.7778-5.3746,4.4941-6.201c6.4361-1.9583,12.9675-3.0547,19.8033,1.106
+ c10.9437,6.661,6.9711,22.2675,18.8154,25.8544c0.4736-9.78-1.0005-15.8474-3.9784-25.2892
+ c-5.2792-9.915-15.2094-18.9065-25.9707-19.7864c-17.1911-2.4703-31.8777,8.5956-48.0536,12.2837
+ c-10.9644,2.9445-25.7526,9.2389-34.6528-1.2178c-3.5193-9.2384,1.3876-18.5107,7.5803-25.2452
+ c22.2576-27.025,48.5933-53.3247,74.244-77.4504c2.3528-2.213,4.5043-4.6229,6.4335-7.2135
+ c6.4152-8.6144,12.4543-17.4785,17.1912-27.1669c4.1503-8.3286,6.8413-17.3386,8.062-26.5587
+ c4.241-29.3466-0.9538-58.8403-2.1971-88.2037c5.9554,4.2864,7.5396,12.496,14.0173,16.2374
+ c-0.2611-1.3059-0.5166-2.5947-0.4371-3.8949c1.3683,0.8574,2.1575,2.3279,3.0204,3.6676c1.652,2.6458,3.0148,5.4561,4.4,8.2606
+ c0.8231-0.4087,2.0211-0.6926,2.0211-1.8337c-0.352-2.0099-1.7259-3.7301-1.6181-5.8193c1.5047,1.6578,2.3676,3.8209,4.0026,5.3993
+ c0.7211,0.9197,1.7941,1.2262,2.9238,0.8174c0.3579-2.7592-3.0941-4.485-2.2767-7.3806c2.6458,2.5095,3.5088,6.7335,7.1933,8.1472
+ c0.1136-2.5605-0.6245-5.1609-2.6626-6.8073c0.3065-0.1987,0.9254-0.5962,1.2377-0.7892c1.5216,1.6353,2.2709,4.3092,4.7065,4.8655
+ c-2.2596-8.34-5.1096-16.5496-8.9929-24.2765c-0.6246-1.2772-2.0042-2.8046-0.5168-3.9968c1.1694,1.442,2.0381,4.2183,4.4454,3.2928
+ c0.4542-2.975-1.6009-5.4275-3.1622-7.7042c0.9935-0.4257,1.97,0.2839,2.7193,0.9141c2.8444,2.6799,4.6215,6.3815,7.8236,8.7148
+ c0.9653,0.9652,2.3957-0.3862,2.1857-1.4989c-0.0739-2.322-1.3455-4.343-2.4299-6.302c0.9141-0.2554,1.7032-0.0453,2.3674,0.6418
+ c2.061,1.8734,3.1453,4.5192,4.9224,6.6366c1.5101-1.0219,1.3682-2.9351,1.5726-4.5192c7.7213,9.8164,16.277,18.9512,23.828,28.9093
+ c1.0162-0.8969,1.8564-2.0723,3.1395-2.6115c1.3442-0.4641,2.2458,1.0096,3.234,1.6278c0.4477-0.2504,0.5383-0.3011,0.9861-0.5515
+ c-0.1156-2.4456-2.4296-4.9897-0.4958-7.2534c1.3625,1.1923,2.3903,2.8615,4.1445,3.5653c0.0454-2.5491,0.0398-5.2288-1.0446-7.5793
+ c-0.4145-1.0615-2.0381-2.2368-0.6132-3.2075c1.8281,1.0219,3.185,2.7706,5.2175,3.4745c0.7155-3.1281-1.7315-5.3992-4.1954-6.7333
+ c1.2604-2.0099,3.1226,0.0851,4.5872,0.6868c-2.0266-10.049-7.2782-19.0985-13.3871-27.2003
+ c1.3454-1.5157,3.378-0.193,5.0811-0.3462c-0.5335-0.8005-1.1012-1.5499-1.3965-2.4299c0.8118,0.2102,1.726,0.3633,2.3787,1.0048
+ c3.7527,3.2872,5.6092,8.4537,10.1287,10.9176c-0.6133-6.2109-4.7976-11.0594-8.0734-16.0668
+ c0.3691-0.3862,0.7551-0.755,1.1583-1.1072c1.5895,1.5897,2.3106,4.2751,4.7234,4.8824c-0.1816-2.6569-0.4314-5.3141-0.1987-7.9652
+ c1.6181,1.3569,2.3051,3.4292,3.4406,5.1664c0.8006-0.8856,1.601-1.7714,2.4128-2.64c0.1818,0.3292,0.5507,0.9822,0.7324,1.3114
+ c0.6813-0.6584,1.5784-1.3286,1.4249-2.3958c-0.4882-2.9126-2.6627-5.3992-2.2936-8.4762c3.1567,1.4363,3.8834,5.507,7.1876,6.7502
+ c-1.5669-13.5236-8.6411-25.588-10.9175-38.9412c2.6002-1.6804,5.876-1.6804,8.6863-0.4995
+ c4.9733,1.8792,9.203,5.2061,13.8472,7.7382c5.7909,3.3611,11.6557,6.7107,16.7598,11.0994
+ c3.219,2.8783,6.3755,6.1088,7.8573,10.2704c1.6351,4.468,0.7778,9.3621-0.4825,13.8187
+ c-1.5331,5.4048-3.679,10.7416-3.9118,16.4189c-0.1987,3.6676,3.0942,6.5006,2.8557,10.1909
+ c-0.0511,1.6238-1.2659,2.8217-2.356,3.8833c1.7599-0.0682,4.1161,0.7041,3.9401,2.901
+ c-0.7324,5.9556-4.6554,11.4854-3.5768,17.6454c1.0844,0.8176,2.0155-0.5165,2.7764-1.1467
+ c-1.0278,5.7795-1.0107,11.6898-0.6473,17.5316c0.9822,14.1823,4.0196,28.0975,6.0805,42.1377
+ c1.9649,13.3865,4.1708,28.0864,0.553,41.3081c-5.7645,21.0676-9.6409,41.9591-14.2791,63.2244
+ c-3.2025,14.677-7.2234,29.1622-11.5855,43.5328c-3.535,11.646-7.2974,23.2212-11.0479,34.7991
+ c-7.2438,17.1122-11.2681,37.7936-27.6103,48.8865c-15.7122,11.2334-31.9145,21.7666-47.9067,32.5796
+ c-6.2997,4.7045-13.6663,6.5701-21.2274,7.8672c-11.1801,1.9181-20.7715,9.172-25.3287,19.5596
+ c-3.5393,8.0674-6.313,16.4353-7.2996,25.2744c7.4267-10.8456,10.8832-11.3732,15.4325-17.7772
+ c3.0444-6.3688,9.9031-9.203,16.0972-11.5829c5.9138,2.4144,7.1387,12.3527,14.8724,11.0232
+ c15.3624,0.7347,23.0609-15.1527,35.274-21.6964c-5.4244,13.0876-13.053,25.0907-20.0168,37.3737
+ c-1.9958,3.4565-4.3543,6.6892-6.8226,9.8512c-11.5735,14.8268-17.6962,33.1792-17.6181,51.988
+ c0.0332,7.9846,0.3159,15.9762,0.5398,23.931l3.8145-12.333c4.8292-18.7213,4.7941-29.5546,19.0016-44.5322
+ c7.6989,2.3094,16.6572,6.7535,23.936,0.7698c12.8078-6.194,8.9936-24.4257,21.5913-30.8999
+ c13.8925-8.2582,17.8469-24.7406,27.6802-36.3934c16.937,5.7737,31.4246,21.2411,50.8112,16.8669
+ c2.0647-5.179,4.1292-10.3582,6.0889-15.5721c15.2574-2.6946,34.6091,0.8398,40.8381,16.9719
+ c5.389,9.4132,5.0043,24.0759,16.7271,28.2755c0.175-25.4056-9.5533-56.4808-36.0088-65.2294
+ c-11.9681-5.6334-24.0408,3.1497-36.1138,2.45c-8.7834-7.3136-20.5064-15.1522-18.6868-28.3101
+ c0.5951-24.4607-3.7093-49.8663,4.6892-73.4172c3.9847-13.9871,9.8431-27.3528,15.644-40.6583
+ c2.8027-6.429,5.6146-12.8555,8.2485-19.356c2.5261-6.2343,4.5917-12.6407,6.4648-19.1012
+ c2.1732-7.496,4.7515-14.8743,7.7213-22.0916c2.4303-5.9069,7.0917-10.9248,10.8121-16.1445
+ c8.2047-11.5112,16.144-23.2834,22.3943-35.9929c5.0814-9.9525,8.7263-20.5804,11.4116-31.4072
+ c1.5159,0.6985,2.3674,2.146,3.2021,3.5314c0.7949,1.3341,2.447,1.7428,3.923,1.6635c-0.5053-26.6383-5.4559-52.8905-8.0674-79.347
+ c1.4307,1.5272,2.8896,3.4064,5.0698,3.8265c2.1461-0.3634,2.1233-3.1395,2.5038-4.8087c1.777,1.7032,3.5938,3.5654,5.9839,4.4
+ c1.1013,0.4087,2.3731,0.051,2.9693-1.005c-3.696-2.0382-7.0626-5.0302-9.0839-8.7544c1.8281,0.085,3.2361,1.4988,4.6953,2.4923
+ c1.0334-0.1023,2.061-0.3463,3.0659-0.6699c0.1531-0.7721-0.0056-1.5103-0.6586-1.9473c-1.7487-1.6011-4.2126-2.6857-4.8996-5.1438
+ c3.2134-0.3917,4.9961,3.1168,7.92,3.6563c1.2036,0.2952,2.1176-0.6755,2.7931-1.5328
+ c-4.5817-7.3864-13.5915-9.7936-19.8082-15.4028c-1.6804-1.5045-3.2589-3.5485-3.0033-5.9271
+ c2.7422-0.3463,4.7689,1.9644,7.3635,2.3051c1.533-0.0513,1.6124-1.9304,1.7543-3.0717c-1.2093-0.488-3.2134-1.635-2.3901-3.1451
+ c0.931-0.3519,1.9131,0,2.8783,0.0966c0.6416-0.9651,1.1696-2.3335,0.1364-3.2247c-2.3902-2.5605-7.2501-3.696-7.2217-7.8518
+ c4.0876-2.6174,8.749-4.3318,13.5802-4.9165c6.4666-0.7213,12.9785,0.2894,19.235,1.9471
+ c9.8958,2.3787,18.8659,7.3749,27.9667,11.7805c6.0012,3.134,12.2576,5.7567,18.1791,9.0668
+ c5.9953,3.3214,11.9055,6.8413,18.1676,9.6686c3.8719,1.6465,8.0334,3.3611,12.3368,2.714
+ c3.5939-0.6246,6.1033-3.5712,8.1017-6.3928c2.7421-4.0482,5.2175-8.3231,8.6238-11.8715
+ c7.1707-7.8632,16.8109-13.1659,26.9959-16.0272c13.6996-3.8549,28.1372-3.9629,42.2227-2.7083
+ c25.8378,2.4528,50.8977,9.5552,75.8441,16.3851c14.3525,3.974,29.0511,6.4778,43.6364,9.4301
+ c11.7295,2.3676,23.2206,5.7626,34.6887,9.1576c23.9188,7.1082,48.0592,15.0167,73.2211,15.7375
+ c13.2795,0.3351,26.7122-1.8109,38.9413-7.125c14.114,6.6595,29.5961,9.6686,43.869,15.942
+ c10.0831,4.3319,17.9916,12.3484,27.927,16.9585c5.0474,2.4185,10.6394,3.6846,16.243,3.6392
+ c1.5726-0.1135,2.6455-1.6862,2.7875-3.1567c0.301-1.4478-0.2384-3.1906,0.9369-4.3262
+ c13.2111,6.8981,26.4792,13.6938,39.543,20.8701c11.2751,6.3871,21.9146,13.9493,33.7804,19.2804
+ c4.9279-0.9481,5.8986-6.5062,8.3171-10.0659c13.8756,7.3918,25.2532,18.6331,39.1399,26.0193
+ c9.9979,5.4276,21.6992,8.1869,33.0311,6.2678c1.8394-4.3717,2.5775-9.2542,5.3821-13.1771
+ c3.9969,3.7925,8.6923,6.7675,13.5122,9.4016c8.7091,4.6554,18.196,8.4537,28.1713,8.8965
+ c5.0697,0.125,10.7019-1.2887,13.813-5.6147c3.5313-5.1382,3.4177-11.7411,2.657-17.6681
+ c9.6855,5.6603,19.3143,11.4172,28.9943,17.0889c6.597-3.3893,14.3184-4.4965,21.5798-2.8671
+ c7.2784,1.5101,14.0457,4.7009,21.0176,7.1932c6.3815,2.3448,13.0238,4.1218,19.8312,4.5703
+ c1.3397,0.2611,1.8792-1.2946,2.2993-2.2653c1.1241-4.2694-0.051-8.7772,1.0842-13.041c0.2954-0.9935,0.7097-2.129,1.7318-2.5718
+ c4.6324,0.4485,9.0211,2.1176,13.4893,3.3383c4.8771,1.3739,10.2649,2.1573,15.1021,0.1815
+ c-0.9084-2.7251-2.0609-5.3594-3.5768-7.7778c-4.5023-7.3408-11.6331-12.5809-19.0305-16.7256
+ c-9.345-5.2175-19.3715-9.0668-28.688-14.324c-31.9182-16.9753-64.6199-32.4746-95.9418-50.5685
+ c-8.8285-5.0471-17.3788-10.5769-26.4966-15.0961c-11.4056-5.9386-23.1353-11.3265-33.9504-18.3208
+ c-4.2299-2.7196-8.1981-5.8082-12.212-8.817c-63.0988-47.321-126.6681-94.0686-191.9637-138.3237
+ c52.5499,8.6126,104.7988,19.0817,156.6218,31.3164c47.8662,11.3774,95.4198,24.1289,142.3945,38.7822
+ c24.2537,7.6418,48.6947,15.5618,74.1293,18.0881c1.8451,0.4713,3.5995-1.1696,4.0934-2.85
+ c0.6807-2.7533-0.3264-5.4494-0.7198-8.1582c0.3279-0.3196,0.4683-0.4564,0.7964-0.7761c13.38,1.9675,25.9043,7.2485,38.541,11.8071
+ c10.8497,3.974,22.2156,7.0399,33.8542,6.9547C1818.4167,1030.0125,1818.1051,1023.7502,1816.1461,1018.158z"/>
+</svg>
diff --git a/Rocket.toml b/Rocket.toml
new file mode 100644
index 0000000..85c5abf
--- /dev/null
+++ b/Rocket.toml
@@ -0,0 +1,116 @@
+[default]
+# identifier used in http response headers
+ident = "minor skulk"
+# limit to request parameter size. must be at least 32KiB.
+limits.string = "32 KiB"
+# limit to binary data size. avatars are sent as binary data,
+# so this setting also limits the maximum permissible avatar
+# size.
+limits.bytes = "128 KiB"
+
+
+
+# connection string for the database (mandatory)
+#
+# current only postgresql is supported, with libpq connection strings.
+#
+#database_url = "postgresql:///minorskulk"
+
+# base location of the server (mandatory)
+#
+# this is the base url of the server, e.g. `https://my.domain` if you are running
+# at the root of a domain or `https://my.domain/minorskulk` if you are running
+# behind a reverse proxy that changes the root path.
+# NOTE: if this is set incorrectly, *nothing will work*. the url is used as part
+# of a request signing scheme, so setting this incorrectly will break all requests
+# that use this signing scheme (which includes the login process).
+#
+#location = "https://my.domain"
+
+# base location of the synstorage token server (mandatory)
+#
+# this is the base url of your syncstorage token server, i.e. a syncstorage-rs
+# instance. the token server must also be configured to use this instance to
+# verify client tokens by setting `tokenserver.fxa_email_domain` to some local
+# value and `tokenserver.fxa_oauth_server_url` to `<location>/oauth/v1`.
+#
+# this url will only be rendered into the client autoconfig descriptor. setting
+# an incorrect url here will not impact the function of minor skulk, but it will
+# require setting not just the account server url in firefox but the token server
+# url as well.
+#
+#token_server_location = "https://synstorage.my.domain"
+
+# vapid key for push notifications (mandatory)
+#
+# this must be set to the path to a valid EC key, generated for example with
+#
+# openssl ecparam -genkey -name prime256v1 -out private_key.pem
+#
+# not setting this key will cause all push notifications to fail, including tab
+# sending between devices on one account.
+#
+#vapid_key = "/etc/secrets/minor-skulk-push-key.pem"
+
+# vapid subject identifier (mandatory)
+#
+# must be set to a mailto: address or a web url. mozilla ostensibly uses this
+# information to contact you if weird things happen, and will reject pushes
+# from server that do not provide this datum.
+#vapid_subject = "mailto:minorskulk@my.domain"
+
+# default push notification TTL (optional)
+#
+# default lifetime of push notifications sent through the mozilla webpush service.
+# notifications that cannot be delivered within this time frame will be dropped.
+#
+#default_push_ttl = "2 days"
+
+# expired token prune interval (optional)
+#
+# pruning interval for:
+# - expired key fetch token
+# - expired oauth tokens
+# - expired oauth authorization code
+# - expired device commands
+# - expired invite codes
+#
+#prune_expired_interval = "5 minutes"
+
+# mail-from address (mandatory)
+#
+# all emails sent by minor skulk will be sent from this address.
+#
+#mail_from = "minor skulk <noreply@my.domain>"
+
+# mail host (optional)
+#
+# mail host to use when sending emails.
+#
+#mail_host = "localhost"
+
+# mail port (optional)
+#
+# port to use when connecting to `mail_host`.
+#
+#mail_port = 25
+
+# invite only mode (optional)
+#
+# if set this instance will run in invite-only mode, disabling public
+# registrations and requiring a single-use invite link to create a new
+# account instead. invite links can be generated by the invite admin
+# using the special (hidden) `#/generate-invite` fragment identifier
+# on their account settings page.
+#
+#invite_only = false
+
+# invite admin (optional)
+#
+# email address of the user capable of creating invite links. if no user
+# is registered with the configured email address during startup a new
+# invite code will be generated (valid for one hour) and written to the
+# log. this check is done on every startup, so not setting this value will
+# produce a new invite code every time minor skulk starts.
+#
+#invite_admin_address = "admin@my.domain"
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..1942086
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,54 @@
+{ stdenv
+, rustPlatform
+, rust
+, lib
+, openssl
+, pkg-config
+, postgresql
+, postgresqlTestHook
+, python3
+}:
+
+rustPlatform.buildRustPackage rec {
+ pname = "minor-skulk";
+ version = "0.1.0";
+
+ src = lib.cleanSource ./.;
+
+ nativeBuildInputs = [
+ pkg-config
+ ];
+ checkInputs = [
+ openssl
+ postgresql
+ postgresqlTestHook
+ (python3.withPackages (p: [ p.pytest p.pyfxa p.requests p.http-ece p.aiosmtpd ]))
+ ];
+ buildInputs = [
+ openssl
+ ];
+
+ postPatch = ''
+ patchShebangs ./tests/run.sh
+ '';
+
+ # tests can't run multithreaded yet
+ dontUseCargoParallelTests = true;
+
+ # test config for postgres hook and integration tests
+ PGDATABASE = "testdb";
+ PGUSER = "testuser";
+ ROCKET_DATABASE_URL = "postgres:///${PGDATABASE}?user=${PGUSER}";
+ ROCKET_LOCATION = "http://localhost:8000";
+ ROCKET_TOKEN_SERVER_LOCATION = "http://localhost:5000";
+ ROCKET_VAPID_KEY = "private_key.pem";
+ ROCKET_VAPID_SUBJECT = "undefined"; # not needed for tests
+ ROCKET_MAIL_FROM = "minor skulk <noreply@localhost>";
+ ROCKET_MAIL_PORT = 2525;
+
+ preCheck = ''
+ openssl ecparam -genkey -name prime256v1 -out private_key.pem
+ '';
+
+ cargoLock.lockFile = ./Cargo.lock;
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..3b26f34
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1653704018,
+ "narHash": "sha256-5dzlxE4okyu+M39yeVtHWQXzDZQxFF5rUB1iY9R6Lb4=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "13f08d71ceff5101321e0291854495a1ec153a5e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable-small",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..8276544
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,32 @@
+{
+ inputs = {
+ nixpkgs.url = github:NixOS/nixpkgs/nixos-unstable-small;
+ };
+
+ outputs = { self, nixpkgs }:
+ let
+ systems = { "x86_64-linux" = {}; };
+ combine = fn: with builtins;
+ let
+ parts = mapAttrs (s: _: fn (nixpkgs.legacyPackages.${s})) systems;
+ keys = foldl' (a: b: a // b) {} (attrValues parts);
+ in
+ mapAttrs (k: _: mapAttrs (s: _: parts.${s}.${k} or {}) systems) keys;
+ in
+ combine (pkgs: rec {
+ packages = rec {
+ minor-skulk = pkgs.callPackage ./default.nix {};
+ default = minor-skulk;
+ };
+
+ devShells.default = pkgs.mkShell {
+ inputsFrom = [ packages.default ];
+ packages = with pkgs; [
+ rustfmt
+ rust-analyzer
+ clippy
+ sqlx-cli
+ ];
+ };
+ });
+}
diff --git a/migrations/20220626163140_init.down.sql b/migrations/20220626163140_init.down.sql
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/migrations/20220626163140_init.down.sql
@@ -0,0 +1 @@
+
diff --git a/migrations/20220626163140_init.up.sql b/migrations/20220626163140_init.up.sql
new file mode 100644
index 0000000..916d31c
--- /dev/null
+++ b/migrations/20220626163140_init.up.sql
@@ -0,0 +1,291 @@
+create domain session_id as bytea check (length(value) = 32);
+create domain user_id as bytea check (length(value) = 16);
+create domain hawk_key as bytea check (length(value) = 32);
+create domain device_id as bytea check (length(value) = 16);
+create domain key_fetch_id as bytea check (length(value) = 32);
+create domain oauth_token_id as bytea check (length(value) = 32);
+create domain oauth_auth_id as bytea check (length(value) = 32);
+create domain secret_key as bytea check (length(value) = 32);
+create domain verify_hash as bytea check (length(value) = 32);
+create domain password_change_id as bytea check (length(value) = 32);
+create domain account_reset_id as bytea check (length(value) = 32);
+create domain avatar_id as bytea check (length(value) = 16);
+
+create type oauth_token_kind as enum ('access', 'refresh');
+create type oauth_access_type as enum ('online', 'offline');
+create type device_command as (name text, body text);
+
+create type device_push_info as (
+ callback text,
+ public_key text,
+ auth_key text
+);
+
+create table users (
+ user_id user_id not null primary key,
+ auth_salt text not null,
+ email text not null check (length(email) <= 256),
+ display_name text check (length(display_name) <= 256),
+ ka secret_key not null,
+ wrapwrap_kb secret_key not null,
+ verify_hash verify_hash not null,
+ verified bool not null default false,
+
+ created_at timestamp with time zone not null default now()
+);
+
+create unique index user__email__idx on users ((lower(email)));
+
+create table user_avatars (
+ user_id user_id not null primary key references users on delete cascade,
+ id avatar_id not null,
+ data bytea not null check (length(data) <= 128 * 1024),
+ content_type text not null check (length(content_type) < 1024)
+);
+
+create table device (
+ device_id device_id not null primary key,
+ user_id user_id not null references users on delete cascade,
+ created_at timestamp with time zone not null default now(),
+ name text not null check (length(name) <= 256),
+ type text not null check (length(type) <= 16),
+ push device_push_info,
+ available_commands device_command[] not null,
+ push_expired boolean not null,
+ location jsonb not null check (jsonb_typeof(location) = 'object')
+);
+
+create function insert_or_update_device(
+ p_device_id device_id,
+ p_user_id user_id,
+ p_name text,
+ p_type text,
+ p_push device_push_info,
+ p_available_commands device_command[],
+ p_location jsonb,
+ out result device)
+ returns device
+ language plpgsql
+ as $$
+ begin
+ update device
+ set name = coalesce(p_name, name),
+ type = coalesce(p_type, type),
+ push = coalesce(p_push, push),
+ available_commands = coalesce(p_available_commands, available_commands),
+ push_expired = push_expired and p_push is null,
+ location = coalesce(p_location, location)
+ where device_id = p_device_id
+ returning *
+ into result;
+
+ if not found then
+ insert into device (device_id, user_id, name, type, push, push_expired,
+ available_commands, location)
+ values (p_device_id, p_user_id, p_name, p_type, p_push, p_push is null,
+ p_available_commands, coalesce(p_location, '{}'::jsonb))
+ returning *
+ into result;
+ end if;
+ end
+ $$;
+
+create table user_session (
+ session_id session_id not null primary key,
+ user_id user_id not null references users on delete cascade,
+ req_hmac_key hawk_key not null,
+ device_id device_id references device on delete cascade,
+ last_active timestamp with time zone default now(),
+
+ created_at timestamp with time zone not null default now(),
+ verified bool not null,
+ verify_code text
+);
+
+create function cascade_session_delete()
+ returns trigger
+ language plpgsql
+ as $$
+ begin
+ delete from device where device.device_id = old.device_id;
+ return null;
+ end
+ $$;
+
+create trigger user_session__cascade_device
+ after delete on user_session
+ for each row
+ when (old.device_id is not null)
+ execute function cascade_session_delete();
+
+create table verify_codes (
+ user_id user_id not null primary key references users on delete cascade,
+ session_id session_id references user_session on delete set null,
+ code text not null,
+ expires_at timestamp with time zone default (now() + '5 minutes'::interval)
+);
+
+create table device_commands (
+ index bigserial not null,
+ device_id device_id not null references device on delete cascade,
+ command text not null check (length(command) <= 2048),
+ payload json not null check (length(command) <= 16 * 1024),
+ expires timestamp with time zone not null,
+ sender varchar(32), -- hex(sender device_id)
+ primary key (index)
+);
+
+create index device_command__device_id__idx on device_commands (device_id);
+create index device_command__expires__idx on device_commands (expires);
+
+create table key_fetch (
+ id key_fetch_id not null primary key,
+ hmac_key hawk_key not null,
+ keys bytea not null,
+ expires_at timestamp with time zone not null default (now() + '5 minutes'::interval)
+);
+
+create index key_fetch__expires_at__idx on key_fetch (expires_at);
+
+create table oauth_token (
+ id oauth_token_id not null primary key,
+ kind oauth_token_kind not null,
+ user_id user_id not null references users on delete cascade,
+ client_id text not null,
+ scope text not null check (length(scope) < 1024),
+ created_at timestamp with time zone not null default now(),
+
+ -- refresh tokens can own sessions. refresh tokens are not owned by
+ -- sessions, otherwise the login procedure would immediately invalidate
+ -- tokens for fenix.
+ session_id session_id
+ references user_session on delete cascade
+ check (session_id is null or kind = 'refresh'),
+
+ -- access tokens can be owned by refresh tokens or sessions. owned tokens
+ -- are invalidated with their parent, revoking eg sync tokens for removed
+ -- devices.
+ parent_refresh oauth_token_id
+ references oauth_token on delete cascade
+ check (parent_refresh is null or kind = 'access'),
+ parent_session session_id
+ references user_session on delete cascade
+ check (parent_session is null or kind = 'access'),
+ expires_at timestamp with time zone check ((expires_at is not null) = (kind = 'access'))
+);
+
+create index oauth_token__expires_at__idx on oauth_token (expires_at);
+
+create function cascade_oauth_delete()
+ returns trigger
+ language plpgsql
+ as $$
+ begin
+ delete from user_session where user_session.session_id = old.session_id;
+ return null;
+ end
+ $$;
+
+create trigger oauth_token__cascade_session
+ after delete on oauth_token
+ for each row
+ when (old.session_id is not null)
+ execute function cascade_oauth_delete();
+
+create function cascade_device_delete()
+ returns trigger
+ language plpgsql
+ as $$
+ begin
+ with sessions as (
+ delete from user_session where device_id = old.device_id returning session_id
+ ) delete from oauth_token where session_id in (select * from sessions);
+ return null;
+ end
+ $$;
+
+create trigger device__cascade_
+ after delete on device
+ for each row
+ execute function cascade_device_delete();
+
+create table oauth_authorization (
+ id oauth_auth_id not null primary key,
+ client_id text not null,
+ user_id user_id not null references users on delete cascade,
+ scope text not null,
+ access_type oauth_access_type not null,
+ code_challenge text not null,
+ keys_jwe text check (length(keys_jwe) < 16 * 1024),
+ auth_at timestamp with time zone not null,
+ expires_at timestamp with time zone default (now() + '5 minutes'::interval)
+);
+
+create index oauth_authorization__expires_at__idx on oauth_authorization (expires_at);
+
+create table password_change_tokens (
+ id password_change_id not null primary key,
+ user_id user_id not null references users on delete cascade,
+ hmac_key hawk_key not null,
+ expires_at timestamp with time zone not null default (now() + '5 minutes'::interval),
+ forgot_code text,
+
+ unique (user_id)
+);
+
+create table account_reset_tokens (
+ id account_reset_id not null primary key,
+ user_id user_id not null references users on delete cascade,
+ hmac_key hawk_key not null,
+ expires_at timestamp with time zone not null default (now() + '5 minutes'::interval),
+
+ unique (user_id)
+);
+
+create procedure reset_user_auth(
+ uid user_id,
+ salt text,
+ wwkb secret_key,
+ verify verify_hash)
+language sql
+begin atomic
+ delete from device where user_id = uid;
+ delete from user_session where user_id = uid;
+ delete from verify_codes where user_id = uid;
+ delete from oauth_token where user_id = uid;
+ delete from oauth_authorization where user_id = uid;
+ update users set auth_salt = salt, wrapwrap_kb = wwkb, verify_hash = verify where user_id = uid;
+end;
+
+create table invite_codes (
+ code text not null primary key,
+ expires_at timestamp with time zone not null
+);
+
+
+
+create procedure prune_expired_tokens()
+language sql
+begin atomic
+ delete from key_fetch where expires_at <= now();
+ delete from oauth_token where expires_at <= now();
+ delete from oauth_authorization where expires_at <= now();
+ delete from device_commands where expires <= now();
+ delete from invite_codes where expires_at <= now();
+end;
+
+create procedure prune_expired_verify_codes()
+language sql
+begin atomic
+ -- verify codes can be removed in two ways: either using them to verify an account,
+ -- in which case the account can stay, or by timeout, in which case the account must
+ -- go. triggers can't distinguish those cases.
+ with old_verify as (
+ delete from verify_codes where expires_at <= now() returning user_id
+ )
+ delete from users where user_id in (select user_id from old_verify);
+
+ delete from user_session where not verified and created_at <= now() - '5 minutes'::interval;
+ delete from password_change_tokens where expires_at <= now();
+ delete from account_reset_tokens where expires_at <= now();
+end;
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..82cb340
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+testpaths = tests
+markers = invite
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..fa0dbda
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,8 @@
+match_block_trailing_comma = true
+use_small_heuristics = "Max"
+# all of these are unstable
+# blank_lines_upper_bound = 3
+# imports_granularity = "Crate"
+# overflow_delimited_expr = true
+# group_imports = "StdExternalCrate"
+# use_field_init_shorthand = true
diff --git a/sqlx-data.json b/sqlx-data.json
new file mode 100644
index 0000000..d0d64ea
--- /dev/null
+++ b/sqlx-data.json
@@ -0,0 +1,1801 @@
+{
+ "db": "PostgreSQL",
+ "0775cc960b9cbb1be71c4717f39fc3b1e5ff2f45fa653928bbafec112d5f6809": {
+ "describe": {
+ "columns": [
+ {
+ "name": "user_id: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "session_id: SessionID",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "code",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "email",
+ "ordinal": 3,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ true,
+ false,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select user_id as \"user_id: UserID\", session_id as \"session_id: SessionID\", code,\n email\n from verify_codes join users using (user_id)\n where user_id = $1"
+ },
+ "09b7b91867e6b72294de43a604dde5cfaacc1e75eca1b3d15dec6ddf127729e0": {
+ "describe": {
+ "columns": [
+ {
+ "name": "?column?",
+ "ordinal": 0,
+ "type_info": "Int4"
+ }
+ ],
+ "nullable": [
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "update user_session\n set verified = true, verify_code = null\n where session_id = $1\n returning 1"
+ },
+ "1a809571b386badb23dde5cec33f167ccdd7f195ab6c154f84dea0e9ca16b116": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "secret_key"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "secret_key"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "verify_hash"
+ }
+ },
+ "Text"
+ ]
+ }
+ },
+ "query": "insert into users (user_id, auth_salt, email, ka, wrapwrap_kb, verify_hash,\n display_name)\n values ($1, $2, $3, $4, $5, $6, $7)"
+ },
+ "1d661b638cff8f0ab00e7c580db305dcdcc38c2747f4fb6db26ef205749ed59a": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": []
+ }
+ },
+ "query": "call prune_expired_tokens()"
+ },
+ "1dbd6bcfd8f5b9ba10688a7e6ba6c6b2479c8a6de6dcdd6b77f727de421a9e13": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": []
+ }
+ },
+ "query": "call prune_expired_verify_codes()"
+ },
+ "20a6d965b2b3e9fe8c403c686695a032fcb1bf8774d92f39fdd867a0964a2e3e": {
+ "describe": {
+ "columns": [
+ {
+ "name": "?column?",
+ "ordinal": 0,
+ "type_info": "Int4"
+ }
+ ],
+ "nullable": [
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ }
+ },
+ "query": "delete from invite_codes where code = $1 and expires_at > now() returning 1"
+ },
+ "250d2e6c4d9184d534cc08c44d97c933e252923a097728a2c9663591cbc9fe34": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "device_id"
+ }
+ },
+ "Bytea"
+ ]
+ }
+ },
+ "query": "update user_session set device_id = $1 where session_id = $2"
+ },
+ "28115effdfc060af711db170ea566e6eaba0a8ed77966443033fe0409c3e4dbd": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "secret_key"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "verify_hash"
+ }
+ }
+ ]
+ }
+ },
+ "query": "call reset_user_auth($1, $2, $3, $4)"
+ },
+ "2c8a4f47fa20723d24beb958a3bedc0c36b40b211e8c3fea96f6a32d6ddaa684": {
+ "describe": {
+ "columns": [
+ {
+ "name": "?column?",
+ "ordinal": 0,
+ "type_info": "Int4"
+ }
+ ],
+ "nullable": [
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from device where user_id = $1 and device_id = $2 returning 1"
+ },
+ "2ca0ad391bc6433cb9caf4cc7b14ff12ef29f64ca721d50af45d023b693e6919": {
+ "describe": {
+ "columns": [
+ {
+ "name": "user_id: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "session_id: SessionID",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "code",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ true,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Text"
+ ]
+ }
+ },
+ "query": "delete from verify_codes\n where user_id = $1 and code = $2\n returning user_id as \"user_id: UserID\", session_id as \"session_id: SessionID\",\n code"
+ },
+ "2d99a9a6c5026bf018b999b6813798ea1dba411bae3961248ac552411b6d460a": {
+ "describe": {
+ "columns": [
+ {
+ "name": "user_id: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "client_id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "scope: ScopeSet",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "parent_refresh: OauthTokenID",
+ "ordinal": 3,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "parent_session: SessionID",
+ "ordinal": 4,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "expires_at!",
+ "ordinal": 5,
+ "type_info": "Timestamptz"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ true,
+ true
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select user_id as \"user_id: UserID\", client_id, scope as \"scope: ScopeSet\",\n parent_refresh as \"parent_refresh: OauthTokenID\",\n parent_session as \"parent_session: SessionID\",\n expires_at as \"expires_at!\"\n from oauth_token\n where id = $1 and kind = 'access' and expires_at > now()"
+ },
+ "2fb1e56d01c53f2d2b7c64409da6118018bd76c47108438d6664c5092c9f047e": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from oauth_token where id = $1"
+ },
+ "3346dd1d2775de8fbf9a36a95812aa55904e4d4db9e626cd69e04e896ba0fc5d": {
+ "describe": {
+ "columns": [
+ {
+ "name": "index",
+ "ordinal": 0,
+ "type_info": "Int8"
+ },
+ {
+ "name": "command",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "payload",
+ "ordinal": 2,
+ "type_info": "Json"
+ },
+ {
+ "name": "expires",
+ "ordinal": 3,
+ "type_info": "Timestamptz"
+ },
+ {
+ "name": "sender",
+ "ordinal": 4,
+ "type_info": "Varchar"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ true
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Bytea",
+ "Bytea",
+ "Int8"
+ ]
+ }
+ },
+ "query": "select index, command, payload, expires, sender\n from device_commands join device using (device_id)\n where index >= $1 and device_id = $2 and user_id = $3\n order by index\n limit $4"
+ },
+ "3a857907a2bf4014a0cd56f4fd7ff50fe41332e9d282a87fa1ee18fccf227cbe": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "oauth_auth_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Enum": [
+ "online",
+ "offline"
+ ]
+ },
+ "name": "oauth_access_type"
+ }
+ },
+ "Text",
+ "Text",
+ "Timestamptz"
+ ]
+ }
+ },
+ "query": "insert into oauth_authorization (id, user_id, client_id, scope, access_type,\n code_challenge, keys_jwe, auth_at)\n values ($1, $2, $3, $4, $5, $6, $7, $8)"
+ },
+ "3b1f4bf37ce4c8ccee3c7a39a69053fe36b5c0b4fcbbff9260096a68fa7c68a3": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "secret_key"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "verify_hash"
+ }
+ }
+ ]
+ }
+ },
+ "query": "update users\n set auth_salt = $2, wrapwrap_kb = $3, verify_hash = $4\n where user_id = $1"
+ },
+ "4ae6964de7c75cf6494732a16a7ea5372ebf5c6aefb7acbd4e4e722968967321": {
+ "describe": {
+ "columns": [
+ {
+ "name": "user_id: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "client_id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "scope: ScopeSet",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "session_id: SessionID",
+ "ordinal": 3,
+ "type_info": "Bytea"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ true
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select user_id as \"user_id: UserID\", client_id, scope as \"scope: ScopeSet\",\n session_id as \"session_id: SessionID\"\n from oauth_token\n where id = $1 and kind = 'refresh'"
+ },
+ "51b56a1a77411b06b6ebb93f986da2861ad75be55dc20b650eab87ba4fe0a8dd": {
+ "describe": {
+ "columns": [
+ {
+ "name": "auth_salt: String",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "email",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "ka: SecretKey",
+ "ordinal": 2,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "wrapwrap_kb: SecretKey",
+ "ordinal": 3,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "verify_hash: VerifyHash",
+ "ordinal": 4,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "display_name",
+ "ordinal": 5,
+ "type_info": "Text"
+ },
+ {
+ "name": "verified",
+ "ordinal": 6,
+ "type_info": "Bool"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select auth_salt as \"auth_salt: String\", email,\n ka as \"ka: SecretKey\", wrapwrap_kb as \"wrapwrap_kb: SecretKey\",\n verify_hash as \"verify_hash: VerifyHash\", display_name, verified\n from users\n where user_id = $1"
+ },
+ "6266ac82d35ed2d8f6d17f8c354b0a4c4386254a5078f33eca3b6aa57e18c916": {
+ "describe": {
+ "columns": [
+ {
+ "name": "device_id!: DeviceID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "name!",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "type_!",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "push: DevicePush",
+ "ordinal": 3,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "callback",
+ "Text"
+ ],
+ [
+ "public_key",
+ "Text"
+ ],
+ [
+ "auth_key",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_push_info"
+ }
+ }
+ },
+ {
+ "name": "available_commands!: DeviceCommands",
+ "ordinal": 4,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Array": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "name",
+ "Text"
+ ],
+ [
+ "body",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_command"
+ }
+ }
+ },
+ "name": "_device_command"
+ }
+ }
+ },
+ {
+ "name": "push_expired!",
+ "ordinal": 5,
+ "type_info": "Bool"
+ },
+ {
+ "name": "location!",
+ "ordinal": 6,
+ "type_info": "Jsonb"
+ },
+ {
+ "name": "last_active!",
+ "ordinal": 7,
+ "type_info": "Timestamptz"
+ }
+ ],
+ "nullable": [
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null
+ ],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "device_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "callback",
+ "Text"
+ ],
+ [
+ "public_key",
+ "Text"
+ ],
+ [
+ "auth_key",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_push_info"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Array": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "name",
+ "Text"
+ ],
+ [
+ "body",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_command"
+ }
+ }
+ },
+ "name": "_device_command"
+ }
+ },
+ "Jsonb"
+ ]
+ }
+ },
+ "query": "select device_id as \"device_id!: DeviceID\", name as \"name!\", type as \"type_!\",\n push as \"push: DevicePush\",\n available_commands as \"available_commands!: DeviceCommands\",\n push_expired as \"push_expired!\", location as \"location!\",\n coalesce(last_active, to_timestamp(0)) as \"last_active!\"\n from insert_or_update_device($1, $2, $3, $4, $5, $6, $7) as iud\n left join user_session using (device_id)"
+ },
+ "6490a0bfec6cf91bb4a0c507456be07fbbc7b742c0327cd4f912ef70e9d2394a": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Timestamptz"
+ ]
+ }
+ },
+ "query": "insert into invite_codes (code, expires_at) values ($1, $2)"
+ },
+ "67ebf7c646aa2e33dea84cea3c7af4a75b5e89509effd96d671112d4b7473d70": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "session_id"
+ }
+ },
+ "Text"
+ ]
+ }
+ },
+ "query": "insert into verify_codes (user_id, session_id, code)\n values ($1, $2, $3)"
+ },
+ "6e51008763091f55a4bc6db5ad719814430d3e910a67e1387ccda35547903a6c": {
+ "describe": {
+ "columns": [
+ {
+ "name": "id: AvatarID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "data",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "content_type",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select id as \"id: AvatarID\", data, content_type\n from user_avatars\n where id = $1"
+ },
+ "6e84f90266300a757d50bb602d2b6aa1a9c086246778abf073354942f106aa32": {
+ "describe": {
+ "columns": [
+ {
+ "name": "client_id?",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "device_id?: DeviceID",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "session_token_id?: SessionID",
+ "ordinal": 2,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "refresh_token_id?: OauthTokenID",
+ "ordinal": 3,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "device_type?",
+ "ordinal": 4,
+ "type_info": "Text"
+ },
+ {
+ "name": "name?",
+ "ordinal": 5,
+ "type_info": "Text"
+ },
+ {
+ "name": "created_time?",
+ "ordinal": 6,
+ "type_info": "Timestamptz"
+ },
+ {
+ "name": "last_access_time?",
+ "ordinal": 7,
+ "type_info": "Timestamptz"
+ },
+ {
+ "name": "scope?",
+ "ordinal": 8,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ null,
+ true,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select\n ot.client_id as \"client_id?\",\n d.device_id as \"device_id?: DeviceID\",\n us.session_id as \"session_token_id?: SessionID\",\n ot.id as \"refresh_token_id?: OauthTokenID\",\n d.type as \"device_type?\",\n d.name as \"name?\",\n coalesce(d.created_at, us.created_at, ot.created_at) as \"created_time?\",\n us.last_active as \"last_access_time?\",\n ot.scope as \"scope?\"\n from device d\n full outer join user_session us on (d.device_id = us.device_id)\n full outer join oauth_token ot on (us.session_id = ot.session_id)\n where\n (ot.kind is null or ot.kind = 'refresh')\n and $1 in (d.user_id, us.user_id, ot.user_id)\n order by d.device_id"
+ },
+ "71274c082aa53ef6d50aedf935ea7ae8fc2b4abb9549083d4e04d0c52907db30": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Text"
+ ]
+ }
+ },
+ "query": "update users\n set display_name = $2\n where user_id = $1"
+ },
+ "71531a84073d291f64f413f39af5e634eb5c55b1003cd70a81e32990555ce754": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "key_fetch_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "hawk_key"
+ }
+ },
+ "Bytea"
+ ]
+ }
+ },
+ "query": "insert into key_fetch (id, hmac_key, keys) values ($1, $2, $3)"
+ },
+ "762e8a42405b37ed74696f8f09aab8de82cbb564b21e5126c1ff0faeb7c872a1": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "avatar_id"
+ }
+ },
+ "Bytea",
+ "Text"
+ ]
+ }
+ },
+ "query": "insert into user_avatars (user_id, id, data, content_type)\n values ($1, $2, $3, $4)\n on conflict (user_id) do update set\n id = $2, data = $3, content_type = $4"
+ },
+ "7cb2caaa204ec71a49a4e70426c549e5899f9c2c606055aa217e1349f6774dc5": {
+ "describe": {
+ "columns": [
+ {
+ "name": "?column?",
+ "ordinal": 0,
+ "type_info": "Int4"
+ }
+ ],
+ "nullable": [
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "update users set verified = true where user_id = $1 returning 1"
+ },
+ "7d5dde8d94f2e93415c7e6a2e52bb998d7790bd2fb37b9eaf6270331210ebd2b": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ }
+ },
+ "query": "delete from users where email = lower($1)"
+ },
+ "8234a164578e19107e610122ae2f07c0ac77dda7260b3eba70cc51fd4b17e15e": {
+ "describe": {
+ "columns": [
+ {
+ "name": "hmac_key: HawkKey",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "user_id: UserID",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "forgot_code",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ true
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Bool"
+ ]
+ }
+ },
+ "query": "delete from password_change_tokens\n where id = $1 and expires_at > now() and (forgot_code is not null) = $2\n returning hmac_key as \"hmac_key: HawkKey\", user_id as \"user_id: UserID\",\n forgot_code"
+ },
+ "86eb609c6a307fac53b3bf7d673683b1a69a695eb02a6832cc5efc982abb3fa9": {
+ "describe": {
+ "columns": [
+ {
+ "name": "index",
+ "ordinal": 0,
+ "type_info": "Int8"
+ }
+ ],
+ "nullable": [
+ false
+ ],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "device_id"
+ }
+ },
+ "Text",
+ "Json",
+ "Timestamptz",
+ "Varchar"
+ ]
+ }
+ },
+ "query": "insert into device_commands (device_id, command, payload, expires, sender)\n values ($1, $2, $3, $4, $5)\n returning index"
+ },
+ "8b838aca5cdd674e44fed8f8c4bca06e0534c48d66c1c8a11b0f13ae67c4dc06": {
+ "describe": {
+ "columns": [
+ {
+ "name": "device_id: DeviceID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "name",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "type_",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "push: DevicePush",
+ "ordinal": 3,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "callback",
+ "Text"
+ ],
+ [
+ "public_key",
+ "Text"
+ ],
+ [
+ "auth_key",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_push_info"
+ }
+ }
+ },
+ {
+ "name": "available_commands: DeviceCommands",
+ "ordinal": 4,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Array": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "name",
+ "Text"
+ ],
+ [
+ "body",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_command"
+ }
+ }
+ },
+ "name": "_device_command"
+ }
+ }
+ },
+ {
+ "name": "push_expired",
+ "ordinal": 5,
+ "type_info": "Bool"
+ },
+ {
+ "name": "location",
+ "ordinal": 6,
+ "type_info": "Jsonb"
+ },
+ {
+ "name": "last_active!",
+ "ordinal": 7,
+ "type_info": "Timestamptz"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select d.device_id as \"device_id: DeviceID\", d.name, d.type as type_,\n d.push as \"push: DevicePush\",\n d.available_commands as \"available_commands: DeviceCommands\",\n d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as \"last_active!\"\n from device d left join user_session us using (device_id)\n where d.user_id = $1"
+ },
+ "8bdcb263a11f3923370a538efcd441b87d6250a8c04f0ff61353dd60e3521111": {
+ "describe": {
+ "columns": [
+ {
+ "name": "hmac_key: HawkKey",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "keys",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ }
+ ],
+ "nullable": [
+ false,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from key_fetch\n where id = $1 and expires_at > now()\n returning hmac_key as \"hmac_key: HawkKey\", keys"
+ },
+ "920895da4fe20ddbb0ffaaa49bc879c28a7bc5ba4bc65331632c7ba5ea1ba1f6": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "update device\n set push_expired = true\n where device_id = $1"
+ },
+ "926453dabc0466c763e22605c9845b79e1e755a395cb0dbc679cab7fc7b52c02": {
+ "describe": {
+ "columns": [
+ {
+ "name": "?column?",
+ "ordinal": 0,
+ "type_info": "Int4"
+ }
+ ],
+ "nullable": [
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from user_session\n where user_id = $1 and session_id = $2\n returning 1"
+ },
+ "9633dc7be0139f99b6ed0ab472620825ffe327aa2e6a81f3151c664f1f5fcacf": {
+ "describe": {
+ "columns": [
+ {
+ "name": "uid: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "req_hmac_key: HawkKey",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "device_id: DeviceID",
+ "ordinal": 2,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "session_id: SessionID",
+ "ordinal": 3,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "created_at",
+ "ordinal": 4,
+ "type_info": "Timestamptz"
+ },
+ {
+ "name": "verified",
+ "ordinal": 5,
+ "type_info": "Bool"
+ },
+ {
+ "name": "verify_code",
+ "ordinal": 6,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ true
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "update user_session\n set last_active = now()\n where session_id = (\n select session_id from oauth_token where kind = 'refresh' and id = $1\n )\n returning user_id as \"uid: UserID\", req_hmac_key as \"req_hmac_key: HawkKey\",\n device_id as \"device_id: DeviceID\", session_id as \"session_id: SessionID\",\n created_at, verified, verify_code"
+ },
+ "9842eb82258490f84375f203e6bfb34127e6389b8f82c18a7be80eb20ed87cf3": {
+ "describe": {
+ "columns": [
+ {
+ "name": "id: AvatarID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ }
+ ],
+ "nullable": [
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select id as \"id: AvatarID\" from user_avatars where user_id = $1"
+ },
+ "a68e2238885dba26b94ddc0534c84dd3a992c338f58ab33ef31cac5c8538dfd3": {
+ "describe": {
+ "columns": [
+ {
+ "name": "user_id: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "client_id",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "scope: ScopeSet",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "access_type: OauthAccessType",
+ "ordinal": 3,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Enum": [
+ "online",
+ "offline"
+ ]
+ },
+ "name": "oauth_access_type"
+ }
+ }
+ },
+ {
+ "name": "code_challenge",
+ "ordinal": 4,
+ "type_info": "Text"
+ },
+ {
+ "name": "keys_jwe",
+ "ordinal": 5,
+ "type_info": "Text"
+ },
+ {
+ "name": "auth_at",
+ "ordinal": 6,
+ "type_info": "Timestamptz"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from oauth_authorization\n where id = $1 and expires_at > now()\n returning user_id as \"user_id: UserID\", client_id, scope as \"scope: ScopeSet\",\n access_type as \"access_type: OauthAccessType\",\n code_challenge, keys_jwe, auth_at"
+ },
+ "ad00d5caef2f8078846c751bec410e0e2a2083f9de1ab7ede6505eca754fd145": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from oauth_token where id = $1 and kind = 'refresh'"
+ },
+ "b272e9b3f4f75818993bdb73301eb117422870ec28c36e9174eab7963dcc037b": {
+ "describe": {
+ "columns": [
+ {
+ "name": "device_id: DeviceID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "name",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "type_",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "push: DevicePush",
+ "ordinal": 3,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "callback",
+ "Text"
+ ],
+ [
+ "public_key",
+ "Text"
+ ],
+ [
+ "auth_key",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_push_info"
+ }
+ }
+ },
+ {
+ "name": "available_commands: DeviceCommands",
+ "ordinal": 4,
+ "type_info": {
+ "Custom": {
+ "kind": {
+ "Array": {
+ "Custom": {
+ "kind": {
+ "Composite": [
+ [
+ "name",
+ "Text"
+ ],
+ [
+ "body",
+ "Text"
+ ]
+ ]
+ },
+ "name": "device_command"
+ }
+ }
+ },
+ "name": "_device_command"
+ }
+ }
+ },
+ {
+ "name": "push_expired",
+ "ordinal": 5,
+ "type_info": "Bool"
+ },
+ {
+ "name": "location",
+ "ordinal": 6,
+ "type_info": "Jsonb"
+ },
+ {
+ "name": "last_active!",
+ "ordinal": 7,
+ "type_info": "Timestamptz"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ false,
+ false,
+ false,
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Bytea"
+ ]
+ }
+ },
+ "query": "select d.device_id as \"device_id: DeviceID\", d.name, d.type as type_,\n d.push as \"push: DevicePush\",\n d.available_commands as \"available_commands: DeviceCommands\",\n d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as \"last_active!\"\n from device d left join user_session us using (device_id)\n where d.user_id = $1 and d.device_id = $2"
+ },
+ "b77775fe6421918088a2ef58b1fef5fd49341b0a614a083c9d0149939a515448": {
+ "describe": {
+ "columns": [
+ {
+ "name": "?column?",
+ "ordinal": 0,
+ "type_info": "Int4"
+ }
+ ],
+ "nullable": [
+ null
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ }
+ },
+ "query": "select 1 from users where email = lower($1)"
+ },
+ "bac51521196d267e983f2118939d59a3f992b1180ddeba257eab5fc3cfc4382b": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "password_change_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "hawk_key"
+ }
+ },
+ "Text"
+ ]
+ }
+ },
+ "query": "insert into password_change_tokens (id, user_id, hmac_key, forgot_code)\n values ($1, $2, $3, $4)\n on conflict (user_id) do update set id = $1, hmac_key = $3, forgot_code = $4,\n expires_at = default"
+ },
+ "c032ea41fcf2c4ac42c0fa14f1ef88a8589306888b8853b1c3aa3195b1db2f24": {
+ "describe": {
+ "columns": [
+ {
+ "name": "id: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "auth_salt: String",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "email",
+ "ordinal": 2,
+ "type_info": "Text"
+ },
+ {
+ "name": "ka: SecretKey",
+ "ordinal": 3,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "wrapwrap_kb: SecretKey",
+ "ordinal": 4,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "verify_hash: VerifyHash",
+ "ordinal": 5,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "display_name",
+ "ordinal": 6,
+ "type_info": "Text"
+ },
+ {
+ "name": "verified",
+ "ordinal": 7,
+ "type_info": "Bool"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ }
+ },
+ "query": "select user_id as \"id: UserID\", auth_salt as \"auth_salt: String\", email,\n ka as \"ka: SecretKey\", wrapwrap_kb as \"wrapwrap_kb: SecretKey\",\n verify_hash as \"verify_hash: VerifyHash\", display_name, verified\n from users\n where email = lower($1)"
+ },
+ "cd0c9015a7736e16fc571db03f333c7e0e57e1ea4c4d5f7eebf8eadb5e2c9b3e": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "oauth_token_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "oauth_token_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "session_id"
+ }
+ },
+ "Timestamptz"
+ ]
+ }
+ },
+ "query": "insert into oauth_token (id, kind, user_id, client_id, scope, session_id,\n parent_refresh, parent_session, expires_at)\n values ($1, 'access', $2, $3, $4, null, $5, $6, $7)"
+ },
+ "d159085f88600222a47bb0ba50b07bcbb4076076c460a1880b13ed44fbcbeac1": {
+ "describe": {
+ "columns": [
+ {
+ "name": "uid: UserID",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "req_hmac_key: HawkKey",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "device_id: DeviceID",
+ "ordinal": 2,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "created_at",
+ "ordinal": 3,
+ "type_info": "Timestamptz"
+ },
+ {
+ "name": "verified",
+ "ordinal": 4,
+ "type_info": "Bool"
+ },
+ {
+ "name": "verify_code",
+ "ordinal": 5,
+ "type_info": "Text"
+ }
+ ],
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ false,
+ true
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "update user_session\n set last_active = now()\n where session_id = $1\n returning user_id as \"uid: UserID\", req_hmac_key as \"req_hmac_key: HawkKey\",\n device_id as \"device_id: DeviceID\", created_at, verified, verify_code"
+ },
+ "e70f7ba02ef7aa6caf9dc1c9ac6bf0211fc170d4ed2298d6954501360f371743": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "account_reset_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "hawk_key"
+ }
+ }
+ ]
+ }
+ },
+ "query": "insert into account_reset_tokens (id, user_id, hmac_key)\n values ($1, $2, $3)\n on conflict (user_id) do update set id = $1, hmac_key = $3, expires_at = default"
+ },
+ "eb7efdefa66929158d12c37e4105e9596f922abf477d27ff7d9807e572dff81b": {
+ "describe": {
+ "columns": [
+ {
+ "name": "created_at",
+ "ordinal": 0,
+ "type_info": "Timestamptz"
+ }
+ ],
+ "nullable": [
+ false
+ ],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "session_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "hawk_key"
+ }
+ },
+ "Bool",
+ "Text"
+ ]
+ }
+ },
+ "query": "insert into user_session (session_id, user_id, req_hmac_key, device_id, verified,\n verify_code)\n values ($1, $2, $3, null, $4, $5)\n returning created_at"
+ },
+ "ed1bbab2b586333c18c012b4fe7b7a75cf949795f8e19ca90b4869486b28f988": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ "Bytea",
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from user_avatars where user_id = $1 and id = $2"
+ },
+ "f27cbc16ac657f8b8e60df8e9564638a9fef007f7ef01b6d4f6dc1a7fe7a34b3": {
+ "describe": {
+ "columns": [],
+ "nullable": [],
+ "parameters": {
+ "Left": [
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "oauth_token_id"
+ }
+ },
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "user_id"
+ }
+ },
+ "Text",
+ "Text",
+ {
+ "Custom": {
+ "kind": {
+ "Domain": "Bytea"
+ },
+ "name": "session_id"
+ }
+ }
+ ]
+ }
+ },
+ "query": "insert into oauth_token (id, kind, user_id, client_id, scope, session_id)\n values ($1, 'refresh', $2, $3, $4, $5)"
+ },
+ "ff68e826c4762e58595b4c90180503ee38f254f848fd50d0eb97221bb50e23a0": {
+ "describe": {
+ "columns": [
+ {
+ "name": "hmac_key: HawkKey",
+ "ordinal": 0,
+ "type_info": "Bytea"
+ },
+ {
+ "name": "user_id: UserID",
+ "ordinal": 1,
+ "type_info": "Bytea"
+ }
+ ],
+ "nullable": [
+ false,
+ false
+ ],
+ "parameters": {
+ "Left": [
+ "Bytea"
+ ]
+ }
+ },
+ "query": "delete from account_reset_tokens\n where id = $1 and expires_at > now()\n returning hmac_key as \"hmac_key: HawkKey\", user_id as \"user_id: UserID\""
+ }
+} \ No newline at end of file
diff --git a/src/api/auth/account.rs b/src/api/auth/account.rs
new file mode 100644
index 0000000..51dd98e
--- /dev/null
+++ b/src/api/auth/account.rs
@@ -0,0 +1,413 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use password_hash::SaltString;
+use rand::{thread_rng, Rng};
+use rocket::request::FromRequest;
+use rocket::State;
+use rocket::{serde::json::Json, Request};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+use crate::api::{Empty, EMPTY};
+use crate::db::{Db, DbConn};
+use crate::mailer::Mailer;
+use crate::push::PushClient;
+use crate::types::AccountResetID;
+use crate::utils::DeferAction;
+use crate::Config;
+use crate::{
+ api::{auth, serialize_dt},
+ auth::{AuthSource, Authenticated},
+ crypto::{AuthPW, KeyBundle, KeyFetchReq, SecretBytes, SessionCredentials},
+ types::{HawkKey, KeyFetchID, OauthToken, SecretKey, SessionID, User, UserID, VerifyHash},
+};
+
+// TODO better error handling
+
+// MISSING get /account/profile
+// MISSING get /account/status
+// MISSING post /account/status
+// MISSING post /account/reset
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Create {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING metricsContext
+ // NOTE we misuse style to communicate an invite token!
+ style: Option<String>,
+ // MISSING verificationMethod
+}
+
+#[derive(Serialize, Debug)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CreateResp {
+ uid: UserID,
+ sessionToken: SecretBytes<32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keyFetchToken: Option<SecretBytes<32>>,
+ #[serde(serialize_with = "serialize_dt")]
+ authAt: DateTime<Utc>,
+ // MISSING verificationMethod
+}
+
+// MISSING arg: service
+#[post("/account/create?<keys>", data = "<data>")]
+pub(crate) async fn create(
+ db: &DbConn,
+ cfg: &State<Config>,
+ mailer: &State<Arc<Mailer>>,
+ keys: Option<bool>,
+ data: Json<Create>,
+) -> auth::Result<CreateResp> {
+ let keys = keys.unwrap_or(false);
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ if db.user_email_exists(&data.email).await? {
+ return Err(auth::Error::AccountExists);
+ }
+
+ match (cfg.invite_only, data.style) {
+ (false, Some(_)) => return Err(auth::Error::InvalidParameter),
+ (false, None) => (),
+ (true, None) => return Err(auth::Error::InviteOnly),
+ (true, Some(code)) => {
+ db.use_invite_code(&code).await.map_err(|e| match e {
+ sqlx::Error::RowNotFound => auth::Error::InviteNotFound,
+ e => auth::Error::Other(anyhow!(e)),
+ })?;
+ },
+ }
+
+ let ka = SecretBytes::generate();
+ let wrapwrap_kb = SecretBytes::generate();
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let key_fetch_token = if keys {
+ let key_fetch_token = SecretBytes::generate();
+ let req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = req.derive_resp().wrap_keys(&KeyBundle {
+ ka: ka.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&wrapwrap_kb),
+ });
+ db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?;
+ Some(key_fetch_token)
+ } else {
+ None
+ };
+ let uid = db
+ .add_user(User {
+ auth_salt,
+ email: data.email.to_owned(),
+ ka: SecretKey(ka),
+ wrapwrap_kb: SecretKey(wrapwrap_kb),
+ verify_hash: VerifyHash(verify_hash),
+ display_name: None,
+ verified: false,
+ })
+ .await?;
+ let session_id = SessionID(session.token_id.0);
+ let auth_at = db
+ .add_session(session_id.clone(), &uid, HawkKey(session.req_hmac_key), false, None)
+ .await?;
+ let verify_code = hex::encode(&SecretBytes::<16>::generate().0);
+ db.add_verify_code(&uid, &session_id, &verify_code).await?;
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_account_verify(&uid, &data.email, &verify_code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(Json(CreateResp {
+ uid,
+ sessionToken: session_token,
+ keyFetchToken: key_fetch_token,
+ authAt: auth_at,
+ }))
+}
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Login {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING reason
+ // MISSING unblockCode
+ // MISSING originalLoginEmail
+ // MISSING verificationMethod
+}
+
+#[derive(Serialize, Debug)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct LoginResp {
+ uid: UserID,
+ sessionToken: SecretBytes<32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keyFetchToken: Option<SecretBytes<32>>,
+ // MISSING verificationMethod
+ // MISSING verificationReason
+ // NOTE this is the *account* verified status, not the session status.
+ // the spec doesn't say.
+ verified: bool,
+ #[serde(serialize_with = "serialize_dt")]
+ authAt: DateTime<Utc>,
+ // MISSING metricsEnabled
+}
+
+// MISSING arg: service
+// MISSING arg: verificationMethod
+#[post("/account/login?<keys>", data = "<data>")]
+pub(crate) async fn login(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ keys: Option<bool>,
+ data: Json<Login>,
+) -> auth::Result<LoginResp> {
+ let keys = keys.unwrap_or(false);
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+ if !user.verified {
+ return Err(auth::Error::UnverifiedAccount);
+ }
+
+ let stretched = data.authPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let key_fetch_token = if keys {
+ let key_fetch_token = SecretBytes::generate();
+ let req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = req.derive_resp().wrap_keys(&KeyBundle {
+ ka: user.ka.0.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0),
+ });
+ db.add_key_fetch(KeyFetchID(req.token_id.0), &HawkKey(req.req_hmac_key), &wrapped).await?;
+ Some(key_fetch_token)
+ } else {
+ None
+ };
+
+ let session_id = SessionID(session.token_id.0);
+ let verify_code = format!("{:06}", thread_rng().gen_range(0..=999999));
+ let auth_at = db
+ .add_session(
+ session_id.clone(),
+ &uid,
+ HawkKey(session.req_hmac_key),
+ false,
+ Some(&verify_code),
+ )
+ .await?;
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_session_verify(&data.email, &verify_code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(Json(LoginResp {
+ uid,
+ sessionToken: session_token,
+ keyFetchToken: key_fetch_token,
+ verified: true,
+ authAt: auth_at,
+ }))
+}
+
+#[derive(Deserialize, Debug, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct Destroy {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ authPW: AuthPW,
+}
+
+// TODO may also be authenticated with a verified session
+#[post("/account/destroy", data = "<data>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ pc: &State<Arc<PushClient>>,
+ data: Json<Destroy>,
+) -> auth::Result<Empty> {
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+
+ let stretched = data.authPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let devs = db.get_devices(&uid).await;
+ db.delete_user(&data.email).await?;
+ match devs {
+ Ok(devs) => defer.spawn_after_success("api::account/destroy(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.account_destroyed(&devs, &uid).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("account_destroyed push failed: {e}"),
+ }
+
+ Ok(EMPTY)
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct KeysResp {
+ bundle: String,
+}
+
+// NOTE the key fetch endpoint must delete a key fetch token from the database
+// once it has identified it, regardless of whether the request succeeds or
+// fails. we'll do this with a single-use auth source that sets the db to always
+// commit. the auth source must not be used for anything else. we can get away
+// with using a request guard because we'll always commit even if the guard
+// fails, but this is only allowable because this is the only handler for the path.
+
+#[derive(Debug)]
+pub(crate) struct WithKeyFetch;
+
+#[async_trait]
+impl AuthSource for WithKeyFetch {
+ type ID = KeyFetchID;
+ type Context = Vec<u8>;
+ async fn hawk(r: &Request<'_>, id: &KeyFetchID) -> Result<(SecretBytes<32>, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ db.always_commit().await?;
+ Ok(db.finish_key_fetch(id).await.map(|(h, ks)| (h.0, ks))?)
+ }
+ async fn bearer_token(_: &Request<'_>, _: &OauthToken) -> Result<(KeyFetchID, Self::Context)> {
+ // key fetch tokens are only valid in hawk requests
+ bail!("invalid key fetch authentication")
+ }
+}
+
+#[get("/account/keys")]
+pub(crate) async fn keys(auth: Authenticated<(), WithKeyFetch>) -> Json<KeysResp> {
+ // NOTE contrary to its own api spec fxa does not delete a key fetch if the
+ // associated session is not verified. we don't duplicate this special case
+ // because we control the clients, and requesting keys on an unverified session
+ // can be interpreted as a protocol violation anyway.
+ Json(KeysResp { bundle: hex::encode(&auth.context) })
+}
+
+#[derive(Debug)]
+pub(crate) struct WithResetToken;
+
+#[async_trait]
+impl AuthSource for WithResetToken {
+ type ID = AccountResetID;
+ type Context = UserID;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &AccountResetID,
+ ) -> Result<(SecretBytes<32>, Self::Context)> {
+ // unlike key fetch we'll use a separate transaction here since the body of the
+ // handler can fail.
+ let pool = <&Db as FromRequest>::from_request(r)
+ .await
+ .success_or_else(|| anyhow!("could not open db connection"))?;
+ let db = pool.begin().await?;
+ let result = db.finish_account_reset(id).await.map(|(h, ctx)| (h.0, ctx))?;
+ db.commit().await?;
+ Ok(result)
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> Result<(AccountResetID, Self::Context)> {
+ bail!("invalid password change authentication")
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct AccountResetReq {
+ authPW: AuthPW,
+ // MISSING wrapKb
+ // MISSING recoveryKeyId
+ // MISSING sessionToken
+}
+
+// NOTE resetting an account does not clear active sync data on the storage server,
+// so an account may be reported as disconnected for a while. this is not an error,
+// just an inconvenience we haven't found out how to fix yet.
+
+// MISSING arg: keys
+#[post("/account/reset", data = "<data>")]
+pub(crate) async fn reset(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ client: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ data: Authenticated<AccountResetReq, WithResetToken>,
+) -> auth::Result<Empty> {
+ let user = db.get_user_by_id(&data.context).await?;
+
+ let notify_devs = db.get_devices(&data.context).await?;
+
+ let wrapwrap_kb = SecretBytes::generate();
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.body.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+
+ db.reset_user_auth(&data.context, auth_salt, SecretKey(wrapwrap_kb), VerifyHash(verify_hash))
+ .await?;
+
+ defer.spawn_after_success("api::auth/account/reset(post)", {
+ let client = Arc::clone(client);
+ async move {
+ client.password_reset(&notify_devs).await;
+ Ok(())
+ }
+ });
+
+ mailer
+ .send_account_reset(&user.email)
+ .await
+ .map_err(|e| {
+ warn!("account reset email send failed: {e}");
+ })
+ .ok();
+
+ Ok(EMPTY)
+}
diff --git a/src/api/auth/device.rs b/src/api/auth/device.rs
new file mode 100644
index 0000000..2b05e12
--- /dev/null
+++ b/src/api/auth/device.rs
@@ -0,0 +1,455 @@
+use std::time::Duration;
+use std::{collections::HashMap, sync::Arc};
+
+use chrono::{DateTime, Utc};
+use futures::future::join_all;
+use rocket::{serde::json::Json, State};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+use crate::api::auth::{WithSession, WithVerifiedFxaLogin, WithVerifiedSession};
+use crate::api::{Empty, EMPTY};
+use crate::db::DbConn;
+use crate::push::PushClient;
+use crate::utils::DeferAction;
+use crate::{
+ api::{auth, serialize_dt_opt},
+ auth::Authenticated,
+ db::Db,
+ types::{
+ Device, DeviceCommand, DeviceCommands, DeviceID, DevicePush, DeviceUpdate, OauthTokenID,
+ SessionID,
+ },
+};
+
+fn map_error(e: sqlx::Error) -> auth::Error {
+ match &e {
+ // not-null violations can presumably only be caused by bad parameters
+ sqlx::Error::Database(de) if de.code().as_deref() == Some("23502") => {
+ auth::Error::MissingParameter
+ },
+ sqlx::Error::RowNotFound => auth::Error::UnknownDevice,
+ _ => auth::Error::Other(anyhow!(e)),
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Info {
+ isCurrentDevice: bool,
+ id: DeviceID,
+ lastAccessTime: i64,
+ name: String,
+ r#type: String,
+ pushCallback: Option<String>,
+ pushPublicKey: Option<String>,
+ pushAuthKey: Option<String>,
+ pushEndpointExpired: bool,
+ availableCommands: HashMap<String, String>,
+ // NOTE location is optional per the spec, but fenix crashes if it isn't present
+ location: Value,
+ // MISSING lastAccessTimeFormatted
+ // MISSING approximateLastAccessTime
+ // MISSING approximateLastAccessTimeFormatted
+}
+
+fn device_to_json(current: Option<&DeviceID>, dev: Device) -> Info {
+ let (pcb, ppk, pak) = match dev.push {
+ Some(p) => (Some(p.callback), Some(p.public_key), Some(p.auth_key)),
+ None => (None, None, None),
+ };
+ Info {
+ isCurrentDevice: Some(&dev.device_id) == current,
+ id: dev.device_id,
+ lastAccessTime: dev.last_active.timestamp(),
+ name: dev.name,
+ r#type: dev.type_,
+ pushCallback: pcb,
+ pushPublicKey: ppk,
+ pushAuthKey: pak,
+ pushEndpointExpired: dev.push_expired,
+ availableCommands: dev.available_commands.into_map(),
+ location: dev.location,
+ }
+}
+
+#[derive(Serialize, Deserialize, PartialEq)]
+#[serde(transparent)]
+pub(crate) struct ListResp(Vec<Info>);
+
+#[get("/account/devices")]
+pub(crate) async fn devices(
+ db: &DbConn,
+ auth: Authenticated<(), WithVerifiedSession>,
+) -> auth::Result<ListResp> {
+ let devs = db.get_devices(&auth.context.uid).await?;
+ Ok(Json(ListResp(
+ devs.into_iter().map(|dev| device_to_json(auth.context.device_id.as_ref(), dev)).collect(),
+ )))
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DeviceReq {
+ id: Option<DeviceID>,
+ name: Option<String>,
+ r#type: Option<String>,
+ pushCallback: Option<String>,
+ pushPublicKey: Option<String>,
+ pushAuthKey: Option<String>,
+ availableCommands: Option<HashMap<String, String>>,
+ // present for legacy reasons, ignored
+ #[allow(dead_code)]
+ capabilities: Option<Vec<String>>,
+ location: Option<Value>,
+}
+
+#[post("/account/device", data = "<data>")]
+pub(crate) async fn device(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ // need to allow registrations to all sessions, otherwise the "now verified"
+ // notification can't be sent
+ data: Authenticated<DeviceReq, WithSession>,
+) -> auth::Result<Info> {
+ let dev = data.body;
+ if let (None, None, None) = (&dev.name, &dev.r#type, &dev.pushCallback) {
+ return Err(auth::Error::MissingParameter);
+ }
+
+ let push = dev.pushCallback.map(|pcb| DevicePush {
+ callback: pcb,
+ public_key: dev.pushPublicKey.unwrap_or_default(),
+ auth_key: dev.pushAuthKey.unwrap_or_default(),
+ });
+
+ let (own_id, changed_id, notify) = match (dev.id, data.context.device_id) {
+ (None, None) => {
+ let new = DeviceID::random();
+ (Some(new.clone()), new, true)
+ },
+ (None, Some(own)) => (Some(own.clone()), own, false),
+ (Some(other), own) => (own, other, false),
+ };
+ let result = db
+ .change_device(
+ &data.context.uid,
+ &changed_id,
+ DeviceUpdate {
+ name: dev.name.as_ref().map(AsRef::as_ref),
+ type_: dev.r#type.as_ref().map(AsRef::as_ref),
+ push,
+ available_commands: dev.availableCommands.map(DeviceCommands),
+ location: dev.location,
+ },
+ )
+ .await
+ .map_err(map_error)?;
+ if notify {
+ db.set_session_device(&data.session, Some(&changed_id)).await?;
+ match db.get_devices(&data.context.uid).await {
+ Err(e) => warn!("device_connected push failed: {e}"),
+ Ok(mut devs) => defer.spawn_after_success("api::auth/account/device(post)", {
+ devs.retain(|d| d.device_id != changed_id);
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ let name = result.name.clone();
+ async move {
+ let db = db.begin().await?;
+ client.device_connected(&db, &devs, &name).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ };
+ }
+ Ok(Json(device_to_json(own_id.as_ref(), result)))
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct Command {
+ target: DeviceID,
+ command: String,
+ payload: Value,
+ ttl: Option<u32>,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[allow(non_snake_case)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct InvokeResp {
+ enqueued: bool,
+ notified: bool,
+ notifyError: Option<String>,
+}
+
+// NOTE fenix doesn't register a push callback for some reason, so receiving tabs
+// always requires opening the tab share menu or tab list first.
+#[post("/account/devices/invoke_command", data = "<cmd>")]
+pub(crate) async fn invoke(
+ client: &State<Arc<PushClient>>,
+ db: &DbConn,
+ cmd: Authenticated<Command, WithVerifiedSession>,
+) -> auth::Result<InvokeResp> {
+ let sender = cmd.context.device_id;
+ let dev = db.get_device(&cmd.context.uid, &cmd.body.target).await.map_err(map_error)?;
+ if dev.available_commands.get(&cmd.body.command).is_none() {
+ return Err(auth::Error::NoDeviceCommand);
+ }
+ let ttl = cmd.body.ttl.unwrap_or(30 * 86400).clamp(60, 30 * 86400);
+ let idx = db
+ .enqueue_command(&cmd.body.target, &sender, &cmd.body.command, &cmd.body.payload, ttl)
+ .await?;
+ let (notified, error) = client
+ .command_received(db, &dev, &cmd.body.command, idx, &sender)
+ .await
+ .map_or_else(|e| (false, Some(e.to_string())), |_| (true, None));
+ Ok(Json(InvokeResp { enqueued: true, notified, notifyError: error }))
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CommandData {
+ command: String,
+ payload: Value,
+ sender: Option<String>,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CommandsEntry {
+ index: i64,
+ data: CommandData,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct CommandsResp {
+ index: i64,
+ last: bool,
+ messages: Vec<CommandsEntry>,
+}
+
+fn map_command(c: DeviceCommand) -> CommandsEntry {
+ CommandsEntry {
+ index: c.index,
+ data: CommandData { command: c.command, payload: c.payload, sender: c.sender },
+ }
+}
+
+#[get("/account/device/commands?<index>&<limit>")]
+pub(crate) async fn commands(
+ db: &DbConn,
+ index: i64,
+ limit: Option<i64>,
+ auth: Authenticated<(), WithVerifiedSession>,
+) -> auth::Result<CommandsResp> {
+ let dev = auth.context.device_id.as_ref().ok_or(auth::Error::UnknownDevice)?;
+ let (more, cmds) =
+ db.get_commands(&auth.context.uid, dev, index, limit.unwrap_or(100).clamp(0, 100)).await?;
+ Ok(Json(CommandsResp {
+ index: cmds.iter().map(|c| c.index).max().unwrap_or(0),
+ last: !more,
+ messages: cmds.into_iter().map(map_command).collect(),
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DestroyReq {
+ id: DeviceID,
+}
+
+#[post("/account/device/destroy", data = "<req>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ req: crate::auth::Authenticated<DestroyReq, WithVerifiedSession>,
+) -> auth::Result<Empty> {
+ db.delete_device(&req.context.uid, &req.body.id).await.map_err(map_error)?;
+ match db.get_devices(&req.context.uid).await {
+ Err(e) => warn!("device_disconnected push failed: {e}"),
+ Ok(devs) => defer.spawn_after_success("api::auth/account/device/destroy(post)", {
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ client.device_disconnected(&db, &devs, &req.body.id).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ };
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum NotifyTarget {
+ #[serde(rename = "all")]
+ All,
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum NotifyEPAction {
+ #[serde(rename = "accountVerify")]
+ AccountVerify,
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(non_snake_case)]
+#[serde(untagged, deny_unknown_fields)]
+pub(crate) enum NotifyReq {
+ // deny_unknown_fields and flatten don't work together
+ All {
+ #[allow(dead_code)]
+ to: NotifyTarget,
+ _endpointAction: Option<NotifyEPAction>,
+ excluded: Option<Vec<DeviceID>>,
+ payload: Value,
+ TTL: Option<u32>,
+ },
+ Some {
+ to: Vec<DeviceID>,
+ _endpointAction: Option<NotifyEPAction>,
+ payload: Value,
+ TTL: Option<u32>,
+ },
+}
+
+#[post("/account/devices/notify", data = "<req>")]
+pub(crate) async fn notify(
+ db: &DbConn,
+ client: &State<Arc<PushClient>>,
+ req: Authenticated<NotifyReq, WithVerifiedSession>,
+) -> auth::Result<Empty> {
+ let (to, payload, ttl) = match req.body {
+ NotifyReq::All { excluded, payload, TTL: ttl, .. } => {
+ let excluded = excluded.unwrap_or_default();
+ let mut devs = db.get_devices(&req.context.uid).await?;
+ devs.retain(|d| !excluded.contains(&d.device_id));
+ (devs, payload, ttl)
+ },
+ NotifyReq::Some { to, payload, TTL: ttl, .. } => {
+ let to = join_all(to.iter().map(|id| db.get_device(&req.context.uid, id)))
+ .await
+ .into_iter()
+ .collect::<Result<Vec<_>, _>>()?;
+ (to, payload, ttl)
+ },
+ };
+ client.push_any(db, &to, Duration::from_secs(ttl.unwrap_or(0).into()), payload).await;
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct AttachedClient {
+ clientId: Option<String>,
+ deviceId: Option<DeviceID>,
+ sessionTokenId: Option<SessionID>,
+ refreshTokenId: Option<OauthTokenID>,
+ isCurrentSession: bool,
+ deviceType: Option<String>,
+ name: Option<String>,
+ #[serde(serialize_with = "serialize_dt_opt")]
+ createdTime: Option<DateTime<Utc>>,
+ // MISSING createdTimeFormatted
+ #[serde(serialize_with = "serialize_dt_opt")]
+ lastAccessTime: Option<DateTime<Utc>>,
+ // MISSING lastAccessTimeFormatted
+ // MISSING approximateLastAccessTime
+ // MISSING approximateLastAccessTimeFormatted
+ scope: Option<String>,
+ // MISSING location
+ // MISSING userAgent
+ // MISSING os
+}
+
+// MISSING filterIdleDevicesTimestamp
+#[get("/account/attached_clients")]
+pub(crate) async fn attached_clients(
+ db: &DbConn,
+ auth: Authenticated<(), WithVerifiedFxaLogin>,
+) -> auth::Result<Vec<AttachedClient>> {
+ let clients = db.get_attached_clients(&auth.context.uid).await?;
+ Ok(Json(
+ clients
+ .into_iter()
+ .map(|dev| AttachedClient {
+ clientId: dev.client_id,
+ deviceId: dev.device_id,
+ refreshTokenId: dev.refresh_token_id,
+ isCurrentSession: dev.session_token_id.as_ref() == Some(&auth.session),
+ sessionTokenId: dev.session_token_id,
+ deviceType: dev.device_type,
+ name: dev.name,
+ createdTime: dev.created_time,
+ lastAccessTime: dev.last_access_time,
+ scope: dev.scope,
+ })
+ .collect::<Vec<_>>(),
+ ))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct DestroyAttachedClientReq {
+ // NOTE should be used to verify token deletion, but since we allow only a fixed
+ // number of clients that makes little sense.
+ #[allow(dead_code)]
+ clientId: Option<String>,
+ sessionTokenId: Option<SessionID>,
+ refreshTokenId: Option<OauthTokenID>,
+ deviceId: Option<DeviceID>,
+}
+
+#[post("/account/attached_client/destroy", data = "<req>")]
+pub(crate) async fn destroy_attached_client(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ req: Authenticated<DestroyAttachedClientReq, WithVerifiedFxaLogin>,
+) -> auth::Result<Empty> {
+ // only one id may be given, otherwise deleting things properly is more work.
+ if (req.body.sessionTokenId.is_some() as u32)
+ + (req.body.refreshTokenId.is_some() as u32)
+ + (req.body.deviceId.is_some() as u32)
+ != 1
+ {
+ return Err(auth::Error::InvalidParameter);
+ }
+
+ if let Some(dev) = req.body.deviceId {
+ let devs = db.get_devices(&req.context.uid).await;
+ db.delete_device(&req.context.uid, &dev).await?;
+ match devs {
+ Err(e) => warn!("device_disconnected push failed: {e}"),
+ Ok(devs) => {
+ defer.spawn_after_success("api::auth/account/attached_client/destroy(post)", {
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ client.device_disconnected(&db, &devs, &dev).await;
+ db.commit().await?;
+ Ok(())
+ }
+ })
+ },
+ };
+ }
+ if let Some(id) = req.body.sessionTokenId {
+ db.delete_session(&req.context.uid, &id).await?;
+ }
+ if let Some(id) = req.body.refreshTokenId {
+ db.delete_refresh_token(&id).await?;
+ }
+
+ Ok(EMPTY)
+}
diff --git a/src/api/auth/email.rs b/src/api/auth/email.rs
new file mode 100644
index 0000000..f206759
--- /dev/null
+++ b/src/api/auth/email.rs
@@ -0,0 +1,126 @@
+use std::sync::Arc;
+
+use rocket::{serde::json::Json, State};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+ api::{
+ auth::{self, WithFxaLogin},
+ Empty, EMPTY,
+ },
+ auth::Authenticated,
+ db::{Db, DbConn},
+ mailer::Mailer,
+ push::PushClient,
+ types::UserID,
+ utils::DeferAction,
+};
+
+// MISSING get /recovery_emails
+// MISSING post /recovery_email
+// MISSING post /recovery_email/destroy
+// MISSING post /recovery_email/resend_code
+// MISSING post /recovery_email/set_primary
+// MISSING post /emails/reminders/cad
+// MISSING post /recovery_email/secondary/resend_code
+// MISSING post /recovery_email/secondary/verify_code
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct StatusResp {
+ email: String,
+ verified: bool,
+ sessionVerified: bool,
+ emailVerified: bool,
+}
+
+// MISSING arg: reason
+#[get("/recovery_email/status")]
+pub(crate) async fn status(
+ db: &DbConn,
+ req: Authenticated<(), WithFxaLogin>,
+) -> auth::Result<StatusResp> {
+ let user = db.get_user_by_id(&req.context.uid).await?;
+ Ok(Json(StatusResp {
+ email: user.email,
+ verified: user.verified,
+ sessionVerified: req.context.verified,
+ emailVerified: user.verified,
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct VerifyReq {
+ uid: UserID,
+ code: String,
+ // MISSING service
+ // MISSING reminder
+ // MISSING type
+ // MISSING style
+ // MISSING marketingOptIn
+ // MISSING newsletters
+}
+
+#[post("/recovery_email/verify_code", data = "<req>")]
+pub(crate) async fn verify_code(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ pc: &State<Arc<PushClient>>,
+ req: Json<VerifyReq>,
+) -> auth::Result<Empty> {
+ let code = match db.try_use_verify_code(&req.uid, &req.code).await? {
+ Some(code) => code,
+ None => return Err(auth::Error::InvalidVerificationCode),
+ };
+ db.set_user_verified(&req.uid).await?;
+ if let Some(sid) = code.session_id {
+ db.set_session_verified(&sid).await?;
+ }
+ match db.get_devices(&req.uid).await {
+ Ok(devs) => defer.spawn_after_success("api::auth/recovery_email/verify_code(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.account_verified(&db, &devs).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("account_verified push failed: {e}"),
+ }
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct ResendReq {
+ // MISSING email
+ // MISSING service
+ // MISSING redirectTo
+ // MISSING resume
+ // MISSING style
+ // MISSING type
+}
+
+// MISSING arg: service
+// MISSING arg: type
+#[post("/recovery_email/resend_code", data = "<req>")]
+pub(crate) async fn resend_code(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ req: Authenticated<ResendReq, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ let (email, code) = match db.get_verify_code(&req.context.uid).await {
+ Ok(v) => v,
+ Err(_) => return Err(auth::Error::InvalidVerificationCode),
+ };
+ // NOTE we send the email in this context rather than a spawn to signal
+ // send errors to the client.
+ mailer.send_account_verify(&req.context.uid, &email, &code.code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(EMPTY)
+}
diff --git a/src/api/auth/invite.rs b/src/api/auth/invite.rs
new file mode 100644
index 0000000..dd81540
--- /dev/null
+++ b/src/api/auth/invite.rs
@@ -0,0 +1,47 @@
+use base64::URL_SAFE_NO_PAD;
+use chrono::{Duration, Utc};
+use rocket::{http::uri::Reference, serde::json::Json, State};
+use serde::{Deserialize, Serialize};
+
+use crate::{api::auth, auth::Authenticated, crypto::SecretBytes, db::DbConn, Config};
+
+use super::WithVerifiedFxaLogin;
+
+pub(crate) async fn generate_invite_link(
+ db: &DbConn,
+ cfg: &Config,
+ ttl: Duration,
+) -> anyhow::Result<Reference<'static>> {
+ let code = base64::encode_config(&SecretBytes::<32>::generate().0, URL_SAFE_NO_PAD);
+ db.add_invite_code(&code, Utc::now() + ttl).await?;
+ Ok(Reference::parse_owned(format!("{}/#/register/{}", cfg.location, code))
+ .map_err(|e| anyhow!("url building failed at {e}"))?)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct GenerateReq {
+ ttl_hours: u32,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct GenerateResp {
+ url: Reference<'static>,
+}
+
+#[post("/generate", data = "<req>")]
+pub(crate) async fn generate(
+ db: &DbConn,
+ cfg: &State<Config>,
+ req: Authenticated<GenerateReq, WithVerifiedFxaLogin>,
+) -> auth::Result<GenerateResp> {
+ if !req.context.verified {
+ return Err(auth::Error::UnverifiedSession);
+ }
+ let user = db.get_user_by_id(&req.context.uid).await?;
+ if user.email != cfg.invite_admin_address {
+ return Err(auth::Error::InvalidAuthToken);
+ }
+ let url = generate_invite_link(&db, &cfg, Duration::hours(req.body.ttl_hours as i64)).await?;
+ Ok(Json(GenerateResp { url }))
+}
diff --git a/src/api/auth/mod.rs b/src/api/auth/mod.rs
new file mode 100644
index 0000000..2c6d34d
--- /dev/null
+++ b/src/api/auth/mod.rs
@@ -0,0 +1,238 @@
+use rocket::{
+ http::Status,
+ response::{self, Responder},
+ serde::json::Json,
+ Request, Response,
+};
+use serde_json::json;
+
+use crate::{
+ auth::Authenticated,
+ crypto::SecretBytes,
+ types::{OauthToken, SessionID, UserSession},
+};
+
+pub(crate) mod account;
+pub(crate) mod device;
+pub(crate) mod email;
+pub(crate) mod invite;
+pub(crate) mod oauth;
+pub(crate) mod password;
+pub(crate) mod session;
+
+// we don't provide any additional fields. some we can't provide anyway (eg
+// invalid parameter `validation`), others are implied by the request body (eg
+// account exists `email`), and *our* client doesn't care about them anyway
+#[derive(Debug)]
+pub(crate) enum Error {
+ AccountExists,
+ UnknownAccount,
+ IncorrectPassword,
+ UnverifiedAccount,
+ InvalidVerificationCode,
+ InvalidBody,
+ InvalidParameter,
+ MissingParameter,
+ InvalidSignature,
+ InvalidAuthToken,
+ RequestTooLarge,
+ IncorrectEmailCase,
+ UnknownDevice,
+ UnverifiedSession,
+ EmailFailed,
+ NoDeviceCommand,
+ UnknownClientID,
+ ScopesNotAllowed,
+
+ InviteOnly,
+ InviteNotFound,
+
+ Other(anyhow::Error),
+ UnexpectedStatus(Status),
+}
+
+#[rustfmt::skip]
+impl<'r> Responder<'r, 'static> for Error {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let (code, errno, msg) = match self {
+ Error::AccountExists => (Status::BadRequest, 101, "account already exists"),
+ Error::UnknownAccount => (Status::BadRequest, 102, "unknown account"),
+ Error::IncorrectPassword => (Status::BadRequest, 103, "incorrect password"),
+ Error::UnverifiedAccount => (Status::BadRequest, 104, "unverified account"),
+ Error::InvalidVerificationCode => (Status::BadRequest, 105, "invalid verification code"),
+ Error::InvalidBody => (Status::BadRequest, 106, "invalid json in request body"),
+ Error::InvalidParameter => (Status::BadRequest, 107, "invalid parameter in request body"),
+ Error::MissingParameter => (Status::BadRequest, 108, "missing parameter in request body"),
+ Error::InvalidSignature => (Status::Unauthorized, 109, "invalid request signature"),
+ Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"),
+ Error::RequestTooLarge => (Status::PayloadTooLarge, 113, "request too large"),
+ Error::IncorrectEmailCase => (Status::BadRequest, 120, "incorrect email case"),
+ Error::UnknownDevice => (Status::BadRequest, 123, "unknown device"),
+ Error::UnverifiedSession => (Status::BadRequest, 138, "unverified session"),
+ Error::EmailFailed => (Status::UnprocessableEntity, 151, "failed to send email"),
+ Error::NoDeviceCommand => (Status::BadRequest, 157, "unavailable device command"),
+ Error::UnknownClientID => (Status::BadRequest, 162, "unknown client_id"),
+ Error::ScopesNotAllowed => (Status::BadRequest, 169, "requested scopes not allowed"),
+ Error::InviteOnly => (Status::BadRequest, -1, "invite code required"),
+ Error::InviteNotFound => (Status::BadRequest, -2, "invite code not found"),
+ Error::Other(e) => {
+ error!("non-api error during request: {:#?}", e);
+ (Status::InternalServerError, 999, "internal error")
+ },
+ Error::UnexpectedStatus(s) => (s, 999, ""),
+ };
+ let body = json!({
+ "code": code.code,
+ "errno": errno,
+ "error": code.reason_lossy(),
+ "message": msg
+ });
+ Response::build_from(Json(body).respond_to(request)?).status(code).ok()
+ }
+}
+
+impl From<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+// hack marker type to convey that auth failed due to an unverified session.
+// without this the catcher could convert the Unauthorized error we get from
+// auth failures into just one thing, even though we have multiple causes.
+#[derive(Clone, Copy, Debug)]
+struct UsedUnverifiedSession;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, req: &Request<'_>) -> Error {
+ match req.local_cache(|| None) {
+ Some(UsedUnverifiedSession) => Error::UnverifiedSession,
+ _ => {
+ match status.code {
+ 401 => Error::InvalidSignature,
+ // these three are caused by Json<T> errors
+ 400 => Error::InvalidBody,
+ 413 => Error::RequestTooLarge,
+ 422 => Error::InvalidParameter,
+ // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints
+ 404 => Error::InvalidSignature,
+ _ => {
+ error!("caught unexpected error {status}");
+ Error::UnexpectedStatus(status)
+ },
+ }
+ },
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithFxaLogin;
+
+#[async_trait]
+impl crate::auth::AuthSource for WithFxaLogin {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ let k = db.use_session(id).await?;
+ Ok((k.req_hmac_key.0.clone(), k))
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ bail!("refresh tokens not allowed here");
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithVerifiedFxaLogin;
+
+#[async_trait]
+impl crate::auth::AuthSource for WithVerifiedFxaLogin {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ let res = WithFxaLogin::hawk(r, id).await?;
+ match res.1.verified {
+ true => Ok(res),
+ false => {
+ r.local_cache(|| Some(UsedUnverifiedSession));
+ bail!("session not verified");
+ },
+ }
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ bail!("refresh tokens not allowed here");
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithSession;
+
+#[rocket::async_trait]
+impl crate::auth::AuthSource for WithSession {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ WithFxaLogin::hawk(r, id).await
+ }
+ async fn bearer_token(
+ r: &Request<'_>,
+ token: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ Ok(db.use_session_from_refresh(&token.hash()).await?)
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithVerifiedSession;
+
+#[rocket::async_trait]
+impl crate::auth::AuthSource for WithVerifiedSession {
+ type ID = SessionID;
+ type Context = UserSession;
+ async fn hawk(
+ r: &Request<'_>,
+ id: &SessionID,
+ ) -> anyhow::Result<(SecretBytes<32>, Self::Context)> {
+ WithVerifiedFxaLogin::hawk(r, id).await
+ }
+ async fn bearer_token(
+ r: &Request<'_>,
+ token: &OauthToken,
+ ) -> anyhow::Result<(SessionID, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ let res = db.use_session_from_refresh(&token.hash()).await?;
+ match res.1.verified {
+ true => Ok(res),
+ false => {
+ // technically unreachable because generating a refresh token requires a
+ // valid fxa session
+ r.local_cache(|| Some(UsedUnverifiedSession));
+ bail!("session not verified");
+ },
+ }
+ }
+}
diff --git a/src/api/auth/oauth.rs b/src/api/auth/oauth.rs
new file mode 100644
index 0000000..b0ed8ee
--- /dev/null
+++ b/src/api/auth/oauth.rs
@@ -0,0 +1,433 @@
+use std::collections::HashMap;
+
+use chrono::{DateTime, Duration, Local, Utc};
+use rocket::serde::json::Json;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use sha2::Digest;
+use subtle::ConstantTimeEq;
+
+use crate::api::auth::WithVerifiedFxaLogin;
+use crate::db::DbConn;
+use crate::types::oauth::{Scope, ScopeSet};
+use crate::{
+ api::{auth, serialize_dt},
+ auth::Authenticated,
+ crypto::{SecretBytes, SessionCredentials},
+ types::{
+ HawkKey, OauthAccessToken, OauthAccessType, OauthAuthorization, OauthAuthorizationID,
+ OauthRefreshToken, OauthToken, OauthTokenID, SessionID, UserID,
+ },
+};
+
+// MISSING get /oauth/client/{client_id}
+
+pub(crate) struct OauthClient {
+ pub(crate) id: &'static str,
+ // NOTE not read so far, but good to have
+ #[allow(dead_code)]
+ pub(crate) name: &'static str,
+ pub(crate) scopes: &'static [Scope<'static>],
+}
+
+const SESSION_SCOPE: Scope = Scope::borrowed("https://identity.mozilla.com/tokens/session");
+
+// NOTE the telemetry scopes don't seem to be needed. since we'd have to give
+// out keys for them (fxa does) we'll exclude them entirely.
+// see fxa-auth-server/config/dev.json for lists of predefined clients and permissions.
+pub(crate) const OAUTH_CLIENTS: [OauthClient; 2] = [
+ OauthClient {
+ id: "5882386c6d801776",
+ name: "Firefox",
+ scopes: &[
+ Scope::borrowed("profile:write"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ Scope::borrowed("https://identity.mozilla.com/tokens/session"),
+ // "https://identity.mozilla.com/ids/ecosystem_telemetry",
+ ],
+ },
+ OauthClient {
+ id: "a2270f727f45f648",
+ name: "Fenix",
+ scopes: &[
+ Scope::borrowed("profile"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ Scope::borrowed("https://identity.mozilla.com/tokens/session"),
+ // "https://identity.mozilla.com/ids/ecosystem_telemetry",
+ ],
+ },
+];
+
+// NOTE fxa dev config allows scoped keys only for:
+// - https://identity.mozilla.com/apps/notes
+// - https://identity.mozilla.com/apps/oldsync
+// - https://identity.mozilla.com/ids/ecosystem_telemetry
+// - https://identity.mozilla.com/apps/send
+// we only implement sync because notes and send are dead and
+// telemetry is of no use to us
+const SCOPES_WITH_KEYS: [Scope; 1] = [Scope::borrowed("https://identity.mozilla.com/apps/oldsync")];
+
+fn check_client_and_scopes(
+ client_id: &str,
+ scope: &ScopeSet,
+) -> Result<&'static OauthClient, auth::Error> {
+ let desc = match OAUTH_CLIENTS.iter().find(|&s| s.id == client_id) {
+ Some(d) => d,
+ None => return Err(auth::Error::UnknownClientID),
+ };
+ if !scope.is_allowed_by(desc.scopes) {
+ return Err(auth::Error::ScopesNotAllowed);
+ }
+ Ok(desc)
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum PkceChallengeType {
+ S256,
+}
+
+#[derive(Debug, Deserialize)]
+pub(crate) enum AuthResponseType {
+ #[serde(rename = "code")]
+ Code,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct OauthAuthReq {
+ client_id: String,
+ state: String,
+ keys_jwe: Option<String>,
+ scope: ScopeSet,
+ access_type: OauthAccessType,
+ // NOTE we don't support confidential clients, so PKCE is mandatory
+ code_challenge: String,
+
+ // MISSING redirect_uri
+ // MISSING acr_value
+
+ // for validation during deserialization only
+ #[allow(dead_code)]
+ code_challenge_method: PkceChallengeType,
+ #[allow(dead_code)]
+ response_type: AuthResponseType,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct OauthAuthResp {
+ code: OauthAuthorizationID,
+ state: String,
+ // MISSING redirect
+}
+
+#[post("/oauth/authorization", data = "<req>")]
+pub(crate) async fn authorization(
+ db: &DbConn,
+ req: Authenticated<OauthAuthReq, WithVerifiedFxaLogin>,
+) -> auth::Result<OauthAuthResp> {
+ check_client_and_scopes(&req.body.client_id, &req.body.scope)?;
+ let id = OauthAuthorizationID::random();
+ db.add_oauth_authorization(
+ &id,
+ OauthAuthorization {
+ user_id: req.context.uid,
+ client_id: req.body.client_id,
+ scope: req.body.scope,
+ access_type: req.body.access_type,
+ code_challenge: req.body.code_challenge,
+ keys_jwe: req.body.keys_jwe,
+ auth_at: req.context.created_at,
+ },
+ )
+ .await?;
+ Ok(Json(OauthAuthResp { code: id, state: req.body.state }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct ScopedKeysReq {
+ client_id: String,
+ scope: ScopeSet,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ScopedKey {
+ identifier: String,
+ keyRotationSecret: &'static str,
+ keyRotationTimestamp: u64,
+}
+
+#[post("/account/scoped-key-data", data = "<data>")]
+pub(crate) async fn scoped_key_data(
+ data: Authenticated<ScopedKeysReq, WithVerifiedFxaLogin>,
+) -> auth::Result<HashMap<String, ScopedKey>> {
+ check_client_and_scopes(&data.body.client_id, &data.body.scope)?;
+ // like fxa we'll stub out key rotation handling entirely and return the same constants.
+ Ok(Json(
+ data.body
+ .scope
+ .split()
+ .filter(|s| SCOPES_WITH_KEYS.contains(s))
+ .map(|scope| {
+ (
+ scope.to_string(),
+ ScopedKey {
+ identifier: scope.to_string(),
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 0,
+ },
+ )
+ })
+ .collect(),
+ ))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct OauthDestroy {
+ client_id: String,
+ token: OauthToken,
+}
+
+#[post("/oauth/destroy", data = "<data>")]
+pub(crate) async fn destroy(db: &DbConn, data: Json<OauthDestroy>) -> auth::Result<()> {
+ // MISSING api spec allows an optional basic auth header, but what for?
+ // TODO fxa also checks the authorization header if present, but firefox doesn't send it
+ let client_id = if let Ok(t) = db.get_refresh_token(&data.token.hash()).await {
+ t.client_id
+ } else if let Ok(t) = db.get_access_token(&data.token.hash()).await {
+ t.client_id
+ } else {
+ return Err(auth::Error::InvalidParameter);
+ };
+ // fxa does constant-time checks for client_id, do that here too.
+ if client_id.as_bytes().ct_eq(data.client_id.as_bytes()).into() {
+ db.delete_oauth_token(&data.token.hash()).await?;
+ Ok(Json(()))
+ } else {
+ Err(auth::Error::InvalidParameter)
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "grant_type")]
+enum TokenReqDetails {
+ // we can't use deny_unknown_fields when flatten is involved, and multiple
+ // flattens in the same struct cause problems if one of them is greedy (like map).
+ // flatten an extra map into every variant instead and check each of them.
+ #[serde(rename = "authorization_code")]
+ AuthCode {
+ code: OauthAuthorizationID,
+ code_verifier: String,
+ // NOTE only useful with redirect flows, which we kinda don't support at all
+ #[allow(dead_code)]
+ redirect_uri: Option<String>,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+ #[serde(rename = "refresh_token")]
+ RefreshToken {
+ refresh_token: OauthToken,
+ scope: ScopeSet,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+ #[serde(rename = "fxa-credentials")]
+ FxaCreds {
+ scope: ScopeSet,
+ access_type: Option<OauthAccessType>,
+ #[serde(flatten)]
+ extra: HashMap<String, Value>,
+ },
+}
+
+impl TokenReqDetails {
+ fn extra_is_empty(&self) -> bool {
+ match self {
+ TokenReqDetails::AuthCode { extra, .. } => extra.is_empty(),
+ TokenReqDetails::RefreshToken { extra, .. } => extra.is_empty(),
+ TokenReqDetails::FxaCreds { extra, .. } => extra.is_empty(),
+ }
+ }
+}
+
+// TODO log errors in all the places
+
+#[derive(Debug, Deserialize)]
+pub(crate) struct TokenReq {
+ client_id: String,
+ ttl: Option<u32>,
+ #[serde(flatten)]
+ details: TokenReqDetails,
+ // MISSING client_secret
+ // MISSING redirect_uri
+ // MISSING ttl
+ // MISSING ppid_seed
+ // MISSING resource
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) enum TokenType {
+ #[serde(rename = "bearer")]
+ Bearer,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct TokenResp {
+ access_token: OauthToken,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ refresh_token: Option<OauthToken>,
+ // MISSING id_token
+ #[serde(skip_serializing_if = "Option::is_none")]
+ session_token: Option<String>,
+ scope: ScopeSet,
+ token_type: TokenType,
+ expires_in: u32,
+ #[serde(serialize_with = "serialize_dt")]
+ auth_at: DateTime<Utc>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ keys_jwe: Option<String>,
+}
+
+#[post("/oauth/token", data = "<req>", rank = 1)]
+pub(crate) async fn token_authenticated(
+ db: &DbConn,
+ req: Authenticated<TokenReq, WithVerifiedFxaLogin>,
+) -> auth::Result<TokenResp> {
+ match &req.body.details {
+ TokenReqDetails::FxaCreds { .. } => (),
+ _ => return Err(auth::Error::InvalidParameter),
+ }
+ token_impl(
+ db,
+ Some(req.context.uid),
+ Some(req.context.created_at),
+ req.body,
+ None,
+ Some(req.session.clone()),
+ )
+ .await
+}
+
+#[post("/oauth/token", data = "<req>", rank = 2)]
+pub(crate) async fn token_unauthenticated(
+ db: &DbConn,
+ req: Json<TokenReq>,
+) -> auth::Result<TokenResp> {
+ let (parent_refresh, auth_at) = match &req.details {
+ TokenReqDetails::RefreshToken { refresh_token, .. } => {
+ let session = db.use_session_from_refresh(&refresh_token.hash()).await?;
+ (Some(refresh_token.hash()), Some(session.1.created_at))
+ },
+ TokenReqDetails::AuthCode { .. } => (None, None),
+ _ => return Err(auth::Error::InvalidParameter),
+ };
+ token_impl(db, None, auth_at, req.into_inner(), parent_refresh, None).await
+}
+
+async fn token_impl(
+ db: &DbConn,
+ user_id: Option<UserID>,
+ auth_at: Option<DateTime<Utc>>,
+ req: TokenReq,
+ parent_refresh: Option<OauthTokenID>,
+ parent_session: Option<SessionID>,
+) -> auth::Result<TokenResp> {
+ if !req.details.extra_is_empty() {
+ return Err(auth::Error::InvalidParameter);
+ }
+ let ttl = req.ttl.unwrap_or(3600).clamp(0, 7 * 86400);
+
+ let (auth_at, scope, keys_jwe, user_id, access_type) = match req.details {
+ TokenReqDetails::AuthCode { code, code_verifier, .. } => {
+ let auth = match db.take_oauth_authorization(&code).await {
+ Ok(a) => a,
+ Err(_) => return Err(auth::Error::InvalidAuthToken),
+ };
+ if !bool::from(auth.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) {
+ return Err(auth::Error::UnknownClientID);
+ }
+ let mut sha = sha2::Sha256::new();
+ sha.update(code_verifier.as_bytes());
+ let challenge = base64::encode_config(&sha.finalize(), base64::URL_SAFE_NO_PAD);
+ if !bool::from(challenge.as_bytes().ct_eq(auth.code_challenge.as_bytes())) {
+ return Err(auth::Error::InvalidParameter);
+ }
+ (auth.auth_at, auth.scope, auth.keys_jwe, auth.user_id, Some(auth.access_type))
+ },
+ TokenReqDetails::RefreshToken { refresh_token, scope, .. } => {
+ let auth_at =
+ auth_at.expect("oauth token requests with refresh token must set auth_at");
+ let base = db.get_refresh_token(&refresh_token.hash()).await?;
+ if !bool::from(base.client_id.as_bytes().ct_eq(req.client_id.as_bytes())) {
+ return Err(auth::Error::UnknownClientID);
+ }
+ check_client_and_scopes(&req.client_id, &scope)?;
+ if !base.scope.implies_all(&scope) {
+ return Err(auth::Error::ScopesNotAllowed);
+ }
+ (auth_at, scope, None, base.user_id, None)
+ },
+ TokenReqDetails::FxaCreds { scope, access_type, .. } => {
+ let user_id = user_id.expect("oauth token requests with fxa must set user_id");
+ let auth_at = auth_at.expect("oauth token requests with fxa must set auth_at");
+ check_client_and_scopes(&req.client_id, &scope)?;
+ (auth_at, scope, None, user_id, access_type)
+ },
+ };
+
+ let access_token = OauthToken::random();
+ db.add_access_token(
+ &access_token.hash(),
+ OauthAccessToken {
+ user_id: user_id.clone(),
+ client_id: req.client_id.clone(),
+ scope: scope.clone(),
+ parent_refresh,
+ parent_session,
+ expires_at: (Local::now() + Duration::seconds(ttl.into())).into(),
+ },
+ )
+ .await?;
+
+ let (refresh_token, session_token) = if access_type == Some(OauthAccessType::Offline) {
+ let (session_token, session_id) = if scope.implies(&SESSION_SCOPE) {
+ let session_token = SecretBytes::generate();
+ let session = SessionCredentials::derive(&session_token);
+ let session_id = SessionID(session.token_id.0);
+ db.add_session(session_id.clone(), &user_id, HawkKey(session.req_hmac_key), true, None)
+ .await?;
+ (Some(session_token.0), Some(SessionID(session.token_id.0)))
+ } else {
+ (None, None)
+ };
+
+ let refresh_token = OauthToken::random();
+ db.add_refresh_token(
+ &refresh_token.hash(),
+ OauthRefreshToken {
+ user_id,
+ client_id: req.client_id,
+ scope: scope.remove(&SESSION_SCOPE),
+ session_id,
+ },
+ )
+ .await?;
+ (Some(refresh_token), session_token)
+ } else {
+ (None, None)
+ };
+
+ Ok(Json(TokenResp {
+ access_token,
+ refresh_token,
+ session_token: session_token.map(hex::encode),
+ scope: scope.remove(&SESSION_SCOPE),
+ token_type: TokenType::Bearer,
+ expires_in: ttl,
+ auth_at,
+ keys_jwe,
+ }))
+}
diff --git a/src/api/auth/password.rs b/src/api/auth/password.rs
new file mode 100644
index 0000000..0eeab4f
--- /dev/null
+++ b/src/api/auth/password.rs
@@ -0,0 +1,260 @@
+use std::sync::Arc;
+
+use anyhow::Result;
+use password_hash::SaltString;
+use rocket::{request::FromRequest, serde::json::Json, Request, State};
+use serde::{Deserialize, Serialize};
+use validator::Validate;
+
+use crate::{
+ api::auth,
+ auth::{AuthSource, Authenticated},
+ crypto::{AccountResetReq, AuthPW, KeyBundle, KeyFetchReq, PasswordChangeReq, SecretBytes},
+ db::{Db, DbConn},
+ mailer::Mailer,
+ types::{
+ AccountResetID, HawkKey, KeyFetchID, OauthToken, PasswordChangeID, SecretKey, UserID,
+ VerifyHash,
+ },
+};
+
+// MISSING get /password/forgot/status
+// MISSING post /password/create
+// MISSING post /password/forgot/resend_code
+
+#[derive(Debug, Deserialize, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeStartReq {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+ oldAuthPW: AuthPW,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeStartResp {
+ keyFetchToken: SecretBytes<32>,
+ passwordChangeToken: SecretBytes<32>,
+}
+
+#[post("/password/change/start", data = "<data>")]
+pub(crate) async fn change_start(
+ db: &DbConn,
+ data: Json<ChangeStartReq>,
+) -> auth::Result<ChangeStartResp> {
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+ if !user.verified {
+ return Err(auth::Error::UnverifiedAccount);
+ }
+
+ let stretched = data.oldAuthPW.stretch(user.auth_salt.as_salt())?;
+ if stretched.verify_hash() != user.verify_hash.0 {
+ return Err(auth::Error::IncorrectPassword);
+ }
+
+ let change_token = SecretBytes::generate();
+ let change_req = PasswordChangeReq::from_change_token(&change_token);
+ let key_fetch_token = SecretBytes::generate();
+ let key_req = KeyFetchReq::from_token(&key_fetch_token);
+ let wrapped = key_req.derive_resp().wrap_keys(&KeyBundle {
+ ka: user.ka.0.clone(),
+ wrap_kb: stretched.decrypt_wwkb(&user.wrapwrap_kb.0),
+ });
+ db.add_key_fetch(KeyFetchID(key_req.token_id.0), &HawkKey(key_req.req_hmac_key), &wrapped)
+ .await?;
+ db.add_password_change(
+ &uid,
+ &PasswordChangeID(change_req.token_id.0),
+ &HawkKey(change_req.req_hmac_key),
+ None,
+ )
+ .await?;
+
+ Ok(Json(ChangeStartResp { keyFetchToken: key_fetch_token, passwordChangeToken: change_token }))
+}
+
+// NOTE we use a plain bool here and in the db instead of an enum because
+// enums aren't usable in const generics in stable.
+#[derive(Debug)]
+pub(crate) struct WithChangeToken<const IS_FORGOT: bool>;
+
+#[async_trait]
+impl<const IS_FORGOT: bool> AuthSource for WithChangeToken<IS_FORGOT> {
+ type ID = PasswordChangeID;
+ type Context = (UserID, Option<String>);
+ async fn hawk(
+ r: &Request<'_>,
+ id: &PasswordChangeID,
+ ) -> Result<(SecretBytes<32>, Self::Context)> {
+ // unlike key fetch we'll use a separate transaction here since the body of the
+ // handler can fail.
+ let pool = <&Db as FromRequest>::from_request(r)
+ .await
+ .success_or_else(|| anyhow!("could not open db connection"))?;
+ let db = pool.begin().await?;
+ let result = db.finish_password_change(id, IS_FORGOT).await.map(|(h, ctx)| (h.0, ctx))?;
+ db.commit().await?;
+ Ok(result)
+ }
+ async fn bearer_token(
+ _: &Request<'_>,
+ _: &OauthToken,
+ ) -> Result<(PasswordChangeID, Self::Context)> {
+ bail!("invalid password change authentication")
+ }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeFinishReq {
+ authPW: AuthPW,
+ wrapKb: SecretBytes<32>,
+ // MISSING sessionToken
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ChangeFinishResp {
+ // NOTE we intentionally deviate from mozilla here. mozilla creates a new
+ // session if sessionToken is set in the request, but we use the "legacy"
+ // password change mechanism that leaves the requesting session and its
+ // device and keys intact. as such this struct is intentionally empty.
+ //
+ // MISSING uid
+ // MISSING sessionToken
+ // MISSING verified
+ // MISSING authAt
+ // MISSING keyFetchToken
+}
+
+#[post("/password/change/finish", data = "<data>")]
+pub(crate) async fn change_finish(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ data: Authenticated<ChangeFinishReq, WithChangeToken<false>>,
+) -> auth::Result<ChangeFinishResp> {
+ let user = db.get_user_by_id(&data.context.0).await?;
+
+ let auth_salt = SaltString::generate(rand::rngs::OsRng);
+ let stretched = data.body.authPW.stretch(auth_salt.as_salt())?;
+ let verify_hash = stretched.verify_hash();
+ let wrapwrap_kb = stretched.rewrap_wkb(&data.body.wrapKb);
+
+ db.change_user_auth(
+ &data.context.0,
+ auth_salt,
+ SecretKey(wrapwrap_kb),
+ VerifyHash(verify_hash),
+ )
+ .await?;
+
+ // NOTE password_changed/password_reset pushes seem to have no effect, so skip them.
+
+ mailer
+ .send_password_changed(&user.email)
+ .await
+ .map_err(|e| {
+ warn!("password change email send failed: {e}");
+ })
+ .ok();
+
+ Ok(Json(ChangeFinishResp {}))
+}
+
+#[derive(Debug, Deserialize, Validate)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotStartReq {
+ #[validate(email, length(min = 3, max = 256))]
+ email: String,
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotStartResp {
+ passwordForgotToken: SecretBytes<32>,
+ ttl: u32,
+ codeLength: u32,
+ tries: u32,
+}
+
+#[post("/password/forgot/send_code", data = "<data>")]
+pub(crate) async fn forgot_start(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ data: Json<ForgotStartReq>,
+) -> auth::Result<ForgotStartResp> {
+ let data = data.into_inner();
+ data.validate().map_err(|_| auth::Error::InvalidParameter)?;
+
+ let (uid, user) = db.get_user(&data.email).await.map_err(|_| auth::Error::UnknownAccount)?;
+ if user.email != data.email {
+ return Err(auth::Error::IncorrectEmailCase);
+ }
+ if !user.verified {
+ return Err(auth::Error::UnverifiedAccount);
+ }
+
+ let forgot_code = hex::encode(SecretBytes::<16>::generate().0);
+ let forgot_token = SecretBytes::generate();
+ let forgot_req = PasswordChangeReq::from_forgot_token(&forgot_token);
+ db.add_password_change(
+ &uid,
+ &PasswordChangeID(forgot_req.token_id.0),
+ &HawkKey(forgot_req.req_hmac_key),
+ Some(&forgot_code),
+ )
+ .await?;
+
+ mailer.send_password_forgot(&user.email, &forgot_code).await?;
+
+ Ok(Json(ForgotStartResp {
+ passwordForgotToken: forgot_token,
+ ttl: 300,
+ codeLength: 16,
+ tries: 1,
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotFinishReq {
+ code: String,
+ // MISSING accountResetWithRecoveryKey
+}
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ForgotFinishResp {
+ accountResetToken: SecretBytes<32>,
+}
+
+#[post("/password/forgot/verify_code", data = "<data>")]
+pub(crate) async fn forgot_finish(
+ db: &DbConn,
+ data: Authenticated<ForgotFinishReq, WithChangeToken<true>>,
+) -> auth::Result<ForgotFinishResp> {
+ if Some(data.body.code) != data.context.1 {
+ return Err(auth::Error::InvalidVerificationCode);
+ }
+
+ let reset_token = SecretBytes::generate();
+ let reset_req = AccountResetReq::from_token(&reset_token);
+ db.add_account_reset(
+ &data.context.0,
+ &AccountResetID(reset_req.token_id.0),
+ &HawkKey(reset_req.req_hmac_key),
+ )
+ .await?;
+
+ Ok(Json(ForgotFinishResp { accountResetToken: reset_token }))
+}
diff --git a/src/api/auth/session.rs b/src/api/auth/session.rs
new file mode 100644
index 0000000..5911b92
--- /dev/null
+++ b/src/api/auth/session.rs
@@ -0,0 +1,107 @@
+use std::sync::Arc;
+
+use rocket::serde::json::Json;
+use rocket::State;
+use serde::{Deserialize, Serialize};
+
+use crate::api::auth::WithFxaLogin;
+use crate::api::{auth, Empty, EMPTY};
+use crate::auth::Authenticated;
+use crate::db::Db;
+use crate::db::DbConn;
+use crate::mailer::Mailer;
+use crate::push::PushClient;
+use crate::types::{SessionID, UserID};
+use crate::utils::DeferAction;
+
+// MISSING post /session/duplicate
+// MISSING post /session/reauth
+// MISSING post /session/verify/send_push
+
+#[derive(Debug, Serialize)]
+pub(crate) struct StatusResp {
+ state: &'static str, // what does this *do*?
+ uid: UserID,
+}
+
+#[get("/session/status")]
+pub(crate) async fn status(req: Authenticated<(), WithFxaLogin>) -> auth::Result<StatusResp> {
+ Ok(Json(StatusResp { state: "", uid: req.context.uid }))
+}
+
+#[post("/session/resend_code", data = "<req>")]
+pub(crate) async fn resend_code(
+ db: &DbConn,
+ mailer: &State<Arc<Mailer>>,
+ req: Authenticated<Empty, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ let code = match req.context.verify_code {
+ Some(code) => code,
+ _ => return Err(auth::Error::InvalidVerificationCode),
+ };
+
+ let user = db.get_user_by_id(&req.context.uid).await?;
+ mailer.send_session_verify(&user.email, &code).await.map_err(|e| {
+ error!("failed to send email: {e}");
+ auth::Error::EmailFailed
+ })?;
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct VerifyReq {
+ code: String,
+ // MISSING service
+ // MISSING scopes
+ // MISSING marketingOptIn
+ // MISSING newsletters
+}
+
+#[post("/session/verify_code", data = "<req>")]
+pub(crate) async fn verify_code(
+ db: &DbConn,
+ req: Authenticated<VerifyReq, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ if req.context.verify_code.as_ref() != Some(&req.body.code) {
+ return Err(auth::Error::InvalidVerificationCode);
+ }
+ db.set_session_verified(&req.session).await?;
+ Ok(EMPTY)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DestroyReq {
+ custom_session_id: Option<SessionID>,
+}
+
+#[post("/session/destroy", data = "<data>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ db_pool: &Db,
+ defer: &DeferAction,
+ client: &State<Arc<PushClient>>,
+ data: Authenticated<DestroyReq, WithFxaLogin>,
+) -> auth::Result<Empty> {
+ if data.body.custom_session_id.is_some() && !data.context.verified {
+ return Err(auth::Error::UnverifiedSession);
+ }
+ let id = data.body.custom_session_id.as_ref().unwrap_or(&data.session);
+ db.delete_session(&data.context.uid, id).await.map_err(|_| auth::Error::UnknownDevice)?;
+ if let Some(id) = data.context.device_id {
+ match db.get_devices(&data.context.uid).await {
+ Err(e) => warn!("device_disconnected push failed: {e}"),
+ Ok(devs) => defer.spawn_after_success("api::auth/session/destroy(post)", {
+ let (client, db) = (Arc::clone(client), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ client.device_disconnected(&db, &devs, &id).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ };
+ }
+ Ok(EMPTY)
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
new file mode 100644
index 0000000..1831659
--- /dev/null
+++ b/src/api/mod.rs
@@ -0,0 +1,32 @@
+use chrono::{DateTime, TimeZone};
+use rocket::serde::json::Json;
+use serde::{Deserialize, Serialize, Serializer};
+
+pub(crate) mod auth;
+pub(crate) mod oauth;
+pub(crate) mod profile;
+
+pub fn serialize_dt<S, TZ>(dt: &DateTime<TZ>, ser: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+ TZ: TimeZone,
+{
+ ser.serialize_i64(dt.timestamp())
+}
+
+pub fn serialize_dt_opt<S, TZ>(dt: &Option<DateTime<TZ>>, ser: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+ TZ: TimeZone,
+{
+ match dt {
+ Some(dt) => serialize_dt(dt, ser),
+ None => ser.serialize_unit(),
+ }
+}
+
+#[derive(Clone, Copy, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Empty {}
+
+pub const EMPTY: Json<Empty> = Json(Empty {});
diff --git a/src/api/oauth.rs b/src/api/oauth.rs
new file mode 100644
index 0000000..0519125
--- /dev/null
+++ b/src/api/oauth.rs
@@ -0,0 +1,163 @@
+use rocket::{
+ http::Status,
+ response::{self, Responder},
+ serde::json::Json,
+ Request, Response,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+use crate::{
+ api::Empty,
+ types::{OauthToken, UserID},
+};
+use crate::{db::DbConn, types::oauth::Scope};
+
+use super::EMPTY;
+
+// we don't provide any additional fields. some we can't provide anyway (eg
+// invalid parameter `validation`), others are implied by the request body (eg
+// account exists `email`), and *our* client doesn't care about them anyway
+#[derive(Debug)]
+pub(crate) enum Error {
+ InvalidParameter,
+ Unauthorized,
+ PayloadTooLarge,
+
+ Other(anyhow::Error),
+ UnexpectedStatus(Status),
+}
+
+#[rustfmt::skip]
+impl<'r> Responder<'r, 'static> for Error {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let (code, errno, msg) = match self {
+ Error::InvalidParameter => (Status::BadRequest, 109, "invalid request parameter"),
+ Error::Unauthorized => (Status::Forbidden, 111, "unauthorized"),
+ Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"),
+ Error::Other(e) => {
+ error!("non-api error during request: {:?}", e);
+ (Status::InternalServerError, 999, "internal error")
+ },
+ Error::UnexpectedStatus(s) => (s, 999, ""),
+ };
+ let body = json!({
+ "code": code.code,
+ "errno": errno,
+ "error": code.reason_lossy(),
+ "message": msg
+ });
+ Response::build_from(Json(body).respond_to(request)?).status(code).ok()
+ }
+}
+
+impl From<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, _r: &Request<'_>) -> Error {
+ match status.code {
+ 401 => Error::Unauthorized,
+ // these three are caused by Json<T> errors
+ 400 => Error::InvalidParameter,
+ 413 => Error::PayloadTooLarge,
+ 422 => Error::InvalidParameter,
+ // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints
+ 404 => Error::Unauthorized,
+ _ => {
+ error!("caught unexpected error {status}");
+ Error::UnexpectedStatus(status)
+ },
+ }
+}
+
+fn map_error(e: sqlx::Error) -> Error {
+ match &e {
+ sqlx::Error::RowNotFound => Error::InvalidParameter,
+ _ => Error::Other(anyhow!(e)),
+ }
+}
+
+// MISSING GET /v1/authorization
+// MISSING POST /v1/authorization
+// MISSING POST /v1/authorized-clients
+// MISSING POST /v1/authorized-clients/destroy
+// MISSING GET /v1/client/:id
+// MISSING POST /v1/introspect
+// MISSING GET /v1/jwks
+// MISSING POST /v1/key-data
+// MISSING POST /v1/token
+// MISSING POST /v1/verify
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct DestroyReq {
+ access_token: Option<OauthToken>,
+ refresh_token: Option<OauthToken>,
+ // NOTE this field does not exist in the spec, but fenix sends it
+ token: Option<OauthToken>,
+ // MISSING client_id
+ // MISSING client_secret
+ // MISSING refresh_token_id
+}
+
+#[post("/destroy", data = "<req>")]
+pub(crate) async fn destroy(
+ db: &DbConn,
+ req: Json<DestroyReq>,
+) -> std::result::Result<Json<Empty>, Error> {
+ // MISSING spec says basic auth is allowed, but nothing seems to use it
+ if let Some(t) = req.0.access_token {
+ db.delete_oauth_token(&t.hash()).await?;
+ }
+ if let Some(t) = req.0.refresh_token {
+ db.delete_oauth_token(&t.hash()).await?;
+ }
+ if let Some(t) = req.0.token {
+ db.delete_oauth_token(&t.hash()).await?;
+ }
+ Ok(EMPTY)
+}
+
+#[get("/jwks")]
+pub(crate) async fn jwks() -> Json<Empty> {
+ // HACK we need to return *something* for /jwks, otherwise PyFxA fails.
+ // since syncstorage-rs uses PyFxA to check oauth tokens this is bad.
+ EMPTY
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct VerifyReq {
+ token: OauthToken,
+}
+
+#[derive(Debug, Serialize)]
+pub(crate) struct VerifyResp {
+ user: UserID,
+ client_id: String,
+ scope: Vec<Scope<'static>>,
+ // MISSING generation
+ // MISSING profile_changed_at
+}
+
+#[post("/verify", data = "<req>")]
+pub(crate) async fn verify(db: &DbConn, req: Json<VerifyReq>) -> Result<VerifyResp> {
+ let token = db.get_access_token(&req.token.hash()).await.map_err(map_error)?;
+ Ok(Json(VerifyResp {
+ user: token.user_id,
+ client_id: token.client_id,
+ scope: token.scope.split().map(|s| s.into_owned()).collect::<Vec<_>>(),
+ }))
+}
diff --git a/src/api/profile/mod.rs b/src/api/profile/mod.rs
new file mode 100644
index 0000000..28d1e03
--- /dev/null
+++ b/src/api/profile/mod.rs
@@ -0,0 +1,324 @@
+use std::sync::Arc;
+
+use either::Either;
+use rocket::{
+ data::ToByteUnit,
+ http::{uri::Absolute, ContentType, Status},
+ response::{self, Responder},
+ serde::json::Json,
+ Request, Response, State,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use sha2::{Digest, Sha256};
+use Either::{Left, Right};
+
+use crate::{
+ api::Empty,
+ auth::{Authenticated, WithBearer, AuthenticatedRequest},
+ cache::Immutable,
+ db::Db,
+ types::{oauth::Scope, UserID},
+ utils::DeferAction,
+};
+use crate::{db::DbConn, types::AvatarID, Config};
+use crate::{push::PushClient, types::Avatar};
+
+use super::EMPTY;
+
+// we don't provide any additional fields. some we can't provide anyway (eg
+// invalid parameter `validation`), others are implied by the request body (eg
+// account exists `email`), and *our* client doesn't care about them anyway
+#[derive(Debug)]
+pub(crate) enum Error {
+ Unauthorized,
+ InvalidParameter,
+ PayloadTooLarge,
+ NotFound,
+
+ // this is actually a response from the auth api (not the profile api),
+ // but firefox needs the *exact response* of this auth error to refresh
+ // profile fetch oauth tokens for its ui. :(
+ InvalidAuthToken,
+
+ Other(anyhow::Error),
+ UnexpectedStatus(Status),
+}
+
+#[rustfmt::skip]
+impl<'r> Responder<'r, 'static> for Error {
+ fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
+ let (code, errno, msg) = match self {
+ Error::Unauthorized => (Status::Forbidden, 100, "unauthorized"),
+ Error::InvalidParameter => (Status::BadRequest, 101, "invalid parameter in request body"),
+ Error::PayloadTooLarge => (Status::PayloadTooLarge, 999, "payload too large"),
+ Error::NotFound => (Status::NotFound, 999, "not found"),
+
+ Error::InvalidAuthToken => (Status::Unauthorized, 110, "invalid authentication token"),
+
+ Error::Other(e) => {
+ error!("non-api error during request: {:?}", e);
+ (Status::InternalServerError, 999, "internal error")
+ },
+ Error::UnexpectedStatus(s) => (s, 999, ""),
+ };
+ let body = json!({
+ "code": code.code,
+ "errno": errno,
+ "error": code.reason_lossy(),
+ "message": msg
+ });
+ Response::build_from(Json(body).respond_to(request)?).status(code).ok()
+ }
+}
+
+impl From<sqlx::Error> for Error {
+ fn from(e: sqlx::Error) -> Self {
+ Error::Other(anyhow!(e))
+ }
+}
+
+impl From<anyhow::Error> for Error {
+ fn from(e: anyhow::Error) -> Self {
+ Error::Other(e)
+ }
+}
+
+pub(crate) type Result<T> = std::result::Result<Json<T>, Error>;
+
+#[catch(default)]
+pub(crate) fn catch_all(status: Status, r: &Request<'_>) -> Error {
+ match status.code {
+ // these three are caused by Json<T> errors
+ 400 | 422 => Error::InvalidParameter,
+ 413 => Error::PayloadTooLarge,
+ // translate forbidden-because-token to the auth api error for firefox
+ 401 if r.invalid_token_used() => Error::InvalidAuthToken,
+ // generic unauthorized instead of 404 for eg wrong method or nonexistant endpoints
+ 401 | 404 => Error::Unauthorized,
+ _ => {
+ error!("caught unexpected error {status}");
+ Error::UnexpectedStatus(status)
+ },
+ }
+}
+
+// MISSING GET /v1/email
+// MISSING GET /v1/subscriptions
+// MISSING GET /v1/uid
+// MISSING GET /v1/display_name
+// MISSING DELETE /v1/cache/:uid
+// MISSING send profile:change webchannel event an avatar/name changes
+
+#[derive(Debug, Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct ProfileResp {
+ uid: Option<UserID>,
+ email: Option<String>,
+ locale: Option<String>,
+ amrValues: Option<Vec<String>>,
+ twoFactorAuthentication: bool,
+ displayName: Option<String>,
+ // NOTE spec does not exist, fxa-profile-server schema says this field is optional,
+ // but fenix exceptions if it's null.
+ // NOTE it also *must* be a valid url, or fenix crashes entirely.
+ avatar: Absolute<'static>,
+ avatarDefault: bool,
+ subscriptions: Option<Vec<String>>,
+}
+
+#[get("/profile")]
+pub(crate) async fn profile(
+ db: &DbConn,
+ cfg: &State<Config>,
+ auth: Authenticated<(), WithBearer>,
+) -> Result<ProfileResp> {
+ let has_scope = |s| auth.context.implies(&Scope::borrowed(s));
+
+ let user = db.get_user_by_id(&auth.session).await?;
+ let (avatar, avatar_default) = if has_scope("profile:avatar") {
+ match db.get_user_avatar_id(&auth.session).await? {
+ Some(id) => (uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string())), false),
+ None => (
+ uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")),
+ true,
+ ),
+ }
+ } else {
+ (uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000")), true)
+ };
+ Ok(Json(ProfileResp {
+ uid: if has_scope("profile:uid") { Some(auth.session) } else { None },
+ email: if has_scope("profile:email") { Some(user.email) } else { None },
+ locale: None,
+ amrValues: None,
+ twoFactorAuthentication: false,
+ displayName: if has_scope("profile:display_name") { user.display_name } else { None },
+ avatar,
+ avatarDefault: avatar_default,
+ subscriptions: None,
+ }))
+}
+
+#[derive(Debug, Deserialize)]
+#[allow(non_snake_case)]
+pub(crate) struct DisplayNameReq {
+ displayName: String,
+}
+
+#[post("/display_name", data = "<req>")]
+pub(crate) async fn display_name_post(
+ db: &DbConn,
+ db_pool: &Db,
+ pc: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ req: Authenticated<DisplayNameReq, WithBearer>,
+) -> Result<Empty> {
+ if !req.context.implies(&Scope::borrowed("profile:display_name:write")) {
+ return Err(Error::Unauthorized);
+ }
+
+ db.set_user_name(&req.session, &req.body.displayName).await?;
+ match db.get_devices(&req.session).await {
+ Ok(devs) => defer.spawn_after_success("api::profile/display_name(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.profile_updated(&db, &devs).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("profile_updated push failed: {e}"),
+ }
+ Ok(EMPTY)
+}
+
+#[derive(Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct AvatarResp {
+ id: AvatarID,
+ avatarDefault: bool,
+ avatar: Absolute<'static>,
+}
+
+#[get("/avatar")]
+pub(crate) async fn avatar_get(
+ db: &DbConn,
+ cfg: &State<Config>,
+ req: Authenticated<(), WithBearer>,
+) -> Result<AvatarResp> {
+ if !req.context.implies(&Scope::borrowed("profile:avatar")) {
+ return Err(Error::Unauthorized);
+ }
+
+ let resp = match db.get_user_avatar_id(&req.session).await? {
+ Some(id) => {
+ let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string()));
+ AvatarResp { id, avatarDefault: false, avatar: url }
+ },
+ None => {
+ let url =
+ uri!(cfg.avatars_prefix(), avatar_get_img("00000000000000000000000000000000"));
+ AvatarResp { id: AvatarID([0; 16]), avatarDefault: true, avatar: url }
+ },
+ };
+ Ok(Json(resp))
+}
+
+#[get("/<id>")]
+pub(crate) async fn avatar_get_img(
+ db: &DbConn,
+ id: &str,
+) -> std::result::Result<(ContentType, Immutable<Either<Vec<u8>, &'static [u8]>>), Error> {
+ let id = id.parse().map_err(|_| Error::NotFound)?;
+
+ if id == AvatarID([0; 16]) {
+ return Ok((
+ ContentType::SVG,
+ Immutable(Right(include_bytes!("../../../Raven-Silhouette.svg"))),
+ ));
+ }
+
+ match db.get_user_avatar(&id).await? {
+ Some(avatar) => {
+ let ct = avatar.content_type.parse().expect("invalid content type in db");
+ Ok((ct, Immutable(Left(avatar.data))))
+ },
+ None => Err(Error::NotFound),
+ }
+}
+
+#[derive(Serialize)]
+#[allow(non_snake_case)]
+pub(crate) struct AvatarUploadResp {
+ url: Absolute<'static>,
+}
+
+#[post("/avatar/upload", data = "<data>")]
+pub(crate) async fn avatar_upload(
+ db: &DbConn,
+ db_pool: &Db,
+ pc: &State<Arc<PushClient>>,
+ defer: &DeferAction,
+ cfg: &State<Config>,
+ ct: &ContentType,
+ req: Authenticated<(), WithBearer>,
+ data: Vec<u8>,
+) -> Result<AvatarUploadResp> {
+ if !req.context.implies(&Scope::borrowed("profile:avatar:write")) {
+ return Err(Error::Unauthorized);
+ }
+ if data.len() >= 128.kibibytes() {
+ return Err(Error::PayloadTooLarge);
+ }
+
+ if !ct.is_png()
+ && !ct.is_gif()
+ && !ct.is_bmp()
+ && !ct.is_jpeg()
+ && !ct.is_webp()
+ && !ct.is_avif()
+ && !ct.is_svg()
+ {
+ return Err(Error::InvalidParameter);
+ }
+
+ let mut sha = Sha256::new();
+ sha.update(&req.session.0);
+ sha.update(&data);
+ let id = AvatarID(sha.finalize()[0..16].try_into().unwrap());
+
+ db.set_user_avatar(&req.session, Avatar { id: id.clone(), data, content_type: ct.to_string() })
+ .await?;
+ match db.get_devices(&req.session).await {
+ Ok(devs) => defer.spawn_after_success("api::profile/avatar/upload(post)", {
+ let (pc, db) = (Arc::clone(pc), db_pool.clone());
+ async move {
+ let db = db.begin().await?;
+ pc.profile_updated(&db, &devs).await;
+ db.commit().await?;
+ Ok(())
+ }
+ }),
+ Err(e) => warn!("profile_updated push failed: {e}"),
+ }
+
+ let url = uri!(cfg.avatars_prefix(), avatar_get_img(id = id.to_string()));
+ Ok(Json(AvatarUploadResp { url }))
+}
+
+#[delete("/avatar/<id>")]
+pub(crate) async fn avatar_delete(
+ db: &DbConn,
+ id: &str,
+ req: Authenticated<(), WithBearer>,
+) -> Result<Empty> {
+ if !req.context.implies(&Scope::borrowed("profile:avatar:write")) {
+ return Err(Error::Unauthorized);
+ }
+ let id = id.parse().map_err(|_| Error::NotFound)?;
+
+ db.delete_user_avatar(&req.session, &id).await?;
+ Ok(EMPTY)
+}
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..f56c5e2
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,241 @@
+use std::str::FromStr;
+use std::time::Duration;
+
+use anyhow::Result;
+use hawk::{DigestAlgorithm, Header, Key, PayloadHasher, RequestBuilder};
+use rocket::data::{self, FromData, ToByteUnit};
+use rocket::http::Status;
+use rocket::outcome::{try_outcome, Outcome};
+use rocket::request::{local_cache, FromRequest, Request};
+use rocket::{request, Data, Ignite, Phase, Rocket, Sentinel};
+use serde::Deserialize;
+use serde_json::error::Category;
+
+use crate::crypto::SecretBytes;
+use crate::db::DbConn;
+use crate::types::oauth::ScopeSet;
+use crate::types::{OauthToken, UserID};
+use crate::Config;
+
+#[rocket::async_trait]
+pub(crate) trait AuthSource {
+ type ID: FromStr + Send + Sync + Clone;
+ type Context: Send + Sync;
+ async fn hawk(r: &Request<'_>, id: &Self::ID) -> Result<(SecretBytes<32>, Self::Context)>;
+ async fn bearer_token(r: &Request<'_>, id: &OauthToken) -> Result<(Self::ID, Self::Context)>;
+}
+
+// marker trait and type to communicate that authentication has failed with invalid
+// tokens used. this is needed to properly translate these error for the profile api.
+pub(crate) trait AuthenticatedRequest {
+ fn invalid_token_used(&self) -> bool;
+}
+
+struct InvalidTokenUsed;
+
+impl<'r> AuthenticatedRequest for Request<'r> {
+ fn invalid_token_used(&self) -> bool {
+ self.local_cache(|| None as Option<InvalidTokenUsed>).is_some()
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct Authenticated<T, Src: AuthSource> {
+ pub body: T,
+ pub session: Src::ID,
+ pub context: Src::Context,
+}
+
+enum AuthKind<'a> {
+ Hawk { header: Header },
+ Token { token: &'a str },
+}
+
+fn drop_auth_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
+ if prefix.len() <= s.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
+ Some(&s[prefix.len()..])
+ } else {
+ None
+ }
+}
+
+impl<T, S: AuthSource> Sentinel for Authenticated<T, S> {
+ fn abort(rocket: &Rocket<Ignite>) -> bool {
+ // NOTE data sentinels are broken in rocket 0.5-rc2
+ Self::try_get_state(rocket).is_none() || <&DbConn as Sentinel>::abort(rocket)
+ }
+}
+
+impl<T, Src: AuthSource> Authenticated<T, Src> {
+ fn try_get_state<S: Phase>(r: &Rocket<S>) -> Option<&Config> {
+ r.state::<Config>()
+ }
+
+ fn state<S: Phase>(r: &Rocket<S>) -> &Config {
+ Self::try_get_state(r).unwrap()
+ }
+
+ async fn parse_auth<'a>(
+ request: &'a Request<'_>,
+ ) -> Outcome<AuthKind<'a>, (Status, anyhow::Error), ()> {
+ let auth = match request.headers().get("authorization").take(2).enumerate().last() {
+ Some((0, h)) => h,
+ Some((_, _)) => {
+ return Outcome::Failure((
+ Status::BadRequest,
+ anyhow!("multiple authorization headers present"),
+ ))
+ },
+ None => return Outcome::Forward(()),
+ };
+ if let Some(hawk) = drop_auth_prefix(auth, "hawk ") {
+ match Header::from_str(hawk) {
+ Ok(header) => Outcome::Success(AuthKind::Hawk { header }),
+ Err(e) => Outcome::Failure((
+ Status::Unauthorized,
+ anyhow!(e).context("malformed hawk header"),
+ )),
+ }
+ } else if let Some(token) = drop_auth_prefix(auth, "bearer ") {
+ Outcome::Success(AuthKind::Token { token })
+ } else {
+ Outcome::Forward(())
+ }
+ }
+
+ pub async fn get_conn<'r>(req: &'r Request<'_>) -> Result<&'r DbConn> {
+ match <&'r DbConn as FromRequest<'r>>::from_request(req).await {
+ Outcome::Success(db) => Ok(db),
+ Outcome::Failure((_, e)) => Err(e.context("get db connection")),
+ _ => Err(anyhow!("could not get db connection")),
+ }
+ }
+
+ async fn verify_hawk(
+ request: &Request<'_>,
+ hawk: Header,
+ data: Option<&str>,
+ ) -> Result<(Src::ID, Src::Context)> {
+ let cfg = Self::state(request.rocket());
+ let url = format!("{}{}", cfg.location, request.uri());
+ let url = url::Url::parse(&url).unwrap();
+ let hash = data
+ .map(|d| PayloadHasher::hash("application/json", DigestAlgorithm::Sha256, d))
+ .transpose()?;
+ let hawk_req = RequestBuilder::from_url(request.method().as_str(), &url)?;
+ let hawk_req = match hash.as_ref() {
+ Some(h) => hawk_req.hash(Some(h.as_ref())).request(),
+ _ => hawk_req.request(),
+ };
+ let id: Src::ID =
+ match hawk.id.clone().ok_or_else(|| anyhow!("missing hawk key id"))?.parse() {
+ Ok(id) => id,
+ Err(_) => bail!("malformed hawk key id"),
+ };
+ let (key, context) = Src::hawk(request, &id).await?;
+ let key = Key::new(&key.0, DigestAlgorithm::Sha256)?;
+ // large skew was taken from fxa-auth-server, large clock skews seem to happen
+ if !hawk_req.validate_header(&hawk, &key, Duration::from_secs(20 * 365 * 86400)) {
+ bail!("bad hawk signature");
+ }
+ Ok((id, context))
+ }
+
+ async fn verify_bearer_token(
+ request: &Request<'_>,
+ token: &str,
+ ) -> Result<(Src::ID, Src::Context)> {
+ let token = match token.parse() {
+ Ok(token) => token,
+ Err(_) => bail!("malformed oauth token"),
+ };
+ Src::bearer_token(request, &token).await
+ }
+}
+
+#[rocket::async_trait]
+impl<'r, Src: AuthSource> FromRequest<'r> for Authenticated<(), Src> {
+ type Error = anyhow::Error;
+
+ async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
+ let auth = try_outcome!(Self::parse_auth(request).await);
+ let result = match auth {
+ AuthKind::Hawk { header } => Self::verify_hawk(request, header, None).await,
+ AuthKind::Token { token } => Self::verify_bearer_token(request, token).await,
+ };
+ match result {
+ Ok((session, context)) => {
+ Outcome::Success(Authenticated { body: (), session, context })
+ },
+ Err(e) => {
+ request.local_cache(|| Some(InvalidTokenUsed));
+ Outcome::Failure((Status::Unauthorized, anyhow!(e)))
+ },
+ }
+ }
+}
+
+#[rocket::async_trait]
+impl<'r, T: Deserialize<'r>, Src: AuthSource> FromData<'r> for Authenticated<T, Src> {
+ type Error = anyhow::Error;
+
+ async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> data::Outcome<'r, Self> {
+ let auth = try_outcome_data!(data, Self::parse_auth(request).await);
+ let limit =
+ request.rocket().config().limits.get("json").unwrap_or_else(|| 1u32.mebibytes());
+ let raw_json = match data.open(limit).into_string().await {
+ Ok(r) if r.is_complete() => local_cache!(request, r.into_inner()),
+ Ok(_) => {
+ return data::Outcome::Failure((
+ Status::PayloadTooLarge,
+ anyhow!("request too large"),
+ ))
+ },
+ Err(e) => return data::Outcome::Failure((Status::InternalServerError, e.into())),
+ };
+ let verify_result = match auth {
+ AuthKind::Hawk { header } => Self::verify_hawk(request, header, Some(raw_json)).await,
+ AuthKind::Token { token } => Self::verify_bearer_token(request, token).await,
+ };
+ let result = match verify_result {
+ Ok((session, context)) => {
+ serde_json::from_str(raw_json).map(|body| Authenticated { body, session, context })
+ },
+ Err(e) => {
+ request.local_cache(|| Some(InvalidTokenUsed));
+ return Outcome::Failure((Status::Unauthorized, anyhow!(e)));
+ },
+ };
+ match result {
+ Ok(r) => Outcome::Success(r),
+ Err(e) => {
+ // match Json<T> here to keep catchers generic
+ let status = match e.classify() {
+ Category::Data => Status::UnprocessableEntity,
+ _ => Status::BadRequest,
+ };
+ Outcome::Failure((status, anyhow!(e)))
+ },
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct WithBearer;
+
+#[rocket::async_trait]
+impl crate::auth::AuthSource for WithBearer {
+ type ID = UserID;
+ type Context = ScopeSet;
+ async fn hawk(_r: &Request<'_>, _id: &Self::ID) -> Result<(SecretBytes<32>, Self::Context)> {
+ bail!("hawk signatures not allowed here")
+ }
+ async fn bearer_token(
+ r: &Request<'_>,
+ token: &OauthToken,
+ ) -> Result<(Self::ID, Self::Context)> {
+ let db = Authenticated::<(), Self>::get_conn(r).await?;
+ let t = db.get_access_token(&token.hash()).await?;
+ Ok((t.user_id, t.scope))
+ }
+}
diff --git a/src/bin/minorskulk.rs b/src/bin/minorskulk.rs
new file mode 100644
index 0000000..1609edb
--- /dev/null
+++ b/src/bin/minorskulk.rs
@@ -0,0 +1,9 @@
+use minor_skulk::build;
+
+#[rocket::main]
+async fn main() -> anyhow::Result<()> {
+ dotenv::dotenv().ok();
+
+ let _ = build().await?.launch().await?;
+ Ok(())
+}
diff --git a/src/cache.rs b/src/cache.rs
new file mode 100644
index 0000000..680d9da
--- /dev/null
+++ b/src/cache.rs
@@ -0,0 +1,42 @@
+use std::borrow::Cow;
+
+use rocket::{
+ http::Header,
+ request::{self, FromRequest},
+ response::{self, Responder},
+ Request,
+};
+
+pub(crate) struct Etagged<'r, T>(pub T, pub Cow<'r, str>);
+
+impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for Etagged<'o, T> {
+ fn respond_to(self, r: &'r Request<'_>) -> response::Result<'o> {
+ let mut resp = self.0.respond_to(r)?;
+ resp.set_header(Header::new("etag", self.1));
+ Ok(resp)
+ }
+}
+
+pub(crate) struct Immutable<T>(pub T);
+
+impl<'r, 'o: 'r, T: Responder<'r, 'o>> Responder<'r, 'o> for Immutable<T> {
+ fn respond_to(self, r: &'r Request<'_>) -> response::Result<'o> {
+ let mut resp = self.0.respond_to(r)?;
+ resp.set_header(Header::new("cache-control", "public, max-age=604800, immutable"));
+ Ok(resp)
+ }
+}
+
+pub(crate) struct IfNoneMatch<'r>(pub &'r str);
+
+#[async_trait]
+impl<'r> FromRequest<'r> for IfNoneMatch<'r> {
+ type Error = ();
+
+ async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
+ match req.headers().get_one("if-none-match") {
+ Some(h) => request::Outcome::Success(Self(h)),
+ None => request::Outcome::Forward(()),
+ }
+ }
+}
diff --git a/src/crypto.rs b/src/crypto.rs
new file mode 100644
index 0000000..cf1044e
--- /dev/null
+++ b/src/crypto.rs
@@ -0,0 +1,408 @@
+#![deny(clippy::pedantic)]
+#![deny(clippy::restriction)]
+#![allow(clippy::blanket_clippy_restriction_lints)]
+#![allow(clippy::implicit_return)]
+#![allow(clippy::missing_docs_in_private_items)]
+#![allow(clippy::shadow_reuse)]
+
+use std::fmt::Debug;
+
+use hmac::{Hmac, Mac};
+use password_hash::{Output, Salt};
+use rand::RngCore;
+use scrypt::scrypt;
+use serde::{Deserialize, Serialize};
+use sha2::Sha256;
+
+const NAMESPACE: &[u8] = b"identity.mozilla.com/picl/v1/";
+
+#[derive(Clone, PartialEq, Eq, Zeroize, Serialize, Deserialize)]
+#[serde(try_from = "String", into = "String")]
+pub struct SecretBytes<const N: usize>(pub [u8; N]);
+
+impl<const N: usize> Drop for SecretBytes<N> {
+ fn drop(&mut self) {
+ self.zeroize();
+ }
+}
+
+#[derive(Clone, PartialEq, Eq)]
+pub struct TokenID(pub [u8; 32]);
+
+impl<const N: usize> Debug for SecretBytes<N> {
+ fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
+ fmt.write_fmt(format_args!("SecretBytes {{ raw: {} }}", hex::encode(&self.0)))
+ }
+}
+
+impl<const N: usize> SecretBytes<N> {
+ fn xor(&self, other: &Self) -> Self {
+ let mut result = self.clone();
+ for (a, b) in result.0.iter_mut().zip(other.0.iter()) {
+ *a ^= b;
+ }
+ result
+ }
+}
+
+impl<const N: usize> From<SecretBytes<N>> for String {
+ fn from(sb: SecretBytes<N>) -> Self {
+ hex::encode(&sb.0)
+ }
+}
+
+impl<const N: usize> TryFrom<String> for SecretBytes<N> {
+ type Error = hex::FromHexError;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ let mut result = Self([0; N]);
+ hex::decode_to_slice(value, &mut result.0)?;
+ Ok(result)
+ }
+}
+
+impl From<SecretBytes<32>> for Output {
+ fn from(s: SecretBytes<32>) -> Output {
+ #[allow(clippy::unwrap_used)]
+ Output::new(&s.0).unwrap()
+ }
+}
+
+mod from_hkdf {
+ use hkdf::Hkdf;
+ use sha2::Sha256;
+
+ // sealing lets us guarantee that SIZE is always correct,
+ // which means that from_hkdf always receives correctly sized slices
+ // and copies never fail
+ mod private {
+ pub trait Seal {}
+ impl<const N: usize> Seal for super::super::SecretBytes<N> {}
+ impl Seal for super::super::TokenID {}
+ impl<L: Seal, R: Seal> Seal for (L, R) {}
+ }
+
+ pub trait FromHkdf: private::Seal {
+ const SIZE: usize;
+ fn from_hkdf(bytes: &[u8]) -> Self;
+ }
+
+ impl<const N: usize> FromHkdf for super::SecretBytes<N> {
+ const SIZE: usize = N;
+ fn from_hkdf(bytes: &[u8]) -> Self {
+ #[allow(clippy::unwrap_used)]
+ Self(bytes.try_into().unwrap())
+ }
+ }
+
+ impl FromHkdf for super::TokenID {
+ const SIZE: usize = 32;
+ fn from_hkdf(bytes: &[u8]) -> Self {
+ #[allow(clippy::expect_used)]
+ Self(bytes.try_into().expect("hkdf failed"))
+ }
+ }
+
+ impl<L: FromHkdf, R: FromHkdf> FromHkdf for (L, R) {
+ const SIZE: usize = L::SIZE + R::SIZE;
+ #[allow(clippy::indexing_slicing)]
+ fn from_hkdf(bytes: &[u8]) -> Self {
+ (L::from_hkdf(&bytes[0..L::SIZE]), R::from_hkdf(&bytes[L::SIZE..]))
+ }
+ }
+
+ pub fn from_hkdf<O: FromHkdf>(key: &[u8], info: &[&[u8]]) -> O {
+ let hk = Hkdf::<Sha256>::new(None, key);
+ let mut buf = vec![0; O::SIZE];
+ #[allow(clippy::expect_used)]
+ // worth keeping an eye out for very large results (>255*32 bytes)
+ hk.expand_multi_info(info, buf.as_mut_slice()).expect("hkdf failed");
+ O::from_hkdf(&buf)
+ }
+}
+
+use from_hkdf::from_hkdf;
+use zeroize::Zeroize;
+
+impl<const N: usize> SecretBytes<N> {
+ pub fn generate() -> Self {
+ let mut result = Self([0; N]);
+ rand::rngs::OsRng.fill_bytes(&mut result.0);
+ result
+ }
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(transparent)]
+pub struct AuthPW {
+ pub pw: SecretBytes<32>,
+}
+
+pub struct StretchedPW {
+ pub pw: Output,
+}
+
+impl AuthPW {
+ pub fn stretch(&self, salt: Salt) -> anyhow::Result<StretchedPW> {
+ let mut result = [0; 32];
+ let mut buf = [0; Salt::MAX_LENGTH];
+ let params = scrypt::Params::new(16, 8, 1)?;
+ let salt = salt.b64_decode(&mut buf)?;
+ scrypt(&self.pw.0, salt, &params, &mut result)?;
+ Ok(StretchedPW { pw: Output::new(&result)? })
+ }
+}
+
+impl StretchedPW {
+ pub fn verify_hash(&self) -> Output {
+ let raw: SecretBytes<32> = from_hkdf(self.pw.as_bytes(), &[NAMESPACE, b"verifyHash"]);
+ raw.into()
+ }
+
+ fn wrap_wrap_key(&self) -> SecretBytes<32> {
+ from_hkdf(self.pw.as_bytes(), &[NAMESPACE, b"wrapwrapKey"])
+ }
+
+ pub fn decrypt_wwkb(&self, wwkb: &SecretBytes<32>) -> SecretBytes<32> {
+ wwkb.xor(&self.wrap_wrap_key())
+ }
+
+ pub fn rewrap_wkb(&self, wkb: &SecretBytes<32>) -> SecretBytes<32> {
+ wkb.xor(&self.wrap_wrap_key())
+ }
+}
+
+pub struct SessionCredentials {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+}
+
+impl SessionCredentials {
+ pub fn derive(seed: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&seed.0, &[NAMESPACE, b"sessionToken"]);
+ Self { token_id, req_hmac_key }
+ }
+}
+
+pub struct KeyFetchReq {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+ pub key_request_key: SecretBytes<32>,
+}
+
+impl KeyFetchReq {
+ pub fn from_token(key_fetch_token: &SecretBytes<32>) -> Self {
+ let (token_id, (req_hmac_key, key_request_key)) =
+ from_hkdf(&key_fetch_token.0, &[NAMESPACE, b"keyFetchToken"]);
+ Self { token_id, req_hmac_key, key_request_key }
+ }
+
+ pub fn derive_resp(&self) -> KeyFetchResp {
+ let (resp_hmac_key, resp_xor_key) =
+ from_hkdf(&self.key_request_key.0, &[NAMESPACE, b"account/keys"]);
+ KeyFetchResp { resp_hmac_key, resp_xor_key }
+ }
+}
+
+pub struct KeyFetchResp {
+ pub resp_hmac_key: SecretBytes<32>,
+ pub resp_xor_key: SecretBytes<64>,
+}
+
+impl KeyFetchResp {
+ pub fn wrap_keys(&self, keys: &KeyBundle) -> WrappedKeyBundle {
+ let ciphertext = self.resp_xor_key.xor(&keys.to_bytes());
+ #[allow(clippy::unwrap_used)]
+ let mut hmac = Hmac::<Sha256>::new_from_slice(&self.resp_hmac_key.0).unwrap();
+ hmac.update(&ciphertext.0);
+ let hmac = *hmac.finalize().into_bytes().as_ref();
+ WrappedKeyBundle { ciphertext, hmac }
+ }
+}
+
+pub struct KeyBundle {
+ pub ka: SecretBytes<32>,
+ pub wrap_kb: SecretBytes<32>,
+}
+
+impl KeyBundle {
+ pub fn to_bytes(&self) -> SecretBytes<64> {
+ let mut result = SecretBytes([0; 64]);
+ result.0[0..32].copy_from_slice(&self.ka.0);
+ result.0[32..].copy_from_slice(&self.wrap_kb.0);
+ result
+ }
+}
+
+#[derive(Debug)]
+pub struct WrappedKeyBundle {
+ pub ciphertext: SecretBytes<64>,
+ pub hmac: [u8; 32],
+}
+
+impl WrappedKeyBundle {
+ pub fn to_bytes(&self) -> [u8; 96] {
+ let mut result = [0; 96];
+ result[0..64].copy_from_slice(&self.ciphertext.0);
+ result[64..].copy_from_slice(&self.hmac);
+ result
+ }
+}
+
+pub struct PasswordChangeReq {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+}
+
+impl PasswordChangeReq {
+ pub fn from_change_token(token: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"passwordChangeToken"]);
+ Self { token_id, req_hmac_key }
+ }
+
+ pub fn from_forgot_token(token: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"passwordForgotToken"]);
+ Self { token_id, req_hmac_key }
+ }
+}
+
+pub struct AccountResetReq {
+ pub token_id: TokenID,
+ pub req_hmac_key: SecretBytes<32>,
+}
+
+impl AccountResetReq {
+ pub fn from_token(token: &SecretBytes<32>) -> Self {
+ let (token_id, req_hmac_key) = from_hkdf(&token.0, &[NAMESPACE, b"accountResetToken"]);
+ Self { token_id, req_hmac_key }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use hex_literal::hex;
+ use password_hash::{Output, SaltString};
+
+ use crate::crypto::{KeyBundle, KeyFetchReq, SessionCredentials};
+
+ use super::{AuthPW, SecretBytes};
+
+ macro_rules! shex {
+ ( $s: literal ) => {
+ SecretBytes(hex!($s))
+ };
+ }
+
+ #[test]
+ fn test_derive_session() {
+ let creds = SessionCredentials::derive(&SecretBytes(hex!(
+ "a0a1a2a3a4a5a6a7 a8a9aaabacadaeaf b0b1b2b3b4b5b6b7 b8b9babbbcbdbebf"
+ )));
+ assert_eq!(
+ creds.token_id.0,
+ hex!("c0a29dcf46174973da1378696e4c82ae10f723cf4f4d9f75e39f4ae3851595ab")
+ );
+ assert_eq!(
+ creds.req_hmac_key.0,
+ hex!("9d8f22998ee7f579 8b887042466b72d5 3e56ab0c094388bf 65831f702d2febc0")
+ );
+ }
+
+ #[test]
+ fn test_key_fetch() {
+ let key_fetch = KeyFetchReq::from_token(&shex!(
+ "8081828384858687 88898a8b8c8d8e8f 9091929394959697 98999a9b9c9d9e9f"
+ ));
+ assert_eq!(
+ key_fetch.token_id.0,
+ hex!("3d0a7c02a15a62a2882f76e39b6494b500c022a8816e048625a495718998ba60")
+ );
+ assert_eq!(
+ key_fetch.req_hmac_key.0,
+ hex!("87b8937f61d38d0e 29cd2d5600b3f4da 0aa48ac41de36a0e fe84bb4a9872ceb7")
+ );
+ assert_eq!(
+ key_fetch.key_request_key.0,
+ hex!("14f338a9e8c6324d 9e102d4e6ee83b20 9796d5c74bb734a4 10e729e014a4a546")
+ );
+
+ let resp = key_fetch.derive_resp();
+ assert_eq!(
+ resp.resp_hmac_key.0,
+ hex!("f824d2953aab9faf 51a1cb65ba9e7f9e 5bf91c8d8fd1ac1c 8c2d31853a8a1210")
+ );
+ assert_eq!(
+ resp.resp_xor_key.0,
+ hex!(
+ "ce7d7aa77859b235 9932970bbe2101f2 e80d01faf9191bd5 ee52181d2f0b7809
+ 8281ba8cff392543 3a89f7c3095e0c89 900a469d60790c83 3281c4df1a11c763"
+ )
+ );
+
+ let bundle = KeyBundle {
+ ka: shex!("2021222324252627 28292a2b2c2d2e2f 3031323334353637 38393a3b3c3d3e3f"),
+ wrap_kb: shex!("7effe354abecbcb2 34a8dfc2d7644b4a d339b525589738f2 d27341bb8622ecd8"),
+ };
+ assert_eq!(
+ bundle.to_bytes().0,
+ hex!(
+ "2021222324252627 28292a2b2c2d2e2f 3031323334353637 38393a3b3c3d3e3f
+ 7effe354abecbcb2 34a8dfc2d7644b4a d339b525589738f2 d27341bb8622ecd8"
+ )
+ );
+
+ let wrapped = resp.wrap_keys(&bundle);
+ assert_eq!(
+ wrapped.ciphertext.0,
+ hex!(
+ "ee5c58845c7c9412 b11bbd20920c2fdd d83c33c9cd2c2de2 d66b222613364636
+ fc7e59d854d599f1 0e212801de3a47c3 4333f3b838ee3471 e0f285649c332bbb"
+ )
+ );
+ assert_eq!(
+ wrapped.hmac,
+ hex!("4c17f42a0b319bbb a327d2b326ad23e9 37219b4de32e3ec7 b3e3f740522ad6ef")
+ );
+ assert_eq!(
+ wrapped.to_bytes(),
+ hex!(
+ "ee5c58845c7c9412 b11bbd20920c2fdd d83c33c9cd2c2de2 d66b222613364636
+ fc7e59d854d599f1 0e212801de3a47c3 4333f3b838ee3471 e0f285649c332bbb
+ 4c17f42a0b319bbb a327d2b326ad23e9 37219b4de32e3ec7 b3e3f740522ad6ef"
+ )
+ );
+ }
+
+ #[test]
+ fn test_stretch() -> anyhow::Result<()> {
+ let auth_pw = AuthPW {
+ pw: SecretBytes(hex!(
+ "247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"
+ )),
+ };
+
+ let stretched = auth_pw.stretch(
+ SaltString::b64_encode(&hex!(
+ "00f0000000000000 0000000000000000 0000000000000000 0000000000000000"
+ ))?
+ .as_salt(),
+ )?;
+ assert_eq!(
+ stretched.pw,
+ Output::new(&hex!(
+ "441509e25c92ee10 3d5a1a874e6f155d f25a44d06e61c894 616c9e85181dba97"
+ ))?
+ );
+
+ assert_eq!(
+ stretched.verify_hash().as_bytes(),
+ hex!("a4765bf103dc057f 4cf4bc2c131ddb67 16e8a4333cc55e1d 3c449f31f0eec4f1")
+ );
+
+ assert_eq!(
+ stretched.wrap_wrap_key().0,
+ hex!("3ebea117efa9faf5 7ce195899b290505 8368e7760cc26ea5 8a2a1be0da7fb287")
+ );
+ Ok(())
+ }
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
new file mode 100644
index 0000000..040507f
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1,1026 @@
+use std::{error::Error, mem::replace, sync::Arc};
+
+use anyhow::Result;
+use chrono::{DateTime, Duration, Utc};
+use password_hash::SaltString;
+use rocket::{
+ fairing::{self, Fairing},
+ futures::lock::{MappedMutexGuard, Mutex, MutexGuard},
+ http::Status,
+ request::{FromRequest, Outcome},
+ Request, Response, Sentinel,
+};
+use serde_json::Value;
+use sqlx::{query, query_as, query_scalar, PgPool, Postgres, Transaction};
+
+use crate::{
+ crypto::WrappedKeyBundle,
+ types::{
+ oauth::ScopeSet, AccountResetID, AttachedClient, Avatar, AvatarID, Device, DeviceCommand,
+ DeviceCommands, DeviceID, DevicePush, DeviceUpdate, HawkKey, KeyFetchID, OauthAccessToken,
+ OauthAccessType, OauthAuthorization, OauthAuthorizationID, OauthRefreshToken, OauthTokenID,
+ PasswordChangeID, SecretKey, SessionID, User, UserID, UserSession, VerifyCode, VerifyHash,
+ },
+};
+
+// we implement a completely custom db type set instead of using rocket_db_pools for two reasons:
+// 1. rocket_db_pools doesn't support sqlx 0.6
+// 2. we want one transaction per *request*, including all guards
+
+#[derive(Clone)]
+pub struct Db {
+ db: Arc<PgPool>,
+}
+
+impl Db {
+ pub async fn connect(db: &str) -> Result<Self> {
+ Ok(Self { db: Arc::new(PgPool::connect(db).await?) })
+ }
+
+ pub async fn begin(&self) -> Result<DbConn> {
+ Ok(DbConn(Mutex::new(ConnState::Capable(self.clone()))))
+ }
+
+ pub async fn migrate(&self) -> Result<()> {
+ sqlx::migrate!().run(&*self.db).await?;
+ Ok(())
+ }
+}
+
+struct ActiveConn {
+ tx: Transaction<'static, Postgres>,
+ always_commit: bool,
+}
+
+#[allow(clippy::large_enum_variant)]
+enum ConnState {
+ None,
+ Capable(Db),
+ Active(ActiveConn),
+ Done,
+}
+
+pub struct DbConn(Mutex<ConnState>);
+
+struct DbWrap(Db);
+struct DbConnWrap(DbConn);
+type DbLock<'c> = MappedMutexGuard<'c, ConnState, ActiveConn>;
+
+#[rocket::async_trait]
+impl Fairing for Db {
+ fn info(&self) -> fairing::Info {
+ fairing::Info {
+ name: "db access",
+ kind: fairing::Kind::Ignite | fairing::Kind::Response | fairing::Kind::Singleton,
+ }
+ }
+
+ async fn on_ignite(&self, rocket: rocket::Rocket<rocket::Build>) -> fairing::Result {
+ Ok(rocket.manage(DbWrap(self.clone())).manage(self.clone()))
+ }
+
+ async fn on_response<'r>(&self, req: &'r Request<'_>, resp: &mut Response<'r>) {
+ let conn = req.local_cache(|| DbConnWrap(DbConn(Mutex::new(ConnState::None))));
+ let s = replace(&mut *conn.0 .0.lock().await, ConnState::Done);
+ if let ConnState::Active(ActiveConn { tx, always_commit }) = s {
+ // don't commit if the request failed, unless explicitly asked to.
+ // this is used by key fetch to invalidate tokens even if the header
+ // signature of the request is incorrect.
+ if !always_commit && resp.status() != Status::Ok {
+ return;
+ }
+ if let Err(e) = tx.commit().await {
+ resp.set_status(Status::InternalServerError);
+ error!("commit failed: {}", e);
+ }
+ }
+ }
+}
+
+impl DbConn {
+ pub async fn commit(self) -> Result<(), sqlx::Error> {
+ match self.0.into_inner() {
+ ConnState::None => Ok(()),
+ ConnState::Capable(_) => Ok(()),
+ ConnState::Active(ac) => ac.tx.commit().await,
+ ConnState::Done => Ok(()),
+ }
+ }
+
+ fn register<'r>(req: &'r Request<'_>) -> &'r DbConn {
+ &req.local_cache(|| {
+ let db = req.rocket().state::<DbWrap>().unwrap().0.clone();
+ DbConnWrap(DbConn(Mutex::new(ConnState::Capable(db))))
+ })
+ .0
+ }
+
+ // defer opening a transaction and thus locking the mutex holding it.
+ // without deferral the order of arguments in route signatures is important,
+ // which may be surprising: placing a DbConn before a guard that also uses
+ // the database causes a concurrent transaction error.
+ // HACK maybe we should return errors instead of panicking, but there's no
+ // way an error here is not a severe bug
+ async fn get(&self) -> sqlx::Result<DbLock<'_>> {
+ let mut m = match self.0.try_lock() {
+ Some(m) => m,
+ None => panic!("attempted to open concurrent transactions"),
+ };
+ match &*m {
+ ConnState::Capable(db) => {
+ *m = ConnState::Active(ActiveConn {
+ tx: db.db.begin().await?,
+ always_commit: false,
+ });
+ },
+ ConnState::None | ConnState::Done => panic!("db connection requested after teardown"),
+ _ => (),
+ }
+ Ok(MutexGuard::map(m, |g| match g {
+ ConnState::Active(ref mut tx) => tx,
+ _ => unreachable!(),
+ }))
+ }
+}
+
+impl<'r> Sentinel for &'r Db {
+ fn abort(rocket: &rocket::Rocket<rocket::Ignite>) -> bool {
+ rocket.state::<DbWrap>().is_none()
+ }
+}
+
+#[rocket::async_trait]
+impl<'r> FromRequest<'r> for &'r Db {
+ type Error = anyhow::Error;
+
+ async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
+ Outcome::Success(&req.rocket().state::<DbWrap>().unwrap().0)
+ }
+}
+
+impl<'r> Sentinel for &'r DbConn {
+ fn abort(rocket: &rocket::Rocket<rocket::Ignite>) -> bool {
+ rocket.state::<DbWrap>().is_none()
+ }
+}
+
+#[rocket::async_trait]
+impl<'r> FromRequest<'r> for &'r DbConn {
+ type Error = anyhow::Error;
+
+ async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
+ Outcome::Success(DbConn::register(req))
+ }
+}
+
+//
+//
+//
+
+//
+//
+//
+
+impl DbConn {
+ pub(crate) async fn always_commit(&self) -> Result<()> {
+ self.get().await?.always_commit = true;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn add_session(
+ &self,
+ id: SessionID,
+ user_id: &UserID,
+ key: HawkKey,
+ verified: bool,
+ verify_code: Option<&str>,
+ ) -> sqlx::Result<DateTime<Utc>> {
+ query_scalar!(
+ r#"insert into user_session (session_id, user_id, req_hmac_key, device_id, verified,
+ verify_code)
+ values ($1, $2, $3, null, $4, $5)
+ returning created_at"#,
+ id as _,
+ user_id as _,
+ key as _,
+ verified,
+ verify_code,
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn use_session(&self, id: &SessionID) -> sqlx::Result<UserSession> {
+ query_as!(
+ UserSession,
+ r#"update user_session
+ set last_active = now()
+ where session_id = $1
+ returning user_id as "uid: UserID", req_hmac_key as "req_hmac_key: HawkKey",
+ device_id as "device_id: DeviceID", created_at, verified, verify_code"#,
+ id as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn use_session_from_refresh(
+ &self,
+ id: &OauthTokenID,
+ ) -> sqlx::Result<(SessionID, UserSession)> {
+ query!(
+ r#"update user_session
+ set last_active = now()
+ where session_id = (
+ select session_id from oauth_token where kind = 'refresh' and id = $1
+ )
+ returning user_id as "uid: UserID", req_hmac_key as "req_hmac_key: HawkKey",
+ device_id as "device_id: DeviceID", session_id as "session_id: SessionID",
+ created_at, verified, verify_code"#,
+ id as _
+ )
+ .map(|r| {
+ (
+ r.session_id.clone(),
+ UserSession {
+ uid: r.uid,
+ req_hmac_key: r.req_hmac_key,
+ device_id: r.device_id,
+ created_at: r.created_at,
+ verified: r.verified,
+ verify_code: r.verify_code,
+ },
+ )
+ })
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn delete_session(&self, user: &UserID, id: &SessionID) -> sqlx::Result<()> {
+ query_scalar!(
+ r#"delete from user_session
+ where user_id = $1 and session_id = $2
+ returning 1"#,
+ user as _,
+ id as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn set_session_device<'d>(
+ &self,
+ id: &SessionID,
+ dev: Option<&'d DeviceID>,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"update user_session set device_id = $1 where session_id = $2"#,
+ dev as _,
+ id as _
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn set_session_verified(&self, id: &SessionID) -> sqlx::Result<()> {
+ query_scalar!(
+ r#"update user_session
+ set verified = true, verify_code = null
+ where session_id = $1
+ returning 1"#,
+ id as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn enqueue_command(
+ &self,
+ target: &DeviceID,
+ sender: &Option<DeviceID>,
+ command: &str,
+ payload: &Value,
+ ttl: u32,
+ ) -> sqlx::Result<i64> {
+ let expires = Utc::now() + Duration::seconds(ttl as i64);
+ query!(
+ r#"insert into device_commands (device_id, command, payload, expires, sender)
+ values ($1, $2, $3, $4, $5)
+ returning index"#,
+ target as _,
+ command,
+ payload,
+ expires,
+ sender.as_ref().map(ToString::to_string)
+ )
+ .map(|x| x.index)
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn get_commands(
+ &self,
+ user: &UserID,
+ dev: &DeviceID,
+ min_index: i64,
+ limit: i64,
+ ) -> sqlx::Result<(bool, Vec<DeviceCommand>)> {
+ // NOTE while fxa api docs state that command queries return only commands enqueued
+ // *after* index, what pushbox actually does is return commands *starting at* index!
+ let mut results = query_as!(
+ DeviceCommand,
+ r#"select index, command, payload, expires, sender
+ from device_commands join device using (device_id)
+ where index >= $1 and device_id = $2 and user_id = $3
+ order by index
+ limit $4"#,
+ min_index,
+ dev as _,
+ user as _,
+ limit + 1
+ )
+ .fetch_all(&mut self.get().await?.tx)
+ .await?;
+ let more = results.len() > limit as usize;
+ results.truncate(limit as usize);
+ Ok((more, results))
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn get_devices(&self, user: &UserID) -> sqlx::Result<Vec<Device>> {
+ query_as!(
+ Device,
+ r#"select d.device_id as "device_id: DeviceID", d.name, d.type as type_,
+ d.push as "push: DevicePush",
+ d.available_commands as "available_commands: DeviceCommands",
+ d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as "last_active!"
+ from device d left join user_session us using (device_id)
+ where d.user_id = $1"#,
+ user as _
+ )
+ .fetch_all(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn get_device(&self, user: &UserID, dev: &DeviceID) -> sqlx::Result<Device> {
+ query_as!(
+ Device,
+ r#"select d.device_id as "device_id: DeviceID", d.name, d.type as type_,
+ d.push as "push: DevicePush",
+ d.available_commands as "available_commands: DeviceCommands",
+ d.push_expired, d.location, coalesce(us.last_active, to_timestamp(0)) as "last_active!"
+ from device d left join user_session us using (device_id)
+ where d.user_id = $1 and d.device_id = $2"#,
+ user as _,
+ dev as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn change_device<'d>(
+ &self,
+ user: &UserID,
+ id: &DeviceID,
+ dev: DeviceUpdate<'d>,
+ ) -> sqlx::Result<Device> {
+ query_as!(
+ Device,
+ r#"select device_id as "device_id!: DeviceID", name as "name!", type as "type_!",
+ push as "push: DevicePush",
+ available_commands as "available_commands!: DeviceCommands",
+ push_expired as "push_expired!", location as "location!",
+ coalesce(last_active, to_timestamp(0)) as "last_active!"
+ from insert_or_update_device($1, $2, $3, $4, $5, $6, $7) as iud
+ left join user_session using (device_id)"#,
+ id as _,
+ user as _,
+ // these two are not optional but are Option anyway. the db will
+ // refuse insertions that don't have them set.
+ dev.name,
+ dev.type_,
+ dev.push as _,
+ dev.available_commands as _,
+ dev.location
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn set_push_expired(&self, dev: &DeviceID) -> sqlx::Result<()> {
+ query!(
+ r#"update device
+ set push_expired = true
+ where device_id = $1"#,
+ dev as _
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn delete_device(&self, user: &UserID, dev: &DeviceID) -> sqlx::Result<()> {
+ query_scalar!(
+ r#"delete from device where user_id = $1 and device_id = $2 returning 1"#,
+ user as _,
+ dev as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn add_key_fetch(
+ &self,
+ id: KeyFetchID,
+ hmac_key: &HawkKey,
+ keys: &WrappedKeyBundle,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into key_fetch (id, hmac_key, keys) values ($1, $2, $3)"#,
+ id as _,
+ hmac_key as _,
+ &keys.to_bytes()[..]
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn finish_key_fetch(
+ &self,
+ id: &KeyFetchID,
+ ) -> sqlx::Result<(HawkKey, Vec<u8>)> {
+ query!(
+ r#"delete from key_fetch
+ where id = $1 and expires_at > now()
+ returning hmac_key as "hmac_key: HawkKey", keys"#,
+ id as _
+ )
+ .map(|r| (r.hmac_key, r.keys))
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn get_refresh_token(
+ &self,
+ id: &OauthTokenID,
+ ) -> sqlx::Result<OauthRefreshToken> {
+ query_as!(
+ OauthRefreshToken,
+ r#"select user_id as "user_id: UserID", client_id, scope as "scope: ScopeSet",
+ session_id as "session_id: SessionID"
+ from oauth_token
+ where id = $1 and kind = 'refresh'"#,
+ id as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn get_access_token(
+ &self,
+ id: &OauthTokenID,
+ ) -> sqlx::Result<OauthAccessToken> {
+ query_as!(
+ OauthAccessToken,
+ r#"select user_id as "user_id: UserID", client_id, scope as "scope: ScopeSet",
+ parent_refresh as "parent_refresh: OauthTokenID",
+ parent_session as "parent_session: SessionID",
+ expires_at as "expires_at!"
+ from oauth_token
+ where id = $1 and kind = 'access' and expires_at > now()"#,
+ id as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn add_refresh_token(
+ &self,
+ id: &OauthTokenID,
+ token: OauthRefreshToken,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into oauth_token (id, kind, user_id, client_id, scope, session_id)
+ values ($1, 'refresh', $2, $3, $4, $5)"#,
+ id as _,
+ token.user_id as _,
+ token.client_id,
+ token.scope as _,
+ token.session_id as _
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn add_access_token(
+ &self,
+ id: &OauthTokenID,
+ token: OauthAccessToken,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into oauth_token (id, kind, user_id, client_id, scope, session_id,
+ parent_refresh, parent_session, expires_at)
+ values ($1, 'access', $2, $3, $4, null, $5, $6, $7)"#,
+ id as _,
+ token.user_id as _,
+ token.client_id,
+ token.scope as _,
+ token.parent_refresh as _,
+ token.parent_session as _,
+ token.expires_at,
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn delete_oauth_token(&self, id: &OauthTokenID) -> sqlx::Result<()> {
+ query!(r#"delete from oauth_token where id = $1"#, id as _)
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn delete_refresh_token(&self, id: &OauthTokenID) -> sqlx::Result<()> {
+ query!(r#"delete from oauth_token where id = $1 and kind = 'refresh'"#, id as _)
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn add_oauth_authorization(
+ &self,
+ id: &OauthAuthorizationID,
+ auth: OauthAuthorization,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into oauth_authorization (id, user_id, client_id, scope, access_type,
+ code_challenge, keys_jwe, auth_at)
+ values ($1, $2, $3, $4, $5, $6, $7, $8)"#,
+ id as _,
+ auth.user_id as _,
+ auth.client_id,
+ auth.scope as _,
+ auth.access_type as _,
+ auth.code_challenge,
+ auth.keys_jwe,
+ auth.auth_at,
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn take_oauth_authorization(
+ &self,
+ id: &OauthAuthorizationID,
+ ) -> sqlx::Result<OauthAuthorization> {
+ query_as!(
+ OauthAuthorization,
+ r#"delete from oauth_authorization
+ where id = $1 and expires_at > now()
+ returning user_id as "user_id: UserID", client_id, scope as "scope: ScopeSet",
+ access_type as "access_type: OauthAccessType",
+ code_challenge, keys_jwe, auth_at"#,
+ id as _
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn user_email_exists(&self, email: &str) -> sqlx::Result<bool> {
+ Ok(query_scalar!(r#"select 1 from users where email = lower($1)"#, email)
+ .fetch_optional(&mut self.get().await?.tx)
+ .await?
+ .is_some())
+ }
+
+ pub(crate) async fn add_user(&self, user: User) -> sqlx::Result<UserID> {
+ let id = UserID::random();
+ query_scalar!(
+ r#"insert into users (user_id, auth_salt, email, ka, wrapwrap_kb, verify_hash,
+ display_name)
+ values ($1, $2, $3, $4, $5, $6, $7)"#,
+ id as _,
+ user.auth_salt.as_str(),
+ user.email,
+ user.ka as _,
+ user.wrapwrap_kb as _,
+ user.verify_hash as _,
+ user.display_name,
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(id)
+ }
+
+ pub(crate) async fn get_user(&self, email: &str) -> sqlx::Result<(UserID, User)> {
+ query!(
+ r#"select user_id as "id: UserID", auth_salt as "auth_salt: String", email,
+ ka as "ka: SecretKey", wrapwrap_kb as "wrapwrap_kb: SecretKey",
+ verify_hash as "verify_hash: VerifyHash", display_name, verified
+ from users
+ where email = lower($1)"#,
+ email
+ )
+ .try_map(|r| {
+ Ok((
+ r.id,
+ User {
+ auth_salt: SaltString::new(&r.auth_salt).map_err(decode_err("auth_salt"))?,
+ email: r.email,
+ ka: r.ka,
+ wrapwrap_kb: r.wrapwrap_kb,
+ verify_hash: r.verify_hash,
+ display_name: r.display_name,
+ verified: r.verified,
+ },
+ ))
+ })
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn get_user_by_id(&self, id: &UserID) -> sqlx::Result<User> {
+ query!(
+ r#"select auth_salt as "auth_salt: String", email,
+ ka as "ka: SecretKey", wrapwrap_kb as "wrapwrap_kb: SecretKey",
+ verify_hash as "verify_hash: VerifyHash", display_name, verified
+ from users
+ where user_id = $1"#,
+ id as _
+ )
+ .try_map(|r| {
+ Ok(User {
+ auth_salt: SaltString::new(&r.auth_salt).map_err(decode_err("auth_salt"))?,
+ email: r.email,
+ ka: r.ka,
+ wrapwrap_kb: r.wrapwrap_kb,
+ verify_hash: r.verify_hash,
+ display_name: r.display_name,
+ verified: r.verified,
+ })
+ })
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn set_user_name(&self, id: &UserID, name: &str) -> sqlx::Result<()> {
+ query!(
+ "update users
+ set display_name = $2
+ where user_id = $1",
+ id as _,
+ name,
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn set_user_verified(&self, id: &UserID) -> sqlx::Result<()> {
+ query_scalar!("update users set verified = true where user_id = $1 returning 1", id as _)
+ .fetch_one(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn delete_user(&self, email: &str) -> sqlx::Result<()> {
+ query_scalar!(r#"delete from users where email = lower($1)"#, email)
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn change_user_auth(
+ &self,
+ uid: &UserID,
+ salt: SaltString,
+ wwkb: SecretKey,
+ verify_hash: VerifyHash,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"update users
+ set auth_salt = $2, wrapwrap_kb = $3, verify_hash = $4
+ where user_id = $1"#,
+ uid as _,
+ salt.to_string(),
+ wwkb as _,
+ verify_hash as _,
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn reset_user_auth(
+ &self,
+ uid: &UserID,
+ salt: SaltString,
+ wwkb: SecretKey,
+ verify_hash: VerifyHash,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"call reset_user_auth($1, $2, $3, $4)"#,
+ uid as _,
+ salt.to_string(),
+ wwkb as _,
+ verify_hash as _,
+ )
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn get_attached_clients(
+ &self,
+ id: &UserID,
+ ) -> sqlx::Result<Vec<AttachedClient>> {
+ query_as!(
+ AttachedClient,
+ r#"select
+ ot.client_id as "client_id?",
+ d.device_id as "device_id?: DeviceID",
+ us.session_id as "session_token_id?: SessionID",
+ ot.id as "refresh_token_id?: OauthTokenID",
+ d.type as "device_type?",
+ d.name as "name?",
+ coalesce(d.created_at, us.created_at, ot.created_at) as "created_time?",
+ us.last_active as "last_access_time?",
+ ot.scope as "scope?"
+ from device d
+ full outer join user_session us on (d.device_id = us.device_id)
+ full outer join oauth_token ot on (us.session_id = ot.session_id)
+ where
+ (ot.kind is null or ot.kind = 'refresh')
+ and $1 in (d.user_id, us.user_id, ot.user_id)
+ order by d.device_id"#,
+ id as _,
+ )
+ .fetch_all(&mut self.get().await?.tx)
+ .await
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn get_user_avatar_id(&self, id: &UserID) -> sqlx::Result<Option<AvatarID>> {
+ query!(r#"select id as "id: AvatarID" from user_avatars where user_id = $1"#, id as _,)
+ .map(|r| r.id)
+ .fetch_optional(&mut *self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn get_user_avatar(&self, id: &AvatarID) -> sqlx::Result<Option<Avatar>> {
+ query_as!(
+ Avatar,
+ r#"select id as "id: AvatarID", data, content_type
+ from user_avatars
+ where id = $1"#,
+ id as _,
+ )
+ .fetch_optional(&mut *self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn set_user_avatar(&self, id: &UserID, avatar: Avatar) -> sqlx::Result<()> {
+ query!(
+ r#"insert into user_avatars (user_id, id, data, content_type)
+ values ($1, $2, $3, $4)
+ on conflict (user_id) do update set
+ id = $2, data = $3, content_type = $4"#,
+ id as _,
+ avatar.id as _,
+ avatar.data,
+ avatar.content_type,
+ )
+ .execute(&mut *self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn delete_user_avatar(
+ &self,
+ user: &UserID,
+ id: &AvatarID,
+ ) -> sqlx::Result<()> {
+ query!(r#"delete from user_avatars where user_id = $1 and id = $2"#, user as _, id as _,)
+ .execute(&mut *self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn add_verify_code(
+ &self,
+ user: &UserID,
+ session: &SessionID,
+ code: &str,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into verify_codes (user_id, session_id, code)
+ values ($1, $2, $3)"#,
+ user as _,
+ session as _,
+ code,
+ )
+ .execute(&mut *self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn get_verify_code(
+ &self,
+ user: &UserID,
+ ) -> sqlx::Result<(String, VerifyCode)> {
+ query!(
+ r#"select user_id as "user_id: UserID", session_id as "session_id: SessionID", code,
+ email
+ from verify_codes join users using (user_id)
+ where user_id = $1"#,
+ user as _,
+ )
+ .map(|r| {
+ (r.email, VerifyCode { user_id: r.user_id, session_id: r.session_id, code: r.code })
+ })
+ .fetch_one(&mut *self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn try_use_verify_code(
+ &self,
+ user: &UserID,
+ code: &str,
+ ) -> sqlx::Result<Option<VerifyCode>> {
+ query_as!(
+ VerifyCode,
+ r#"delete from verify_codes
+ where user_id = $1 and code = $2
+ returning user_id as "user_id: UserID", session_id as "session_id: SessionID",
+ code"#,
+ user as _,
+ code,
+ )
+ .fetch_optional(&mut *self.get().await?.tx)
+ .await
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn add_password_change(
+ &self,
+ user: &UserID,
+ id: &PasswordChangeID,
+ key: &HawkKey,
+ forgot_code: Option<&str>,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into password_change_tokens (id, user_id, hmac_key, forgot_code)
+ values ($1, $2, $3, $4)
+ on conflict (user_id) do update set id = $1, hmac_key = $3, forgot_code = $4,
+ expires_at = default"#,
+ id as _,
+ user as _,
+ key as _,
+ forgot_code,
+ )
+ .execute(&mut *self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn finish_password_change(
+ &self,
+ id: &PasswordChangeID,
+ is_forgot: bool,
+ ) -> sqlx::Result<(HawkKey, (UserID, Option<String>))> {
+ query!(
+ r#"delete from password_change_tokens
+ where id = $1 and expires_at > now() and (forgot_code is not null) = $2
+ returning hmac_key as "hmac_key: HawkKey", user_id as "user_id: UserID",
+ forgot_code"#,
+ id as _,
+ is_forgot,
+ )
+ .map(|r| (r.hmac_key, (r.user_id, r.forgot_code)))
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ pub(crate) async fn add_account_reset(
+ &self,
+ user: &UserID,
+ id: &AccountResetID,
+ key: &HawkKey,
+ ) -> sqlx::Result<()> {
+ query!(
+ r#"insert into account_reset_tokens (id, user_id, hmac_key)
+ values ($1, $2, $3)
+ on conflict (user_id) do update set id = $1, hmac_key = $3, expires_at = default"#,
+ id as _,
+ user as _,
+ key as _,
+ )
+ .execute(&mut *self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn finish_account_reset(
+ &self,
+ id: &AccountResetID,
+ ) -> sqlx::Result<(HawkKey, UserID)> {
+ query!(
+ r#"delete from account_reset_tokens
+ where id = $1 and expires_at > now()
+ returning hmac_key as "hmac_key: HawkKey", user_id as "user_id: UserID""#,
+ id as _,
+ )
+ .map(|r| (r.hmac_key, r.user_id))
+ .fetch_one(&mut self.get().await?.tx)
+ .await
+ }
+
+ //
+ //
+ //
+
+ pub async fn add_invite_code(&self, code: &str, expires: DateTime<Utc>) -> sqlx::Result<()> {
+ query!(r#"insert into invite_codes (code, expires_at) values ($1, $2)"#, code, expires,)
+ .execute(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ pub(crate) async fn use_invite_code(&self, code: &str) -> sqlx::Result<()> {
+ query_scalar!(
+ r#"delete from invite_codes where code = $1 and expires_at > now() returning 1"#,
+ code,
+ )
+ .fetch_one(&mut self.get().await?.tx)
+ .await?;
+ Ok(())
+ }
+
+ //
+ //
+ //
+
+ pub(crate) async fn prune_expired_tokens(&self) -> sqlx::Result<()> {
+ query!("call prune_expired_tokens()").execute(&mut self.get().await?.tx).await?;
+ Ok(())
+ }
+
+ pub(crate) async fn prune_expired_verify_codes(&self) -> sqlx::Result<()> {
+ query!("call prune_expired_verify_codes()").execute(&mut self.get().await?.tx).await?;
+ Ok(())
+ }
+}
+
+fn decode_err<E: Error + Send + Sync + 'static>(c: &str) -> impl FnOnce(E) -> sqlx::Error {
+ let index = c.to_string();
+ move |e| sqlx::Error::ColumnDecode { index, source: Box::new(e) }
+}
diff --git a/src/js.rs b/src/js.rs
new file mode 100644
index 0000000..f3d662c
--- /dev/null
+++ b/src/js.rs
@@ -0,0 +1,53 @@
+use std::{collections::HashMap, path::PathBuf};
+
+use rocket::http::{ContentType, Status};
+use sha2::{Digest, Sha256};
+
+use crate::cache::{Etagged, IfNoneMatch};
+
+struct Entry {
+ data: &'static str,
+ hash: String,
+}
+
+fn enter(data: &'static str) -> Entry {
+ let mut sha = Sha256::new();
+ sha.update(data.as_bytes());
+ let hash = base64::encode(sha.finalize());
+ Entry { data, hash }
+}
+
+lazy_static! {
+ static ref JS: HashMap<&'static str, Entry> = {
+ let mut m = HashMap::new();
+ m.insert("main", enter(include_str!("../web/js//main.js")));
+ m.insert("crypto", enter(include_str!("../web/js//crypto.js")));
+ m.insert("auth-client/browser", enter(include_str!("../web/js//browser/browser.js")));
+ m.insert("auth-client/lib/client", enter(include_str!("../web/js//browser/lib/client.js")));
+ m.insert("auth-client/lib/crypto", enter(include_str!("../web/js//browser/lib/crypto.js")));
+ m.insert("auth-client/lib/hawk", enter(include_str!("../web/js//browser/lib/hawk.js")));
+ m.insert(
+ "auth-client/lib/recoveryKey",
+ enter(include_str!("../web/js//browser/lib/recoveryKey.js")),
+ );
+ m.insert("auth-client/lib/utils", enter(include_str!("../web/js//browser/lib/utils.js")));
+ m
+ };
+}
+
+#[get("/<name..>")]
+pub(crate) async fn static_js(
+ name: PathBuf,
+ inm: Option<IfNoneMatch<'_>>,
+) -> (Status, Result<(ContentType, Etagged<'_, &'static str>), ()>) {
+ let entry = JS.get(name.to_string_lossy().as_ref());
+ match entry {
+ Some(e) => match inm {
+ Some(h) if h.0 == e.hash => (Status::NotModified, Err(())),
+ _ => {
+ (Status::Ok, Ok((ContentType::JavaScript, Etagged(e.data, e.hash.as_str().into()))))
+ },
+ },
+ _ => (Status::NotFound, Err(())),
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..1e6fa31
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,319 @@
+use std::{
+ path::PathBuf,
+ sync::Arc,
+ time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
+};
+
+use anyhow::Context;
+use chrono::Duration;
+use db::Db;
+use futures::Future;
+use lettre::message::Mailbox;
+use mailer::Mailer;
+use push::PushClient;
+use rocket::{
+ fairing::AdHoc,
+ http::{uri::Absolute, ContentType, Header},
+ request::{self, FromRequest},
+ response::Redirect,
+ tokio::{
+ spawn,
+ time::{interval_at, Instant, MissedTickBehavior},
+ },
+ Request, State,
+};
+use serde_json::{json, Value};
+use utils::DeferredActions;
+
+use crate::api::auth::invite::generate_invite_link;
+
+#[macro_use]
+extern crate rocket;
+#[macro_use]
+extern crate anyhow;
+#[macro_use]
+extern crate lazy_static;
+
+#[macro_use]
+pub(crate) mod utils;
+pub(crate) mod api;
+mod auth;
+mod cache;
+mod crypto;
+pub mod db;
+mod js;
+mod mailer;
+mod push;
+mod types;
+
+fn default_push_ttl() -> std::time::Duration {
+ std::time::Duration::from_secs(2 * 86400)
+}
+
+fn default_task_interval() -> std::time::Duration {
+ std::time::Duration::from_secs(5 * 60)
+}
+
+#[derive(serde::Deserialize)]
+struct Config {
+ database_url: String,
+ location: Absolute<'static>,
+ token_server_location: Absolute<'static>,
+ vapid_key: PathBuf,
+ vapid_subject: String,
+ #[serde(default = "default_push_ttl", with = "humantime_serde")]
+ default_push_ttl: std::time::Duration,
+ #[serde(default = "default_task_interval", with = "humantime_serde")]
+ prune_expired_interval: std::time::Duration,
+
+ mail_from: Mailbox,
+ mail_host: Option<String>,
+ mail_port: Option<u16>,
+
+ #[serde(default)]
+ invite_only: bool,
+ #[serde(default)]
+ invite_admin_address: String,
+}
+
+impl Config {
+ pub fn avatars_prefix(&self) -> Absolute<'static> {
+ Absolute::parse_owned(format!("{}/avatars", self.location)).unwrap()
+ }
+}
+
+#[get("/")]
+async fn root() -> (ContentType, &'static str) {
+ (ContentType::HTML, include_str!("../web/index.html"))
+}
+
+#[get("/settings/<_..>")]
+async fn settings() -> Redirect {
+ Redirect::to(uri!("/#/settings"))
+}
+
+#[get("/auth/v1/authorization")]
+async fn auth_auth() -> (ContentType, &'static str) {
+ root().await
+}
+
+#[get("/force_auth")]
+async fn force_auth() -> Redirect {
+ Redirect::to(uri!("/#/force_auth"))
+}
+
+#[derive(Debug)]
+struct IsFenix(bool);
+
+#[rocket::async_trait]
+impl<'r> FromRequest<'r> for IsFenix {
+ type Error = std::convert::Infallible;
+
+ async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
+ let ua = request.headers().get_one("user-agent");
+ request::Outcome::Success(IsFenix(
+ ua.map(|ua| ua.contains("Firefox") && ua.contains("Android")).unwrap_or(false),
+ ))
+ }
+}
+
+#[get("/.well-known/fxa-client-configuration")]
+async fn fxa_client_configuration(cfg: &State<Config>, is_fenix: IsFenix) -> Value {
+ let base = &cfg.location;
+ json!({
+ "auth_server_base_url": format!("{base}/auth"),
+ "oauth_server_base_url": format!("{base}/oauth"),
+ "pairing_server_base_uri": format!("{base}/pairing"),
+ "profile_server_base_url": format!("{base}/profile"),
+ // NOTE trailing slash is *essential*, otherwise fenix will refuse to sync.
+ // likewise firefox desktop seems to misbehave if there *is* a trailing slash.
+ "sync_tokenserver_base_url": format!("{}{}", cfg.token_server_location, if is_fenix.0 { "/" } else { "" })
+ })
+}
+
+// NOTE it looks like firefox does not implement refresh token rotation.
+// since it also looks like it doesn't implement MTLS we can't secure
+// refresh tokens against being stolen as advised by
+// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
+// section 2.2.2
+
+// NOTE firefox "oldsync" scope is the current version?
+// https://github.com/mozilla/fxa/blob/main/packages/fxa-auth-server/docs/oauth/scopes.md
+// https://mozilla.github.io/ecosystem-platform/explanation/onepw-protocol
+// https://mozilla.github.io/ecosystem-platform/api
+// https://github.com/mozilla/fxa/blob/main/packages/fxa-auth-server/docs/device_registration.md
+// -> push for everything
+// https://mozilla.github.io/ecosystem-platform/explanation/scoped-keys
+
+#[get("/.well-known/openid-configuration")]
+fn oid(cfg: &State<Config>) -> Value {
+ let base = &cfg.location;
+ json!({
+ "authorization_endpoint": format!("{base}/auth/v1/authorization"),
+ "introspection_endpoint": format!("{base}/oauth/v1/introspect"),
+ "issuer": base.to_string(),
+ "jwks_uri": format!("{base}/oauth/v1/jwks"),
+ "revocation_endpoint": format!("{base}/oauth/v1/destroy"),
+ "token_endpoint": format!("{base}/auth/v1/oauth/token"),
+ "userinfo_endpoint": format!("{base}/profile/v1/profile"),
+ "claims_supported": ["aud","exp","iat","iss","sub"],
+ "id_token_signing_alg_values_supported": ["RS256"],
+ "response_types_supported": ["code","token"],
+ "scopes_supported": ["openid","profile","email"],
+ "subject_types_supported": ["public"],
+ "token_endpoint_auth_methods_supported": ["client_secret_post"],
+ })
+}
+
+fn spawn_periodic<A, P, F>(context: &'static str, t: StdDuration, p: P, f: A)
+where
+ A: Fn(P) -> F + Send + Sync + Sized + 'static,
+ P: Clone + Send + Sync + 'static,
+ F: Future<Output = anyhow::Result<()>> + Send + Sized,
+{
+ let mut interval = interval_at(Instant::now() + t, t);
+ interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
+
+ spawn(async move {
+ loop {
+ interval.tick().await;
+ info!("starting periodic {context}");
+ if let Err(e) = f(p.clone()).await {
+ error!("periodic {context} failed: {e}");
+ }
+ }
+ });
+}
+
+async fn ensure_invite_admin(db: &Db, cfg: &Config) -> anyhow::Result<()> {
+ if !cfg.invite_only {
+ return Ok(());
+ }
+
+ let tx = db.begin().await?;
+ match tx.get_user(&cfg.invite_admin_address).await {
+ Err(sqlx::Error::RowNotFound) => {
+ let url = generate_invite_link(&tx, cfg, Duration::hours(1)).await?;
+ tx.commit().await?;
+ warn!("admin user {} does not exist, register at {url}", cfg.invite_admin_address);
+ Ok(())
+ },
+ Err(e) => Err(anyhow!(e)),
+ Ok(_) => Ok(()),
+ }
+}
+
+pub async fn build() -> anyhow::Result<rocket::Rocket<rocket::Build>> {
+ let rocket = rocket::build();
+ let config = rocket.figment().extract::<Config>().context("reading config")?;
+ let db = Arc::new(Db::connect(&config.database_url).await.unwrap());
+
+ db.migrate().await.context("running db migrations")?;
+
+ ensure_invite_admin(&db, &config).await?;
+ let push = Arc::new(
+ PushClient::new(
+ &config.vapid_key,
+ &config.vapid_subject,
+ config.location.clone(),
+ config.default_push_ttl,
+ )
+ .context("setting up push notifications")?,
+ );
+ let mailer = Arc::new(
+ Mailer::new(
+ config.mail_from.clone(),
+ config.mail_host.as_deref().unwrap_or("localhost"),
+ config.mail_port.unwrap_or(25),
+ config.location.clone(),
+ )
+ .context("setting up mail notifications")?,
+ );
+ spawn_periodic("verify code prune", StdDuration::from_secs(5 * 60), Arc::clone(&db), {
+ |db| async move {
+ let tx = db.begin().await?;
+ tx.prune_expired_verify_codes().await?;
+ tx.commit().await?;
+ Ok(())
+ }
+ });
+ spawn_periodic("expired token prune", config.prune_expired_interval, Arc::clone(&db), {
+ |db| async move {
+ let tx = db.begin().await?;
+ tx.prune_expired_tokens().await?;
+ tx.commit().await?;
+ Ok(())
+ }
+ });
+ let rocket = rocket
+ .manage(config)
+ .manage(push)
+ .manage(mailer)
+ .attach(db)
+ .attach(DeferredActions)
+ .mount("/", routes![root, settings, oid, auth_auth, force_auth, fxa_client_configuration,])
+ .register("/auth/v1", catchers![api::auth::catch_all,])
+ .mount(
+ "/auth/v1",
+ routes![
+ api::auth::account::create,
+ api::auth::account::login,
+ api::auth::account::destroy,
+ api::auth::account::keys,
+ api::auth::account::reset,
+ api::auth::oauth::token_authenticated,
+ api::auth::oauth::token_unauthenticated,
+ api::auth::oauth::destroy,
+ api::auth::oauth::scoped_key_data,
+ api::auth::device::devices,
+ api::auth::device::device,
+ api::auth::device::invoke,
+ api::auth::device::commands,
+ api::auth::session::status,
+ api::auth::session::resend_code,
+ api::auth::session::verify_code,
+ api::auth::session::destroy,
+ api::auth::oauth::authorization,
+ api::auth::device::destroy,
+ api::auth::device::notify,
+ api::auth::device::attached_clients,
+ api::auth::device::destroy_attached_client,
+ api::auth::email::status,
+ api::auth::email::verify_code,
+ api::auth::email::resend_code,
+ api::auth::password::change_start,
+ api::auth::password::change_finish,
+ api::auth::password::forgot_start,
+ api::auth::password::forgot_finish,
+ ],
+ )
+ // slight hack to allow the js auth client to "just work"
+ .register("/_invite/v1", catchers![api::auth::catch_all,])
+ .mount("/_invite/v1", routes![api::auth::invite::generate,])
+ .attach(AdHoc::on_response("/auth Timestamp", |req, resp| {
+ Box::pin(async move {
+ if req.uri().path().as_str().starts_with("/auth/v1/") {
+ if let Ok(ts) = SystemTime::now().duration_since(UNIX_EPOCH) {
+ resp.set_header(Header::new("timestamp", ts.as_secs().to_string()));
+ }
+ }
+ })
+ }))
+ .register("/profile", catchers![api::profile::catch_all,])
+ .mount(
+ "/profile/v1",
+ routes![
+ api::profile::profile,
+ api::profile::display_name_post,
+ api::profile::avatar_get,
+ api::profile::avatar_upload,
+ api::profile::avatar_delete,
+ ],
+ )
+ .register("/avatars", catchers![api::profile::catch_all,])
+ .mount("/avatars", routes![api::profile::avatar_get_img])
+ .register("/oauth/v1", catchers![api::oauth::catch_all,])
+ .mount("/oauth/v1", routes![api::oauth::destroy, api::oauth::jwks, api::oauth::verify,])
+ .mount("/js", routes![js::static_js]);
+ Ok(rocket)
+}
diff --git a/src/mailer.rs b/src/mailer.rs
new file mode 100644
index 0000000..1ea1a8b
--- /dev/null
+++ b/src/mailer.rs
@@ -0,0 +1,105 @@
+use std::time::Duration;
+
+use lettre::{
+ message::Mailbox,
+ transport::smtp::client::{Tls, TlsParameters},
+ AsyncSmtpTransport, Message, Tokio1Executor,
+};
+use rocket::http::uri::Absolute;
+use serde_json::json;
+
+use crate::types::UserID;
+
+pub struct Mailer {
+ from: Mailbox,
+ verify_base: Absolute<'static>,
+ transport: AsyncSmtpTransport<Tokio1Executor>,
+}
+
+impl Mailer {
+ pub fn new(
+ from: Mailbox,
+ host: &str,
+ port: u16,
+ verify_base: Absolute<'static>,
+ ) -> anyhow::Result<Self> {
+ Ok(Mailer {
+ from,
+ verify_base,
+ transport: AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host)
+ .port(port)
+ .tls(Tls::Opportunistic(TlsParameters::new(host.to_string())?))
+ .timeout(Some(Duration::from_secs(5)))
+ .build(),
+ })
+ }
+
+ pub(crate) async fn send_account_verify(
+ &self,
+ uid: &UserID,
+ to: &str,
+ code: &str,
+ ) -> anyhow::Result<()> {
+ let fragment = base64::encode_config(
+ serde_json::to_string(&json!({
+ "uid": uid,
+ "email": to,
+ "code": code,
+ }))?,
+ base64::URL_SAFE,
+ );
+ let email = Message::builder()
+ .from(self.from.clone())
+ .to(to.parse()?)
+ .subject("account verify code")
+ .body(format!("{}/#/verify/{fragment}", self.verify_base))?;
+ lettre::AsyncTransport::send(&self.transport, email).await?;
+ Ok(())
+ }
+
+ pub(crate) async fn send_session_verify(&self, to: &str, code: &str) -> anyhow::Result<()> {
+ let email = Message::builder()
+ .from(self.from.clone())
+ .to(to.parse()?)
+ .subject("session verify code")
+ .body(format!("{code}"))?;
+ lettre::AsyncTransport::send(&self.transport, email).await?;
+ Ok(())
+ }
+
+ pub(crate) async fn send_password_changed(&self, to: &str) -> anyhow::Result<()> {
+ let email = Message::builder()
+ .from(self.from.clone())
+ .to(to.parse()?)
+ .subject("account password has been changed")
+ .body(String::from(
+ "your account password has been changed. if you haven't done this, \
+ you're probably in trouble now.",
+ ))?;
+ lettre::AsyncTransport::send(&self.transport, email).await?;
+ Ok(())
+ }
+
+ pub(crate) async fn send_password_forgot(&self, to: &str, code: &str) -> anyhow::Result<()> {
+ let email = Message::builder()
+ .from(self.from.clone())
+ .to(to.parse()?)
+ .subject("account reset code")
+ .body(code.to_string())?;
+ lettre::AsyncTransport::send(&self.transport, email).await?;
+ Ok(())
+ }
+
+ pub(crate) async fn send_account_reset(&self, to: &str) -> anyhow::Result<()> {
+ let email = Message::builder()
+ .from(self.from.clone())
+ .to(to.parse()?)
+ .subject("account has been reset")
+ .body(String::from(
+ "your account has been reset. if you haven't done this, \
+ you're probably in trouble now.",
+ ))?;
+ lettre::AsyncTransport::send(&self.transport, email).await?;
+ Ok(())
+ }
+}
diff --git a/src/push.rs b/src/push.rs
new file mode 100644
index 0000000..6ee1afb
--- /dev/null
+++ b/src/push.rs
@@ -0,0 +1,198 @@
+use anyhow::Result;
+use rocket::http::uri::Absolute;
+use serde_json::{json, Value};
+use std::time::Duration;
+use std::{fs::File, io::Read, path::Path};
+
+use serde::Serialize;
+use web_push::{
+ ContentEncoding, SubscriptionInfo, VapidSignatureBuilder, WebPushClient, WebPushMessageBuilder,
+};
+
+use crate::db::DbConn;
+use crate::types::{Device, DeviceID, DevicePush, UserID};
+
+pub(crate) struct PushClient {
+ key: Box<[u8]>,
+ client: WebPushClient,
+ subject: String,
+ base_uri: Absolute<'static>,
+ default_ttl: Duration,
+}
+
+impl PushClient {
+ pub(crate) fn new<P: AsRef<Path>>(
+ key: P,
+ subject: &str,
+ base_uri: Absolute<'static>,
+ default_ttl: Duration,
+ ) -> Result<Self> {
+ let mut key_bytes = vec![];
+ File::open(key).and_then(|mut f| f.read_to_end(&mut key_bytes))?;
+ Ok(PushClient {
+ key: key_bytes.into_boxed_slice(),
+ client: WebPushClient::new()?,
+ subject: subject.to_string(),
+ base_uri,
+ default_ttl,
+ })
+ }
+
+ async fn push_raw(&self, to: &DevicePush, ttl: Duration, data: Option<&[u8]>) -> Result<()> {
+ let sub = SubscriptionInfo::new(&to.callback, &to.public_key, &to.auth_key);
+ let mut sig = VapidSignatureBuilder::from_pem(&*self.key, &sub)?;
+ // mozilla requires {aud,exp,sub} or message will get a 401 unauthorized.
+ // {aud,exp} are added automatically
+ sig.add_claim("sub", self.subject.as_str());
+ let mut builder = WebPushMessageBuilder::new(&sub)?;
+ if let Some(data) = data {
+ builder.set_payload(ContentEncoding::Aes128Gcm, data);
+ }
+ builder.set_vapid_signature(sig.build()?);
+ builder.set_ttl(ttl.as_secs().min(u32::MAX as u64) as u32);
+ Ok(self.client.send(builder.build()?).await?)
+ }
+
+ async fn push_one(
+ &self,
+ context: &str,
+ db: Option<&DbConn>,
+ to: &Device,
+ ttl: Duration,
+ data: Option<&[u8]>,
+ ) -> Result<()> {
+ match (to.push_expired, to.push.as_ref()) {
+ (false, Some(ep)) => match self.push_raw(ep, ttl, data).await {
+ Ok(()) => Ok(()),
+ Err(e) => {
+ warn!("{} push to {} failed: {}", context, &to.device_id, e);
+ if let Some(db) = db {
+ if let Err(e) = db.set_push_expired(&to.device_id).await {
+ warn!("failed to set {} push_endpoint_expired: {}", &to.device_id, e);
+ }
+ }
+ Err(e)
+ },
+ },
+ (_, None) => Err(anyhow!("no push callback")),
+ (true, _) => Err(anyhow!("push endpoint expired")),
+ }
+ }
+
+ async fn push_all(
+ &self,
+ context: &str,
+ db: Option<&DbConn>,
+ to: &[Device],
+ ttl: Duration,
+ msg: impl Serialize,
+ ) {
+ let msg = serde_json::to_vec(&msg).expect("push message serialization failed");
+ for dev in to {
+ // ignore errors here, except by logging them. we can't notify the client
+ // about anything and failing isn't an option either.
+ let _ = self.push_one(context, db, dev, ttl, Some(&msg)).await;
+ }
+ }
+
+ pub(crate) async fn command_received(
+ &self,
+ db: &DbConn,
+ to: &Device,
+ command: &str,
+ index: i64,
+ sender: &Option<DeviceID>,
+ ) -> Result<()> {
+ let url =
+ format!("{}/auth/v1/account/device/commands?index={}&limit=1", self.base_uri, index);
+ let msg = json!({
+ "version": 1,
+ "command": "fxaccounts:command_received",
+ "data": {
+ "command": command,
+ "index": index,
+ "sender": sender,
+ "url": url,
+ },
+ });
+ let msg = serde_json::to_vec(&msg)?;
+ self.push_one("command_received", Some(db), to, self.default_ttl, Some(&msg)).await
+ }
+
+ pub(crate) async fn device_connected(&self, db: &DbConn, to: &[Device], name: &str) {
+ let msg = json!({
+ "version": 1,
+ "command": "fxaccounts:device_connected",
+ "data": {
+ "deviceName": name,
+ },
+ });
+ self.push_all("device_connected", Some(db), to, self.default_ttl, &msg).await;
+ }
+
+ pub(crate) async fn device_disconnected(&self, db: &DbConn, to: &[Device], id: &DeviceID) {
+ let msg = json!({
+ "version": 1,
+ "command": "fxaccounts:device_disconnected",
+ "data": {
+ "id": id,
+ },
+ });
+ self.push_all("device_disconnected", Some(db), to, self.default_ttl, &msg).await;
+ }
+
+ pub(crate) async fn profile_updated(&self, db: &DbConn, to: &[Device]) {
+ let msg = json!({
+ "version": 1,
+ "command": "fxaccounts:profile_updated",
+ });
+ self.push_all("profile_updated", Some(db), to, self.default_ttl, &msg).await;
+ }
+
+ pub(crate) async fn account_verified(&self, db: &DbConn, to: &[Device]) {
+ for dev in to {
+ // ignore errors here, except by logging them. we can't notify the client
+ // about anything and failing isn't an option either.
+ let _ = self.push_one("account_verified", Some(db), dev, Duration::ZERO, None).await;
+ }
+ }
+
+ pub(crate) async fn account_destroyed(&self, to: &[Device], uid: &UserID) {
+ let msg = json!({
+ "version": 1,
+ "command": "fxaccounts:account_destroyed",
+ "data": {
+ "uid": uid,
+ },
+ });
+ self.push_all("account_destroyed", None, to, self.default_ttl, &msg).await;
+ }
+
+ pub(crate) async fn password_reset(&self, to: &[Device]) {
+ let msg = serde_json::to_vec(&json!({
+ "version": 1u32,
+ "command": "fxaccounts:password_reset",
+ }))
+ .expect("serde failed");
+ for dev in to {
+ // ignore errors here, except by logging them. we can't notify the client
+ // about anything and failing isn't an option either.
+ let _ = self.push_one("password_reset", None, dev, self.default_ttl, Some(&msg)).await;
+ // NOTE password_reset alone doesn't seem to do much, se we also disconnect
+ // each device explicitly.
+ let msg = serde_json::to_vec(&json!({
+ "version": 1,
+ "command": "fxaccounts:device_disconnected",
+ "data": {
+ "id": dev.device_id,
+ },
+ }))
+ .expect("serde failed");
+ let _ = self.push_one("password_reset", None, dev, self.default_ttl, Some(&msg)).await;
+ }
+ }
+
+ pub(crate) async fn push_any(&self, db: &DbConn, to: &[Device], ttl: Duration, payload: Value) {
+ self.push_all("push_any", Some(db), to, ttl, &payload).await;
+ }
+}
diff --git a/src/types.rs b/src/types.rs
new file mode 100644
index 0000000..c0c5dfe
--- /dev/null
+++ b/src/types.rs
@@ -0,0 +1,436 @@
+use crate::crypto::SecretBytes;
+use chrono::{DateTime, Utc};
+use password_hash::{rand_core::OsRng, Output, SaltString};
+use rand::RngCore;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use sha2::{Digest, Sha256};
+use sqlx::{
+ postgres::{PgArgumentBuffer, PgTypeInfo, PgValueRef},
+ Decode, Encode, Postgres, Type,
+};
+use std::{
+ collections::HashMap,
+ fmt::{Debug, Display},
+ ops::Deref,
+ str::FromStr,
+};
+
+use self::oauth::ScopeSet;
+
+pub(crate) mod oauth;
+
+macro_rules! array_type {
+ (
+ $( #[ $attr:meta ] )*
+ $name:ident($inner:ty) as $sql_name:ident {
+ $( $body:tt )*
+ }
+ ) => {
+ $( #[ $attr ] )*
+ pub(crate) struct $name(pub(crate) $inner);
+
+ impl $name {
+ $( $body )*
+ }
+
+ impl Type<Postgres> for $name {
+ fn type_info() -> PgTypeInfo {
+ PgTypeInfo::with_name(stringify!($sql_name))
+ }
+ }
+
+ impl Encode<'_, Postgres> for $name {
+ fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> sqlx::encode::IsNull {
+ let raw = self.0.iter().map(Self::encode_elem).collect::<Vec<_>>();
+ Encode::<'_, Postgres>::encode_by_ref(&raw, buf)
+ }
+ }
+
+ impl Decode<'_, Postgres> for $name {
+ fn decode(value: PgValueRef) -> Result<Self, sqlx::error::BoxDynError> {
+ Ok(Self::decode_elems(Decode::<'_, Postgres>::decode(value)?)?)
+ }
+ }
+ }
+}
+
+macro_rules! bytea_types {
+ () => {};
+ (
+ #[simple_array]
+ struct $name:ident($inner:ty) as $sql_name:ident;
+
+ $( $rest:tt )*
+ ) => {
+ bytea_types!{
+ #[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+ #[serde(try_from = "String", into = "String")]
+ struct $name($inner) as $sql_name {
+ fn decode(v) -> _ { &v.0[..] }
+ fn encode(v) -> _ { v }
+ }
+
+ impl FromStr for $name {}
+ impl ToString for $name {}
+ impl Debug for $name {}
+
+ $( $rest )*
+ }
+ };
+ (
+ $( #[ $attr:meta ] )*
+ struct $name:ident($inner:ty) as $sql_name:ident {
+ $( fn arbitrary($a:ident) -> _ { $ae:expr } )?
+ fn decode($d:ident) -> _ { $de:expr }
+ fn encode($e:ident) -> _ { $ee:expr }
+
+ $( $impls:tt )*
+ }
+
+ $( $rest:tt )*
+ ) => {
+ $( #[ $attr ] )*
+ pub(crate) struct $name(pub(crate) $inner);
+
+ impl $name {
+ $( $impls )*
+ }
+
+ impl Type<Postgres> for $name {
+ fn type_info() -> PgTypeInfo {
+ PgTypeInfo::with_name(stringify!($sql_name))
+ }
+ }
+
+ impl Encode<'_, Postgres> for $name {
+ fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> sqlx::encode::IsNull {
+ let $d = self;
+ <&[u8] as Encode<'_, Postgres>>::encode_by_ref(&$de, buf)
+ }
+ }
+
+ impl Decode<'_, Postgres> for $name {
+ fn decode(value: PgValueRef) -> Result<Self, sqlx::error::BoxDynError> {
+ let $e = <&[u8] as Decode<'_, Postgres>>::decode(value)?.try_into()?;
+ Ok($name($ee))
+ }
+ }
+
+ bytea_types!{ $( $rest )* }
+ };
+ ( impl ToString for $name:ident {} $( $rest:tt )* ) => {
+ impl Display for $name {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
+ f.write_str(&hex::encode(&self.0))
+ }
+ }
+ impl From<$name> for String {
+ fn from(s: $name) -> String {
+ format!("{}", s)
+ }
+ }
+ bytea_types!{ $( $rest )* }
+ };
+ ( impl Debug for $name:ident {} $( $rest:tt )* ) => {
+ impl Debug for $name {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_tuple(stringify!($name)).field(&self.to_string()).finish()
+ }
+ }
+ bytea_types!{ $( $rest )* }
+ };
+ ( impl FromStr for $name:ident {} $( $rest:tt )* ) => {
+ impl FromStr for $name {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ Ok(Self(hex::decode(s)?.as_slice().try_into()?))
+ }
+ }
+ impl TryFrom<String> for $name {
+ type Error = anyhow::Error;
+
+ fn try_from(s: String) -> Result<Self, Self::Error> {
+ s.parse()
+ }
+ }
+ bytea_types!{ $( $rest )* }
+ }
+}
+
+//
+//
+//
+
+bytea_types! {
+ #[derive(Clone, Debug, PartialEq, Eq)]
+ struct HawkKey(SecretBytes<32>) as hawk_key {
+ fn arbitrary(a) -> _ { SecretBytes(a) }
+ fn decode(v) -> _ { v.0.0.as_ref() }
+ fn encode(v) -> _ { SecretBytes(v) }
+ }
+
+ #[simple_array]
+ struct SessionID([u8; 32]) as session_id;
+
+ #[simple_array]
+ struct DeviceID([u8; 16]) as device_id;
+
+ #[simple_array]
+ struct UserID([u8; 16]) as user_id;
+
+ #[simple_array]
+ struct KeyFetchID([u8; 32]) as key_fetch_id;
+
+ #[simple_array]
+ struct OauthTokenID([u8; 32]) as oauth_token_id;
+
+ #[simple_array]
+ struct OauthAuthorizationID([u8; 32]) as oauth_auth_id;
+
+ #[simple_array]
+ struct PasswordChangeID([u8; 32]) as password_change_id;
+
+ #[simple_array]
+ struct AccountResetID([u8; 32]) as account_reset_id;
+
+ #[simple_array]
+ struct AvatarID([u8; 16]) as avatar_id;
+
+ #[derive(Clone, Debug, PartialEq, Eq)]
+ struct SecretKey(SecretBytes<32>) as secret_key {
+ fn arbitrary(a) -> _ { SecretBytes(a) }
+ fn decode(v) -> _ { v.0.0.as_ref() }
+ fn encode(v) -> _ { SecretBytes(v) }
+ }
+
+ #[derive(Clone, Debug, PartialEq, Eq)]
+ struct VerifyHash(Output) as verify_hash {
+ fn arbitrary(a) -> _ { Output::new(<[u8; 32]>::as_ref(&a)).unwrap() }
+ fn decode(v) -> _ { v.0.as_ref() }
+ fn encode(v) -> _ { v }
+ }
+}
+
+impl DeviceID {
+ pub fn random() -> Self {
+ let mut result = Self([0; 16]);
+ OsRng.fill_bytes(&mut result.0);
+ result
+ }
+}
+
+impl UserID {
+ pub fn random() -> Self {
+ let mut result = Self([0; 16]);
+ OsRng.fill_bytes(&mut result.0);
+ result
+ }
+}
+
+impl OauthAuthorizationID {
+ pub fn random() -> Self {
+ let mut result = Self([0; 32]);
+ OsRng.fill_bytes(&mut result.0);
+ result
+ }
+}
+
+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(try_from = "String", into = "String")]
+pub(crate) struct OauthToken([u8; 32]);
+
+impl OauthToken {
+ pub fn random() -> Self {
+ let mut result = Self([0; 32]);
+ OsRng.fill_bytes(&mut result.0);
+ result
+ }
+
+ pub fn hash(&self) -> OauthTokenID {
+ let mut sha = Sha256::new();
+ sha.update(&self.0);
+ OauthTokenID(*sha.finalize().as_ref())
+ }
+}
+
+bytea_types! {
+ impl Debug for OauthToken {}
+ impl FromStr for OauthToken {}
+ impl ToString for OauthToken {}
+}
+
+#[derive(Debug, Deserialize, PartialEq, Eq, Type)]
+#[sqlx(type_name = "oauth_access_type", rename_all = "lowercase")]
+#[serde(rename_all = "lowercase")]
+pub enum OauthAccessType {
+ Online,
+ Offline,
+}
+
+#[derive(Debug)]
+pub(crate) struct UserSession {
+ pub(crate) uid: UserID,
+ pub(crate) req_hmac_key: HawkKey,
+ pub(crate) device_id: Option<DeviceID>,
+ pub(crate) created_at: DateTime<Utc>,
+ pub(crate) verified: bool,
+ pub(crate) verify_code: Option<String>,
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct DeviceCommand {
+ pub(crate) index: i64,
+ pub(crate) command: String,
+ pub(crate) payload: Value,
+ #[allow(dead_code)]
+ pub(crate) expires: DateTime<Utc>,
+ // NOTE this is a device ID, but we don't link it to the actual sender device
+ // because removing a device would also remove its queued commands. this mirrors
+ // what fxa does.
+ pub(crate) sender: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, sqlx::Type)]
+#[sqlx(type_name = "device_push_info")]
+pub(crate) struct DevicePush {
+ pub(crate) callback: String,
+ pub(crate) public_key: String,
+ pub(crate) auth_key: String,
+}
+
+#[derive(Clone, Debug, PartialEq, sqlx::Type)]
+#[sqlx(type_name = "device_command")]
+struct DeviceCommandsEntry {
+ name: String,
+ body: String,
+}
+
+array_type! {
+ #[derive(Clone, Debug, PartialEq)]
+ DeviceCommands(HashMap<String, String>) as _device_command {
+ fn encode_elem(e: (&String, &String)) -> DeviceCommandsEntry {
+ DeviceCommandsEntry { name: e.0.clone(), body: e.1.clone() }
+ }
+ fn decode_elems(e: Vec<DeviceCommandsEntry>) -> anyhow::Result<Self> {
+ Ok(Self(e.into_iter().map(|e| (e.name, e.body)).collect()))
+ }
+
+ pub(crate) fn into_map(self) -> HashMap<String, String> {
+ self.0
+ }
+ }
+}
+
+impl Deref for DeviceCommands {
+ type Target = HashMap<String, String>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct Device {
+ pub(crate) device_id: DeviceID,
+ // taken from session, otherwise UNIX_EPOCH
+ pub(crate) last_active: DateTime<Utc>,
+ pub(crate) name: String,
+ pub(crate) type_: String,
+ pub(crate) push: Option<DevicePush>,
+ pub(crate) available_commands: DeviceCommands,
+ pub(crate) push_expired: bool,
+ // actually a str->str map, but we treat it as opaque for simplicity.
+ // writing a HashMap<String, String> to the db through sqlx is an immense pain,
+ // and we don't care about the value anyway—it only has to exist for fenix.
+ pub(crate) location: Value,
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct DeviceUpdate<'a> {
+ pub(crate) name: Option<&'a str>,
+ pub(crate) type_: Option<&'a str>,
+ pub(crate) push: Option<DevicePush>,
+ pub(crate) available_commands: Option<DeviceCommands>,
+ pub(crate) location: Option<Value>,
+}
+
+#[derive(Debug, sqlx::Type)]
+#[sqlx(type_name = "oauth_token_kind", rename_all = "lowercase")]
+pub(crate) enum OauthTokenKind {
+ Access,
+ Refresh,
+}
+
+#[derive(Debug)]
+pub(crate) struct OauthAccessToken {
+ pub(crate) user_id: UserID,
+ pub(crate) client_id: String,
+ pub(crate) scope: ScopeSet,
+ pub(crate) parent_refresh: Option<OauthTokenID>,
+ pub(crate) parent_session: Option<SessionID>,
+ pub(crate) expires_at: DateTime<Utc>,
+}
+
+#[derive(Debug)]
+pub(crate) struct OauthRefreshToken {
+ pub(crate) user_id: UserID,
+ pub(crate) client_id: String,
+ pub(crate) scope: ScopeSet,
+ pub(crate) session_id: Option<SessionID>,
+}
+
+#[derive(Debug)]
+pub(crate) struct OauthAuthorization {
+ pub(crate) user_id: UserID,
+ pub(crate) client_id: String,
+ pub(crate) scope: ScopeSet,
+ pub(crate) access_type: OauthAccessType,
+ pub(crate) code_challenge: String,
+ pub(crate) keys_jwe: Option<String>,
+ pub(crate) auth_at: DateTime<Utc>,
+}
+
+#[derive(Debug)]
+#[cfg_attr(test, derive(Clone))]
+pub(crate) struct User {
+ pub(crate) auth_salt: SaltString,
+ pub(crate) email: String,
+ pub(crate) display_name: Option<String>,
+ pub(crate) ka: SecretKey,
+ pub(crate) wrapwrap_kb: SecretKey,
+ pub(crate) verify_hash: VerifyHash,
+ pub(crate) verified: bool,
+}
+
+// MISSING user secondary email addresses
+
+#[derive(Debug)]
+pub(crate) struct Avatar {
+ pub(crate) id: AvatarID,
+ pub(crate) data: Vec<u8>,
+ pub(crate) content_type: String,
+}
+
+#[derive(Debug)]
+pub(crate) struct AttachedClient {
+ pub(crate) client_id: Option<String>,
+ pub(crate) device_id: Option<DeviceID>,
+ pub(crate) session_token_id: Option<SessionID>,
+ pub(crate) refresh_token_id: Option<OauthTokenID>,
+ pub(crate) device_type: Option<String>,
+ pub(crate) name: Option<String>,
+ pub(crate) created_time: Option<DateTime<Utc>>,
+ pub(crate) last_access_time: Option<DateTime<Utc>>,
+ pub(crate) scope: Option<String>,
+}
+
+#[derive(Debug)]
+pub(crate) struct VerifyCode {
+ #[allow(dead_code)]
+ pub(crate) user_id: UserID,
+ pub(crate) session_id: Option<SessionID>,
+ #[allow(dead_code)]
+ pub(crate) code: String,
+}
diff --git a/src/types/oauth.rs b/src/types/oauth.rs
new file mode 100644
index 0000000..222c567
--- /dev/null
+++ b/src/types/oauth.rs
@@ -0,0 +1,267 @@
+use std::{borrow::Cow, fmt::Display};
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(transparent)]
+pub(crate) struct Scope<'a>(pub Cow<'a, str>);
+
+impl<'a> Scope<'a> {
+ pub const fn borrowed(s: &'a str) -> Self {
+ Self(Cow::Borrowed(s))
+ }
+
+ pub fn into_owned(self) -> Scope<'static> {
+ Scope(Cow::Owned(self.0.into_owned()))
+ }
+
+ pub fn implies(&self, other: &Scope) -> bool {
+ let (a, b) = (&*self.0, &*other.0);
+ match (a.strip_prefix("https://"), b.strip_prefix("https://")) {
+ (Some(a), Some(b)) => {
+ let (a_origin, a_path) = a.split_once('/').unwrap_or((a, ""));
+ let (b_origin, b_path) = b.split_once('/').unwrap_or((b, ""));
+ if a_origin != b_origin {
+ false
+ } else {
+ let (a_path, a_frag) = match a_path.split_once('#') {
+ Some((p, f)) => (p, Some(f)),
+ None => (a_path, None),
+ };
+ let (b_path, b_frag) = match b_path.split_once('#') {
+ Some((p, f)) => (p, Some(f)),
+ None => (b_path, None),
+ };
+ if b_path
+ .strip_prefix(a_path)
+ .map_or(false, |br| br.is_empty() || br.starts_with('/'))
+ {
+ match (a_frag, b_frag) {
+ (Some(af), Some(bf)) => af == bf,
+ (Some(_), None) => false,
+ _ => true,
+ }
+ } else {
+ false
+ }
+ }
+ },
+ (None, None) => {
+ let (a, a_write) =
+ a.strip_suffix(":write").map(|s| (s, true)).unwrap_or((a, false));
+ let (b, b_write) =
+ b.strip_suffix(":write").map(|s| (s, true)).unwrap_or((b, false));
+ if b_write && !a_write {
+ false
+ } else {
+ b.strip_prefix(a).map_or(false, |br| br.is_empty() || br.starts_with(':'))
+ }
+ },
+ _ => false,
+ }
+ }
+}
+
+impl<'a> Display for Scope<'a> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, sqlx::Type)]
+#[serde(transparent)]
+#[sqlx(transparent)]
+pub(crate) struct ScopeSet(String);
+
+impl ScopeSet {
+ pub fn split(&self) -> impl Iterator<Item = Scope> {
+ // not using split_whitespace because the oauth spec explicitly says to split on SP
+ self.0.split(' ').filter(|s| !s.is_empty()).map(Scope::borrowed)
+ }
+
+ pub fn implies(&self, scope: &Scope) -> bool {
+ self.split().any(|a| a.implies(scope))
+ }
+
+ pub fn implies_all(&self, scopes: &ScopeSet) -> bool {
+ scopes.split().all(|b| self.implies(&b))
+ }
+
+ pub fn is_allowed_by(&self, allowed: &[Scope]) -> bool {
+ self.split().all(|scope| allowed.iter().any(|perm| perm.implies(&scope)))
+ }
+
+ pub fn remove(&self, remove: &Scope) -> ScopeSet {
+ let remaining = self.split().filter(|s| !remove.implies(s));
+ ScopeSet(remaining.map(|s| s.0).collect::<Vec<_>>().join(" "))
+ }
+}
+
+impl PartialEq for ScopeSet {
+ fn eq(&self, other: &Self) -> bool {
+ let (mut a, mut b) = (self.split().collect::<Vec<_>>(), other.split().collect::<Vec<_>>());
+ a.sort_by(|a, b| a.0.cmp(&b.0));
+ b.sort_by(|a, b| a.0.cmp(&b.0));
+ a.eq(&b)
+ }
+}
+
+impl Eq for ScopeSet {}
+
+impl Display for ScopeSet {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.0.fmt(f)
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::{Scope, ScopeSet};
+
+ #[test]
+ fn test_scope_implies() {
+ assert!(ScopeSet("profile:write".to_string()).implies(&Scope::borrowed("profile")));
+ assert!(ScopeSet("profile".to_string()).implies(&Scope::borrowed("profile:email")));
+ assert!(ScopeSet("profile:write".to_string()).implies(&Scope::borrowed("profile:email")));
+ assert!(
+ ScopeSet("profile:write".to_string()).implies(&Scope::borrowed("profile:email:write"))
+ );
+ assert!(
+ ScopeSet("profile:email:write".to_string()).implies(&Scope::borrowed("profile:email"))
+ );
+ assert!(ScopeSet("profile profile:email:write".to_string())
+ .implies(&Scope::borrowed("profile:email")));
+ assert!(ScopeSet("profile profile:email:write".to_string())
+ .implies(&Scope::borrowed("profile:display_name")));
+ assert!(ScopeSet("profile https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("profile")));
+ assert!(ScopeSet("profile https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync")));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync#read")));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks")));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read")));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync#read".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read")));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync#read profile".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read")));
+
+ assert!(!ScopeSet("profile:email:write".to_string()).implies(&Scope::borrowed("profile")));
+ assert!(
+ !ScopeSet("profile:email:write".to_string()).implies(&Scope::borrowed("profile:write"))
+ );
+ assert!(!ScopeSet("profile:email".to_string())
+ .implies(&Scope::borrowed("profile:display_name")));
+ assert!(!ScopeSet("profilebogey".to_string()).implies(&Scope::borrowed("profile")));
+ assert!(!ScopeSet("profile:write".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync")));
+ assert!(!ScopeSet("profile profile:email:write".to_string())
+ .implies(&Scope::borrowed("profile:write")));
+ assert!(!ScopeSet("https".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("profile")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync#read".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync#write".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/bookmarks#read")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync/passwords")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsyncer".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync")));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsyncer")));
+ assert!(!ScopeSet("https://identity.mozilla.org/apps/oldsync".to_string())
+ .implies(&Scope::borrowed("https://identity.mozilla.com/apps/oldsync")));
+ }
+
+ #[test]
+ fn test_scopes_allowed_by() {
+ const ALLOWED: [Scope; 2] = [
+ Scope::borrowed("profile:write"),
+ Scope::borrowed("https://identity.mozilla.com/apps/oldsync"),
+ ];
+
+ assert!(ScopeSet("profile".to_string()).is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("profile:write".to_string()).is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("profile:email".to_string()).is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("profile:email:write".to_string()).is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync#read".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("https://identity.mozilla.com/apps/oldsync/bookmarks#read".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(ScopeSet("profile https://identity.mozilla.com/apps/oldsync".to_string())
+ .is_allowed_by(&ALLOWED));
+
+ assert!(!ScopeSet("storage".to_string()).is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("storage:write".to_string()).is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("storage:email".to_string()).is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("storage:email:write".to_string()).is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync#read".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync/bookmarks".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("https://identity.mozilla.com/apps/newsync/bookmarks#read".to_string())
+ .is_allowed_by(&ALLOWED));
+ assert!(!ScopeSet("storage https://identity.mozilla.com/apps/newsync".to_string())
+ .is_allowed_by(&ALLOWED));
+ }
+
+ #[test]
+ fn test_scopes_remove() {
+ assert_eq!(
+ ScopeSet("profile foo".to_string()).remove(&Scope::borrowed("profile")),
+ ScopeSet("foo".to_string())
+ );
+ assert_ne!(
+ ScopeSet("profile:write foo".to_string()).remove(&Scope::borrowed("profile")),
+ ScopeSet("foo".to_string())
+ );
+ assert_eq!(
+ ScopeSet("profile:write foo".to_string()).remove(&Scope::borrowed("profile:write")),
+ ScopeSet("foo".to_string())
+ );
+ assert_eq!(
+ ScopeSet("profile:x foo".to_string()).remove(&Scope::borrowed("profile")),
+ ScopeSet("foo".to_string())
+ );
+ assert_ne!(
+ ScopeSet("profile:x:write foo".to_string()).remove(&Scope::borrowed("profile")),
+ ScopeSet("foo".to_string())
+ );
+ assert_eq!(
+ ScopeSet("profile:x:write foo".to_string()).remove(&Scope::borrowed("profile:write")),
+ ScopeSet("foo".to_string())
+ );
+
+ assert_eq!(
+ ScopeSet("https://foo/bar foo".to_string()).remove(&Scope::borrowed("https://foo/bar")),
+ ScopeSet("foo".to_string())
+ );
+ assert_eq!(
+ ScopeSet("https://foo/bar/baz foo".to_string())
+ .remove(&Scope::borrowed("https://foo/bar")),
+ ScopeSet("foo".to_string())
+ );
+ assert_eq!(
+ ScopeSet("https://foo/bar#read foo".to_string())
+ .remove(&Scope::borrowed("https://foo/bar")),
+ ScopeSet("foo".to_string())
+ );
+ assert_eq!(
+ ScopeSet("https://foo/bar/baz#read foo".to_string())
+ .remove(&Scope::borrowed("https://foo/bar")),
+ ScopeSet("foo".to_string())
+ );
+ }
+}
diff --git a/src/utils.rs b/src/utils.rs
new file mode 100644
index 0000000..5a2407a
--- /dev/null
+++ b/src/utils.rs
@@ -0,0 +1,124 @@
+use std::convert::Infallible;
+
+use futures::Future;
+use rocket::{
+ fairing::{self, Fairing, Info, Kind},
+ http::Status,
+ request::{FromRequest, Outcome},
+ tokio::{
+ self,
+ sync::broadcast::{channel, Sender},
+ task::JoinHandle,
+ },
+ Request, Response, Sentinel,
+};
+
+// does the same as try_outcome!(...).map_forward(|_| data),
+// but without moving data in non-failure cases.
+macro_rules! try_outcome_data {
+ ($data:expr, $e:expr) => {
+ match $e {
+ ::rocket::outcome::Outcome::Success(val) => val,
+ ::rocket::outcome::Outcome::Failure(e) => {
+ return ::rocket::outcome::Outcome::Failure(::std::convert::From::from(e))
+ },
+ ::rocket::outcome::Outcome::Forward(()) => {
+ return ::rocket::outcome::Outcome::Forward($data)
+ },
+ }
+ };
+}
+
+pub trait CanBeLogged: Send + 'static {
+ fn into_message(self) -> Option<String>;
+ fn not_run() -> Self;
+}
+
+impl CanBeLogged for Result<(), anyhow::Error> {
+ fn into_message(self) -> Option<String> {
+ self.err().map(|e| e.to_string())
+ }
+ fn not_run() -> Self {
+ Ok(())
+ }
+}
+
+pub fn spawn_logged<T>(context: &'static str, future: T) -> JoinHandle<()>
+where
+ T: Future + Send + 'static,
+ T::Output: CanBeLogged + Send + 'static,
+{
+ tokio::spawn(async move {
+ if let Some(msg) = future.await.into_message() {
+ warn!("{context}: {msg}");
+ }
+ })
+}
+
+pub struct DeferredActions;
+struct HasDeferredActions;
+
+pub struct DeferAction(Sender<()>);
+
+#[async_trait]
+impl Fairing for DeferredActions {
+ fn info(&self) -> Info {
+ Info { name: "deferred actions", kind: Kind::Ignite | Kind::Response }
+ }
+
+ async fn on_ignite(&self, rocket: rocket::Rocket<rocket::Build>) -> fairing::Result {
+ Ok(rocket.manage(HasDeferredActions))
+ }
+
+ async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) {
+ if res.status() == Status::Ok {
+ if let Some(DeferAction(tx)) = req.local_cache(|| None) {
+ // could have no receivers, that's not an error here
+ tx.send(()).ok();
+ }
+ }
+ }
+}
+
+impl<'r> Sentinel for &'r DeferAction {
+ fn abort(rocket: &rocket::Rocket<rocket::Ignite>) -> bool {
+ rocket.state::<HasDeferredActions>().is_none()
+ }
+}
+
+#[async_trait]
+impl<'r> FromRequest<'r> for &'r DeferAction {
+ type Error = Infallible;
+
+ async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
+ Outcome::Success(
+ req.local_cache(|| {
+ let (tx, _) = channel(1);
+ Some(DeferAction(tx))
+ })
+ .as_ref()
+ .unwrap(),
+ )
+ }
+}
+
+impl DeferAction {
+ pub fn spawn_after_success<T>(&self, context: &'static str, future: T)
+ where
+ T: Future + Send + 'static,
+ T::Output: CanBeLogged + Send + 'static,
+ {
+ let mut r = self.0.subscribe();
+ spawn_logged(context, async move {
+ match r.recv().await {
+ Ok(_) => {
+ // the request finished with success, now wait for it to be dropped
+ // to ensure that all other fairings have run to completion.
+ r.recv().await.ok();
+ future.await
+ },
+ Err(_) => CanBeLogged::not_run(),
+ }
+ });
+ }
+}
diff --git a/tests/_utils.py b/tests/_utils.py
new file mode 100644
index 0000000..6ccd99c
--- /dev/null
+++ b/tests/_utils.py
@@ -0,0 +1,421 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+"""
+
+fxa._utils: miscellaneous low-level utilities for PyFxA
+
+This private-api stuff that will most likely change, move, refactor
+etc as we go. So don't import any of it outside of this package.
+
+"""
+from __future__ import absolute_import
+import os
+import time
+import hashlib
+import hmac
+import six
+from binascii import hexlify, unhexlify
+from base64 import b64encode
+try:
+ import cPickle as pickle
+except ImportError: # pragma: no cover
+ import pickle
+
+from six import PY3
+from six.moves.urllib.parse import urlparse, urljoin
+
+import requests
+import requests.auth
+import requests.utils
+import hawkauthlib
+
+import fxa
+import fxa.errors
+import fxa.crypto
+
+
+# Send a custom user-agent header
+# so we're easy to identify in server logs etc.
+
+USER_AGENT_HEADER = ' '.join((
+ 'Mozilla/5.0 (Mobile; Firefox Accounts; rv:1.0)',
+ 'PyFxA/%s' % (fxa.__version__),
+ requests.utils.default_user_agent(),
+))
+
+
+if not PY3:
+ hexstr = hexlify
+else: # pragma: no cover
+ def hexstr(data):
+ """Like binascii.hexlify, but always returns a str instance."""
+ return hexlify(data).decode("ascii")
+
+
+def uniq(size=10):
+ """Generate a short random hex string."""
+ return hexstr(os.urandom(size // 2 + 1))[:size]
+
+
+def get_hmac(data, secret, algorithm=hashlib.sha256):
+ """Generate an hexdigest hmac for given data, secret and algorithm."""
+ return hmac.new(secret.encode('utf-8'),
+ data.encode('utf-8'),
+ algorithm).hexdigest()
+
+
+def scope_matches(provided, required):
+ """Check that required scopes match the ones provided. This is used during
+ token verification to raise errors if expected scopes are not met.
+
+ :note:
+
+ The rules for parsing and matching scopes in FxA are documented at
+ https://github.com/mozilla/fxa-oauth-server/blob/master/docs/scopes.md
+
+ :param provided: list of scopes provided for the current token.
+ :param required: the scope required (e.g. by the application).
+ :returns: ``True`` if all required scopes are provided, ``False`` if not.
+ """
+ if isinstance(provided, six.string_types):
+ raise ValueError("Provided scopes must be a list, not a single string")
+
+ if not isinstance(required, (list, tuple)):
+ required = [required]
+
+ for req in required:
+ if not any(_match_single_scope(prov, req) for prov in provided):
+ return False
+
+ return True
+
+
+def _match_single_scope(provided, required):
+ if provided.startswith('https:'):
+ return _match_url_scope(provided, required)
+ else:
+ return _match_shortname_scope(provided, required)
+
+
+def _match_shortname_scope(provided, required):
+ if required.startswith('https:'):
+ return False
+ prov_names = provided.split(':')
+ req_names = required.split(':')
+ # If we require :write, it must be provided.
+ if req_names[-1] == 'write':
+ if prov_names[-1] != 'write':
+ return False
+ req_names.pop()
+ prov_names.pop()
+ elif prov_names[-1] == 'write':
+ prov_names.pop()
+ # Provided names must be a prefix of required names.
+ if len(prov_names) > len(req_names):
+ return False
+ for (p, r) in zip(prov_names, req_names):
+ if p != r:
+ return False
+ # It matches!
+ return True
+
+
+def _match_url_scope(provided, required):
+ if not required.startswith('https:'):
+ return False
+ # Pop the hash fragments
+ (prov_url, prov_hash) = (provided.rsplit('#', 1) + [None])[:2]
+ (req_url, req_hash) = (required.rsplit('#', 1) + [None])[:2]
+ # Provided URL must be a prefix of required.
+ if req_url != prov_url:
+ if not (req_url.startswith(prov_url + '/')):
+ return False
+ # If hash is provided, it must match that required.
+ if prov_hash:
+ if not req_hash or req_hash != prov_hash:
+ return False
+ # It matches!
+ return True
+
+
+class APIClient(object):
+ """A requests.Session wrapper specialized for FxA API access.
+
+ An instance of this class should be used for making requests to an FxA
+ web API endpoint. It wraps a requests.Session instance and provides
+ a broadly similar interface, with some additional functionality that's
+ specific to Firefox Accounts:
+
+ * default base server URL
+ * backoff protocol support
+ * sensible request timeouts
+ * timestamp skew tracking with automatic retry on clockskew error
+
+ """
+
+ def __init__(self, server_url, session=None):
+ if session is None:
+ session = requests.Session()
+ # Properties that can be customized to change behaviour.
+ self.server_url = server_url
+ self.timeout = 30
+ self.max_retry_after = None
+ # Internal state.
+ self._session = session
+ self._backoff_until = 0
+ self._backoff_response = None
+ self._clockskew = None
+
+ # Reflect useful properties of the wrapped Session object.
+
+ @property
+ def headers(self):
+ return self._session.headers
+
+ @headers.setter
+ def headers(self, value):
+ self._session.headers = value
+
+ @property
+ def auth(self):
+ return self._session.auth
+
+ @auth.setter
+ def auth(self, value):
+ if getattr(value, "apiclient", None) is None:
+ value.apiclient = self
+ self._session.auth = value
+
+ @property
+ def hooks(self):
+ return self._session.hooks
+
+ @hooks.setter
+ def hooks(self, value):
+ self._session.hooks = value
+
+ @property
+ def verify(self):
+ return self._session.verify
+
+ @verify.setter
+ def verify(self, value):
+ self._session.verify = value
+
+ # Add some handy utility methods of our own.
+
+ def client_curtime(self):
+ """Get the current timestamp, as seen by the client.
+
+ This is a helper function that returns the current local time.
+ It's mostly here for symmetry with server_curtime() and to assist
+ in testability of this class.
+ """
+ return time.time()
+
+ def server_curtime(self):
+ """Get the current timestamp, as seen by the server.
+
+ This is a helper function that automatically applies any detected
+ clock-skew, to report what the current timestamp is on the server
+ instead of on the client.
+ """
+ return self.client_curtime() + (self._clockskew or 0)
+
+ # The actual request-making stuff.
+
+ def request(self, method, url, json=None, retry_auth_errors=True, **kwds):
+ """Make a request to the API and process the response.
+
+ This method implements the low-level details of interacting with an
+ FxA Web API, stripping away most of the details of HTTP. It will
+ return the parsed JSON of a successful responses, or raise an exception
+ for an error response. It's also responsible for backoff handling
+ and clock-skew tracking.
+ """
+ # Don't make requests if we're in backoff.
+ # Instead just synthesize a backoff response.
+ if self._backoff_response is not None:
+ if self._backoff_until >= self.client_curtime():
+ resp = pickle.loads(self._backoff_response)
+ resp.request = None
+ resp.headers["Timestamp"] = str(int(self.server_curtime()))
+ return resp
+ else:
+ self._backoff_until = 0
+ self._backoff_response = None
+
+ # Apply defaults and perform the request.
+ while url.startswith("/"):
+ url = url[1:]
+ if self.server_url.endswith("/"):
+ url = urljoin(self.server_url, url)
+ else:
+ url = urljoin(self.server_url + "/", url)
+ if self.timeout is not None:
+ kwds.setdefault("timeout", self.timeout)
+
+ # Configure the user agent
+ headers = kwds.get('headers', {})
+ headers.setdefault('User-Agent', USER_AGENT_HEADER)
+ kwds['headers'] = headers
+
+ resp = self._session.request(method, url, json=json, **kwds)
+
+ # Everything should return a valid JSON response. Even errors.
+ content_type = resp.headers.get("content-type", "")
+ if not content_type.startswith("application/json"):
+ msg = "API responded with non-json content-type: {0}"
+ raise fxa.errors.OutOfProtocolError(msg.format(content_type))
+ try:
+ body = resp.json()
+ except ValueError as e:
+ msg = "API responded with invalid json: {0}"
+ raise fxa.errors.OutOfProtocolError(msg.format(e))
+
+ # Check for backoff indicator from the server.
+ # If found, backoff up to the client-specified max time.
+ if resp.status_code in (429, 500, 503):
+ try:
+ retry_after = int(resp.headers["retry-after"])
+ except (KeyError, ValueError):
+ pass
+ else:
+ if self.max_retry_after is not None:
+ retry_after = max(retry_after, self.max_retry_after)
+ self._backoff_until = self.client_curtime() + retry_after
+ self._backoff_response = pickle.dumps(resp)
+
+ # If we get a 401 with "serverTime" field in the body, then we're
+ # probably out of sync with the server's clock. Check our skew,
+ # adjust if necessary and try again.
+ if retry_auth_errors:
+ if resp.status_code == 401 and "serverTime" in body:
+ try:
+ server_timestamp = int(body["serverTime"])
+ except ValueError:
+ msg = "API responded with non-integer serverTime: {0}"
+ msg = msg.format(body["serverTime"])
+ raise fxa.errors.OutOfProtocolError(msg)
+ # If our guestimate is more than 30 seconds out, try again.
+ # This assumes the auth hook will use the updated clockskew.
+ if abs(server_timestamp - self.server_curtime()) > 30:
+ self._clockskew = server_timestamp - self.client_curtime()
+ return self.request(method, url, json, False, **kwds)
+
+ # See if we need to adjust for clock skew between client and server.
+ # We do this automatically once per session in the hopes of avoiding
+ # having to retry subsequent auth failures. We do it *after* the retry
+ # checking above, because it wrecks the "were we out of sync?" check.
+ if self._clockskew is None and "timestamp" in resp.headers:
+ try:
+ server_timestamp = int(resp.headers["timestamp"])
+ except ValueError:
+ msg = "API responded with non-integer timestamp: {0}"
+ msg = msg.format(resp.headers["timestamp"])
+ raise fxa.errors.OutOfProtocolError(msg)
+ else:
+ self._clockskew = server_timestamp - self.client_curtime()
+
+ # Raise exceptions for any error responses.
+ # XXX TODO: hooks for raising error subclass based on errno.
+ if 400 <= resp.status_code < 500:
+ raise fxa.errors.ClientError(body)
+ if 500 <= resp.status_code < 600:
+ raise fxa.errors.ServerError(body)
+ if resp.status_code < 200 or resp.status_code >= 300:
+ msg = "API responded with unexpected status code: {0}"
+ raise fxa.errors.OutOfProtocolError(msg.format(resp.status_code))
+
+ # Return the parsed JSON body for successful responses.
+ return body
+
+ def get(self, url, **kwds):
+ return self.request("GET", url, **kwds)
+
+ def post(self, url, json=None, **kwds):
+ return self.request("POST", url, json, **kwds)
+
+ def put(self, url, json=None, **kwds):
+ return self.request("PUT", url, json, **kwds)
+
+ def delete(self, url, **kwds):
+ return self.request("DELETE", url, **kwds)
+
+
+class HawkTokenAuth(requests.auth.AuthBase):
+ """A requests auth hook implementing token-based hawk auth.
+
+ This auth hook implements the hkdf-derived-hawk-token auth scheme
+ as used by the Firefox Accounts auth server. It uses HKDF to derive
+ an id and secret key from a random 32-byte token, then signs the request
+ with those credentials using the Hawk request-signing scheme.
+ """
+
+ def __init__(self, token, tokentype, apiclient=None):
+ tokendata = unhexlify(token)
+ key_material = fxa.crypto.derive_key(tokendata, tokentype, 3*32)
+ self.id = hexstr(key_material[:32])
+ self.auth_key = key_material[32:64]
+ self.bundle_key = key_material[64:]
+ self.apiclient = apiclient
+
+ def __call__(self, req):
+ # Requests doesn't include the port in the Host header by default.
+ # Ensure a fully-correct value so that signatures work properly.
+ req.headers["Host"] = urlparse(req.url).netloc
+ params = {}
+ if req.body:
+ body = _encoded(req.body, 'utf-8')
+ hasher = hashlib.sha256()
+ hasher.update(b"hawk.1.payload\napplication/json\n")
+ hasher.update(body)
+ hasher.update(b"\n")
+ hash = b64encode(hasher.digest())
+ if PY3:
+ hash = hash.decode("ascii")
+ params["hash"] = hash
+ if self.apiclient is not None:
+ params["ts"] = str(int(self.apiclient.server_curtime()))
+ hawkauthlib.sign_request(req, self.id, self.auth_key, params=params)
+ return req
+
+ def bundle(self, namespace, payload):
+ """Bundle encrypted response data."""
+ return fxa.crypto.bundle(self.bundle_key, namespace, payload)
+
+ def unbundle(self, namespace, payload):
+ """Unbundle encrypted response data."""
+ return fxa.crypto.unbundle(self.bundle_key, namespace, payload)
+
+
+class BearerTokenAuth(requests.auth.AuthBase):
+ """A requests auth hook implementing OAuth bearer-token-based auth.
+
+ This auth hook implements the simple "bearer token" auth scheme.
+ The provided token is passed directly in the Authorization header.
+ """
+
+ def __init__(self, token, apiclient=None):
+ self.token = token
+
+ def __call__(self, req):
+ req.headers["Authorization"] = "Bearer {0}".format(self.token)
+ return req
+
+
+def _decoded(value, encoding='utf-8'):
+ """Make sure the value is of type ``unicode`` in both PY2 and PY3."""
+ value_type = type(value)
+ if value_type != six.text_type:
+ value = value.decode(encoding)
+ return value
+
+
+def _encoded(value, encoding='utf-8'):
+ """Make sure the value is of type ``bytes`` in both PY2 and PY3."""
+ value_type = type(value)
+ if value_type != six.binary_type:
+ return value.encode(encoding)
+ return value
diff --git a/tests/api.py b/tests/api.py
new file mode 100644
index 0000000..9d2f70d
--- /dev/null
+++ b/tests/api.py
@@ -0,0 +1,252 @@
+import asyncio
+import base64
+import binascii
+import http.server
+import http_ece
+import json
+import os
+import queue
+import quopri
+import threading
+from _utils import HawkTokenAuth, APIClient, hexstr
+from aiosmtpd.controller import Controller
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from fxa.crypto import quick_stretch_password, derive_key, xor
+
+AUTH_URL = "http://localhost:8000/auth"
+PROFILE_URL = "http://localhost:8000/profile"
+OAUTH_URL = "http://localhost:8000/oauth"
+INVITE_URL = "http://localhost:8000/_invite"
+PUSH_PORT = 10264
+SMTP_PORT = 2525
+
+def auth_pw(email, pw):
+ return derive_key(quick_stretch_password(email, pw), "authPW").hex()
+
+class AuthClient:
+ def __init__(self, /, email=None, session=None, bearer=None, props=None):
+ self.password = ""
+ self.client = APIClient(f"{AUTH_URL}/v1")
+ self.email = email
+ assert int(session is not None) + int(bearer is not None) < 2
+ self.session = session
+ self.auth = HawkTokenAuth(session, "sessionToken", self.client) if session else None
+ self.bearer = bearer
+ self.headers = { 'authorization': f'bearer {bearer}' } if bearer else {}
+ self.props = props
+
+ def post(self, url, json=None, **kwds):
+ return self.client.post(url, json, **kwds)
+ def post_a(self, url, json=None, **kwds):
+ kwds.setdefault('headers', {})
+ kwds['headers'] |= self.headers
+ return self.client.post(url, json, auth=self.auth, **kwds)
+
+ def get(self, url, **kwds):
+ return self.client.get(url, **kwds)
+ def get_a(self, url, **kwds):
+ kwds.setdefault('headers', {})
+ kwds['headers'] |= self.headers
+ return self.client.get(url, auth=self.auth, **kwds)
+
+ def delete(self, url, **kwds):
+ return self.client.delete(url, **kwds)
+ def delete_a(self, url, **kwds):
+ kwds.setdefault('headers', {})
+ kwds['headers'] |= self.headers
+ return self.client.delete(url, auth=self.auth, **kwds)
+
+ def create_account(self, email, pw, keys=None, invite=None, **kwds):
+ body = {
+ "email": email,
+ "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")),
+ "style": invite,
+ }
+ params = { 'keys': str(keys).lower() } if keys is not None else {}
+ resp = self.client.post("/account/create", body, params=params, **kwds)
+ return AuthClient(email=email, session=resp['sessionToken'], props=resp)
+ def destroy_account(self, email, pw, **kwds):
+ body = { "email": email, "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")) }
+ return self.client.post("/account/destroy", body)
+
+ def fetch_keys(self, key_fetch_token, pw):
+ pw = quick_stretch_password(self.email, pw)
+ auth = HawkTokenAuth(key_fetch_token, "keyFetchToken", self.client)
+ resp = self.client.get("/account/keys", auth=auth)
+ bundle = binascii.unhexlify(resp["bundle"])
+ keys = auth.unbundle("account/keys", bundle)
+ unwrap_key = derive_key(pw, "unwrapBkey")
+ return (keys[:32], xor(keys[32:], unwrap_key))
+
+ def login(self, email, pw, keys=None, **kwds):
+ body = { "email": email, "authPW": hexstr(derive_key(quick_stretch_password(email, pw), "authPW")) }
+ params = { "keys": str(keys).lower() } if keys is not None else {}
+ resp = self.client.post("/account/login", body, params=params)
+ return AuthClient(email=email, session=resp['sessionToken'], props=resp)
+ def destroy_session(self, **kwds):
+ return self.post_a("/session/destroy", kwds)
+
+ def profile(self):
+ token = self.post_a("/oauth/token", {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "online",
+ "scope": "profile:write",
+ })
+ return Profile(token['access_token'])
+
+class Invite:
+ def __init__(self, token):
+ self.client = APIClient(f"{INVITE_URL}/v1")
+ self.auth = HawkTokenAuth(token, "sessionToken", self.client)
+
+ def post(self, url, json=None, **kwds):
+ return self.client.post(url, json, **kwds)
+ def post_a(self, url, json=None, **kwds):
+ return self.client.post(url, json, auth=self.auth, **kwds)
+
+class PasswordChange:
+ def __init__(self, client, token, hkdf='passwordChangeToken'):
+ self.client = client
+ self.auth = HawkTokenAuth(token, hkdf, self.client)
+
+ def post(self, url, json=None, **kwds):
+ return self.client.post(url, json, **kwds)
+ def post_a(self, url, json=None, **kwds):
+ return self.client.post(url, json, auth=self.auth, **kwds)
+
+class AccountReset:
+ def __init__(self, client, token):
+ self.client = client
+ self.auth = HawkTokenAuth(token, 'accountResetToken', self.client)
+
+ def post(self, url, json=None, **kwds):
+ return self.client.post(url, json, **kwds)
+ def post_a(self, url, json=None, **kwds):
+ return self.client.post(url, json, auth=self.auth, **kwds)
+
+class Profile:
+ def __init__(self, token):
+ self.client = APIClient(f"{PROFILE_URL}/v1")
+ self.token = token
+
+ def get(self, url, **kwds):
+ return self.client.get(url, **kwds)
+ def get_a(self, url, **kwds):
+ kwds.setdefault('headers', {})
+ kwds['headers']['authorization'] = f'bearer {self.token}'
+ return self.client.get(url, **kwds)
+
+ def post(self, url, json=None, **kwds):
+ return self.client.post(url, json, **kwds)
+ def post_a(self, url, json=None, **kwds):
+ kwds.setdefault('headers', {})
+ kwds['headers']['authorization'] = f'bearer {self.token}'
+ return self.client.post(url, json, **kwds)
+
+ def delete(self, url, **kwds):
+ return self.client.delete(url, **kwds)
+ def delete_a(self, url, **kwds):
+ kwds.setdefault('headers', {})
+ kwds['headers']['authorization'] = f'bearer {self.token}'
+ return self.client.delete(url, **kwds)
+
+class Oauth:
+ def __init__(self):
+ self.client = APIClient(f"{OAUTH_URL}/v1")
+
+ def post(self, url, json=None, **kwds):
+ return self.client.post(url, json, **kwds)
+
+class Device:
+ def __init__(self, auth, name, type="desktop", commands={}, pcb=None):
+ self.auth = auth
+ dev = auth.post_a("/account/device", {
+ "name": name,
+ "type": type,
+ "availableCommands": commands,
+ } | self._mk_push(pcb))
+ self.id = dev['id']
+ self.props = dev
+
+ def _mk_push(self, pcb):
+ if not pcb:
+ return {}
+
+ self.priv = ec.generate_private_key(ec.SECP256R1, default_backend())
+ self.public = self.priv.public_key().public_bytes(
+ encoding=serialization.Encoding.X962,
+ format=serialization.PublicFormat.UncompressedPoint)
+ self.authkey = os.urandom(16)
+ return {
+ "pushCallback": pcb,
+ "pushPublicKey": base64.urlsafe_b64encode(self.public).decode('utf8'),
+ "pushAuthKey": base64.urlsafe_b64encode(self.authkey).decode('utf8'),
+ }
+
+ def update_pcb(self, pcb):
+ self.props = self.auth.post_a("/account/device", { "id": self.id } | self._mk_push(pcb))
+
+ def decrypt(self, data):
+ raw = http_ece.decrypt(data, private_key=self.priv, auth_secret=self.authkey)
+ return json.loads(raw.decode('utf8'))
+
+class PushServer:
+ def __init__(self):
+ q = self.q = queue.Queue()
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def do_POST(self):
+ if self.path.startswith("/err/"):
+ self.send_response(410)
+ self.end_headers()
+ else:
+ self.send_response(200)
+ self.end_headers()
+
+ q.put((self.path, self.headers, self.rfile.read(int(self.headers['content-length']))))
+
+ server = self.server = http.server.ThreadingHTTPServer(("localhost", PUSH_PORT), Handler)
+ threading.Thread(target=server.serve_forever).start()
+
+ def wait(self, timeout=2):
+ return self.q.get(timeout=timeout)
+ def done(self, timeout=2):
+ try:
+ self.q.get(timeout=timeout)
+ return False
+ except queue.Empty:
+ return True
+
+ def good(self, id):
+ return f"http://localhost:{PUSH_PORT}/{id}"
+ def bad(self, id):
+ return f"http://localhost:{PUSH_PORT}/err/{id}"
+
+class MailServer:
+ def __init__(self):
+ q = self.q = queue.Queue()
+
+ class Handler:
+ async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
+ envelope.rcpt_tos.append(address)
+ return '250 OK'
+
+ async def handle_DATA(self, server, session, envelope):
+ headers, body = envelope.content.decode('utf8').split("\r\n\r\n", maxsplit=1)
+ if "Content-Transfer-Encoding: quoted-printable" in headers:
+ body = quopri.decodestring(body).decode('utf8')
+ q.put((envelope.rcpt_tos, body))
+ return '250 Message accepted for delivery'
+
+ self.controller = Controller(Handler(), hostname="localhost", port=SMTP_PORT)
+ self.controller.start()
+
+ def stop(self):
+ self.controller.stop()
+
+ def wait(self, timeout=2):
+ return self.q.get(timeout=timeout)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..15149cb
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,115 @@
+import base64
+import json
+import pytest
+from fxa.errors import ClientError
+
+from api import *
+
+@pytest.fixture
+def push_server():
+ s = PushServer()
+ yield s
+ s.server.shutdown()
+ s.server.server_close()
+
+@pytest.fixture
+def mail_server():
+ s = MailServer()
+ yield s
+ s.stop()
+
+@pytest.fixture
+def client():
+ return AuthClient()
+
+def _login(client, email, mail_server):
+ # unverified accounts and unverified session behave the same, so we don't bother
+ # with dedicated unverified-session tests and just always verify.
+ c = client.login(email, "")
+ (to, body) = mail_server.wait()
+ assert to == [email]
+ c.post_a('/session/verify_code', { 'code': body.strip() })
+ return c
+
+def _account(client, primary, email, mail_server):
+ s = client.create_account(email, "")
+ try:
+ (to, body) = mail_server.wait()
+ assert to == [email]
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ if primary:
+ yield s
+ else:
+ c = _login(client, email, mail_server)
+ yield c
+ s.password = c.password
+ finally:
+ try:
+ s.destroy_account(email, s.password)
+ except ClientError as e:
+ # don't fail if the account was already deleted
+ if e.details['errno'] != 102:
+ raise
+
+@pytest.fixture(params=[True, False], ids=["primary", "secondary"])
+def account(client, request, mail_server):
+ for a in _account(client, request.param, "test.account@test-auth", mail_server):
+ yield a
+@pytest.fixture(params=[True, False], ids=["primary", "secondary"])
+def account2(client, request, mail_server):
+ for a in _account(client, request.param, "test.account2@test-auth", mail_server):
+ yield a
+
+@pytest.fixture
+def unverified_account(client, mail_server):
+ s = client.create_account("test.account@test-auth", "")
+ yield s
+ s.destroy_account("test.account@test-auth", "")
+
+@pytest.fixture
+def login(client, mail_server):
+ return _login(client, "test.account@test-auth", mail_server)
+@pytest.fixture
+def login2(client, mail_server):
+ return _login(client, "test.account2@test-auth", mail_server)
+
+def _refresh_token(account, scope):
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "offline",
+ "scope": scope,
+ }
+ yield account.post_a("/oauth/token", body)
+
+@pytest.fixture
+def refresh_token(account):
+ for r in _refresh_token(account, "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"):
+ yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r)
+@pytest.fixture
+def narrow_refresh_token(account):
+ for r in _refresh_token(account, "profile https://identity.mozilla.com/tokens/session"):
+ yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r)
+
+def _account_or_rt(account, request, scope):
+ if request.param:
+ yield account
+ else:
+ for r in _refresh_token(account, scope):
+ yield AuthClient(email=account.email, bearer=r['refresh_token'], props=r)
+
+@pytest.fixture(params=[True, False], ids=["session", "refresh_token"])
+def account_or_rt(account, request):
+ for r in _account_or_rt(account, request, "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"):
+ yield r
+
+@pytest.fixture
+def forgot_token(account):
+ resp = account.post_a("/password/forgot/send_code", { 'email': account.email })
+ assert 'passwordForgotToken' in resp
+ assert resp['ttl'] == 300
+ assert resp['codeLength'] == 16
+ assert resp['tries'] == 1
+ return PasswordChange(account.client, resp['passwordForgotToken'], 'passwordForgotToken')
diff --git a/tests/integration.rs b/tests/integration.rs
new file mode 100644
index 0000000..afded52
--- /dev/null
+++ b/tests/integration.rs
@@ -0,0 +1,73 @@
+use std::env;
+
+use anyhow::Context;
+use base64::URL_SAFE_NO_PAD;
+use chrono::{Duration, Utc};
+use futures::channel::oneshot::channel;
+use minor_skulk::{build, db::Db};
+use password_hash::rand_core::OsRng;
+use rand::RngCore;
+use rocket::{
+ fairing::AdHoc,
+ tokio::{process::Command, spawn},
+};
+
+#[macro_use]
+extern crate rocket;
+extern crate anyhow;
+
+async fn run_pytest(markers: &'static str, invite: bool) -> anyhow::Result<()> {
+ dotenv::dotenv().ok();
+ // at this point this is only a test runner to be used by the nix build.
+ env::set_var("ROCKET_LOG_LEVEL", "off");
+
+ let (tx, rx) = channel();
+ let rocket = build().await?.attach(AdHoc::on_liftoff("notify startup", move |rocket| {
+ Box::pin(async move {
+ // add an invite code as-if generated during startup and move it to an
+ // env var, emulating the user looking at the logs and copying the code.
+ // invite_only needs this to function.
+ if invite {
+ let db = rocket.state::<Db>().unwrap();
+ let tx = db.begin().await.unwrap();
+ let mut code = [0; 32];
+ OsRng.fill_bytes(&mut code);
+ let code = base64::encode_config(code, URL_SAFE_NO_PAD);
+ tx.add_invite_code(&code, Utc::now() + Duration::minutes(5)).await.unwrap();
+ tx.commit().await.unwrap();
+ env::set_var("INVITE_CODE", code);
+ }
+
+ tx.send(()).unwrap();
+ })
+ }));
+
+ spawn(async move { rocket.launch().await });
+ let test = spawn(async move {
+ rx.await.expect("test trigger failed");
+ let mut child = Command::new("pytest")
+ .arg("-vvv")
+ .arg("-m")
+ .arg(markers)
+ .spawn()
+ .expect("failed to spawn");
+ child.wait().await
+ });
+
+ assert!(test.await.context("starting pytest")?.context("running pytest")?.success());
+
+ Ok(())
+}
+
+#[async_test]
+async fn open() -> anyhow::Result<()> {
+ env::set_var("ROCKET_INVITE_ONLY", "false");
+ run_pytest("not invite", false).await
+}
+
+#[async_test]
+async fn invite_only() -> anyhow::Result<()> {
+ env::set_var("ROCKET_INVITE_ONLY", "true");
+ env::set_var("ROCKET_INVITE_ADMIN_ADDRESS", "test.account@test-auth");
+ run_pytest("invite", true).await
+}
diff --git a/tests/smtp.py b/tests/smtp.py
new file mode 100644
index 0000000..fa6e16b
--- /dev/null
+++ b/tests/smtp.py
@@ -0,0 +1,27 @@
+import asyncio
+import quopri
+from aiosmtpd.controller import Controller
+
+class PrintHandler:
+ async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
+ envelope.rcpt_tos.append(address)
+ return '250 OK'
+
+ async def handle_DATA(self, server, session, envelope):
+ print('Message from %s' % envelope.mail_from)
+ print('Message for %s' % envelope.rcpt_tos)
+ print('Message data:\n')
+ headers, body = envelope.content.decode('utf8').split("\r\n\r\n", maxsplit=1)
+ if "Content-Transfer-Encoding: quoted-printable" in headers:
+ body = quopri.decodestring(body).decode('utf8')
+ print(headers)
+ print()
+ print(body)
+ print()
+ print('End of message')
+ return '250 Message accepted for delivery'
+
+if __name__ == '__main__':
+ controller = Controller(PrintHandler(), hostname="localhost", port=2525)
+ controller.start()
+ input()
diff --git a/tests/test_auth_account.py b/tests/test_auth_account.py
new file mode 100644
index 0000000..68a407b
--- /dev/null
+++ b/tests/test_auth_account.py
@@ -0,0 +1,348 @@
+import os
+import pytest
+from fxa.errors import ClientError
+
+from api import *
+
+def test_create_no_args(client):
+ with pytest.raises(ClientError) as e:
+ client.post("/account/create")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 106,
+ 'error': 'Bad Request',
+ 'message': 'invalid json in request body'
+ }
+
+@pytest.mark.parametrize("args", [
+ {"email": "", "authPW": "", "extra": ""},
+ {"email": "", "authPW": "00"},
+ {"email": "a" * 257, "authPW": "0" * 64},
+ {"email": "a@test", "authPW": "00"},
+ {"email": "a@test", "authPW": "00" * 32, "style": "foo"},
+])
+def test_create_invalid_args(client, args):
+ with pytest.raises(ClientError) as e:
+ client.post("/account/create", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_create_nomail(client):
+ with pytest.raises(ClientError) as e:
+ client.create_account("test.account@test-auth", "")
+ assert e.value.details == {
+ 'code': 422,
+ 'errno': 151,
+ 'error': 'Unprocessable Entity',
+ 'message': 'failed to send email'
+ }
+
+def test_create_exists(account):
+ with pytest.raises(ClientError) as e:
+ account.create_account(account.email, "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 101,
+ 'error': 'Bad Request',
+ 'message': 'account already exists'
+ }
+
+@pytest.mark.parametrize("keys", [None, False, True])
+@pytest.mark.parametrize("verify", [False, True])
+def test_create_destroy(client, keys, verify, mail_server):
+ s = client.create_account("test.create.destroy@test-auth", "", keys=keys)
+ try:
+ if verify:
+ (to, body) = mail_server.wait()
+ assert to == [s.email]
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ if keys:
+ k = s.fetch_keys(s.props['keyFetchToken'], "")
+ with pytest.raises(ClientError) as e:
+ s.fetch_keys(s.props['keyFetchToken'], "")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+ finally:
+ client.destroy_account("test.create.destroy@test-auth", "")
+
+def test_login_no_args(client):
+ with pytest.raises(ClientError) as e:
+ client.post("/account/login")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 106,
+ 'error': 'Bad Request',
+ 'message': 'invalid json in request body'
+ }
+
+@pytest.mark.parametrize("args", [
+ {"email": "", "authPW": "", "extra": ""},
+ {"email": "", "authPW": "00"},
+ {"email": "a" * 257, "authPW": "0" * 64},
+ {"email": "a@test", "authPW": "00"},
+])
+def test_login_invalid_args(client, args):
+ with pytest.raises(ClientError) as e:
+ client.post("/account/login", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_login_noaccount(client):
+ with pytest.raises(ClientError) as e:
+ client.login("test@test", "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 102,
+ 'error': 'Bad Request',
+ 'message': 'unknown account'
+ }
+
+def test_login_unverified(client, unverified_account):
+ with pytest.raises(ClientError) as e:
+ client.login(unverified_account.email, "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 104,
+ 'error': 'Bad Request',
+ 'message': 'unverified account'
+ }
+
+def test_login_badcase(account):
+ with pytest.raises(ClientError) as e:
+ account.login(account.email.upper(), "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 120,
+ 'error': 'Bad Request',
+ 'message': 'incorrect email case'
+ }
+
+def test_login_badpasswd(account):
+ with pytest.raises(ClientError) as e:
+ account.login(account.email, "ca0cb780-f114-405a-a5c2-1a4deb933a51")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 103,
+ 'error': 'Bad Request',
+ 'message': 'incorrect password'
+ }
+
+@pytest.mark.parametrize("keys", [None, False, True])
+def test_login(client, account, keys):
+ s = client.login(account.email, "", keys=keys)
+ try:
+ if keys:
+ s.fetch_keys(s.props['keyFetchToken'], "")
+ with pytest.raises(ClientError) as e:
+ s.fetch_keys(s.props['keyFetchToken'], "")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+ finally:
+ s.destroy_session()
+
+@pytest.mark.parametrize("args", [
+ {"email": "", "authPW": "", "extra": ""},
+ {"email": "", "authPW": "00"},
+ {"email": "a" * 257, "authPW": "0" * 64},
+ {"email": "a@test", "authPW": "00"},
+])
+def test_destroy_invalid_args(client, args):
+ with pytest.raises(ClientError) as e:
+ client.post("/account/destroy", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_destroy_noaccount(client):
+ with pytest.raises(ClientError) as e:
+ client.destroy_account("test@test", "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 102,
+ 'error': 'Bad Request',
+ 'message': 'unknown account'
+ }
+
+def test_destroy_badcase(account):
+ with pytest.raises(ClientError) as e:
+ account.destroy_account(account.email.upper(), "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 120,
+ 'error': 'Bad Request',
+ 'message': 'incorrect email case'
+ }
+
+def test_destroy_badpasswd(account):
+ with pytest.raises(ClientError) as e:
+ account.destroy_account(account.email, "ca0cb780-f114-405a-a5c2-1a4deb933a51")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 103,
+ 'error': 'Bad Request',
+ 'message': 'incorrect password'
+ }
+
+@pytest.mark.parametrize("auth", [
+ {},
+ {"authorization": "bearer invalid"},
+ {"authorization": "hawk id=invalid"},
+])
+def test_keys_invalid(client, auth):
+ with pytest.raises(ClientError) as e:
+ client.get("/account/keys", headers=auth)
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+# create and login do the remaining keyfetch tests.
+# we don't check whether the bundle is actually *valid* because we'd have to look
+# into the db to do a full test, so we'll trust it is correct if sync works.
+
+@pytest.fixture
+def reset_token(account, forgot_token, mail_server):
+ (to, body) = mail_server.wait()
+ assert account.email in to
+ resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() })
+ return AccountReset(account.client, resp['accountResetToken'])
+
+@pytest.mark.parametrize("args", [
+ { 'authPW': '00', },
+ { 'authPW': '00' * 32, 'extra': 0, },
+])
+def test_reset_invalid(reset_token, args):
+ with pytest.raises(ClientError) as e:
+ reset_token.post_a("/account/reset", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_reset(account, reset_token, mail_server, push_server):
+ dev = Device(account, "dev", pcb=push_server.good("d4105515-f4f0-4d26-85c1-f48c5c348edb"))
+ resp = reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") })
+ assert resp == {}
+ (to, body) = mail_server.wait()
+ assert account.email in to
+ assert 'account has been reset' in body
+ p = push_server.wait()
+ assert p[0] == "/d4105515-f4f0-4d26-85c1-f48c5c348edb"
+ assert dev.decrypt(p[2]) == {'command': 'fxaccounts:password_reset', 'version': 1}
+ p = push_server.wait()
+ assert p[0] == "/d4105515-f4f0-4d26-85c1-f48c5c348edb"
+ assert dev.decrypt(p[2]) == {
+ 'command': 'fxaccounts:device_disconnected',
+ 'data': {'id': dev.id},
+ 'version': 1
+ }
+ assert push_server.done()
+
+def test_reset(account, reset_token, mail_server, push_server):
+ reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") })
+ with pytest.raises(ClientError) as e:
+ reset_token.post_a("/account/reset", { 'authPW': auth_pw(account.email, "") })
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+@pytest.mark.invite
+def test_create_noinvite(client):
+ with pytest.raises(ClientError) as e:
+ client.create_account("test.create.destroy@test-auth", "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': -1,
+ 'error': 'Bad Request',
+ 'message': 'invite code required'
+ }
+
+@pytest.mark.invite
+def test_create_badinvite(client):
+ with pytest.raises(ClientError) as e:
+ client.create_account("test.create.destroy@test-auth", "", { 'style': '' })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': -1,
+ 'error': 'Bad Request',
+ 'message': 'invite code required'
+ }
+
+@pytest.mark.invite
+def test_create_invite(client, mail_server):
+ # all in one test because restarting the server from python would be a pain
+ c = client.create_account("test.account@test-auth", "", invite=os.environ['INVITE_CODE'])
+ c2 = None
+ try:
+ invite = Invite(c.props['sessionToken'])
+ # unverified sessions fail
+ with pytest.raises(ClientError) as e:
+ code = invite.post_a('/generate', { 'ttl_hours': 1 })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+ # verified session works
+ (to, body) = mail_server.wait()
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ c.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ code = invite.post_a('/generate', { 'ttl_hours': 1 })
+ assert 'url' in code
+ code = code['url'].split('/')[-1]
+ # code allows registration
+ c2 = client.create_account("test.account2@test-auth", "", invite=code)
+ (to, body) = mail_server.wait()
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ c2.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ # token is consumed
+ with pytest.raises(ClientError) as e:
+ client.create_account("test.account3@test-auth", "", invite=code)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': -2,
+ 'error': 'Bad Request',
+ 'message': 'invite code not found'
+ }
+ # non-admin accounts can't generate tokens
+ with pytest.raises(ClientError) as e:
+ invite2 = Invite(c2.props['sessionToken'])
+ invite2.post_a('/generate', { 'ttl_hours': 1 })
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 110,
+ 'error': 'Unauthorized',
+ 'message': 'invalid authentication token'
+ }
+ finally:
+ c.destroy_account(c.email, "")
+ if c2 is not None:
+ c2.destroy_account(c2.email, "")
diff --git a/tests/test_auth_device.py b/tests/test_auth_device.py
new file mode 100644
index 0000000..8504ba7
--- /dev/null
+++ b/tests/test_auth_device.py
@@ -0,0 +1,434 @@
+import copy
+import pytest
+import random
+import time
+from fxa.errors import ClientError
+
+from api import *
+
+device_data = [
+ { "name": "testdev1", "type": "desktop", "availableCommands": { "a": "b" } },
+ { "name": "testdev2", "type": "desktop", "availableCommands": { "a": "b" } },
+]
+
+@pytest.fixture
+def populate_devices(account_or_rt, login):
+ devs = []
+ for (account, dev) in [(account_or_rt, device_data[0]), (login, device_data[1])]:
+ dev = account.post_a("/account/device", dev)
+ devs.append(dev)
+ return devs
+
+def test_create_noauth(client):
+ with pytest.raises(ClientError) as e:
+ client.post_a("/account/device", device_data[0])
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+@pytest.mark.parametrize("args,code,errno,error,message", [
+ ({ 'name': 'dev', 'availableCommands': {'a':1} },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({ 'name': 'dev', 'availableCommands': {'a':1}, 'extra': 0 },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({},
+ 400, 108, 'Bad Request', 'missing parameter in request body'),
+ ({ 'id': '00' * 16, 'name': 'dev' },
+ 400, 108, 'Bad Request', 'missing parameter in request body'),
+])
+def test_create_invalid(account_or_rt, args, code, errno, error, message):
+ with pytest.raises(ClientError) as e:
+ account_or_rt.post_a("/account/device", args)
+ assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message}
+
+def test_destroy_noauth(client, populate_devices):
+ with pytest.raises(ClientError) as e:
+ client.post_a("/account/device/destroy")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+ with pytest.raises(ClientError) as e:
+ client.post_a("/account/device/destroy", {'id': populate_devices[0]['id']})
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+@pytest.mark.parametrize("args,code,errno,error,message", [
+ ({ 'id': '00' },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({ 'id': '00' * 16, 'extra': 0 },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({ 'id': '00' * 16 },
+ 400, 123, 'Bad Request', 'unknown device'),
+])
+def test_destroy_invalid(account_or_rt, args, code, errno, error, message):
+ with pytest.raises(ClientError) as e:
+ account_or_rt.post_a("/account/device/destroy", args)
+ assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message}
+
+def test_create_destroy(account_or_rt):
+ dev = account_or_rt.post_a("/account/device", device_data[0])
+ account_or_rt.post_a("/account/device/destroy", {'id': dev['id']})
+
+def test_create_unverified(unverified_account):
+ unverified_account.post_a("/account/device", device_data[0])
+
+def test_list_noauth(client):
+ with pytest.raises(ClientError) as e:
+ client.get("/account/devices")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_list_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.get_a("/account/devices")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+def test_list_none(account_or_rt):
+ devs = account_or_rt.get_a("/account/devices")
+ assert devs == []
+
+@pytest.mark.usefixtures("populate_devices")
+def test_list(account_or_rt):
+ devs = account_or_rt.get_a("/account/devices")
+ assert len(devs) == 2
+ (first, second) = (0, 1) if devs[0]['name'] == 'testdev1' else (1, 0)
+ assert devs[first]['isCurrentDevice']
+ assert not devs[second]['isCurrentDevice']
+ for (k, v) in device_data[first].items():
+ assert devs[0][k] == v
+ for (k, v) in device_data[second].items():
+ assert devs[1][k] == v
+
+def test_change(account_or_rt, populate_devices):
+ devs1 = populate_devices
+ devs = copy.deepcopy(devs1)
+ (devs[0]['name'], devs[1]['name']) = (devs[1]['name'], devs[0]['name'])
+ (devs[0]['pushCallback'], devs[1]['pushCallback']) = (devs[1]['pushCallback'] or "", devs[0]['pushCallback'] or "")
+ (devs[0]['pushPublicKey'], devs[1]['pushPublicKey']) = (devs[1]['pushPublicKey'] or "", devs[0]['pushPublicKey'] or "")
+ (devs[0]['pushAuthKey'], devs[1]['pushAuthKey']) = (devs[1]['pushAuthKey'] or "", devs[0]['pushAuthKey'] or "")
+ for dev in devs:
+ del dev['isCurrentDevice']
+ del dev['lastAccessTime']
+ del dev['pushEndpointExpired']
+ account_or_rt.post_a("/account/device", dev)
+ devs2 = account_or_rt.get_a("/account/devices")
+ mdevs1 = {
+ devs1[0]['id']: devs1[0],
+ devs1[1]['id']: devs1[1],
+ }
+ mdevs2 = {
+ devs2[0]['id']: devs2[0],
+ devs2[1]['id']: devs2[1],
+ }
+ (id1, id2) = mdevs1.keys()
+ for (i1, i2) in [(id1, id2), (id2, id1)]:
+ assert mdevs1[i1]['name'] == mdevs2[i2]['name']
+ assert mdevs1[i1]['pushCallback'] or '' == mdevs2[i2]['pushCallback'] or ''
+ assert mdevs1[i1]['pushPublicKey'] or '' == mdevs2[i2]['pushPublicKey'] or ''
+ assert mdevs1[i1]['pushAuthKey'] or '' == mdevs2[i2]['pushAuthKey'] or ''
+
+def test_invoke_noauth(client):
+ body = {"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10}
+ with pytest.raises(ClientError) as e:
+ client.post_a("/account/devices/invoke_command", body)
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_invoke_unverified(unverified_account):
+ body = {"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10}
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/account/devices/invoke_command", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+@pytest.mark.parametrize("args,code,errno,error,message", [
+ ({"target": "00", "command": "foo", "payload": {}, "ttl": 10},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"target": "00" * 16, "command": "foo", "payload": {}, "ttl": 10, 'extra': 0},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"target": "0" * 32, "command": "foo", "payload": {}, "ttl": 10},
+ 400, 123, 'Bad Request', 'unknown device'),
+])
+def test_invoke_invalid(account_or_rt, args, code, errno, error, message):
+ with pytest.raises(ClientError) as e:
+ account_or_rt.post_a("/account/devices/invoke_command", args)
+ assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message}
+
+def test_invoke_nocmd(account_or_rt, populate_devices):
+ body = {"target": populate_devices[0]['id'], "command": "foo", "payload": {}, "ttl": 10}
+ with pytest.raises(ClientError) as e:
+ account_or_rt.post_a("/account/devices/invoke_command", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 157,
+ 'error': 'Bad Request',
+ 'message': 'unavailable device command'
+ }
+
+def test_invoke_other_account(account_or_rt, account2):
+ dev = account2.post_a("/account/device", device_data[0])
+ body = {"target": dev['id'], "command": "foo", "payload": {}, "ttl": 10}
+ with pytest.raises(ClientError) as e:
+ account_or_rt.post_a("/account/devices/invoke_command", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 123,
+ 'error': 'Bad Request',
+ 'message': 'unknown device'
+ }
+
+@pytest.mark.parametrize("ttl", [None, 1, 60, 999999999])
+@pytest.mark.parametrize("has_sender", [False, True])
+def test_invoke(account_or_rt, login, ttl, has_sender):
+ sender = account_or_rt.post_a("/account/device", device_data[1])['id'] if has_sender else None
+ dev = login.post_a("/account/device", device_data[0])
+
+ body = {
+ "target": dev['id'],
+ "command": "a",
+ "payload": { "data": str(random.random()), },
+ "ttl": ttl,
+ }
+ resp = account_or_rt.post_a("/account/devices/invoke_command", body)
+ assert resp['enqueued']
+ assert not resp['notified']
+ assert resp['notifyError'] == "no push callback"
+
+ cmd = login.get_a("/account/device/commands?index=0&limit=11")
+ assert cmd['last']
+ assert len(cmd['messages']) == 1
+ assert cmd['messages'][0]['data']['command'] == 'a'
+ assert cmd['messages'][0]['data']['payload'] == body['payload']
+ assert cmd['messages'][0]['data']['sender'] == sender
+
+def test_commands_noauth(client):
+ with pytest.raises(ClientError) as e:
+ client.get_a("/account/device/commands?index=1")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_commands_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.get_a("/account/device/commands?index=1")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+def test_commands_nodev(account_or_rt):
+ with pytest.raises(ClientError) as e:
+ account_or_rt.get_a("/account/device/commands?index=1")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 123,
+ 'error': 'Bad Request',
+ 'message': 'unknown device'
+ }
+
+@pytest.mark.parametrize('n_cmds,offset,limit', [
+ (1, 0, 0),
+ (1, 0, 1),
+ (1, 0, 100),
+ (1, 1, 0),
+ (1, 1, 1),
+ (1, 1, 100),
+ (2, 0, 0),
+ (2, 0, 1),
+ (2, 0, 100),
+ (2, 1, 0),
+ (2, 1, 1),
+ (2, 1, 100),
+ (101, 0, 100),
+ (101, 10, 100),
+])
+def test_device_commands_list(account_or_rt, login, n_cmds, offset, limit):
+ account_or_rt.post_a("/account/device", device_data[1])['id']
+ dev = login.post_a("/account/device", device_data[0])
+
+ bodies = [ {
+ "target": dev['id'],
+ "command": "a",
+ "payload": { "data": str(i), },
+ } for i in range(0, n_cmds) ]
+
+ for b in bodies:
+ resp = account_or_rt.post_a("/account/devices/invoke_command", b)
+ assert resp['enqueued']
+ assert not resp['notified']
+ assert resp['notifyError'] == "no push callback"
+
+ cmd = login.get_a("/account/device/commands", params={ "index": 0, "limit": 1 })
+ assert cmd == login.get_a("/account/device/commands", params={ "index": 0, "limit": 1 })
+
+ first_index = cmd['index']
+ cmds = login.get_a("/account/device/commands", params={ "limit": limit, "index": first_index + offset })
+ assert cmds['last'] == (offset + limit >= n_cmds)
+ assert len(cmds['messages']) == min(max(n_cmds - offset, 0), limit)
+
+def test_attached_clients_unauth(client):
+ with pytest.raises(ClientError) as e:
+ client.get_a("/account/attached_clients")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_attached_clients_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.get_a("/account/attached_clients")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+def test_attached_clients_badauth(refresh_token):
+ with pytest.raises(ClientError) as e:
+ refresh_token.get_a("/account/attached_clients")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_attached_clients(account, refresh_token, push_server):
+ dev1 = Device(account, "dev1")
+ dev2 = Device(refresh_token, "dev2", "mobile")
+ # filter potential login-only sessions
+ devs = [ d for d in account.get_a("/account/attached_clients") if d['name'] != None ]
+ assert len(devs) == 2
+ devs = {
+ devs[0]['name']: devs[0],
+ devs[1]['name']: devs[1],
+ }
+ assert devs['dev1']['deviceId'] == dev1.id
+ assert devs['dev1']['sessionTokenId'] != None
+ assert devs['dev1']['refreshTokenId'] == None
+ assert devs['dev1']['isCurrentSession']
+ assert devs['dev1']['deviceType'] == "desktop"
+ assert (time.time() - devs['dev1']['createdTime']) < 10
+ assert (time.time() - devs['dev1']['lastAccessTime']) < 10
+ assert devs['dev1']['scope'] == None
+ #
+ assert devs['dev2']['deviceId'] == dev2.id
+ assert devs['dev2']['sessionTokenId'] != None
+ assert devs['dev2']['refreshTokenId'] != None
+ assert not devs['dev2']['isCurrentSession']
+ assert devs['dev2']['deviceType'] == "mobile"
+ assert (time.time() - devs['dev2']['createdTime']) < 10
+ assert (time.time() - devs['dev2']['lastAccessTime']) < 10
+ assert devs['dev2']['scope'] == "profile https://identity.mozilla.com/apps/oldsync"
+
+def test_attached_client_destroy_unauth(client):
+ with pytest.raises(ClientError) as e:
+ client.post_a("/account/attached_client/destroy")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_attached_client_destroy_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/account/attached_client/destroy")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+def test_attached_client_destroy_badauth(refresh_token):
+ with pytest.raises(ClientError) as e:
+ refresh_token.post_a("/account/attached_client/destroy")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+@pytest.mark.parametrize("args,code,errno,error,message", [
+ ({"sessionTokenId": "0"},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"refreshTokenId": "0"},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"deviceId": "0"},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"sessionTokenId": "00" * 16, 'extra': 0},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"sessionTokenId": "00" * 16, 'refreshTokenId': "00" * 16},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({"sessionTokenId": "00" * 16, 'refreshTokenId': "00" * 16, 'deviceId': "00" * 16},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({'refreshTokenId': "00" * 16, 'deviceId': "00" * 16},
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+])
+def test_attached_client_destroy_invalid(account, args, code, errno, error, message):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/account/attached_client/destroy", args)
+ assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message}
+
+@pytest.mark.parametrize("fn", [
+ (lambda d: {'sessionTokenId': d['sessionTokenId']}),
+ (lambda d: {'refreshTokenId': d['refreshTokenId']}),
+ (lambda d: {'deviceId': d['deviceId']}),
+], ids=["session", "refresh", "device"])
+def test_attached_client_destroy(account, refresh_token, fn):
+ Device(refresh_token, "dev2")
+ devs = account.get_a("/account/attached_clients")
+ account.post_a("/account/attached_client/destroy", fn(devs[0]))
+ devs2 = account.get_a("/account/attached_clients")
+ assert len(devs2) == len(devs) - 1
+
+def test_attached_client_destroy_push(account, refresh_token, push_server):
+ dev = Device(account, "dev")
+ dev2 = Device(refresh_token, "dev2")
+ dev.update_pcb(push_server.good("4ed8d1d3-e756-4866-9169-aafe612eb1e9"))
+ account.post_a("/account/attached_client/destroy", { 'deviceId': dev2.id })
+ p = push_server.wait()
+ assert p[0] == "/4ed8d1d3-e756-4866-9169-aafe612eb1e9"
+ assert dev.decrypt(p[2]) == {
+ 'command': 'fxaccounts:device_disconnected',
+ 'data': {'id': dev2.id},
+ 'version': 1
+ }
+ assert push_server.done()
diff --git a/tests/test_auth_email.py b/tests/test_auth_email.py
new file mode 100644
index 0000000..319f2d4
--- /dev/null
+++ b/tests/test_auth_email.py
@@ -0,0 +1,96 @@
+import pytest
+from fxa.errors import ClientError
+
+from api import *
+
+def test_status_noauth(client, refresh_token):
+ with pytest.raises(ClientError) as e:
+ client.post_a("/recovery_email/status")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+ with pytest.raises(ClientError) as e:
+ refresh_token.post_a("/recovery_email/status")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_status_unverified(unverified_account):
+ resp = unverified_account.get_a("/recovery_email/status")
+ assert resp == {
+ 'email': unverified_account.email,
+ 'emailVerified': False,
+ 'sessionVerified': False,
+ 'verified': False
+ }
+
+def test_status_verified(account):
+ resp = account.get_a("/recovery_email/status")
+ assert resp == {
+ 'email': account.email,
+ 'emailVerified': True,
+ 'sessionVerified': True,
+ 'verified': True
+ }
+
+@pytest.mark.parametrize("args,code,errno,error,message", [
+ ({ 'uid': '00', 'code': "" },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({ 'id': '00' * 16, 'code': 0 },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+ ({ 'id': '00' * 16, 'code': "", 'extra': 0 },
+ 400, 107, 'Bad Request', 'invalid parameter in request body'),
+])
+def test_verify_code_invalid(unverified_account, args, code, errno, error, message):
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/recovery_email/verify_code", args)
+ assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message}
+
+def test_verify_code(account):
+ # fixture does all the work
+ pass
+
+def test_verify_code_reuse(client, mail_server):
+ s = client.create_account("test@test", "")
+ (to, body) = mail_server.wait()
+ assert to == ["test@test"]
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ with pytest.raises(ClientError) as e:
+ s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ s.destroy_account("test@test", "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 105,
+ 'error': 'Bad Request',
+ 'message': 'invalid verification code'
+ }
+
+def test_resend_code(client, mail_server):
+ s = client.create_account("test@test", "")
+ (to, body) = mail_server.wait()
+ assert to == ["test@test"]
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ s.post_a('/recovery_email/resend_code', {})
+ (to2, body2) = mail_server.wait()
+ assert to == to2
+ assert body == body2
+ s.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ with pytest.raises(ClientError) as e:
+ s.post_a('/recovery_email/resend_code', {})
+ (to, body) = mail_server.wait()
+ assert to == ["test@test"]
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ s.destroy_account("test@test", "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 105,
+ 'error': 'Bad Request',
+ 'message': 'invalid verification code'
+ }
diff --git a/tests/test_auth_oauth.py b/tests/test_auth_oauth.py
new file mode 100644
index 0000000..f8ad201
--- /dev/null
+++ b/tests/test_auth_oauth.py
@@ -0,0 +1,369 @@
+import copy
+import pytest
+import time
+from fxa.errors import ClientError
+
+@pytest.mark.parametrize("client_id,scopes", [
+ # firefox
+ ("5882386c6d801776", "profile:write https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"),
+ # fenix
+ ("a2270f727f45f648", "profile https://identity.mozilla.com/apps/oldsync https://identity.mozilla.com/tokens/session"),
+])
+def test_oauth_client_scopes(account, client_id, scopes):
+ body = {
+ "client_id": client_id,
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "online",
+ "scope": scopes,
+ }
+ s = account.post_a("/oauth/token", body)['access_token']
+ account.post_a("/oauth/destroy", { "client_id": client_id, "token": s })
+
+def test_oauth_authorization_noauth(account):
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "online",
+ "scope": "profile",
+ }
+ with pytest.raises(ClientError) as e:
+ account.post("/oauth/authorization", body)
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_oauth_authorization_unverified(unverified_account):
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "online",
+ "scope": "profile",
+ }
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/oauth/authorization", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+@pytest.mark.parametrize("args", [
+ { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "invalid",
+ "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" },
+ { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "online",
+ "code_challenge": "", "code_challenge_method": "invalid", "response_type": "code" },
+ { "client_id": "5882386c6d801776", "state": "", "scope": "profile", "access_type": "online",
+ "code_challenge": "", "code_challenge_method": "S256", "response_type": "invalid" },
+])
+def test_oauth_authorization_invalid(account, args):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/oauth/authorization", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_oauth_authorization_badclientid(account):
+ args = { "client_id": "invalid", "state": "", "scope": "profile", "access_type": "online",
+ "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/oauth/authorization", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 162,
+ 'error': 'Bad Request',
+ 'message': 'unknown client_id'
+ }
+
+def test_oauth_authorization_badscope(account):
+ args = { "client_id": "5882386c6d801776", "state": "", "scope": "invalid", "access_type": "online",
+ "code_challenge": "", "code_challenge_method": "S256", "response_type": "code" }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/oauth/authorization", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 169,
+ 'error': 'Bad Request',
+ 'message': 'requested scopes not allowed'
+ }
+
+# see below for combined /authorization + unauthed /token
+
+def test_oauth_destroy_notoken(account):
+ args = { "client_id": "5882386c6d801776", "token": "00" * 32 }
+ with pytest.raises(ClientError) as e:
+ account.post("/oauth/destroy", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_oauth_destroy_badclient(account, refresh_token):
+ args = { "client_id": "other", "token": refresh_token.bearer }
+ with pytest.raises(ClientError) as e:
+ account.post("/oauth/destroy", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_oauth_scoped_keys_badclient(account):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/account/scoped-key-data", {
+ "client_id": "invalid",
+ "scope": "https://identity.mozilla.com/apps/oldsync"
+ })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 162,
+ 'error': 'Bad Request',
+ 'message': 'unknown client_id'
+ }
+
+def test_oauth_scoped_keys_badscope(account):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/account/scoped-key-data", {
+ "client_id": "5882386c6d801776",
+ "scope": "scope"
+ })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 169,
+ 'error': 'Bad Request',
+ 'message': 'requested scopes not allowed'
+ }
+
+def test_oauth_scoped_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/account/scoped-key-data", {
+ "client_id": "5882386c6d801776",
+ "scope": "https://identity.mozilla.com/apps/oldsync"
+ })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+def test_oauth_scoped_keys(account):
+ resp = account.post_a("/account/scoped-key-data", {
+ "client_id": "5882386c6d801776",
+ "scope": "https://identity.mozilla.com/apps/oldsync"
+ })
+ assert resp == {
+ "https://identity.mozilla.com/apps/oldsync": {
+ "identifier": "https://identity.mozilla.com/apps/oldsync",
+ "keyRotationSecret": "00" * 32,
+ "keyRotationTimestamp": 0,
+ },
+ }
+
+@pytest.mark.parametrize("access_type", ["online", "offline"])
+def test_oauth_token_fxa_badclient(account, access_type):
+ body = { "client_id": "invalid", "ttl": 60, "grant_type": "fxa-credentials",
+ "access_type": access_type, "scope": "profile" }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/oauth/token", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 162,
+ 'error': 'Bad Request',
+ 'message': 'unknown client_id'
+ }
+
+@pytest.mark.parametrize("access_type", ["online", "offline"])
+def test_oauth_token_fxa_badscope(account, access_type):
+ body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials",
+ "access_type": access_type, "scope": "scope" }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/oauth/token", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 169,
+ 'error': 'Bad Request',
+ 'message': 'requested scopes not allowed'
+ }
+
+@pytest.mark.parametrize("grant_type,access_type", [
+ ("authorization_code", "online"),
+ ("refresh_token", "online"),
+ ("fxa-credentials", "foo"),
+])
+def test_oauth_token_fxa_invalid(account, grant_type, access_type):
+ body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": grant_type,
+ "access_type": access_type, "scope": "scope" }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/oauth/token", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_oauth_token_unverified(unverified_account):
+ body = { "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials",
+ "access_type": "online", "scope": "profile" }
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/oauth/token", body)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 138,
+ 'error': 'Bad Request',
+ 'message': 'unverified session'
+ }
+
+@pytest.fixture
+def auth_code(account):
+ body = {
+ "client_id": "5882386c6d801776",
+ "state": "test",
+ "scope": "profile",
+ "access_type": "online",
+ "code_challenge": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", # "test"
+ "code_challenge_method": "S256",
+ "response_type": "code",
+ }
+ return account.post_a("/oauth/authorization", body)['code']
+
+@pytest.mark.parametrize("args,code,error,errno,message", [
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code",
+ "code": "invalid", "code_verifier": "test" },
+ 400, 'Bad Request', 107, 'invalid parameter in request body'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code",
+ "code_verifier": "test", "extra": 0 },
+ 400, 'Bad Request', 107, 'invalid parameter in request body'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code",
+ "code": "00" * 32, "code_verifier": "test" },
+ 401, 'Unauthorized', 110, 'invalid authentication token'),
+ ({ "client_id": "invalid", "ttl": 60, "grant_type": "authorization_code",
+ "code_verifier": "test" },
+ 400, 'Bad Request', 162, 'unknown client_id'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "authorization_code",
+ "code_verifier": "invalid" },
+ 400, 'Bad Request', 107, 'invalid parameter in request body'),
+])
+def test_oauth_token_other_invalidcode(account, args, code, error, errno, message, auth_code):
+ args = copy.deepcopy(args)
+ if 'code' not in args: args['code'] = auth_code
+ with pytest.raises(ClientError) as e:
+ account.post("/oauth/token", args)
+ assert e.value.details == { 'code': code, 'errno': errno, 'error': error, 'message': message }
+
+@pytest.mark.parametrize("args,code,error,errno,message", [
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "fxa-credentials",
+ "scope": "profile", "access_type": "online" },
+ 400, 'Bad Request', 107, 'invalid parameter in request body'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token",
+ "refresh_token": "invalid", "code_verifier": "test" },
+ 400, 'Bad Request', 107, 'invalid parameter in request body'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token",
+ "scope": "profile", "extra": 0 },
+ 400, 'Bad Request', 107, 'invalid parameter in request body'),
+ ({ "client_id": "invalid", "ttl": 60, "grant_type": "refresh_token",
+ "scope": "profile" },
+ 400, 'Bad Request', 162, 'unknown client_id'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token",
+ "scope": "foo" },
+ 400, 'Bad Request', 169, 'requested scopes not allowed'),
+ ({ "client_id": "5882386c6d801776", "ttl": 60, "grant_type": "refresh_token",
+ "scope": "profile:write" },
+ 400, 'Bad Request', 169, 'requested scopes not allowed'),
+])
+def test_oauth_token_other_invalidrefresh(account, args, code, error, errno, message, refresh_token):
+ args = copy.deepcopy(args)
+ if 'refresh_token' not in args: args['refresh_token'] = refresh_token.bearer
+ with pytest.raises(ClientError) as e:
+ account.post("/oauth/token", args)
+ assert e.value.details == { 'code': code, 'errno': errno, 'error': error, 'message': message }
+
+@pytest.mark.parametrize("refresh", [False, True])
+def test_oauth_fxa(account, refresh):
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "offline" if refresh else "online",
+ "scope": "profile",
+ }
+ resp = account.post_a("/oauth/token", body)
+
+ assert 'access_token' in resp
+ assert ('refresh_token' in resp) == refresh
+ assert 'session_token' not in resp
+ assert resp['scope'] == 'profile'
+ assert resp['token_type'] == 'bearer'
+ assert resp['expires_in'] <= 60
+ assert (resp['auth_at'] - time.time()) < 10
+ assert 'keys_jwe' not in resp
+
+@pytest.mark.parametrize("keys_jwe", [None, "keyskeyskeys"])
+@pytest.mark.parametrize("refresh,session", [
+ (False, False),
+ (True, False),
+ (True, True),
+])
+def test_oauth_auth_code(account, keys_jwe, refresh, session):
+ scope = "profile" + (" https://identity.mozilla.com/tokens/session" if session else "")
+ body = {
+ "client_id": "5882386c6d801776",
+ "state": "test",
+ "keys_jwe": keys_jwe,
+ "scope": scope,
+ "access_type": "offline" if refresh else "online",
+ "code_challenge": "n4bQgYhMfWWaL-qgxVrQFaO_TxsrC4Is0V1sFbDwCgg", # "test"
+ "code_challenge_method": "S256",
+ "response_type": "code",
+ }
+ resp = account.post_a("/oauth/authorization", body)
+ assert resp['state'] == "test"
+
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "authorization_code",
+ "code": resp['code'],
+ "code_verifier": "test",
+ }
+ resp = account.post("/oauth/token", body)
+ assert 'access_token' in resp
+ assert ('refresh_token' in resp) == refresh
+ assert ('session_token' in resp) == session
+ assert resp['scope'] == 'profile'
+ assert resp['token_type'] == 'bearer'
+ assert resp['expires_in'] <= 60
+ assert (resp['auth_at'] - time.time()) < 10
+ assert keys_jwe is None or (resp['keys_jwe'] == keys_jwe)
+
+def test_oauth_refresh(account, refresh_token):
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token.bearer,
+ "scope": "profile",
+ }
+ resp = account.post("/oauth/token", body)
+
+ assert 'access_token' in resp
+ assert 'refresh_token' not in resp
+ assert 'session_token' not in resp
+ assert resp['scope'] == 'profile'
+ assert resp['token_type'] == 'bearer'
+ assert resp['expires_in'] <= 60
+ assert (resp['auth_at'] - time.time()) < 10
+ assert 'keys_jwe' not in resp
diff --git a/tests/test_auth_password.py b/tests/test_auth_password.py
new file mode 100644
index 0000000..7c2064a
--- /dev/null
+++ b/tests/test_auth_password.py
@@ -0,0 +1,211 @@
+import pytest
+from fxa.crypto import derive_key, quick_stretch_password
+from fxa.errors import ClientError
+
+from api import *
+
+@pytest.mark.parametrize("args", [
+ { 'email': "", 'oldAuthPW': '00' * 32 },
+ { 'email': "test0@test", 'oldAuthPW': '00' },
+ { 'email': "test0@test", 'oldAuthPW': '00' * 32, 'extra': 0 },
+])
+def test_change_start_invalid(account, args):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/change/start", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_change_start_badaccount(account):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/change/start", { 'email': "test0@test", 'oldAuthPW': '00' * 32 })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 102,
+ 'error': 'Bad Request',
+ 'message': 'unknown account'
+ }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/change/start", { 'email': account.email.upper(), 'oldAuthPW': '00' * 32 })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 120,
+ 'error': 'Bad Request',
+ 'message': 'incorrect email case'
+ }
+
+def test_change_start_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/password/change/start", {
+ 'email': unverified_account.email,
+ 'oldAuthPW': '00' * 32
+ })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 104,
+ 'error': 'Bad Request',
+ 'message': 'unverified account'
+ }
+
+def test_change_start_badpw(account):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/change/start", { 'email': account.email, 'oldAuthPW': '00' * 32 })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 103,
+ 'error': 'Bad Request',
+ 'message': 'incorrect password'
+ }
+
+@pytest.fixture
+def change_token(account):
+ pw = auth_pw(account.email, "")
+ resp = account.post_a("/password/change/start", { 'email': account.email, 'oldAuthPW': pw })
+ assert 'keyFetchToken' in resp
+ return PasswordChange(account.client, resp['passwordChangeToken'])
+
+@pytest.mark.parametrize("args", [
+ { 'authPW': '00', 'wrapKb': '00' * 32, 'sessionToken': '00' * 32, },
+ { 'authPW': '00' * 32, 'wrapKb': '00', 'sessionToken': '00' * 32, },
+ { 'authPW': '00' * 32, 'wrapKb': '00' * 32, 'sessionToken': '00', },
+])
+def test_change_finish_invalid(change_token, args):
+ with pytest.raises(ClientError) as e:
+ change_token.post_a("/password/change/finish", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_change_finish(account, change_token, mail_server):
+ pw = auth_pw(account.email, "new")
+ change_token.post_a("/password/change/finish", {
+ 'authPW': pw,
+ 'wrapKb': '00' * 32,
+ })
+ account.password = "new" # for fixture teardown
+ (to, body) = mail_server.wait()
+ assert account.email in to
+ assert 'password has been changed' in body
+
+ # just do a login test to see that the password was really changed
+ account.login(account.email, "new")
+ with pytest.raises(ClientError) as e:
+ account.login(account.email, "")
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 103,
+ 'error': 'Bad Request',
+ 'message': 'incorrect password'
+ }
+
+def test_change_finish_twice(account, change_token, mail_server):
+ pw = auth_pw(account.email, "new")
+ change_token.post_a("/password/change/finish", {
+ 'authPW': pw,
+ 'wrapKb': '00' * 32,
+ })
+ account.password = "new" # for fixture teardown
+
+ with pytest.raises(ClientError) as e:
+ change_token.post_a("/password/change/finish", {
+ 'authPW': pw,
+ 'wrapKb': '00' * 32,
+ })
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+@pytest.mark.parametrize("args", [
+ { 'email': "" },
+ { 'email': "test0@test", 'extra': 0 },
+])
+def test_forgot_start_invalid(account, args):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/forgot/send_code", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_change_forgot_badaccount(account):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/forgot/send_code", { 'email': "test0@test" })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 102,
+ 'error': 'Bad Request',
+ 'message': 'unknown account'
+ }
+ with pytest.raises(ClientError) as e:
+ account.post_a("/password/forgot/send_code", { 'email': account.email.upper() })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 120,
+ 'error': 'Bad Request',
+ 'message': 'incorrect email case'
+ }
+
+def test_change_forgot_unverified(unverified_account):
+ with pytest.raises(ClientError) as e:
+ unverified_account.post_a("/password/forgot/send_code", { 'email': unverified_account.email })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 104,
+ 'error': 'Bad Request',
+ 'message': 'unverified account'
+ }
+
+@pytest.mark.parametrize("args", [
+ { 'code': '', 'extra': 0, },
+])
+def test_forgot_finish_invalid(change_token, args):
+ with pytest.raises(ClientError) as e:
+ change_token.post_a("/password/forgot/send_code", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_forgot_finish_badcode(account, forgot_token, mail_server):
+ (to, body) = mail_server.wait()
+ assert account.email in to
+ with pytest.raises(ClientError) as e:
+ resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': '' })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 105,
+ 'error': 'Bad Request',
+ 'message': 'invalid verification code'
+ }
+
+def test_forgot_finish(account, forgot_token, mail_server):
+ (to, body) = mail_server.wait()
+ assert account.email in to
+ resp = forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() })
+ assert 'accountResetToken' in resp
+
+def test_forgot_finish_twice(account, forgot_token, mail_server):
+ (to, body) = mail_server.wait()
+ forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() })
+
+ with pytest.raises(ClientError) as e:
+ forgot_token.post_a("/password/forgot/verify_code", { 'code': body.strip() })
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
diff --git a/tests/test_auth_session.py b/tests/test_auth_session.py
new file mode 100644
index 0000000..3a6e7c4
--- /dev/null
+++ b/tests/test_auth_session.py
@@ -0,0 +1,69 @@
+import pytest
+from fxa.errors import ClientError
+
+from api import *
+
+def test_session_loggedout(client):
+ with pytest.raises(ClientError) as e:
+ client.post("/session/destroy")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_status(account):
+ resp = account.get_a("/session/status")
+ assert resp == { 'state': '', 'uid': account.props['uid'] }
+
+def test_resend(account, mail_server):
+ c = account.login(account.email, "")
+ (to, body) = mail_server.wait()
+ assert to == [account.email]
+ c.post_a("/session/resend_code", {})
+ (to2, body2) = mail_server.wait()
+ assert to == to2
+ assert body == body2
+
+@pytest.mark.parametrize("args", [
+ { 'custom_session_id': '00' },
+ { 'extra': '00' },
+])
+def test_session_invalid(account, args):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/session/destroy", args)
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 107,
+ 'error': 'Bad Request',
+ 'message': 'invalid parameter in request body'
+ }
+
+def test_session_noid(account):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/session/destroy", { 'custom_session_id': '0' * 64 })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 123,
+ 'error': 'Bad Request',
+ 'message': 'unknown device'
+ }
+
+def test_session_destroy_other(account, account2):
+ with pytest.raises(ClientError) as e:
+ account.post_a("/session/destroy", { 'custom_session_id': account2.auth.id })
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 123,
+ 'error': 'Bad Request',
+ 'message': 'unknown device'
+ }
+
+def test_session_destroy_unverified(unverified_account):
+ unverified_account.destroy_session()
+ unverified_account.destroy_session = lambda *args: None
+
+def test_session_destroy(account):
+ s = account.login(account.email, "")
+ s.destroy_session()
diff --git a/tests/test_oauth.py b/tests/test_oauth.py
new file mode 100644
index 0000000..3eb32ac
--- /dev/null
+++ b/tests/test_oauth.py
@@ -0,0 +1,97 @@
+import pytest
+from fxa.errors import ClientError
+
+from api import *
+
+@pytest.fixture
+def oauth():
+ return Oauth()
+
+@pytest.fixture
+def access_token(account):
+ body = {
+ "client_id": "5882386c6d801776",
+ "ttl": 60,
+ "grant_type": "fxa-credentials",
+ "access_type": "online",
+ "scope": "profile",
+ }
+ resp = account.post_a("/oauth/token", body)
+ return resp['access_token']
+
+@pytest.mark.parametrize("args,code,errno,error,message", [
+ ({"access_token": "0"},
+ 400, 109, 'Bad Request', 'invalid request parameter'),
+ ({"refresh_token": "0"},
+ 400, 109, 'Bad Request', 'invalid request parameter'),
+ ({"token": "0"},
+ 400, 109, 'Bad Request', 'invalid request parameter'),
+])
+def test_destroy_invalid(oauth, args, code, errno, error, message):
+ with pytest.raises(ClientError) as e:
+ oauth.post("/destroy", args)
+ assert e.value.details == {'code': code, 'errno': errno, 'error': error, 'message': message}
+
+def test_destroy_access(oauth, access_token):
+ oauth.post("/verify", {'token': access_token})
+ oauth.post("/destroy", {'access_token': access_token})
+ with pytest.raises(ClientError) as e:
+ oauth.post("/verify", {'token': access_token})
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 109,
+ 'error': 'Bad Request',
+ 'message': 'invalid request parameter'
+ }
+
+def test_destroy_refresh(oauth, refresh_token):
+ refresh_token.get_a("/account/devices")
+ oauth.post("/destroy", {'refresh_token': refresh_token.bearer})
+ with pytest.raises(ClientError) as e:
+ refresh_token.get_a("/account/devices")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_destroy_any(oauth, access_token, refresh_token):
+ oauth.post("/verify", {'token': access_token})
+ oauth.post("/destroy", {'token': access_token})
+ with pytest.raises(ClientError) as e:
+ oauth.post("/verify", {'token': access_token})
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 109,
+ 'error': 'Bad Request',
+ 'message': 'invalid request parameter'
+ }
+
+ refresh_token.get_a("/account/devices")
+ oauth.post("/destroy", {'token': refresh_token.bearer})
+ with pytest.raises(ClientError) as e:
+ refresh_token.get_a("/account/devices")
+ assert e.value.details == {
+ 'code': 401,
+ 'errno': 109,
+ 'error': 'Unauthorized',
+ 'message': 'invalid request signature'
+ }
+
+def test_oauth_verify(account, oauth, access_token):
+ assert oauth.post("/verify", {'token': access_token}) == {
+ 'user': account.props['uid'],
+ 'client_id': "5882386c6d801776",
+ 'scope': ['profile'],
+ }
+
+def test_oauth_verify_refresh(oauth, refresh_token):
+ with pytest.raises(ClientError) as e:
+ oauth.post("/verify", {'token': refresh_token.bearer})
+ assert e.value.details == {
+ 'code': 400,
+ 'errno': 109,
+ 'error': 'Bad Request',
+ 'message': 'invalid request parameter'
+ }
diff --git a/tests/test_profile.py b/tests/test_profile.py
new file mode 100644
index 0000000..5e7308a
--- /dev/null
+++ b/tests/test_profile.py
@@ -0,0 +1,134 @@
+import pytest
+from fxa.errors import ClientError
+
+from api import *
+
+@pytest.fixture
+def profile(account):
+ return account.profile()
+
+def test_profile_noauth(profile):
+ with pytest.raises(ClientError) as e:
+ profile.get("/profile")
+ assert e.value.details == {
+ 'code': 403,
+ 'errno': 100,
+ 'error': 'Forbidden',
+ 'message': 'unauthorized'
+ }
+
+def test_display_name_noauth(profile):
+ with pytest.raises(ClientError) as e:
+ profile.post("/display_name", {'displayName': 'foo'})
+ assert e.value.details == {
+ 'code': 403,
+ 'errno': 100,
+ 'error': 'Forbidden',
+ 'message': 'unauthorized'
+ }
+
+def test_avatar_upload_noauth(profile):
+ with pytest.raises(ClientError) as e:
+ profile.post("/avatar/upload", "foo", headers={'content-type': 'image/png'})
+ assert e.value.details == {
+ 'code': 403,
+ 'errno': 100,
+ 'error': 'Forbidden',
+ 'message': 'unauthorized'
+ }
+
+def test_avatar_delete_noauth(profile):
+ with pytest.raises(ClientError) as e:
+ profile.delete("/avatar/00000000000000000000000000000000")
+ assert e.value.details == {
+ 'code': 403,
+ 'errno': 100,
+ 'error': 'Forbidden',
+ 'message': 'unauthorized'
+ }
+
+def test_profile(account, profile):
+ resp = profile.get_a("/profile")
+ assert resp == {
+ 'amrValues': None,
+ 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000',
+ 'avatarDefault': True,
+ 'displayName': None,
+ 'email': account.email,
+ 'locale': None,
+ 'subscriptions': None,
+ 'twoFactorAuthentication': False,
+ 'uid': account.props['uid']
+ }
+
+def test_display_name(account, profile):
+ resp = profile.get_a("/profile")
+ assert resp == {
+ 'amrValues': None,
+ 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000',
+ 'avatarDefault': True,
+ 'displayName': None,
+ 'email': account.email,
+ 'locale': None,
+ 'subscriptions': None,
+ 'twoFactorAuthentication': False,
+ 'uid': account.props['uid']
+ }
+ profile.post_a("/display_name", {'displayName': 'foo'})
+ resp = profile.get_a("/profile")
+ assert resp == {
+ 'amrValues': None,
+ 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000',
+ 'avatarDefault': True,
+ 'displayName': 'foo',
+ 'email': account.email,
+ 'locale': None,
+ 'subscriptions': None,
+ 'twoFactorAuthentication': False,
+ 'uid': account.props['uid']
+ }
+
+def test_avatar(account, profile):
+ resp = profile.get_a("/avatar")
+ assert resp == {
+ 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000',
+ 'avatarDefault': True,
+ 'id': '00000000000000000000000000000000'
+ }
+
+def test_avatar_upload(account, profile):
+ # server does not parse the bytes
+ profile.post_a("/avatar/upload", "foo", headers={'content-type': 'image/png'})
+ resp = profile.get_a("/avatar")
+ new_id = resp['id']
+ assert resp['avatar'] != 'http://localhost:8000/avatars/00000000000000000000000000000000'
+ assert not resp['avatarDefault']
+ assert resp['id'] != '00000000000000000000000000000000'
+ resp = profile.get_a("/profile")
+ assert resp['avatar'] != 'http://localhost:8000/avatars/00000000000000000000000000000000'
+ assert not resp['avatarDefault']
+
+def test_avatar_delete(account, profile):
+ # server does not parse the bytes
+ profile.post_a("/avatar/upload", "foo", headers={'content-type': 'image/png'})
+ resp = profile.get_a("/avatar")
+ new_id = resp['id']
+ profile.delete_a(f"/avatar/{new_id}")
+ resp = profile.get_a("/avatar")
+ assert resp == {
+ 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000',
+ 'avatarDefault': True,
+ 'id': '00000000000000000000000000000000'
+ }
+ resp = profile.get_a("/profile")
+ assert resp == {
+ 'amrValues': None,
+ 'avatar': 'http://localhost:8000/avatars/00000000000000000000000000000000',
+ 'avatarDefault': True,
+ 'displayName': None,
+ 'email': account.email,
+ 'locale': None,
+ 'subscriptions': None,
+ 'twoFactorAuthentication': False,
+ 'uid': account.props['uid']
+ }
diff --git a/tests/test_push.py b/tests/test_push.py
new file mode 100644
index 0000000..e6d732a
--- /dev/null
+++ b/tests/test_push.py
@@ -0,0 +1,147 @@
+import pytest
+
+from api import *
+
+def test_account_destroy(account, push_server):
+ dev = Device(account, "dev", pcb=push_server.good("09d0114e-3b23-4ba3-8474-efac7433e3ba"))
+ account.destroy_account(account.email, "")
+ p = push_server.wait()
+ assert p[0] == "/09d0114e-3b23-4ba3-8474-efac7433e3ba"
+ assert dev.decrypt(p[2]) == {
+ 'command': 'fxaccounts:account_destroyed',
+ 'data': {'uid': account.props['uid']},
+ 'version': 1,
+ }
+ assert push_server.done()
+
+def test_account_verify(client, push_server, mail_server):
+ account = client.create_account("test@test", "")
+ try:
+ (to, body) = mail_server.wait()
+ assert to == ["test@test"]
+ data = json.loads(base64.urlsafe_b64decode(body.split("#/verify/", maxsplit=1)[1]).decode('utf8'))
+ dev = Device(account, "dev", pcb=push_server.good("8accbe08-4040-44c2-8fd9-cf2669b56cb1"))
+ account.post_a('/recovery_email/verify_code', { 'uid': data['uid'], 'code': data['code'] })
+ p = push_server.wait()
+ assert p[0] == "/8accbe08-4040-44c2-8fd9-cf2669b56cb1"
+ assert p[2] == b''
+ assert push_server.done()
+ finally:
+ account.destroy_account(account.email, "")
+
+def test_session_destroy(client, account, push_server):
+ dev1 = Device(account, "dev1")
+ session = client.login(account.email, "")
+ dev2 = Device(session, "dev2")
+ dev1.update_pcb(push_server.good("e6d21a00-9e5e-4d21-92bc-90860cba836e"))
+ session.destroy_session()
+ p = push_server.wait()
+ assert p[0] == "/e6d21a00-9e5e-4d21-92bc-90860cba836e"
+ assert dev1.decrypt(p[2]) == {
+ 'command': 'fxaccounts:device_disconnected',
+ 'data': {'id': dev2.id},
+ 'version': 1,
+ }
+ assert push_server.done()
+
+def test_device_connected(client, account, push_server):
+ dev1 = Device(account, "dev1", pcb=push_server.good("236b8205-daee-4879-b911-64b1f4fd8fd7"))
+ session = client.login(account.email, "")
+ dev2 = Device(session, "dev2")
+ p = push_server.wait()
+ assert p[0] == "/236b8205-daee-4879-b911-64b1f4fd8fd7"
+ assert dev1.decrypt(p[2]) == {
+ 'command': 'fxaccounts:device_connected',
+ 'data': {'deviceName': 'dev2'},
+ 'version': 1,
+ }
+ assert push_server.done()
+
+def test_device_invoke(account, login, push_server):
+ dev = Device(account, "dev1", commands={'a':'a'}, pcb=push_server.good("3610b071-e2ef-4daa-a4e3-eaa74e50f2a0"))
+ account.post_a("/account/devices/invoke_command", {
+ "target": dev.id,
+ "command": "a",
+ "payload": {"data": "foo"},
+ "ttl": 10,
+ })
+ p = push_server.wait()
+ assert p[0] == "/3610b071-e2ef-4daa-a4e3-eaa74e50f2a0"
+ msg = dev.decrypt(p[2])
+ # NOTE needed because index is unpredictable due to pg sequence use
+ del msg['data']['index']
+ del msg['data']['url']
+ assert msg == {
+ 'command': 'fxaccounts:command_received',
+ 'data': {'command': 'a', 'sender': dev.id},
+ 'version': 1,
+ }
+ assert push_server.done()
+
+def test_expiry(account, login, push_server):
+ dev = Device(account, "dev1", commands={'a':'a'}, pcb=push_server.bad("59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e"))
+ account.post_a("/account/devices/invoke_command", {
+ "target": dev.id,
+ "command": "a",
+ "payload": {"data": "foo"},
+ "ttl": 10,
+ })
+ p = push_server.wait()
+ assert p[0] == "/err/59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e"
+ account.post_a("/account/devices/invoke_command", {
+ "target": dev.id,
+ "command": "a",
+ "payload": {"data": "foo"},
+ "ttl": 10,
+ })
+ with pytest.raises(queue.Empty):
+ push_server.wait()
+ dev.update_pcb(push_server.good("59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e"))
+ account.post_a("/account/devices/invoke_command", {
+ "target": dev.id,
+ "command": "a",
+ "payload": {"data": "foo"},
+ "ttl": 10,
+ })
+ p = push_server.wait()
+ assert p[0] == "/59ba8fcc-f3b0-4b1f-ac27-36d66e022d1e"
+ assert push_server.done()
+
+def test_device_notify(account, login, push_server):
+ dev1 = Device(account, "dev1")
+ dev2 = Device(login, "dev2")
+ dev1.update_pcb(push_server.good("738ac7e3-96ef-461c-880c-0af20e311354"))
+ dev2.update_pcb(push_server.good("85a98191-9486-46e2-877a-1152e3d4af4e"))
+ account.post_a("/account/devices/notify", {
+ "to": "all",
+ "_endpointAction": "accountVerify",
+ "excluded": [dev2.id],
+ "payload": {'a':1},
+ "TTL": 0,
+ })
+ p = push_server.wait()
+ assert p[0] == "/738ac7e3-96ef-461c-880c-0af20e311354"
+ assert dev1.decrypt(p[2]) == {'a':1}
+ account.post_a("/account/devices/notify", {
+ "to": [dev2.id],
+ "_endpointAction": "accountVerify",
+ "payload": {'a':2},
+ "TTL": 0,
+ })
+ p = push_server.wait()
+ assert p[0] == "/85a98191-9486-46e2-877a-1152e3d4af4e"
+ assert dev2.decrypt(p[2]) == {'a':2}
+ assert push_server.done()
+
+def test_profile(account, push_server):
+ dev = Device(account, "dev", pcb=push_server.good("12608154-8942-4f1c-9de2-a56465d27d6e"))
+ profile = account.profile()
+ profile.post_a("/display_name", {"displayName": "foo"})
+ p = push_server.wait()
+ assert p[0] == "/12608154-8942-4f1c-9de2-a56465d27d6e"
+ assert dev.decrypt(p[2]) == {'command': 'fxaccounts:profile_updated', 'version': 1}
+ profile.post_a("/avatar/upload", "doesn't parse the image", headers={'content-type': 'image/png'})
+ p = push_server.wait()
+ assert p[0] == "/12608154-8942-4f1c-9de2-a56465d27d6e"
+ assert dev.decrypt(p[2]) == {'command': 'fxaccounts:profile_updated', 'version': 1}
+ assert push_server.done()
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..f36654f
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,392 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script src="/js/main" type="module"></script>
+ <style>
+ @keyframes fade-blink {
+ 0% { opacity: 100%; }
+ 100% { opacity: 0%; }
+ }
+
+ #message-modal.error {
+ border: 2px solid red;
+ background: #fff0f0;
+ }
+
+ #message-modal-content {
+ text-align: center;
+ }
+
+ #message-modal-content p {
+ font-size: 150%;
+ }
+
+ #message-modal.animate #message-modal-content {
+ animation: fade-blink 1s ease-in-out infinite alternate;
+ }
+
+ #settings-avatar-img {
+ max-width: 200px;
+ max-height: 200px;
+ }
+
+ [hidden] {
+ display: none !important;
+ }
+
+ input[type=email]:invalid {
+ background: #ff8080;
+ }
+
+ .container.dialog {
+ display: flex;
+ width: 30em;
+ max-width: 100vw;
+ justify-content: center;
+ align-content: center;
+ text-align: center;
+ flex-direction: column;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ }
+
+ .container > ol {
+ margin-left: 1em;
+ }
+
+ hr {
+ width: 100%;
+ }
+
+ form {
+ display: flex;
+ justify-content: center;
+ align-content: center;
+ flex-direction: column;
+ }
+
+ table {
+ display: block;
+ padding: 1em;
+ border-collapse: collapse;
+ }
+
+ td {
+ padding: 0.5ex 1ch;
+ }
+ td:not(:last-child) {
+ border-right: 1px dashed black;
+ }
+ tr:not(:last-child) {
+ border-bottom: 1px dashed black;
+ }
+ thead > tr {
+ border-bottom: 1px solid black;
+ }
+
+ .cwts-container {
+ display: flex;
+ text-align: left;
+ flex-flow: row wrap;
+ }
+
+ .cwts-container div {
+ min-width: 33%;
+ }
+
+ .settings-container {
+ margin: 2em;
+ }
+
+ .settings-container form {
+ display: block;
+ }
+
+ .disabled {
+ color: grey;
+ font-style: italic;
+ }
+ </style>
+ <template id="tpl-fenix-style">
+ <style>
+ body { font-size: 300%; }
+ input { font-size: 100%; }
+ </style>
+ </template>
+ </head>
+ <body>
+ <noscript>this thing requires javascript!</noscript>
+
+ <dialog id="message-modal">
+ <div id="message-modal-content">
+ <p id="message"></p>
+ <a href="#" id="message-modal-close" hidden>close</a>
+ </div>
+ </dialog>
+
+ <div id="desktop-signedin" class="container dialog" hidden>
+ successfully signed in!
+ </div>
+
+ <div id="desktop-deleted" class="container dialog" hidden>
+ account has been deleted.
+ </div>
+
+ <div id="desktop-signup" class="container dialog" hidden>
+ <form id="frm-signup">
+ <label for="frm-signup-email">email</label>
+ <input id="frm-signup-email" type="email" name="email" maxlength="256" value="">
+ <label for="frm-signup-password">password</label>
+ <input id="frm-signup-password" type="password" name="password">
+ <input type="submit" name="submit" value="sign up">
+ </form>
+ </div>
+
+ <div id="desktop-signup-unverified" class="container dialog" hidden>
+ <p>signup completed! please verify your account by clicking the
+ link in the email you've just received.</p>
+ <p>if you haven't received anything, go to Settings/Sync and resend
+ the verification code.</p>
+ </div>
+
+ <div id="desktop-signin" class="container dialog" hidden>
+ <form id="frm-signin">
+ <label for="frm-signin-email">email</label>
+ <input id="frm-signin-email" type="email" name="email" maxlength="256" value="">
+ <label for="frm-signin-password">password</label>
+ <input id="frm-signin-password" type="password" name="password">
+ <input type="submit" name="submit" value="sign in">
+ <hr>
+ <div>
+ <a href="#" class="_signup">sign up</a> |
+ <a href="#" class="_reset">reset password</a>
+ </div>
+ </form>
+ </div>
+
+ <div id="desktop-signin-confirm" class="container dialog" hidden>
+ <p>signin completed! please verify your session by copying the code
+ you've just received as an email into this box.</p>
+ <form id="frm-signin-confirm">
+ <label for="frm-signin-confirm-code">code</label>
+ <input id="frm-signin-confirm-code" type="text" name="code" maxlength="6">
+ <input type="submit" name="submit" value="confirm signin">
+ <hr>
+ <a href="#" class="_resend">no email appeared? resend code</a>
+ </form>
+ </div>
+
+ <div id="desktop-resetpw" class="container dialog" hidden>
+ <form id="frm-resetpw">
+ <label for="frm-resetpw-email">email</label>
+ <input id="frm-resetpw-email" type="email" name="email" maxlength="256" value="">
+ <input type="submit" name="submit" value="send reset code">
+ </form>
+ </div>
+
+ <div id="desktop-resetpw-newpw" class="container dialog" hidden>
+ <form id="frm-resetpw-newpw">
+ <table>
+ <tr>
+ <td><label for="frm-resetpw-newpw-old">old password</label></td>
+ <td><input id="frm-resetpw-newpw-old" type="password" name="old"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-resetpw-newpw-new">new password</label></td>
+ <td><input id="frm-resetpw-newpw-new" type="password" name="new"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-resetpw-newpw-new-confirm">new password (confirm)</label></td>
+ <td>
+ <input id="frm-resetpw-newpw-new-confirm" type="password" name="new-confirm">
+ </td>
+ </tr>
+ </table>
+ <div>
+ <input type="submit" name="submit" value="change password">
+ </div>
+ </form>
+ </div>
+
+ <div id="desktop-cwts" class="container dialog" hidden>
+ <form id="frm-cwts">
+ <p>choose what to sync:</p>
+ <div class="cwts-container">
+ <div>
+ <input type="checkbox" id="frm-cwts-addons" name="addons">
+ <label for="frm-cwts-addons">add-ons</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-addresses" name="addresses">
+ <label for="frm-cwts-addresses">addresses</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-bookmarks" name="bookmarks">
+ <label for="frm-cwts-bookmarks">bookmarks</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-creditcards" name="creditcards">
+ <label for="frm-cwts-creditcards">credit cards</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-history" name="history">
+ <label for="frm-cwts-history">history</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-passwords" name="passwords">
+ <label for="frm-cwts-passwords">passwords</label>
+ </div>
+ <!-- NOTE the spec says this key is named `preferences` -->
+ <div>
+ <input type="checkbox" id="frm-cwts-prefs" name="prefs">
+ <label for="frm-cwts-prefs">preferences</label>
+ </div>
+ <div>
+ <input type="checkbox" id="frm-cwts-tabs" name="tabs">
+ <label for="frm-cwts-tabs">open tabs</label>
+ </div>
+ </div>
+ <input type="submit" name="submit" value="start syncing">
+ </form>
+ </div>
+
+ <div id="desktop-settings" class="container" hidden>
+ <nav>
+ <a href="#/settings">settings</a> |
+ <a href="#/settings/change-password">change password</a> |
+ <a href="#/settings/destroy">delete account</a>
+ </nav>
+ <div class="settings-container tab" id="desktop-settings-main" hidden>
+ <form id="frm-settings-avatar">
+ <img id="settings-avatar-img">
+ <input type="file" accept="image/*" id="settings-avatar">
+ <input type="submit" id="settings-avatar-save" value="save">
+ </form>
+ <form id="frm-settings-name">
+ <label for="settings-name">user name:</label>
+ <input type="text" id="settings-name" maxlength="256">
+ <input type="submit" id="settings-name-save" value="save">
+ </form>
+ <table id="settings-clients">
+ <thead>
+ <tr>
+ <td>name</td>
+ <td>deviceType</td>
+ <td>createdTime</td>
+ <td>lastAccessTime</td>
+ <td>oauth?</td>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+ <div class="settings-container tab" id="desktop-settings-destroy" hidden>
+ <p>deleting your account requires confirmation.</p>
+ <form id="frm-settings-destroy">
+ <label for="frm-settings-destroy-email">email</label>
+ <input id="frm-settings-destroy-email" type="email"
+ name="email" maxlength="256" value="">
+ <label for="frm-settings-destroy-password">password</label>
+ <input id="frm-settings-destroy-password" type="password" name="password">
+ <input type="submit" name="submit" value="really delete account">
+ </form>
+ </div>
+ <div class="settings-container tab" id="desktop-settings-chpw" hidden>
+ <form id="frm-settings-chpw">
+ <table>
+ <tr>
+ <td><label for="frm-settings-chpw-old">old password</label></td>
+ <td><input id="frm-settings-chpw-old" type="password" name="old"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-settings-chpw-new">new password</label></td>
+ <td><input id="frm-settings-chpw-new" type="password" name="new"></td>
+ </tr>
+ <tr>
+ <td><label for="frm-settings-chpw-new-confirm">new password (confirm)</label></td>
+ <td>
+ <input id="frm-settings-chpw-new-confirm" type="password" name="new-confirm">
+ </td>
+ </tr>
+ </table>
+ <div>
+ <input type="submit" name="submit" value="change password">
+ </div>
+ </form>
+ </div>
+ </div>
+
+ <div id="desktop-generate-invite" class="container dialog" hidden>
+ <form id="frm-generate-invite">
+ <label for="frm-generate-invite-email">invite valid for</label>
+ <select id="frm-generate-invite-email" name="ttl" maxlength="256">
+ <option value="1">one hour</option>
+ <option value="24">one day</option>
+ <option value="168">one week</option>
+ </select>
+ <input type="submit" name="submit" value="generate invite">
+ </form>
+ <div id="desktop-generate-invite-result" hidden>
+ invite link is <a id="desktop-generate-invite-result-link"></a>
+ </div>
+ </div>
+
+ <div id="fenix-signin-warning" class="container" hidden>
+ <p>it looks like you're trying to sign in to sync from an android firefox instance.
+ we're very sorry, but this is going to hurt a bit.</p>
+ <p>to sign in you'll need to follow these steps:</p>
+ <ol>
+ <li>
+ enable USB debugging in the android:
+ <ol>
+ <li>go to setting → about phone</li>
+ <li>scroll to the build number</li>
+ <li>tap it until a "your are new a developer" message appears</li>
+ <li>go to settings → system → developer options</li>
+ <li>scroll down to "USB debugging", enable it</li>
+ </ol>
+ </li>
+ <li>enable USB debugging in the android firefox settings</li>
+ <li>connect your android device to a PC for USB debugging</li>
+ <li>on this PC, open firefox and go to <pre>about:debugging</pre></li>
+ <li>enable USB devices</li>
+ <li>connect to your android device</li>
+ <li>open this page in a normal tab, not through the sign-in interface.
+ this is very important, if there's no normal tab with this URL open
+ <em>signin will not work</em></li>
+ <li>open the login page through the sign-in interface as well</li>
+ <li>in the PC debugger, inspect the new tab that has just appeared</li>
+ <li><a href="#" id="fenix-signin-dialog-show">actually sign in</a>.
+ clicking this link will lead away from this page, finishing sign-in
+ will bring you back.</li>
+ <li>go to the javascript debug console of the tab we inspected previously,
+ copy the long block of code</li>
+ <li>locate the <pre>Firefox Accounts WebChannel</pre> extension in the
+ debugger and inspect it. this may fail, if it does go to the setup
+ tab of the debugger and disable USB devices, enable then, reconnect
+ to your devices, and repeat this step
+ <p><b>WARNING:</b> this is known to not work on firefox 102. if you get
+ a blank tab instead of a tab with debug tools as seen earlier you
+ may have to downgrade your android firefox to 101 to log in.
+ unfortunately this seems to require uninstalling and reinstalling
+ firefox, which wipes your data!</p>
+ </li>
+ <li>go to the javascript debug console, paste the block of code, and run it</li>
+ <li>enjoy sync!</li>
+ </ol>
+ </div>
+
+ <div id="fenix-signin" class="container dialog" hidden>
+ <form id="frm-fenix-signin">
+ <label for="email">email</label>
+ <input type="text" name="email" value="">
+ <label for="password">password</label>
+ <input type="password" name="password">
+ <input type="submit" name="submit" value="do the fenix dance">
+ </form>
+ </div>
+ </body>
+</html>
diff --git a/web/js/browser/browser.js b/web/js/browser/browser.js
new file mode 100644
index 0000000..aa8b444
--- /dev/null
+++ b/web/js/browser/browser.js
@@ -0,0 +1,4 @@
+import AuthClient from './lib/client';
+export * from './lib/client';
+export * from './lib/recoveryKey';
+export default AuthClient;
diff --git a/web/js/browser/lib/client.js b/web/js/browser/lib/client.js
new file mode 100644
index 0000000..fadbdd9
--- /dev/null
+++ b/web/js/browser/lib/client.js
@@ -0,0 +1,792 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __rest = (this && this.__rest) || function (s, e) {
+ var t = {};
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
+ t[p] = s[p];
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
+ t[p[i]] = s[p[i]];
+ }
+ return t;
+};
+import * as crypto from './crypto';
+import * as hawk from './hawk';
+var ERRORS;
+(function (ERRORS) {
+ ERRORS[ERRORS["INVALID_TIMESTAMP"] = 111] = "INVALID_TIMESTAMP";
+ ERRORS[ERRORS["INCORRECT_EMAIL_CASE"] = 120] = "INCORRECT_EMAIL_CASE";
+})(ERRORS || (ERRORS = {}));
+var tokenType;
+(function (tokenType) {
+ tokenType["sessionToken"] = "sessionToken";
+ tokenType["passwordForgotToken"] = "passwordForgotToken";
+ tokenType["keyFetchToken"] = "keyFetchToken";
+ tokenType["accountResetToken"] = "accountResetToken";
+ tokenType["passwordChangeToken"] = "passwordChangeToken";
+})(tokenType || (tokenType = {}));
+var AUTH_PROVIDER;
+(function (AUTH_PROVIDER) {
+ AUTH_PROVIDER["GOOGLE"] = "google";
+ AUTH_PROVIDER["APPLE"] = "apple";
+})(AUTH_PROVIDER || (AUTH_PROVIDER = {}));
+function langHeader(lang) {
+ return new Headers(lang
+ ? {
+ 'Accept-Language': lang,
+ }
+ : {});
+}
+function pathWithKeys(path, keys) {
+ return `${path}${keys ? '?keys=true' : ''}`;
+}
+function fetchOrTimeout(input, init = {}, timeout) {
+ return __awaiter(this, void 0, void 0, function* () {
+ let id = 0;
+ if (typeof AbortController !== 'undefined') {
+ const aborter = new AbortController();
+ init.signal = aborter.signal;
+ id = setTimeout((() => aborter.abort()), timeout);
+ }
+ try {
+ return yield fetch(input, init);
+ }
+ finally {
+ if (id) {
+ clearTimeout(id);
+ }
+ }
+ });
+}
+function cleanStringify(value) {
+ // remove keys with null values
+ return JSON.stringify(value, (_, v) => (v == null ? undefined : v));
+}
+export default class AuthClient {
+ constructor(authServerUri, options = {}) {
+ if (new RegExp(`/${AuthClient.VERSION}$`).test(authServerUri)) {
+ this.uri = authServerUri;
+ }
+ else {
+ this.uri = `${authServerUri}/${AuthClient.VERSION}`;
+ }
+ this.localtimeOffsetMsec = 0;
+ this.timeout = options.timeout || 30000;
+ }
+ static create(authServerUri) {
+ return __awaiter(this, void 0, void 0, function* () {
+ if (typeof TextEncoder === 'undefined') {
+ yield import(
+ // @ts-ignore
+ /* webpackChunkName: "fast-text-encoding" */ 'fast-text-encoding');
+ }
+ yield crypto.checkWebCrypto();
+ return new AuthClient(authServerUri);
+ });
+ }
+ url(path) {
+ return `${this.uri}${path}`;
+ }
+ request(method, path, payload, headers = new Headers()) {
+ return __awaiter(this, void 0, void 0, function* () {
+ headers.set('Content-Type', 'application/json');
+ const response = yield fetchOrTimeout(this.url(path), {
+ method,
+ headers,
+ body: cleanStringify(payload),
+ }, this.timeout);
+ let result = yield response.text();
+ try {
+ result = JSON.parse(result);
+ }
+ catch (e) { }
+ if (result.errno) {
+ throw result;
+ }
+ if (!response.ok) {
+ throw {
+ error: 'Unknown error',
+ message: result,
+ errno: 999,
+ code: response.status,
+ };
+ }
+ return result;
+ });
+ }
+ hawkRequest(method, path, token, kind, payload, extraHeaders = new Headers()) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const makeHeaders = () => __awaiter(this, void 0, void 0, function* () {
+ const headers = yield hawk.header(method, this.url(path), token, kind, {
+ payload: cleanStringify(payload),
+ contentType: 'application/json',
+ localtimeOffsetMsec: this.localtimeOffsetMsec,
+ });
+ for (const [name, value] of extraHeaders) {
+ headers.set(name, value);
+ }
+ return headers;
+ });
+ try {
+ return yield this.request(method, path, payload, yield makeHeaders());
+ }
+ catch (e) {
+ if (e.errno === ERRORS.INVALID_TIMESTAMP) {
+ const serverTime = e.serverTime * 1000 || Date.now();
+ this.localtimeOffsetMsec = serverTime - Date.now();
+ return this.request(method, path, payload, yield makeHeaders());
+ }
+ throw e;
+ }
+ });
+ }
+ sessionGet(path, sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('GET', path, sessionToken, tokenType.sessionToken);
+ });
+ }
+ sessionPost(path, sessionToken, payload, headers) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('POST', path, sessionToken, tokenType.sessionToken, payload, headers);
+ });
+ }
+ sessionPut(path, sessionToken, payload, headers) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('PUT', path, sessionToken, tokenType.sessionToken, payload, headers);
+ });
+ }
+ signUp(email, password, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payloadOptions = (_a) => {
+ var { keys, lang } = _a, rest = __rest(_a, ["keys", "lang"]);
+ return rest;
+ };
+ const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options));
+ const accountData = yield this.request('POST', pathWithKeys('/account/create', options.keys), payload, langHeader(options.lang));
+ if (options.keys) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ signIn(email, password, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payloadOptions = (_a) => {
+ var { keys } = _a, rest = __rest(_a, ["keys"]);
+ return rest;
+ };
+ const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options));
+ try {
+ const accountData = yield this.request('POST', pathWithKeys('/account/login', options.keys), payload);
+ if (options.keys) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ options.originalLoginEmail = email;
+ return this.signIn(error.email, password, options);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ verifyCode(uid, code, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/recovery_email/verify_code', Object.assign({ uid,
+ code }, options));
+ });
+ }
+ recoveryEmailStatus(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/recovery_email/status', sessionToken);
+ });
+ }
+ recoveryEmailResendCode(sessionToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payloadOptions = (_a) => {
+ var { lang } = _a, rest = __rest(_a, ["lang"]);
+ return rest;
+ };
+ return this.sessionPost('/recovery_email/resend_code', sessionToken, payloadOptions(options), langHeader(options.lang));
+ });
+ }
+ passwordForgotSendCode(email, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payloadOptions = (_a) => {
+ var { lang } = _a, rest = __rest(_a, ["lang"]);
+ return rest;
+ };
+ const payload = Object.assign({ email }, payloadOptions(options));
+ return this.request('POST', '/password/forgot/send_code', payload, langHeader(options.lang));
+ });
+ }
+ passwordForgotResendCode(email, passwordForgotToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payloadOptions = (_a) => {
+ var { lang } = _a, rest = __rest(_a, ["lang"]);
+ return rest;
+ };
+ const payload = Object.assign({ email }, payloadOptions(options));
+ return this.hawkRequest('POST', '/password/forgot/resend_code', passwordForgotToken, tokenType.passwordForgotToken, payload, langHeader(options.lang));
+ });
+ }
+ passwordForgotVerifyCode(code, passwordForgotToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = Object.assign({ code }, options);
+ return this.hawkRequest('POST', '/password/forgot/verify_code', passwordForgotToken, tokenType.passwordForgotToken, payload);
+ });
+ }
+ passwordForgotStatus(passwordForgotToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('GET', '/password/forgot/status', passwordForgotToken, tokenType.passwordForgotToken);
+ });
+ }
+ accountReset(email, newPassword, accountResetToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, newPassword);
+ const payloadOptions = (_a) => {
+ var { keys } = _a, rest = __rest(_a, ["keys"]);
+ return rest;
+ };
+ const payload = Object.assign({ authPW: credentials.authPW }, payloadOptions(options));
+ const accountData = yield this.hawkRequest('POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload);
+ if (options.keys && accountData.keyFetchToken) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ finishSetup(token, email, newPassword) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, newPassword);
+ const payload = {
+ token,
+ authPW: credentials.authPW,
+ };
+ return yield this.request('POST', '/account/finish_setup', payload);
+ });
+ }
+ verifyAccountThirdParty(code, provider = AUTH_PROVIDER.GOOGLE, metricsContext = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = {
+ code,
+ provider,
+ metricsContext,
+ };
+ return yield this.request('POST', '/linked_account/login', payload);
+ });
+ }
+ unlinkThirdParty(sessionToken, providerId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ let provider;
+ switch (providerId) {
+ case 2: {
+ provider = AUTH_PROVIDER.APPLE;
+ break;
+ }
+ default: {
+ provider = AUTH_PROVIDER.GOOGLE;
+ }
+ }
+ return yield this.sessionPost('/linked_account/unlink', sessionToken, {
+ provider,
+ });
+ });
+ }
+ accountKeys(keyFetchToken, unwrapBKey) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield hawk.deriveHawkCredentials(keyFetchToken, 'keyFetchToken');
+ const keyData = yield this.hawkRequest('GET', '/account/keys', keyFetchToken, tokenType.keyFetchToken);
+ const keys = yield crypto.unbundleKeyFetchResponse(credentials.bundleKey, keyData.bundle);
+ return {
+ kA: keys.kA,
+ kB: crypto.unwrapKB(keys.wrapKB, unwrapBKey),
+ };
+ });
+ }
+ accountDestroy(email, password, options = {}, sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payload = {
+ email,
+ authPW: credentials.authPW,
+ };
+ try {
+ if (sessionToken) {
+ return yield this.sessionPost('/account/destroy', sessionToken, payload);
+ }
+ else {
+ return yield this.request('POST', '/account/destroy', payload);
+ }
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ return this.accountDestroy(error.email, password, options, sessionToken);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ accountStatus(uid) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('GET', `/account/status?uid=${uid}`);
+ });
+ }
+ accountStatusByEmail(email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/account/status', { email });
+ });
+ }
+ accountProfile(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/profile', sessionToken);
+ });
+ }
+ account(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account', sessionToken);
+ });
+ }
+ sessionDestroy(sessionToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/destroy', sessionToken, options);
+ });
+ }
+ sessionStatus(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/session/status', sessionToken);
+ });
+ }
+ sessionVerifyCode(sessionToken, code, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify_code', sessionToken, Object.assign({ code }, options));
+ });
+ }
+ sessionResendVerifyCode(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/resend_code', sessionToken, {});
+ });
+ }
+ sessionReauth(sessionToken, email, password, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, password);
+ const payloadOptions = (_a) => {
+ var { keys } = _a, rest = __rest(_a, ["keys"]);
+ return rest;
+ };
+ const payload = Object.assign({ email, authPW: credentials.authPW }, payloadOptions(options));
+ try {
+ const accountData = yield this.sessionPost(pathWithKeys('/session/reauth', options.keys), sessionToken, payload);
+ if (options.keys) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ options.originalLoginEmail = email;
+ return this.sessionReauth(sessionToken, error.email, password, options);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ certificateSign(sessionToken, publicKey, duration, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = {
+ publicKey,
+ duration,
+ };
+ return this.sessionPost(`/certificate/sign${options.service
+ ? `?service=${encodeURIComponent(options.service)}`
+ : ''}`, sessionToken, payload);
+ });
+ }
+ passwordChange(email, oldPassword, newPassword, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const oldCredentials = yield this.passwordChangeStart(email, oldPassword);
+ const keys = yield this.accountKeys(oldCredentials.keyFetchToken, oldCredentials.unwrapBKey);
+ const newCredentials = yield crypto.getCredentials(oldCredentials.email, newPassword);
+ const wrapKb = crypto.unwrapKB(keys.kB, newCredentials.unwrapBKey);
+ const sessionToken = options.sessionToken
+ ? (yield hawk.deriveHawkCredentials(options.sessionToken, 'sessionToken'))
+ .id
+ : undefined;
+ const payload = {
+ wrapKb,
+ authPW: newCredentials.authPW,
+ sessionToken,
+ };
+ const accountData = yield this.hawkRequest('POST', pathWithKeys('/password/change/finish', options.keys), oldCredentials.passwordChangeToken, tokenType.passwordChangeToken, payload);
+ if (options.keys && accountData.keyFetchToken) {
+ accountData.unwrapBKey = newCredentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ passwordChangeStart(email, oldPassword, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const oldCredentials = yield crypto.getCredentials(email, oldPassword);
+ try {
+ const passwordData = yield this.request('POST', '/password/change/start', {
+ email,
+ oldAuthPW: oldCredentials.authPW,
+ });
+ return {
+ authPW: oldCredentials.authPW,
+ unwrapBKey: oldCredentials.unwrapBKey,
+ email: email,
+ keyFetchToken: passwordData.keyFetchToken,
+ passwordChangeToken: passwordData.passwordChangeToken,
+ };
+ }
+ catch (error) {
+ if (error &&
+ error.email &&
+ error.errno === ERRORS.INCORRECT_EMAIL_CASE &&
+ !options.skipCaseError) {
+ options.skipCaseError = true;
+ return this.passwordChangeStart(error.email, oldPassword, options);
+ }
+ else {
+ throw error;
+ }
+ }
+ });
+ }
+ createPassword(sessionToken, email, newPassword) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const newCredentials = yield crypto.getCredentials(email, newPassword);
+ const payload = {
+ authPW: newCredentials.authPW,
+ };
+ return this.sessionPost('/password/create', sessionToken, payload);
+ });
+ }
+ getRandomBytes() {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/get_random_bytes');
+ });
+ }
+ deviceRegister(sessionToken, name, type, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = Object.assign({ name,
+ type }, options);
+ return this.sessionPost('/account/device', sessionToken, payload);
+ });
+ }
+ deviceUpdate(sessionToken, id, name, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = Object.assign({ id,
+ name }, options);
+ return this.sessionPost('/account/device', sessionToken, payload);
+ });
+ }
+ deviceDestroy(sessionToken, id) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/account/device/destroy', sessionToken, { id });
+ });
+ }
+ deviceList(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/devices', sessionToken);
+ });
+ }
+ sessions(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/sessions', sessionToken);
+ });
+ }
+ securityEvents(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/securityEvents', sessionToken);
+ });
+ }
+ deleteSecurityEvents(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('DELETE', '/securityEvents', sessionToken, tokenType.sessionToken, {});
+ });
+ }
+ attachedClients(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/account/attached_clients', sessionToken);
+ });
+ }
+ attachedClientDestroy(sessionToken, clientInfo) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/account/attached_client/destroy', sessionToken, {
+ clientId: clientInfo.clientId,
+ deviceId: clientInfo.deviceId,
+ refreshTokenId: clientInfo.refreshTokenId,
+ sessionTokenId: clientInfo.sessionTokenId,
+ });
+ });
+ }
+ sendUnblockCode(email, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/account/login/send_unblock_code', Object.assign({ email }, options));
+ });
+ }
+ rejectUnblockCode(uid, unblockCode) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/account/login/reject_unblock_code', {
+ uid,
+ unblockCode,
+ });
+ });
+ }
+ consumeSigninCode(code, flowId, flowBeginTime, deviceId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/signinCodes/consume', {
+ code,
+ metricsContext: {
+ deviceId,
+ flowId,
+ flowBeginTime,
+ },
+ });
+ });
+ }
+ createSigninCode(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/signinCodes', sessionToken, {});
+ });
+ }
+ createCadReminder(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/emails/reminders/cad', sessionToken, {});
+ });
+ }
+ recoveryEmails(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/recovery_emails', sessionToken);
+ });
+ }
+ recoveryEmailCreate(sessionToken, email, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email', sessionToken, Object.assign({ email }, options));
+ });
+ }
+ recoveryEmailDestroy(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/destroy', sessionToken, { email });
+ });
+ }
+ recoveryEmailSetPrimaryEmail(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/set_primary', sessionToken, {
+ email,
+ });
+ });
+ }
+ recoveryEmailSecondaryVerifyCode(sessionToken, email, code) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/secondary/verify_code', sessionToken, { email, code });
+ });
+ }
+ recoveryEmailSecondaryResendCode(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recovery_email/secondary/resend_code', sessionToken, { email });
+ });
+ }
+ createTotpToken(sessionToken, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/totp/create', sessionToken, options);
+ });
+ }
+ deleteTotpToken(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/totp/destroy', sessionToken, {});
+ });
+ }
+ checkTotpTokenExists(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/totp/exists', sessionToken);
+ });
+ }
+ verifyTotpCode(sessionToken, code, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify/totp', sessionToken, Object.assign({ code }, options));
+ });
+ }
+ replaceRecoveryCodes(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionGet('/recoveryCodes', sessionToken);
+ });
+ }
+ updateRecoveryCodes(sessionToken, recoveryCodes) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPut('/recoveryCodes', sessionToken, { recoveryCodes });
+ });
+ }
+ consumeRecoveryCode(sessionToken, code) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify/recoveryCode', sessionToken, {
+ code,
+ });
+ });
+ }
+ createRecoveryKey(sessionToken, recoveryKeyId, recoveryData, enabled) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recoveryKey', sessionToken, {
+ recoveryKeyId,
+ recoveryData,
+ enabled,
+ });
+ });
+ }
+ getRecoveryKey(accountResetToken, recoveryKeyId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('GET', `/recoveryKey/${recoveryKeyId}`, accountResetToken, tokenType.accountResetToken);
+ });
+ }
+ resetPasswordWithRecoveryKey(accountResetToken, email, newPassword, recoveryKeyId, keys, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield crypto.getCredentials(email, newPassword);
+ const newWrapKb = crypto.unwrapKB(keys.kB, credentials.unwrapBKey);
+ const payload = {
+ wrapKb: newWrapKb,
+ authPW: credentials.authPW,
+ sessionToken: options.sessionToken,
+ recoveryKeyId,
+ };
+ const accountData = yield this.hawkRequest('POST', pathWithKeys('/account/reset', options.keys), accountResetToken, tokenType.accountResetToken, payload);
+ if (options.keys && accountData.keyFetchToken) {
+ accountData.unwrapBKey = credentials.unwrapBKey;
+ }
+ return accountData;
+ });
+ }
+ deleteRecoveryKey(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.hawkRequest('DELETE', '/recoveryKey', sessionToken, tokenType.sessionToken, {});
+ });
+ }
+ recoveryKeyExists(sessionToken, email) {
+ return __awaiter(this, void 0, void 0, function* () {
+ if (sessionToken) {
+ return this.sessionPost('/recoveryKey/exists', sessionToken, { email });
+ }
+ return this.request('POST', '/recoveryKey/exists', { email });
+ });
+ }
+ verifyRecoveryKey(sessionToken, recoveryKeyId) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/recoveryKey/verify', sessionToken, {
+ recoveryKeyId,
+ });
+ });
+ }
+ createOAuthCode(sessionToken, clientId, state, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/oauth/authorization', sessionToken, {
+ access_type: options.access_type,
+ acr_values: options.acr_values,
+ client_id: clientId,
+ code_challenge: options.code_challenge,
+ code_challenge_method: options.code_challenge_method,
+ keys_jwe: options.keys_jwe,
+ redirect_uri: options.redirect_uri,
+ response_type: options.response_type,
+ scope: options.scope,
+ state,
+ });
+ });
+ }
+ createOAuthToken(sessionToken, clientId, options = {}) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/oauth/token', sessionToken, {
+ grant_type: 'fxa-credentials',
+ access_type: options.access_type,
+ client_id: clientId,
+ scope: options.scope,
+ ttl: options.ttl,
+ });
+ });
+ }
+ getOAuthScopedKeyData(sessionToken, clientId, scope) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/account/scoped-key-data', sessionToken, {
+ client_id: clientId,
+ scope,
+ });
+ });
+ }
+ getSubscriptionPlans() {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('GET', '/oauth/subscriptions/plans');
+ });
+ }
+ getActiveSubscriptions(accessToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('GET', '/oauth/subscriptions/active', null, new Headers({
+ authorization: `Bearer ${accessToken}`,
+ }));
+ });
+ }
+ createSupportTicket(accessToken, supportTicket) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.request('POST', '/support/ticket', supportTicket, new Headers({
+ authorization: `Bearer ${accessToken}`,
+ }));
+ });
+ }
+ updateNewsletters(sessionToken, newsletters) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/newsletters', sessionToken, {
+ newsletters,
+ });
+ });
+ }
+ verifyIdToken(idToken, clientId, expiryGracePeriod) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const payload = {
+ id_token: idToken,
+ client_id: clientId,
+ };
+ if (expiryGracePeriod) {
+ payload.expiry_grace_period = expiryGracePeriod;
+ }
+ return this.request('POST', '/oauth/id-token-verify', payload);
+ });
+ }
+ sendPushLoginRequest(sessionToken) {
+ return __awaiter(this, void 0, void 0, function* () {
+ return this.sessionPost('/session/verify/send_push', sessionToken, {});
+ });
+ }
+}
+AuthClient.VERSION = 'v1';
diff --git a/web/js/browser/lib/crypto.js b/web/js/browser/lib/crypto.js
new file mode 100644
index 0000000..6fa6107
--- /dev/null
+++ b/web/js/browser/lib/crypto.js
@@ -0,0 +1,163 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+import { hexToUint8, uint8ToBase64Url, uint8ToHex, xor } from './utils';
+const encoder = () => new TextEncoder();
+const NAMESPACE = 'identity.mozilla.com/picl/v1/';
+// These functions implement the onepw protocol
+// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol
+export function getCredentials(email, password) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const passkey = yield crypto.subtle.importKey('raw', encoder().encode(password), 'PBKDF2', false, ['deriveBits']);
+ const quickStretchedRaw = yield crypto.subtle.deriveBits({
+ name: 'PBKDF2',
+ salt: encoder().encode(`${NAMESPACE}quickStretch:${email}`),
+ iterations: 1000,
+ hash: 'SHA-256',
+ }, passkey, 256);
+ const quickStretchedKey = yield crypto.subtle.importKey('raw', quickStretchedRaw, 'HKDF', false, ['deriveBits']);
+ const authPW = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // The builtin ts type definition for HKDF was wrong
+ // at the time this was written, hence the ignore
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}authPW`),
+ hash: 'SHA-256',
+ }, quickStretchedKey, 256);
+ const unwrapBKey = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}unwrapBkey`),
+ hash: 'SHA-256',
+ }, quickStretchedKey, 256);
+ return {
+ authPW: uint8ToHex(new Uint8Array(authPW)),
+ unwrapBKey: uint8ToHex(new Uint8Array(unwrapBKey)),
+ };
+ });
+}
+export function deriveBundleKeys(key, keyInfo, payloadBytes = 64) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const baseKey = yield crypto.subtle.importKey('raw', hexToUint8(key), 'HKDF', false, ['deriveBits']);
+ const keyMaterial = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}${keyInfo}`),
+ hash: 'SHA-256',
+ }, baseKey, (32 + payloadBytes) * 8);
+ const hmacKey = yield crypto.subtle.importKey('raw', new Uint8Array(keyMaterial.slice(0, 32)), {
+ name: 'HMAC',
+ hash: 'SHA-256',
+ length: 256,
+ }, true, ['verify']);
+ const xorKey = new Uint8Array(keyMaterial.slice(32));
+ return {
+ hmacKey,
+ xorKey,
+ };
+ });
+}
+export function unbundleKeyFetchResponse(key, bundle) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const b = hexToUint8(bundle);
+ const keys = yield deriveBundleKeys(key, 'account/keys');
+ const ciphertext = b.subarray(0, 64);
+ const expectedHmac = b.subarray(b.byteLength - 32);
+ const valid = yield crypto.subtle.verify('HMAC', keys.hmacKey, expectedHmac, ciphertext);
+ if (!valid) {
+ throw new Error('Bad HMac');
+ }
+ const keyAWrapB = xor(ciphertext, keys.xorKey);
+ return {
+ kA: uint8ToHex(keyAWrapB.subarray(0, 32)),
+ wrapKB: uint8ToHex(keyAWrapB.subarray(32)),
+ };
+ });
+}
+export function unwrapKB(wrapKB, unwrapBKey) {
+ return uint8ToHex(xor(hexToUint8(wrapKB), hexToUint8(unwrapBKey)));
+}
+export function hkdf(keyMaterial, salt, info, bytes) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const key = yield crypto.subtle.importKey('raw', keyMaterial, 'HKDF', false, [
+ 'deriveBits',
+ ]);
+ const result = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt,
+ // @ts-ignore
+ info,
+ hash: 'SHA-256',
+ }, key, bytes * 8);
+ return new Uint8Array(result);
+ });
+}
+export function jweEncrypt(keyMaterial, kid, data, forTestingOnly) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const key = yield crypto.subtle.importKey('raw', keyMaterial, {
+ name: 'AES-GCM',
+ }, false, ['encrypt']);
+ const jweHeader = uint8ToBase64Url(encoder().encode(JSON.stringify({
+ enc: 'A256GCM',
+ alg: 'dir',
+ kid,
+ })));
+ const iv = (forTestingOnly === null || forTestingOnly === void 0 ? void 0 : forTestingOnly.testIV) || crypto.getRandomValues(new Uint8Array(12));
+ const encrypted = yield crypto.subtle.encrypt({
+ name: 'AES-GCM',
+ iv,
+ additionalData: encoder().encode(jweHeader),
+ tagLength: 128,
+ }, key, data);
+ const ciphertext = new Uint8Array(encrypted.slice(0, encrypted.byteLength - 16));
+ const authenticationTag = new Uint8Array(encrypted.slice(encrypted.byteLength - 16));
+ // prettier-ignore
+ const compactJWE = `${jweHeader}..${uint8ToBase64Url(iv)}.${uint8ToBase64Url(ciphertext)}.${uint8ToBase64Url(authenticationTag)}`;
+ return compactJWE;
+ });
+}
+export function checkWebCrypto() {
+ return __awaiter(this, void 0, void 0, function* () {
+ try {
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(16)), 'PBKDF2', false, ['deriveKey']);
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), 'HKDF', false, ['deriveKey']);
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), {
+ name: 'HMAC',
+ hash: 'SHA-256',
+ length: 256,
+ }, false, ['sign']);
+ yield crypto.subtle.importKey('raw', crypto.getRandomValues(new Uint8Array(32)), {
+ name: 'AES-GCM',
+ }, false, ['encrypt']);
+ yield crypto.subtle.digest('SHA-256', crypto.getRandomValues(new Uint8Array(16)));
+ return true;
+ }
+ catch (err) {
+ try {
+ console.warn('loading webcrypto shim', err);
+ // prettier-ignore
+ // @ts-ignore
+ window.asmCrypto = yield import(/* webpackChunkName: "asmcrypto.js" */ 'asmcrypto.js');
+ // prettier-ignore
+ // @ts-ignore
+ yield import(/* webpackChunkName: "webcrypto-liner" */ 'webcrypto-liner/build/shim');
+ return true;
+ }
+ catch (e) {
+ return false;
+ }
+ }
+ });
+}
diff --git a/web/js/browser/lib/hawk.d.ts b/web/js/browser/lib/hawk.d.ts
new file mode 100644
index 0000000..d4e5263
--- /dev/null
+++ b/web/js/browser/lib/hawk.d.ts
@@ -0,0 +1,24 @@
+/// <reference types="./lib/types" />
+export declare function deriveHawkCredentials(token: hexstring, context: string): Promise<{
+ id: string;
+ key: Uint8Array;
+ bundleKey: string;
+}>;
+export declare function hawkHeader(method: string, uri: string, options: {
+ credentials: {
+ id: string;
+ key: Uint8Array;
+ };
+ payload?: string;
+ timestamp?: number;
+ nonce?: string;
+ contentType?: string;
+ localtimeOffsetMsec?: number;
+}): Promise<string>;
+export declare function header(method: string, uri: string, token: string, kind: string, options: {
+ payload?: string;
+ timestamp?: number;
+ nonce?: string;
+ contentType?: string;
+ localtimeOffsetMsec?: number;
+}): Promise<Headers>;
diff --git a/web/js/browser/lib/hawk.js b/web/js/browser/lib/hawk.js
new file mode 100644
index 0000000..69c7153
--- /dev/null
+++ b/web/js/browser/lib/hawk.js
@@ -0,0 +1,145 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+import { hexToUint8, uint8ToBase64, uint8ToHex } from './utils';
+const encoder = () => new TextEncoder();
+const NAMESPACE = 'identity.mozilla.com/picl/v1/';
+export function deriveHawkCredentials(token, context) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const baseKey = yield crypto.subtle.importKey('raw', hexToUint8(token), 'HKDF', false, ['deriveBits']);
+ const keyMaterial = yield crypto.subtle.deriveBits({
+ name: 'HKDF',
+ salt: new Uint8Array(0),
+ // @ts-ignore
+ info: encoder().encode(`${NAMESPACE}${context}`),
+ hash: 'SHA-256',
+ }, baseKey, 32 * 3 * 8);
+ const id = new Uint8Array(keyMaterial.slice(0, 32));
+ const authKey = new Uint8Array(keyMaterial.slice(32, 64));
+ const bundleKey = new Uint8Array(keyMaterial.slice(64));
+ return {
+ id: uint8ToHex(id),
+ key: authKey,
+ bundleKey: uint8ToHex(bundleKey),
+ };
+ });
+}
+// The following is adapted from https://github.com/hapijs/hawk/blob/master/lib/browser.js
+/*
+ HTTP Hawk Authentication Scheme
+ Copyright (c) 2012-2013, Eran Hammer <eran@hueniverse.com>
+ MIT Licensed
+ */
+function parseUri(input) {
+ const parts = input.match(/^([^:]+)\:\/\/(?:[^@/]*@)?([^\/:]+)(?:\:(\d+))?([^#]*)(?:#.*)?$/);
+ if (!parts) {
+ return { host: '', port: '', resource: '' };
+ }
+ const scheme = parts[1].toLowerCase();
+ const uri = {
+ host: parts[2],
+ port: parts[3] || (scheme === 'http' ? '80' : scheme === 'https' ? '443' : ''),
+ resource: parts[4],
+ };
+ return uri;
+}
+function randomString(size) {
+ const randomSource = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ const len = randomSource.length;
+ const result = [];
+ for (let i = 0; i < size; ++i) {
+ result[i] = randomSource[Math.floor(Math.random() * len)];
+ }
+ return result.join('');
+}
+function generateNormalizedString(type, options) {
+ let normalized = 'hawk.1.' +
+ type +
+ '\n' +
+ options.ts +
+ '\n' +
+ options.nonce +
+ '\n' +
+ (options.method || '').toUpperCase() +
+ '\n' +
+ (options.resource || '') +
+ '\n' +
+ options.host.toLowerCase() +
+ '\n' +
+ options.port +
+ '\n' +
+ (options.hash || '') +
+ '\n';
+ if (options.ext) {
+ normalized += options.ext.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
+ }
+ normalized += '\n';
+ if (options.app) {
+ normalized += options.app + '\n' + (options.dlg || '') + '\n';
+ }
+ return normalized;
+}
+function calculatePayloadHash(payload = '', contentType = '') {
+ return __awaiter(this, void 0, void 0, function* () {
+ const data = encoder().encode(`hawk.1.payload\n${contentType}\n${payload}\n`);
+ const hash = yield crypto.subtle.digest('SHA-256', data);
+ return uint8ToBase64(new Uint8Array(hash));
+ });
+}
+function calculateMac(type, credentials, options) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const normalized = generateNormalizedString(type, options);
+ const key = yield crypto.subtle.importKey('raw', credentials.key, {
+ name: 'HMAC',
+ hash: 'SHA-256',
+ length: 256,
+ }, true, ['sign']);
+ const hmac = yield crypto.subtle.sign('HMAC', key, encoder().encode(normalized));
+ return uint8ToBase64(new Uint8Array(hmac));
+ });
+}
+export function hawkHeader(method, uri, options) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const timestamp = options.timestamp ||
+ Math.floor((Date.now() + (options.localtimeOffsetMsec || 0)) / 1000);
+ const parsedUri = parseUri(uri);
+ const hash = yield calculatePayloadHash(options.payload, options.contentType);
+ const artifacts = {
+ ts: timestamp,
+ nonce: options.nonce || randomString(6),
+ method,
+ resource: parsedUri.resource,
+ host: parsedUri.host,
+ port: parsedUri.port,
+ hash,
+ };
+ const mac = yield calculateMac('header', options.credentials, artifacts);
+ const header = 'Hawk id="' +
+ options.credentials.id +
+ '", ts="' +
+ artifacts.ts +
+ '", nonce="' +
+ artifacts.nonce +
+ (artifacts.hash ? '", hash="' + artifacts.hash : '') +
+ '", mac="' +
+ mac +
+ '"';
+ return header;
+ });
+}
+export function header(method, uri, token, kind, options) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const credentials = yield deriveHawkCredentials(token, kind);
+ const authorization = yield hawkHeader(method, uri, Object.assign({ credentials }, options));
+ return new Headers({ authorization });
+ });
+}
diff --git a/web/js/browser/lib/recoveryKey.js b/web/js/browser/lib/recoveryKey.js
new file mode 100644
index 0000000..cc8604d
--- /dev/null
+++ b/web/js/browser/lib/recoveryKey.js
@@ -0,0 +1,38 @@
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+import { jweEncrypt, hkdf } from './crypto';
+import { hexToUint8, uint8ToHex } from './utils';
+function randomKey() {
+ return __awaiter(this, void 0, void 0, function* () {
+ // The key is displayed in base32 'Crockford' so the length should be
+ // divisible by (5 bits per character) and (8 bits per byte).
+ // 20 bytes == 160 bits == 32 base32 characters
+ const recoveryKey = yield crypto.getRandomValues(new Uint8Array(20));
+ // Flip bits to set first char to base32 'A' as a version identifier.
+ // Why 'A'? https://github.com/mozilla/fxa-content-server/pull/6323#discussion_r201211711
+ recoveryKey[0] = 0x50 | (0x07 & recoveryKey[0]);
+ return recoveryKey;
+ });
+}
+export function generateRecoveryKey(uid, keys, forTestingOnly) {
+ return __awaiter(this, void 0, void 0, function* () {
+ const recoveryKey = (forTestingOnly === null || forTestingOnly === void 0 ? void 0 : forTestingOnly.testRecoveryKey) || (yield randomKey());
+ const encoder = new TextEncoder();
+ const salt = hexToUint8(uid);
+ const encryptionKey = yield hkdf(recoveryKey, salt, encoder.encode('fxa recovery encrypt key'), 32);
+ const recoveryKeyId = uint8ToHex(yield hkdf(recoveryKey, salt, encoder.encode('fxa recovery fingerprint'), 16));
+ const recoveryData = yield jweEncrypt(encryptionKey, recoveryKeyId, encoder.encode(JSON.stringify(keys)), forTestingOnly ? { testIV: forTestingOnly.testIV } : undefined);
+ return {
+ recoveryKey,
+ recoveryKeyId,
+ recoveryData,
+ };
+ });
+}
diff --git a/web/js/browser/lib/utils.js b/web/js/browser/lib/utils.js
new file mode 100644
index 0000000..5bf0383
--- /dev/null
+++ b/web/js/browser/lib/utils.js
@@ -0,0 +1,26 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/;
+export function hexToUint8(str) {
+ if (!HEX_STRING.test(str)) {
+ throw new Error(`invalid hex string: ${str}`);
+ }
+ const bytes = str.match(/[a-fA-F0-9]{2}/g);
+ return new Uint8Array(bytes.map((byte) => parseInt(byte, 16)));
+}
+export function uint8ToHex(array) {
+ return array.reduce((str, byte) => str + ('00' + byte.toString(16)).slice(-2), '');
+}
+export function uint8ToBase64(array) {
+ return btoa(String.fromCharCode(...array));
+}
+export function uint8ToBase64Url(array) {
+ return uint8ToBase64(array)
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+}
+export function xor(array1, array2) {
+ return new Uint8Array(array1.map((byte, i) => byte ^ array2[i]));
+}
diff --git a/web/js/crypto.js b/web/js/crypto.js
new file mode 100644
index 0000000..5c12a2c
--- /dev/null
+++ b/web/js/crypto.js
@@ -0,0 +1,137 @@
+'use strict';
+
+// taken from fxa
+function hexToUint8(str) {
+ const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/;
+ if (!HEX_STRING.test(str)) {
+ throw new Error(`invalid hex string: ${str}`);
+ }
+ const bytes = str.match(/[a-fA-F0-9]{2}/g);
+ return new Uint8Array(bytes.map((byte) => parseInt(byte, 16)));
+}
+function uint8ToHex(array) {
+ return array.reduce((str, byte) => str + ('00' + byte.toString(16)).slice(-2), '');
+}
+function uint8ToBase64(array) {
+ return btoa(String.fromCharCode(...array));
+}
+function uint8ToBase64Url(array) {
+ return uint8ToBase64(array).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+export function urlatob(s) {
+ return atob(s.replace(/-/g, '+').replace(/_/g, "/"));
+}
+
+export async function deriveScopedKey(kB, uid, identifier, key_rotation_secret, key_rotation_timestamp) {
+ async function importKey(k) {
+ return await crypto.subtle.importKey('raw', hexToUint8(k), 'HKDF', false, ['deriveBits']);
+ }
+
+ async function derive(salt, k, info, len) {
+ let params = {
+ name: 'HKDF',
+ salt,
+ info: new TextEncoder().encode(info),
+ hash: 'SHA-256',
+ };
+ return new Uint8Array(await crypto.subtle.deriveBits(params, await importKey(k), len * 8));
+ }
+
+ if (identifier == "https://identity.mozilla.com/apps/oldsync") {
+ const bits = await derive(
+ new Uint8Array(),
+ kB,
+ "identity.mozilla.com/picl/v1/oldsync",
+ 64);
+ const kHash = await crypto.subtle.digest("SHA-256", hexToUint8(kB));
+ const kid = key_rotation_timestamp.toString() + "-" +
+ uint8ToBase64Url(new Uint8Array(kHash.slice(0, 16)));
+ return {
+ k: uint8ToBase64Url(bits),
+ kty: "oct",
+ kid,
+ scope: identifier,
+ };
+ } else {
+ const bits = await derive(
+ hexToUint8(uid),
+ kB + key_rotation_secret,
+ "identity.mozilla.com/picl/v1/scoped_key\n" + identifier,
+ 16 + 32);
+ const fp = new Uint8Array(bits.slice(0, 16));
+ const key = new Uint8Array(bits.slice(16));
+ const kid = key_rotation_timestamp.toString() + "-" + uint8ToBase64Url(fp);
+ return {
+ k: uint8ToBase64Url(key),
+ kty: "oct",
+ kid,
+ scope: identifier,
+ };
+ }
+}
+
+export async function encryptScopedKeys(bundle, to, local_key, iv) {
+ let encode = s => new TextEncoder().encode(s);
+
+ let peer_key = await crypto.subtle.importKey(
+ "jwk",
+ JSON.parse(urlatob(to)),
+ { name: "ECDH", namedCurve: "P-256" },
+ false,
+ ['deriveKey']);
+ local_key = local_key || await crypto.subtle.generateKey(
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ['deriveKey']);
+ iv = iv || new Uint8Array(await crypto.subtle.exportKey(
+ "raw",
+ await crypto.subtle.generateKey({ name: "AES-CBC", length: 128 }, true, ['encrypt'])
+ )).slice(0, 12);
+
+ let key = await crypto.subtle.deriveKey(
+ { name: "ECDH", public: peer_key },
+ local_key.privateKey,
+ { name: "AES-GCM", length: 256 },
+ true,
+ ['encrypt']);
+ key = new Uint8Array(await crypto.subtle.exportKey("raw", key));
+ key = await crypto.subtle.digest(
+ "SHA-256",
+ new Uint8Array([
+ 0, 0, 0, 1, // rounds
+ ...key,
+ 0, 0, 0, 7, ...encode("A256GCM"),
+ 0, 0, 0, 0, // apu
+ 0, 0, 0, 0, // apv
+ 0, 0, 1, 0, // key size
+ ]));
+ key = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, ['encrypt']);
+
+ let exported_key = await crypto.subtle.exportKey("jwk", local_key.publicKey);
+ let header = {
+ "alg": "ECDH-ES",
+ "enc": "A256GCM",
+ "epk": {
+ crv: "P-256",
+ kty: "EC",
+ x: exported_key.x,
+ y: exported_key.y
+ }
+ };
+
+ let ciphered = await crypto.subtle.encrypt(
+ { name: "AES-GCM", iv, additionalData: encode(uint8ToBase64Url(encode(JSON.stringify(header)))) },
+ key,
+ encode(JSON.stringify(bundle)));
+ let tag = ciphered.slice(-16);
+ ciphered = ciphered.slice(0, -16);
+
+ return (uint8ToBase64Url(encode(JSON.stringify(header))) +
+ ".." +
+ uint8ToBase64Url(new Uint8Array(iv)) +
+ "." +
+ uint8ToBase64Url(new Uint8Array(ciphered)) +
+ "." +
+ uint8ToBase64Url(new Uint8Array(tag)));
+}
diff --git a/web/js/crypto.test.js b/web/js/crypto.test.js
new file mode 100644
index 0000000..8e3247c
--- /dev/null
+++ b/web/js/crypto.test.js
@@ -0,0 +1,49 @@
+async function testCrypto() {
+ let keys = await deriveScopedKey(
+ "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45",
+ "aeaa1725c7a24ff983c6295725d5fc9b",
+ "app_key:https%3A//example.com",
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ 1510726317);
+ if (keys.k != "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ") throw "assert";
+ if (keys.kid != "1510726317-Voc-Eb9IpoTINuo9ll7bjA") throw "assert";
+ if (keys.kty != "oct") throw "assert";
+
+ let keys_jwk = "eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IlNpQm42dWViamlnbVF" +
+ "xdzRUcE56czNBVXlDYWUxX3NHMmI5RnpocTNGeW8iLCJ5IjoicTk5WHExUld" +
+ "OVEZwazk5cGRRT1NqVXZ3RUxzczUxUGttQUdDWGhMZk1WNCJ9";
+ let bundle = {
+ app_key: {
+ k: "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ",
+ kid: "1510726317-Voc-Eb9IpoTINuo9ll7bjA",
+ kty: "oct"
+ }
+ };
+ let local_key = {
+ "crv": "P-256",
+ "kty": "EC",
+ "d": "X9tJG0Ue55tuepC-6msMg04Qv5gJtL95AIJ0X0gDj8Q",
+ "x": "N4zPRazB87vpeBgHzFvkvd_48owFYYxEVXRMrOU6LDo",
+ "y": "4ncUxN6x_xT1T1kzy_S_V2fYZ7uUJT_HVRNZBLJRsxU"
+ };
+ let iv = new Uint8Array([0xff, 0x4b, 0x18, 0x7f, 0xb1, 0xdd, 0x5a, 0xe4, 0x6f, 0xd9, 0xc3, 0x34]);
+ let k = await crypto.subtle.importKey(
+ "jwk",
+ local_key,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ['deriveKey']);
+ console.log(await encryptScopedKeys(
+ bundle,
+ keys_jwk,
+ { publicKey: k, privateKey: k },
+ iv));
+
+ console.log(await deriveScopedKey(
+ "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9",
+ "aeaa1725c7a24ff983c6295725d5fc9b",
+ "https://identity.mozilla.com/apps/oldsync",
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ 1510726317123
+ ));
+}
diff --git a/web/js/main.js b/web/js/main.js
new file mode 100644
index 0000000..bffcdfb
--- /dev/null
+++ b/web/js/main.js
@@ -0,0 +1,761 @@
+// https://mozilla.github.io/ecosystem-platform/reference/webchannels-in-firefox-desktop-fennec
+
+'use strict';
+
+import AuthClient from './auth-client/browser';
+import { deriveScopedKey, encryptScopedKeys, urlatob } from './crypto';
+
+class Channel {
+ constructor() {
+ this.waiting = {};
+ this.idseq = 0;
+
+ window.addEventListener(
+ 'WebChannelMessageToContent',
+ ev => {
+ if (ev.detail.message.error) {
+ for (const wait in this.waiting) {
+ this.waiting[wait].reject(new Error(ev.detail.message.error));
+ }
+ this.waiting = {};
+ } else {
+ let message = this.waiting[ev.detail.message.messageId];
+ delete this.waiting[ev.detail.message.messageId];
+ if (message) {
+ message.resolve(ev.detail.message.data);
+ }
+ }
+ },
+ true
+ );
+ }
+
+ _send(id, command, data) {
+ const messageId = this.idseq++;
+ window.dispatchEvent(
+ new window.CustomEvent('WebChannelMessageToChrome', {
+ detail: JSON.stringify({
+ id,
+ message: { command, data, messageId },
+ }),
+ })
+ );
+ return messageId;
+ }
+
+ _send_wait(id, command, data) {
+ return new Promise((resolve, reject) => {
+ let messageId = this._send(id, command, data);
+ this.waiting[messageId] = { resolve, reject };
+ });
+ }
+
+ async getStatus(isPairing, service) {
+ return await channel._send_wait(
+ 'account_updates',
+ 'fxaccounts:fxa_status',
+ { isPairing, service });
+ }
+
+ loadCredentials(email, creds, engines) {
+ let services = undefined;
+ if (engines) {
+ services = {
+ sync: {
+ declinedEngines: engines.declined,
+ offeredEngines: engines.offered,
+ }
+ };
+ }
+ this._send('account_updates', 'fxaccounts:login', {
+ customizeSync: !!engines,
+ services,
+ email,
+ keyFetchToken: creds.keyFetchToken,
+ sessionToken: creds.sessionToken,
+ uid: creds.uid,
+ unwrapBKey: creds.unwrapBKey,
+ verified: creds.verified || false,
+ verifiedCanLinkAccount: false
+ });
+ }
+
+ passwordChanged(email, uid) {
+ this._send('account_updates', 'fxaccounts:change_password', {
+ email,
+ uid,
+ verified: true,
+ });
+ }
+
+ accountDestroyed(email, uid) {
+ this._send('account_updates', 'fxaccounts:delete', {
+ email,
+ uid,
+ });
+ }
+}
+
+class ProfileClient {
+ constructor(authClient, session) {
+ this._authClient = authClient;
+ this._session = session;
+ }
+
+ async _acquireToken(scope) {
+ // NOTE the api has destroy commands for tokens, the client doesn't
+ let token = await this._authClient.createOAuthToken(
+ this._session.signedInUser.sessionToken,
+ this._session.clientId,
+ { scope, ttl: 60 });
+ return token.access_token;
+ }
+
+ async _request(endpoint, token, options) {
+ options = options || {};
+ options.mode = "same-origin";
+ options.headers = new Headers({
+ authorization: `bearer ${token}`,
+ ...(options.headers || {}),
+ });
+ let req = new Request(`${client_config.profile_server_base_url}${endpoint}`, options);
+ let resp = await fetch(req);
+ if (!resp.ok) throw new Error(resp.statusText);
+ return await resp.json();
+ }
+
+ async getProfile() {
+ let token = await this._acquireToken("profile");
+ return await this._request("/v1/profile", token);
+ }
+
+ async setDisplayName(name) {
+ let token = await this._acquireToken("profile:display_name:write");
+ return await this._request("/v1/display_name", token, {
+ method: "POST",
+ body: JSON.stringify({ displayName: name }),
+ });
+ }
+
+ async setAvatar(avatar) {
+ let token = await this._acquireToken("profile:avatar:write");
+ return await this._request("/v1/avatar/upload", token, {
+ method: "POST",
+ body: avatar.slice(),
+ headers: {
+ "Content-Type": avatar.type,
+ },
+ });
+ }
+}
+
+function $(id) {
+ return document.getElementById(id);
+}
+
+function showMessage(message, className) {
+ let m = $("message-modal");
+ m.className = className || "";
+ $("message").innerText = message;
+ $("message-modal-close").hidden = true;
+ m.showModal();
+}
+
+function showError(prefix, e) {
+ console.log(e);
+ if (e instanceof Object && "errno" in e)
+ e = e.message;
+ showMessage(prefix + String(e), "error");
+}
+
+function showRecoverableError(prefix, e) {
+ if (e === undefined) {
+ e = prefix;
+ prefix = "";
+ }
+
+ let close = $("message-modal-close");
+ close.onclick = ev => {
+ ev.preventDefault();
+ hideMessage();
+ };
+ showError(prefix, e, "error");
+ close.hidden = false;
+}
+
+function hideMessage() {
+ $("message-modal").close();
+}
+
+function wrapHandler(fn) {
+ function failed(e) {
+ showRecoverableError("failed: ", e);
+ console.log(e);
+ }
+
+ return function() {
+ try {
+ let val = fn.apply(this, arguments);
+ if (val instanceof Promise) val = val.catch(failed);
+ return val;
+ } catch (e) {
+ failed(e);
+ throw e;
+ }
+ };
+}
+
+function switchTo(id, tabID) {
+ for (const screen of document.getElementsByClassName("container")) {
+ screen.hidden = true;
+ }
+ $(id).hidden = false;
+
+ if (tabID) {
+ for (const tab of $(id).getElementsByClassName("tab")) {
+ tab.hidden = true;
+ }
+ $(tabID).hidden = false;
+ }
+}
+
+function dateDiffText(when) {
+ let diff = Math.round(((+new Date()) - (+when)) / 1000);
+ let finalize = diff < 0 ? (s => `in ${s}`) : (s => `${s} ago`);
+ diff = Math.abs(diff);
+ let s;
+ if (diff < 5)
+ return "now";
+ else if (diff < 60)
+ s = `${diff} second`;
+ else if (diff < 60 * 60)
+ s = `${Math.round(diff / 60)} minute`;
+ else if (diff < 60 * 60 * 24)
+ s = `${Math.round(diff / 60 / 60)} hour`;
+ else if (diff < 60 * 60 * 24 * 31)
+ s = `${Math.round(diff / 60 / 60 / 24)} day`;
+ else
+ s = `${Math.round(diff / 60 / 60 / 24 / 31)} month`;
+ if (!/1 /.test(s))
+ s += "s";
+ return finalize(s);
+}
+
+//////////////////////////////////////////
+// initialization
+//////////////////////////////////////////
+
+let client_config;
+var channel = new Channel();
+const isAndroid = /Android/.test(navigator.userAgent);
+
+document.body.onload = () => {
+ showMessage("Loading ...", "animate");
+
+ if (isAndroid) {
+ document.head.appendChild($("tpl-fenix-style").content);
+ }
+
+ fetch("/.well-known/fxa-client-configuration")
+ .then(resp => {
+ if (!resp.ok) throw new Error(resp.statusText);
+ return resp.json();
+ })
+ .then(data => {
+ client_config = data;
+ hideMessage();
+ return initUI();
+ })
+ .catch(e => {
+ showError("initialization failed: ", e);
+ });
+};
+
+async function initUI() {
+ return isAndroid
+ ? await initUIAndroid()
+ : await initUIDesktop();
+}
+
+async function initUIDesktop() {
+ let present = wrapHandler(async () => {
+ if (window.location.hash.startsWith("#/verify/")) {
+ verify_init(JSON.parse(urlatob(window.location.hash.substr(9))));
+ } else if (window.location.hash.startsWith("#/register/")) {
+ signup_init(window.location.hash.substr(11));
+ } else {
+ let data = await channel.getStatus();
+ if (!data.signedInUser) {
+ signin_init();
+ } else if (window.location.hash == "#/settings") {
+ settings_init(data);
+ } else if (window.location.hash == "#/settings/change-password") {
+ settings_chpw_init(data);
+ } else if (window.location.hash == "#/settings/destroy") {
+ settings_destroy_init(data);
+ } else if (window.location.hash == "#/force_auth") {
+ signin_init();
+ } else if (window.location.hash == "#/generate-invite") {
+ generate_invite_init(data);
+ } else {
+ switchTo("desktop-signedin");
+ }
+ }
+ hideMessage();
+ });
+
+ window.onpopstate = () => window.setTimeout(present, 0);
+
+ for (let a of $("desktop-settings").querySelectorAll("nav a")) {
+ a.onclick = async ev => {
+ ev.preventDefault();
+ window.location = ev.target.href;
+ await present();
+ };
+ }
+ await present();
+}
+
+async function initUIAndroid() {
+ fenix_signin_init();
+}
+
+//////////////////////////////////////////
+// signin form
+//////////////////////////////////////////
+
+function signin_init() {
+ switchTo("desktop-signin");
+ let frm = $("frm-signin");
+ frm.onsubmit = wrapHandler(signin_run);
+ frm.getElementsByClassName("_signup")[0].onclick = wrapHandler(signin_signup_instead);
+ frm.getElementsByClassName("_reset")[0].onclick = wrapHandler(ev => {
+ ev.preventDefault();
+ password_reset_init();
+ });
+}
+
+function signin_signup_instead(ev) {
+ ev.preventDefault();
+ signup_init();
+}
+
+// NOTE it looks like firefox discards its session token before asking for reauth
+// (eg if oauth verification of a sync token fails). we can't use the reauth
+// endpoint for that, so we'll just always log in.
+async function signin_run(ev) {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm[0].value == "" || !frm[0].validity.valid) {
+ return showRecoverableError("email is not valid");
+ }
+ if (frm[1].value == "" || !frm[1].validity.valid) {
+ return showRecoverableError("password is not valid");
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ try {
+ let session = await c.signIn(frm["email"].value, frm["password"].value, {
+ keys: true,
+ });
+
+ signin_confirm_init(frm["email"].value, session);
+ } catch (e) {
+ if (e.errno == 104) {
+ showRecoverableError(`
+ account is not verified. please verify or wait a few minutes and register again.
+ `.trim());
+ } else {
+ throw e;
+ }
+ }
+}
+
+//////////////////////////////////////////
+// sign-in confirmation
+//////////////////////////////////////////
+
+function signin_confirm_init(email, session) {
+ let frm = $("frm-signin-confirm");
+ frm.getElementsByClassName("_resend")[0].onclick = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ await c.sessionResendVerifyCode(session.sessionToken);
+ alert("code resent!");
+ });
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ c.sessionVerifyCode(session.sessionToken, frm["code"].value)
+ .then(resp => {
+ channel.loadCredentials(email, session, { offered: [], declined: [] });
+ switchTo("desktop-signedin");
+ })
+ .catch(e => {
+ showError("verification failed: ", e);
+ });
+ });
+ switchTo("desktop-signin-confirm");
+}
+
+//////////////////////////////////////////
+// password reset
+//////////////////////////////////////////
+
+function password_reset_init() {
+ let frm = $("frm-resetpw");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let token = await c.passwordForgotSendCode(frm["email"].value);
+ let code = prompt(`
+ please enter your password reset code from the email you've just received
+ `.trim());
+ if (code === null) {
+ return showRecoverableError("password reset aborted");
+ }
+
+ let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken);
+ password_reset_newpw(frm['email'].value, reset_token.accountResetToken);
+ });
+ switchTo("desktop-resetpw");
+}
+
+function password_reset_newpw(email, reset_token) {
+ let frm = $("frm-resetpw-newpw");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ if (frm['new'].value != frm['new-confirm'].value) {
+ return showRecoverableError("passwords don't match!");
+ }
+
+ await c.accountReset(email, frm['new'].value, reset_token);
+ switchTo("desktop-signin");
+ });
+ switchTo("desktop-resetpw-newpw");
+}
+
+//////////////////////////////////////////
+// signup form
+//////////////////////////////////////////
+
+function signup_init(code) {
+ let frm = $("frm-signup");
+ switchTo("desktop-signup");
+ frm.onsubmit = wrapHandler(async ev => signup_run(ev, code));
+}
+
+async function signup_run(ev, code) {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm[0].value == "" || !frm[0].validity.valid) {
+ return showRecoverableError("email is not valid");
+ }
+ if (frm[1].value == "" || !frm[1].validity.valid) {
+ return showRecoverableError("password is not valid");
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let session = await c.signUp(frm["email"].value, frm["password"].value, {
+ keys: true,
+ style: code,
+ });
+
+ cwts_continue(frm["email"].value, session);
+}
+
+//////////////////////////////////////////
+// choose-what-to-sync form
+//////////////////////////////////////////
+
+function cwts_continue(email, session) {
+ // TODO we don't query browser capabilities, but we probably should
+ let frm = $("frm-cwts");
+ frm.onsubmit = wrapHandler((ev) => {
+ ev.preventDefault();
+
+ let offered = [], declined = [];
+ for (const engine of frm.querySelectorAll("input[type=checkbox]")) {
+ if (!engine.checked) declined.push(engine.name);
+ else offered.push(engine.name);
+ }
+ channel.loadCredentials(email, session, { offered, declined });
+
+ switchTo("desktop-signup-unverified");
+ });
+ switchTo("desktop-cwts");
+}
+
+//////////////////////////////////////////
+// verification
+//////////////////////////////////////////
+
+function verify_init(context) {
+ let c = new AuthClient(client_config.auth_server_base_url);
+ c.verifyCode(context.uid, context.code)
+ .then(resp => {
+ switchTo("desktop-signedin");
+ })
+ .catch(e => {
+ showError("verification failed: ", e);
+ });
+}
+
+//////////////////////////////////////////
+// settings root
+//////////////////////////////////////////
+
+function settings_init(session) {
+ switchTo("desktop-settings", "desktop-settings-main");
+
+ let inner = async () => {
+ showMessage("Loading ...", "animate");
+
+ let ac = new AuthClient(client_config.auth_server_base_url)
+ let pc = new ProfileClient(ac, session);
+
+ let initProfile = async () => {
+ let profile = await pc.getProfile();
+ $("settings-name").value = profile.displayName || "";
+ $("frm-settings-name").onsubmit = wrapHandler(async ev => {
+ showMessage("Applying ...")
+ ev.preventDefault();
+ await pc.setDisplayName($("settings-name").value);
+ hideMessage();
+ });
+ $("settings-avatar-img").src = profile.avatar;
+ $("frm-settings-avatar").onsubmit = wrapHandler(async ev => {
+ showMessage("Saving ...")
+ ev.preventDefault();
+ await pc.setAvatar($("settings-avatar").files[0]);
+ settings_init(session);
+ });
+ };
+
+ await Promise.all([
+ initProfile(),
+ settings_populateClients(ac, session),
+ ]);
+
+ hideMessage();
+ };
+
+ inner().catch(e => {
+ showError("initialization failed: ", e);
+ });
+}
+
+async function settings_populateClients(authClient, session) {
+ let clients = await authClient.attachedClients(session.signedInUser.sessionToken);
+
+ let body = $("settings-clients").getElementsByTagName("tbody")[0];
+ body.innerHTML = "";
+ for (const c of clients) {
+ let row = document.createElement("tr");
+ let add = (val, tip) => {
+ let cell = document.createElement("td");
+ cell.innerText = val || "";
+ if (tip) cell.title = tip;
+ row.appendChild(cell);
+ };
+ let addDateDiff = val => {
+ let text = dateDiffText(new Date(val));
+ add(text, new Date(val));
+ };
+ add(c.name);
+ add(c.deviceType);
+ addDateDiff(c.createdTime * 1000);
+ addDateDiff(c.lastAccessTime * 1000);
+ add(c.scope ? "yes" : "", (c.scope ? c.scope : "").replace(/ +/g, "\n"));
+ if (c.isCurrentSession) {
+ let cell = document.createElement("td");
+ cell.innerHTML = `<span class="disabled">current session</span>`;
+ row.appendChild(cell);
+ } else if (c.deviceId || c.sessionTokenId || c.refreshTokenId) {
+ let remove = document.createElement("button");
+ remove.innerText = 'remove';
+ remove.onclick = wrapHandler(async ev => {
+ ev.preventDefault();
+ let info = { clientId: c.clientId };
+ if (c.deviceId)
+ info.deviceId = c.deviceId;
+ else if (c.sessionTokenId)
+ info.sessionTokenId = c.sessionTokenId;
+ else if (c.refreshTokenId)
+ info.refreshTokenId = c.refreshTokenId;
+ showMessage("Processing ...", "animate");
+ await authClient.attachedClientDestroy(session.signedInUser.sessionToken, info);
+ await settings_populateClients(authClient, session);
+ hideMessage();
+ });
+ row.appendChild(remove);
+ }
+ body.appendChild(row);
+ }
+}
+
+//////////////////////////////////////////
+// settings change password
+//////////////////////////////////////////
+
+function settings_chpw_init(session) {
+ switchTo("desktop-settings", "desktop-settings-chpw");
+ let frm = $("frm-settings-chpw");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm["new"].value != frm["new-confirm"].value) {
+ showRecoverableError("passwords don't match");
+ return;
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let resp = await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {});
+
+ channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid);
+ alert("password changed");
+ });
+}
+
+//////////////////////////////////////////
+// settings destroy
+//////////////////////////////////////////
+
+function settings_destroy_init(session) {
+ switchTo("desktop-settings", "desktop-settings-destroy");
+ let frm = $("frm-settings-destroy");
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+ let frm = ev.target;
+
+ if (frm[0].value == "" || !frm[0].validity.valid) {
+ return showRecoverableError("email is not valid");
+ }
+ if (frm[1].value == "" || !frm[1].validity.valid) {
+ return showRecoverableError("password is not valid");
+ }
+
+ let c = new AuthClient(client_config.auth_server_base_url);
+ await c.accountDestroy(frm["email"].value, frm["password"].value);
+
+ channel.accountDestroyed(frm["email"], session.signedInUser.uid);
+ switchTo("desktop-deleted");
+ });
+}
+
+//////////////////////////////////////////
+// generate invite
+//////////////////////////////////////////
+
+function generate_invite_init(session) {
+ let frm = $("frm-generate-invite");
+ $("desktop-generate-invite-result").hidden = true;
+ frm.onsubmit = wrapHandler(async ev => {
+ ev.preventDefault();
+
+ let server_url = new URL(client_config.auth_server_base_url);
+ server_url.pathname = server_url.pathname.split("/").slice(0, -1).join("/") + "/_invite";
+ let c = new AuthClient(server_url.toString());
+ let resp = await c.sessionPost(
+ "/generate",
+ session.signedInUser.sessionToken,
+ { 'ttl_hours': parseInt(frm["ttl"].value) });
+
+ $("desktop-generate-invite-result-link").href = resp.url;
+ $("desktop-generate-invite-result-link").innerText = resp.url;
+ $("desktop-generate-invite-result").hidden = false;
+
+ });
+ switchTo("desktop-generate-invite");
+}
+
+//////////////////////////////////////////
+// fenix signin
+//////////////////////////////////////////
+
+function fenix_signin_init() {
+ switchTo("fenix-signin-warning");
+ $("fenix-signin-dialog-show").onclick = wrapHandler(async ev => {
+ ev.preventDefault();
+ switchTo("fenix-signin");
+ $("frm-fenix-signin").onsubmit = wrapHandler(fenix_signin_step2);
+ });
+}
+
+async function fenix_signin_step2(ev) {
+ ev.preventDefault();
+
+ let url = new URL(window.location);
+ let params = new URLSearchParams(url.search);
+ let param = (p) => {
+ let val = params.get(p);
+ if (val === undefined) throw `missing parameter ${p}`;
+ return val;
+ };
+
+ let frm = ev.target;
+ let c = new AuthClient(client_config.auth_server_base_url);
+ let session = await c.signIn(frm["email"].value, frm["password"].value, {
+ keys: true,
+ });
+ let verifyCode = prompt("enter verify code");
+ await c.sessionVerifyCode(session.sessionToken, verifyCode);
+ try {
+ let keys = await c.accountKeys(session.keyFetchToken, session.unwrapBKey);
+ let scoped_keys = await c.getOAuthScopedKeyData(
+ session.sessionToken,
+ param("client_id"),
+ param("scope"));
+ for (var scope in scoped_keys) {
+ scoped_keys[scope] = await deriveScopedKey(
+ keys.kB,
+ session.uid,
+ scope,
+ scoped_keys[scope].keyRotationSecret,
+ scoped_keys[scope].keyRotationTimestamp);
+ }
+
+ let keys_jwe = await encryptScopedKeys(scoped_keys, param("keys_jwk"));
+
+ let code = await c.createOAuthCode(
+ session.sessionToken,
+ param("client_id"),
+ param("state"),
+ {
+ access_type: param("access_type"),
+ keys_jwe,
+ response_type: param("response_type"),
+ scope: param("scope"),
+ code_challenge_method: param("code_challenge_method"),
+ code_challenge: param("code_challenge"),
+ });
+
+ console.log(`browser.tabs.executeScript({code: \`
+ port = browser.runtime.connectNative("mozacWebchannel");
+ port.postMessage({
+ id: "account_updates",
+ message: {
+ command: "fxaccounts:oauth_login",
+ data: {
+ "code": "${code.code}",
+ "state": "${code.state}",
+ "redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel",
+ "action": "signin"
+ },
+ messageId: 1,
+ }});
+ \`});`);
+
+ switchTo("fenix-signin-warning");
+ } finally {
+ c.sessionDestroy(session.sessionToken);
+ }
+}