diff --git a/Cargo.lock b/Cargo.lock index 796006a..fc30fb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,46 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -91,73 +51,12 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "apple-native-keyring-store" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" -dependencies = [ - "keyring-core", - "log", - "security-framework", -] - [[package]] name = "arrayref" version = "0.3.9" @@ -170,12 +69,6 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -185,62 +78,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - [[package]] name = "async-lock" version = "3.4.2" @@ -252,59 +89,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.92" @@ -325,16 +109,16 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atproto-dasl" version = "0.15.0-rc.3" -source = "git+https://tangled.org/ngerakines.me/atproto-crates?rev=c7f7345ce49936ac9949454a9e7b7cbc27521a1a#c7f7345ce49936ac9949454a9e7b7cbc27521a1a" +source = "git+https://tangled.org/ngerakines.me/atproto-crates?rev=f949929216d04758e4210a4de413d055338b2846#f949929216d04758e4210a4de413d055338b2846" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "blake3", "cid", "multihash", "serde", "serde_bytes", "serde_json", - "sha2 0.11.0", + "sha2", "tempfile", "thiserror 2.0.20", "tokio", @@ -345,12 +129,12 @@ dependencies = [ [[package]] name = "atproto-identity" version = "0.15.0-rc.3" -source = "git+https://tangled.org/ngerakines.me/atproto-crates?rev=c7f7345ce49936ac9949454a9e7b7cbc27521a1a#c7f7345ce49936ac9949454a9e7b7cbc27521a1a" +source = "git+https://tangled.org/ngerakines.me/atproto-crates?rev=f949929216d04758e4210a4de413d055338b2846#f949929216d04758e4210a4de413d055338b2846" dependencies = [ "anyhow", "async-trait", "atproto-dasl", - "base64 0.22.1", + "base64 0.23.1", "chrono", "data-encoding", "ecdsa", @@ -366,7 +150,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "thiserror 2.0.20", "tokio", "tracing", @@ -377,12 +161,12 @@ dependencies = [ [[package]] name = "atproto-oauth" version = "0.15.0-rc.3" -source = "git+https://tangled.org/ngerakines.me/atproto-crates?rev=c7f7345ce49936ac9949454a9e7b7cbc27521a1a#c7f7345ce49936ac9949454a9e7b7cbc27521a1a" +source = "git+https://tangled.org/ngerakines.me/atproto-crates?rev=f949929216d04758e4210a4de413d055338b2846#f949929216d04758e4210a4de413d055338b2846" dependencies = [ "anyhow", "async-trait", "atproto-identity", - "base64 0.22.1", + "base64 0.23.1", "chrono", "ecdsa", "elliptic-curve", @@ -390,13 +174,13 @@ dependencies = [ "multibase", "p256", "p384", - "rand 0.10.2", + "rand", "reqwest", "reqwest-chain", "reqwest-middleware", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "thiserror 2.0.20", "tokio", "tracing", @@ -491,9 +275,9 @@ checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" [[package]] name = "base16ct" -version = "0.2.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base256emoji" @@ -567,7 +351,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures", ] [[package]] @@ -576,15 +360,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.1" @@ -594,15 +369,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.5.1" @@ -621,41 +387,12 @@ dependencies = [ "objc2 0.6.4", ] -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "bstr" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" -dependencies = [ - "memchr", - "serde_core", -] - [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - [[package]] name = "bytemuck" version = "1.25.2" @@ -676,12 +413,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -702,52 +433,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.1", - "polling", - "rustix 1.1.4", - "slab", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" -dependencies = [ - "calloop 0.13.0", - "rustix 0.38.44", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.4.2" @@ -779,8 +464,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -810,57 +495,6 @@ dependencies = [ "unsigned-varint", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", - "terminal_size", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "clipboard-win" version = "5.4.1" @@ -881,25 +515,6 @@ dependencies = [ "objc2-foundation 0.2.2", ] -[[package]] -name = "clipboard_wayland" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" -dependencies = [ - "smithay-clipboard", -] - -[[package]] -name = "clipboard_x11" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" -dependencies = [ - "thiserror 2.0.20", - "x11rb", -] - [[package]] name = "cmake" version = "0.1.58" @@ -909,6 +524,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -920,12 +541,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -945,24 +560,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -981,15 +578,6 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -1085,13 +673,10 @@ dependencies = [ ] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "cpubits" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" @@ -1117,16 +702,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -1163,99 +738,39 @@ dependencies = [ [[package]] name = "crypto-bigint" -version = "0.5.5" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ - "generic-array", - "rand_core 0.6.4", + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core", "subtle", "zeroize", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core", ] [[package]] -name = "ctor" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" -dependencies = [ - "dtor", -] - -[[package]] -name = "cucumber" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a87e18d925b19ebe0fd47ea45316abd216d81ec0879c2448c3f9a0e9da62be" -dependencies = [ - "anyhow", - "clap", - "console", - "cucumber-codegen", - "cucumber-expressions", - "derive_more", - "either", - "futures", - "gherkin", - "globwalk", - "humantime", - "inventory", - "itertools", - "linked-hash-map", - "pin-project", - "ref-cast", - "regex", - "sealed", - "smart-default", -] - -[[package]] -name = "cucumber-codegen" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2fc8a8bbb73af3230db699e8690c5c786655f75eb89e5f18d76055fa1a9a4d" -dependencies = [ - "cucumber-expressions", - "inflections", - "itertools", - "proc-macro2", - "quote", - "regex", - "syn 2.0.119", - "synthez", -] - -[[package]] -name = "cucumber-expressions" -version = "0.5.0" +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6401038de3af44fe74e6fccdb8a5b7db7ba418f480c8e9ad584c6f65c05a27a6" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "derive_more", - "either", - "nom", - "nom_locate", - "regex", - "regex-syntax", + "cmov", + "subtle", ] [[package]] @@ -1266,15 +781,16 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "curve25519-dalek" -version = "4.1.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "curve25519-dalek-derive", - "digest 0.10.7", + "digest", "fiat-crypto", + "rand_core", "rustc_version", "subtle", "zeroize", @@ -1333,11 +849,11 @@ dependencies = [ [[package]] name = "der" -version = "0.7.10" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid 0.9.6", + "const-oid", "pem-rfc7468", "zeroize", ] @@ -1348,50 +864,16 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", - "subtle", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", ] [[package]] @@ -1439,24 +921,12 @@ dependencies = [ "litrs", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dpi" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "dtor" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" - [[package]] name = "dunce" version = "1.0.5" @@ -1465,24 +935,25 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecdsa" -version = "0.16.9" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", "serdect", "signature", "spki", + "zeroize", ] [[package]] name = "ed25519" -version = "2.2.3" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", "signature", @@ -1490,15 +961,16 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core 0.6.4", + "rand_core", "serde", - "sha2 0.10.9", + "sha2", + "signature", "subtle", "zeroize", ] @@ -1511,68 +983,33 @@ checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" -version = "0.13.8" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ "base16ct", - "base64ct", "crypto-bigint", - "digest 0.10.7", + "crypto-common", + "digest", "ff", - "generic-array", "group", - "hkdf", + "hybrid-array", "pem-rfc7468", "pkcs8", - "rand_core 0.6.4", + "rand_core", "sec1", - "serde_json", "serdect", "subtle", "zeroize", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "cfg-if", ] [[package]] @@ -1644,19 +1081,19 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "ff" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.6.4", + "rand_core", "subtle", ] [[package]] name = "fiat-crypto" -version = "0.2.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" @@ -1872,27 +1309,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.4", - "windows-link 0.2.1", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1906,18 +1322,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -1927,28 +1331,11 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", + "rand_core", "wasm-bindgen", ] -[[package]] -name = "gherkin" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2c0d8c632f8a251ce9a8198079b1022adc586ff4e3d33e18debd40eb463b31" -dependencies = [ - "heck", - "peg", - "quote", - "serde", - "serde_json", - "syn 2.0.119", - "textwrap", - "thiserror 2.0.20", - "typed-builder", -] - [[package]] name = "gl_generator" version = "0.14.0" @@ -1966,30 +1353,6 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" -[[package]] -name = "globset" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "globwalk" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" -dependencies = [ - "bitflags 2.13.1", - "ignore", - "walkdir", -] - [[package]] name = "glow" version = "0.16.0" @@ -2064,12 +1427,12 @@ dependencies = [ [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -2164,24 +1527,12 @@ dependencies = [ "foldhash 0.2.0", ] -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hermit-abi" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hexf-parse" version = "0.2.1" @@ -2204,7 +1555,7 @@ dependencies = [ "idna", "ipnet", "jni", - "rand 0.10.2", + "rand", "thiserror 2.0.20", "tinyvec", "tokio", @@ -2224,7 +1575,7 @@ dependencies = [ "jni", "once_cell", "prefix-trie", - "rand 0.10.2", + "rand", "ring", "thiserror 2.0.20", "tinyvec", @@ -2249,7 +1600,7 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand 0.10.2", + "rand", "resolv-conf", "smallvec", "system-configuration", @@ -2265,11 +1616,9 @@ dependencies = [ "atproto-identity", "atproto-oauth", "atrium-api", - "atrium-common", "atrium-xrpc", "base64 0.23.1", "bytes", - "cucumber", "getrandom 0.4.3", "hickory-resolver", "http-body-util", @@ -2278,16 +1627,16 @@ dependencies = [ "hyper-util", "iced", "iconflow", - "keyring", "notify-rust", "objc", "open", "rustls", "rustls-native-certs", "secrecy", + "security-framework", "serde", "serde_json", - "sha2 0.11.0", + "sha2", "single-instance", "tokio", "tokio-rustls", @@ -2297,22 +1646,13 @@ dependencies = [ "zeroize", ] -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.10.7", + "digest", ] [[package]] @@ -2354,19 +1694,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "humantime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" - [[package]] name = "hybrid-array" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2404,7 +1740,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -2751,22 +2086,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "ignore" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -2777,31 +2096,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "inflections" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - [[package]] name = "ipconfig" version = "0.3.4" @@ -2854,21 +2148,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -2956,37 +2235,17 @@ dependencies = [ [[package]] name = "k256" -version = "0.13.4" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" dependencies = [ - "cfg-if", + "cpubits", "ecdsa", "elliptic-curve", - "once_cell", - "sha2 0.10.9", + "primeorder", + "sha2", "signature", -] - -[[package]] -name = "keyring" -version = "4.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72585bb6cc9bc370d1d545b7e23fcce71dfd4461c5e15275e3cf51bdfd9a980a" -dependencies = [ - "apple-native-keyring-store", - "keyring-core", - "windows-native-keyring-store", - "zbus-secret-service-keyring-store", -] - -[[package]] -name = "keyring-core" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" -dependencies = [ - "log", + "wnaf", ] [[package]] @@ -3074,12 +2333,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3253,15 +2506,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "metal" version = "0.32.0" @@ -3413,27 +2657,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "memoffset 0.6.5", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nom_locate" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" -dependencies = [ - "bytecount", - "memchr", - "nom", + "memoffset", ] [[package]] @@ -3445,42 +2669,7 @@ dependencies = [ "futures-lite", "log", "mac-notification-sys", - "serde", "tauri-winrt-notification", - "zbus", -] - -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", ] [[package]] @@ -3489,36 +2678,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3842,19 +3001,12 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "open" version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" dependencies = [ - "dunce", "is-wsl", "libc", ] @@ -3884,49 +3036,33 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - [[package]] name = "p256" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", "elliptic-curve", + "primefield", "primeorder", "serdect", - "sha2 0.10.9", + "sha2", ] [[package]] name = "p384" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" dependencies = [ "ecdsa", "elliptic-curve", + "fiat-crypto", + "primefield", "primeorder", "serdect", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -3964,38 +3100,11 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "peg" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" -dependencies = [ - "peg-macros", - "peg-runtime", -] - -[[package]] -name = "peg-macros" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" -dependencies = [ - "peg-runtime", - "proc-macro2", - "quote", -] - -[[package]] -name = "peg-runtime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" - [[package]] name = "pem-rfc7468" -version = "0.7.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ "base64ct", ] @@ -4033,27 +3142,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "piper" -version = "0.2.5" +name = "pin-utils" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkcs8" -version = "0.10.2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der", "spki", @@ -4115,15 +3213,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "prefix-trie" version = "0.8.4" @@ -4141,14 +3230,31 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" -version = "0.13.6" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ "elliptic-curve", + "once_cell", + "primefield", "serdect", + "wnaf", ] [[package]] @@ -4175,15 +3281,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.11" @@ -4210,10 +3307,11 @@ version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -4249,28 +3347,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -4279,35 +3361,7 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -4322,7 +3376,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -4392,26 +3446,6 @@ dependencies = [ "bitflags 2.13.1", ] -[[package]] -name = "ref-cast" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "regex" version = "1.13.1" @@ -4449,9 +3483,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -4474,6 +3508,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -4487,14 +3522,13 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] name = "reqwest-chain" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5c014fb79a8227db44a0433d748107750d2550b7fca55c59a3d7ee7d2ee2b2" +checksum = "d891de5e89a6c625ebc48ca55bdf6731de178557e6dbe6c623a27dabe3321a3e" dependencies = [ "anyhow", "async-trait", @@ -4504,16 +3538,16 @@ dependencies = [ [[package]] name = "reqwest-middleware" -version = "0.4.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", "http", "reqwest", "serde", - "thiserror 1.0.69", + "thiserror 2.0.20", "tower-service", ] @@ -4525,12 +3559,12 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "rfc6979" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ + "crypto-bigint", "hmac", - "subtle", ] [[package]] @@ -4609,7 +3643,6 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4638,6 +3671,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.14" @@ -4680,52 +3740,22 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sctk-adwaita" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit 0.19.2", - "tiny-skia", -] - -[[package]] -name = "sealed" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "sec1" -version = "0.7.3" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct", + "ctutils", "der", - "generic-array", - "pkcs8", + "hybrid-array", "serdect", "subtle", "zeroize", @@ -4741,25 +3771,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "secret-service" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf", - "num", - "once_cell", - "serde", - "sha2 0.10.9", - "zbus", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4861,17 +3872,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -4895,25 +3895,14 @@ dependencies = [ [[package]] name = "serdect" -version = "0.2.0" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ "base16ct", "serde", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha2" version = "0.11.0" @@ -4921,8 +3910,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -4931,24 +3920,14 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "signature" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", + "digest", + "rand_core", ] [[package]] @@ -5021,86 +4000,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "smart-default" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "smawk" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" - -[[package]] -name = "smithay-client-toolkit" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.1", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.1", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.20", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -5126,11 +4025,8 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" dependencies = [ - "as-raw-xcb-connection", "bytemuck", - "fastrand", "js-sys", - "memmap2", "ndk", "objc2 0.6.4", "objc2-core-foundation", @@ -5139,16 +4035,10 @@ dependencies = [ "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", - "rustix 1.1.4", - "tiny-xlib", "tracing", "wasm-bindgen", - "wayland-backend", - "wayland-client", - "wayland-sys", "web-sys", "windows-sys 0.61.2", - "x11rb", ] [[package]] @@ -5162,9 +4052,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", "der", @@ -5188,12 +4078,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -5249,46 +4133,13 @@ dependencies = [ ] [[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "synthez" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8a928f38f1bc873f28e0d2ba8298ad65374a6ac2241dabd297271531a736cd" -dependencies = [ - "syn 2.0.119", - "synthez-codegen", - "synthez-core", -] - -[[package]] -name = "synthez-codegen" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fb83b8df4238e11746984dfb3819b155cd270de0e25847f45abad56b3671047" -dependencies = [ - "syn 2.0.119", - "synthez-core", -] - -[[package]] -name = "synthez-core" -version = "0.4.0" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "906fba967105d822e7c7ed60477b5e76116724d33de68a585681fb253fc30d5c" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "sealed", "syn 2.0.119", ] @@ -5361,27 +4212,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "smawk", - "unicode-linebreak", - "unicode-width", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -5466,19 +4296,6 @@ dependencies = [ "strict-num", ] -[[package]] -name = "tiny-xlib" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" -dependencies = [ - "as-raw-xcb-connection", - "ctor", - "libloading", - "pkg-config", - "tracing", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -5514,10 +4331,8 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -5658,7 +4473,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5710,50 +4524,19 @@ dependencies = [ "core_maths", ] -[[package]] -name = "typed-builder" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" -dependencies = [ - "typed-builder-macro", -] - -[[package]] -name = "typed-builder-macro" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset 0.9.1", - "tempfile", - "windows-sys 0.61.2", -] - [[package]] name = "ulid" -version = "1.2.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +checksum = "947dde63b6d514cc5e044edad4e0ca7261afd1099d16c83d942cb2b2f348689c" dependencies = [ - "rand 0.9.5", + "rand", "web-time", ] @@ -5799,12 +4582,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "unsigned-varint" version = "0.8.0" @@ -5835,12 +4612,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.24.0" @@ -5849,7 +4620,6 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -5884,15 +4654,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -5962,141 +4723,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "wayland-backend" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.4", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" -dependencies = [ - "bitflags 2.13.1", - "rustix 1.1.4", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.13.1", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.4", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.104" @@ -6118,10 +4744,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" +name = "webpki-root-certs" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -6328,8 +4954,6 @@ checksum = "d5654226305eaf2dde8853fb482861d28e5dcecbbd40cb88e8393d94bb80d733" dependencies = [ "clipboard-win", "clipboard_macos", - "clipboard_wayland", - "clipboard_x11", "raw-window-handle", "thiserror 2.0.20", ] @@ -6472,19 +5096,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-native-keyring-store" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" -dependencies = [ - "byteorder", - "keyring-core", - "regex", - "windows-sys 0.61.2", - "zeroize", -] - [[package]] name = "windows-numerics" version = "0.2.0" @@ -6676,13 +5287,11 @@ version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "ahash", "android-activity", "atomic-waker", "bitflags 2.13.1", "block2 0.5.1", - "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -6691,34 +5300,24 @@ dependencies = [ "dpi", "js-sys", "libc", - "memmap2", "ndk", "objc2 0.5.2", "objc2-app-kit", "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", - "percent-encoding", "pin-project", "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", "wasm-bindgen", "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", "web-sys", "web-time", "windows-sys 0.52.0", - "x11-dl", - "x11rb", "xkbcommon-dl", ] @@ -6732,10 +5331,15 @@ dependencies = [ ] [[package]] -name = "wit-bindgen" -version = "0.57.1" +name = "wnaf" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" +dependencies = [ + "ff", + "group", + "hybrid-array", +] [[package]] name = "writeable" @@ -6743,44 +5347,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "libloading", - "once_cell", - "rustix 1.1.4", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xcursor" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" - [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -6835,88 +5401,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tokio", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus-secret-service-keyring-store" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" -dependencies = [ - "keyring-core", - "secret-service", - "zbus", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - [[package]] name = "zeno" version = "0.3.3" @@ -7023,44 +5507,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.3", - "winnow", -] diff --git a/Cargo.toml b/Cargo.toml index 4b132cb..d461f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,17 +25,13 @@ test-support = [] [dependencies] # `agent` and `bluesky` are the only generated namespaces used by Hitomi. atrium-api = { version = "=0.25.8", default-features = false, features = ["agent", "bluesky"] } -atrium-common = "=0.1.4" -atproto-identity = { git = "https://tangled.org/ngerakines.me/atproto-crates", rev = "c7f7345ce49936ac9949454a9e7b7cbc27521a1a", default-features = false, features = ["zeroize"] } +atproto-identity = { git = "https://tangled.org/ngerakines.me/atproto-crates", rev = "f949929216d04758e4210a4de413d055338b2846", default-features = false, features = ["zeroize"] } atrium-xrpc = "=0.12.4" -atproto-oauth = { git = "https://tangled.org/ngerakines.me/atproto-crates", rev = "c7f7345ce49936ac9949454a9e7b7cbc27521a1a", default-features = false, features = ["zeroize"] } -# Native WGPU UI, Tokio-backed Iced tasks, and Linux native window backends. -iced = { version = "0.14.0", default-features = false, features = ["tokio", "wgpu", "x11", "wayland", "canvas"] } -# The future browser adapter invokes the platform opener; Windows requires this -# opt-in normalization support for ShellExecute. -open = { version = "=5.4.1", default-features = false, features = ["shellexecute-on-windows"] } +atproto-oauth = { git = "https://tangled.org/ngerakines.me/atproto-crates", rev = "f949929216d04758e4210a4de413d055338b2846", default-features = false, features = ["zeroize"] } +# Native WGPU UI and Tokio-backed Iced tasks for macOS. +iced = { version = "0.14.0", default-features = false, features = ["tokio", "wgpu", "canvas"] } +open = { version = "=5.4.1", default-features = false } tokio = { version = "=1.53.1", default-features = false, features = ["macros", "net", "rt", "sync", "time", "test-util"] } -keyring = { version = "=4.1.6", default-features = false, features = ["v1"] } serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" base64 = "=0.23.1" @@ -64,27 +60,16 @@ serde_json = "=1.0.151" toml = "=1.1.4" sha2 = "=0.11.0" [dev-dependencies] -# Harness-free behavior runner and step-definition macros. -cucumber = { version = "=0.23.0", default-features = false, features = ["macros"] } toml = "=1.1.4" [target.'cfg(target_os = "macos")'.dependencies] notify-rust = { version = "=4.18.0", default-features = false } objc = "=0.2.7" - -[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies] -notify-rust = { version = "=4.18.0", default-features = false, features = ["z-with-tokio"] } - -[target.'cfg(target_os = "windows")'.dependencies] -notify-rust = { version = "=4.18.0", default-features = false } +security-framework = { version = "=3.7.0", default-features = false, features = ["OSX_10_14"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] } -[[test]] -name = "behavior" -harness = false -required-features = ["test-support"] [[test]] name = "feed_adapter" diff --git a/build.rs b/build.rs index 4777bd9..a46cb52 100644 --- a/build.rs +++ b/build.rs @@ -5,10 +5,22 @@ use std::path::Path; use serde::Deserialize; use sha2::{Digest as _, Sha256}; +const CLIENT_ID: &str = "https://hitomi.nateledford.com/client-metadata-v2.json"; +const CLIENT_NAME: &str = "Hitomi"; +const REDIRECT_URI: &str = "com.nateledford.hitomi:/oauth/callback"; + #[derive(Deserialize)] +#[serde(deny_unknown_fields)] struct ClientMetadata { client_id: String, + client_name: String, redirect_uris: Vec, + grant_types: Vec, + response_types: Vec, + scope: String, + token_endpoint_auth_method: String, + application_type: String, + dpop_bound_access_tokens: bool, } #[derive(Deserialize)] @@ -70,6 +82,18 @@ fn main() { fs::read(&metadata_path).expect("release client metadata must be readable"); let metadata: ClientMetadata = serde_json::from_slice(&metadata_bytes) .expect("release client metadata must be valid JSON"); + if metadata.client_id != CLIENT_ID + || metadata.redirect_uris.as_slice() != [REDIRECT_URI] + || metadata.client_name != CLIENT_NAME + || metadata.grant_types.as_slice() != ["authorization_code", "refresh_token"] + || metadata.response_types.as_slice() != ["code"] + || metadata.scope != "atproto" + || metadata.application_type != "native" + || metadata.token_endpoint_auth_method != "none" + || !metadata.dpop_bound_access_tokens + { + panic!("release client metadata must match the exact public native-client contract"); + } let manifest: CargoManifest = toml::from_str(&fs::read_to_string(&cargo_toml_path).expect("Cargo.toml must be readable")) .expect("Cargo.toml must be valid TOML"); diff --git a/src/application.rs b/src/application.rs index 781a9f8..40782ee 100644 --- a/src/application.rs +++ b/src/application.rs @@ -1,48 +1,31 @@ -//! Desktop composition root for authentication workflows. - +//! Desktop authentication facade. +//! +//! Iced owns presentation and stale-operation filtering; this facade owns finite +//! tasks and delegates every protocol or credential transition to one coordinator. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::{Duration, Instant}; use tokio::sync::Mutex; -use crate::authentication::command::ValidatedAuthorizationCallback; -use crate::authentication::ports::{CallbackListenerLifecycle, SessionRefresh}; -use crate::authentication::workflow::{ - AuthenticationApplication, PendingAuthorization, RestoreResult, SignInStart, -}; +use crate::authentication::command::AuthorizationCallbackUrl; use crate::authentication::{ - AccountGenerationReference, AccountHandle, AuthenticationAttemptId, AuthenticationCapability, - AuthenticationError, AuthenticationErrorCategory, AuthenticationErrorReference, + AccountHandle, AuthenticationAttemptId, AuthenticationError, AuthenticationErrorCategory, AuthenticationEvent, AuthenticationFailureReason, AuthenticationFailureStage, - AuthenticationPhase, AuthenticationStateMachine, AuthenticationTimeout, Epoch, - ManualAuthorizationUrl, RefreshOutcome, RemoteOAuthErrorCode, RestorationStatus, + AuthenticationPhase, AuthenticationStateMachine, Epoch, LogoutReport, ManualAuthorizationUrl, TransitionResult, }; use crate::feed::{HomeFeed, HomeFeedReader}; use crate::infrastructure::browser::SystemBrowser; -use crate::infrastructure::callback::NativeCallbackListener; -use crate::infrastructure::clock::SystemDeadlineClock; use crate::infrastructure::feed::OAuthHomeFeedReader; use crate::infrastructure::notification::SystemDesktopNotifier; -use crate::infrastructure::oauth::{ - AtprotoOAuthAdapter, AtprotoSessionRefresh, OAuthSessionService, -}; -use crate::infrastructure::session_vault::{KeyringSecretStore, SessionVault}; +use crate::infrastructure::oauth::OAuthCoordinator; +use crate::infrastructure::session_vault::{CredentialVault, MacOsKeychainVault}; +use crate::infrastructure::transport::{HardenedHttpClient, OutboundHttp}; use crate::notification::{DesktopNotification, DesktopNotifier, NotificationError}; -type Workflow = AuthenticationApplication< - AtprotoOAuthAdapter, - OAuthSessionService, - SystemBrowser, - NativeCallbackListener, - SystemDeadlineClock, ->; -type NativePending = - PendingAuthorization<::Bound>; -type SharedWorkflow = Arc; -type SharedState = Arc>; - #[derive(Clone, Debug)] pub enum SignInOutcome { BrowserOpened, @@ -50,1761 +33,506 @@ pub enum SignInOutcome { SignedIn, } -/// Credential-free routing result for startup session restoration. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum SessionRestorationOutcome { SignedIn, SignedOut, RecoveryRequired, - /// A newer operation took ownership before this restoration could apply. Superseded, } -struct PendingUiFlow { - pending: NativePending, +struct ActiveAttempt { attempt: AuthenticationAttemptId, epoch: Epoch, url: ManualAuthorizationUrl, } -#[derive(Clone)] -struct ActiveUiAttempt { - attempt: AuthenticationAttemptId, - epoch: Epoch, - reference: AuthenticationErrorReference, - started_at: Instant, -} - -#[derive(Clone, Copy)] -enum AuthorizationAttemptOutcome { - Denied, - Cancelled, - TimedOut, - Failed, -} - -const PAR_REQUEST_SHAPE_VERSION: u8 = 1; - -struct AuthenticationDiagnostics; - -impl AuthenticationDiagnostics { - fn record( - reference: AuthenticationErrorReference, - error: AuthenticationError, - outcome: AuthorizationAttemptOutcome, - started_at: Instant, - ) { - let elapsed_millis = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); - let provider_error_fingerprint = error.provider_error_fingerprint().map_or_else( - || "unavailable".to_owned(), - |fingerprint| fingerprint.to_string(), - ); - match error.par_response_diagnostic() { - Some(diagnostic) => eprintln!( - "auth outcome reference={reference} stage={} category={} reason={} par_status={} par_content_type={} par_body_len={} par_body_shape={} par_request_shape_version={PAR_REQUEST_SHAPE_VERSION} par_request_context={} local_provider_error_fingerprint={provider_error_fingerprint} outcome={outcome:?} elapsed_ms={elapsed_millis} version={} release_source_revision={} release_metadata_sha256={} release_client_id_sha256={} release_redirect_uri_sha256={}", - stage_name(error.stage()), - category_name(error.category()), - reason_name(error.reason()), - diagnostic.status(), - response_content_type_name(diagnostic.content_type()), - diagnostic.body_len(), - par_body_shape_name(diagnostic.body_shape()), - error - .par_request_diagnostic() - .map_or_else(|| "unavailable".to_owned(), |context| context.to_string()), - env!("CARGO_PKG_VERSION"), - crate::release_identity::SOURCE_REVISION, - crate::release_identity::METADATA_SHA256, - crate::release_identity::CLIENT_ID_SHA256, - crate::release_identity::REDIRECT_URI_SHA256, - ), - None => eprintln!( - "auth outcome reference={reference} stage={} category={} reason={} local_provider_error_fingerprint={provider_error_fingerprint} outcome={outcome:?} elapsed_ms={elapsed_millis} version={} release_source_revision={} release_metadata_sha256={} release_client_id_sha256={} release_redirect_uri_sha256={}", - stage_name(error.stage()), - category_name(error.category()), - reason_name(error.reason()), - env!("CARGO_PKG_VERSION"), - crate::release_identity::SOURCE_REVISION, - crate::release_identity::METADATA_SHA256, - crate::release_identity::CLIENT_ID_SHA256, - crate::release_identity::REDIRECT_URI_SHA256, - ), - } - } -} - -impl std::fmt::Debug for AuthorizationAttemptOutcome { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self { - Self::Denied => "denied", - Self::Cancelled => "cancelled", - Self::TimedOut => "timed-out", - Self::Failed => "failed", - }) - } -} - -const fn stage_name(stage: AuthenticationFailureStage) -> &'static str { - match stage { - AuthenticationFailureStage::InputValidation => "input-validation", - AuthenticationFailureStage::StateTransition => "state-transition", - AuthenticationFailureStage::CallbackListener => "callback-listener", - AuthenticationFailureStage::IdentityResolution => "identity-resolution", - AuthenticationFailureStage::ProtectedResourceMetadata => "protected-resource-metadata", - AuthenticationFailureStage::AuthorizationServerMetadata => "authorization-server-metadata", - AuthenticationFailureStage::PushedAuthorizationRequest => "pushed-authorization-request", - AuthenticationFailureStage::BrowserLaunch => "browser-launch", - AuthenticationFailureStage::AuthorizationWait => "authorization-wait", - AuthenticationFailureStage::CallbackValidation => "callback-validation", - AuthenticationFailureStage::TokenExchange => "token-exchange", - AuthenticationFailureStage::SessionValidation => "session-validation", - AuthenticationFailureStage::SessionPersistence => "session-persistence", - AuthenticationFailureStage::SessionRestoration => "session-restoration", - AuthenticationFailureStage::SessionRefresh => "session-refresh", - AuthenticationFailureStage::SessionRevocation => "session-revocation", - AuthenticationFailureStage::SessionInvalidation => "session-invalidation", - AuthenticationFailureStage::FeedRequest => "feed-request", - } -} - -const fn category_name(category: AuthenticationErrorCategory) -> &'static str { - match category { - AuthenticationErrorCategory::InvalidInput => "invalid-input", - AuthenticationErrorCategory::CredentialStoreUnavailable => "credential-store-unavailable", - AuthenticationErrorCategory::TransportUnavailable => "transport-unavailable", - AuthenticationErrorCategory::AuthorizationDenied => "authorization-denied", - AuthenticationErrorCategory::IssuerMismatch => "issuer-mismatch", - AuthenticationErrorCategory::CallbackRejected => "callback-rejected", - AuthenticationErrorCategory::BrowserUnavailable => "browser-unavailable", - AuthenticationErrorCategory::CallbackListenerUnavailable => "callback-listener-unavailable", - AuthenticationErrorCategory::DeadlineElapsed => "deadline-elapsed", - AuthenticationErrorCategory::Cancelled => "cancelled", - AuthenticationErrorCategory::AccessTokenRejected => "access-token-rejected", - AuthenticationErrorCategory::RefreshRecoveryRequired => "refresh-recovery-required", - AuthenticationErrorCategory::CredentialCorrupt => "credential-corrupt", - AuthenticationErrorCategory::SessionCleanupIncomplete => "session-cleanup-incomplete", - AuthenticationErrorCategory::AuthenticationUnavailable => "authentication-unavailable", - AuthenticationErrorCategory::ProtocolRejected => "protocol-rejected", - } -} - -const fn reason_name(reason: AuthenticationFailureReason) -> &'static str { - match reason { - AuthenticationFailureReason::InvalidInput => "invalid-input", - AuthenticationFailureReason::StateRejected => "state-rejected", - AuthenticationFailureReason::ListenerUnavailable => "listener-unavailable", - AuthenticationFailureReason::IdentityUnresolved => "identity-unresolved", - AuthenticationFailureReason::UpstreamUnavailable => "upstream-unavailable", - AuthenticationFailureReason::MalformedResponse => "malformed-response", - AuthenticationFailureReason::ParResponseRejected(_) => "par-response-rejected", - AuthenticationFailureReason::RequiredCapability(capability) => capability_name(capability), - AuthenticationFailureReason::RemoteRejected(code) => remote_code_name(code), - AuthenticationFailureReason::BrowserUnavailable => "browser-unavailable", - AuthenticationFailureReason::DeadlineElapsed => "deadline-elapsed", - AuthenticationFailureReason::CallbackRejected => "callback-rejected", - AuthenticationFailureReason::SubjectMismatch => "subject-mismatch", - AuthenticationFailureReason::AuthorityMismatch => "authority-mismatch", - AuthenticationFailureReason::SessionArtifactInvalid => "session-artifact-invalid", - AuthenticationFailureReason::CredentialStoreUnavailable => "credential-store-unavailable", - AuthenticationFailureReason::CleanupIncomplete => "cleanup-incomplete", - AuthenticationFailureReason::RefreshAmbiguous => "refresh-ambiguous", - AuthenticationFailureReason::AccessTokenRejected => "access-token-rejected", - AuthenticationFailureReason::Cancelled => "cancelled", - } -} - -const fn response_content_type_name( - content_type: crate::authentication::OAuthResponseContentType, -) -> &'static str { - match content_type { - crate::authentication::OAuthResponseContentType::Json => "json", - crate::authentication::OAuthResponseContentType::Missing => "missing", - crate::authentication::OAuthResponseContentType::Invalid => "invalid", - crate::authentication::OAuthResponseContentType::Other => "other", - } -} - -const fn par_body_shape_name( - body_shape: crate::authentication::ParResponseBodyShape, -) -> &'static str { - match body_shape { - crate::authentication::ParResponseBodyShape::NotExamined => "not-examined", - crate::authentication::ParResponseBodyShape::InvalidJson => "invalid-json", - crate::authentication::ParResponseBodyShape::JsonError => "json-error", - } -} - -const fn capability_name(capability: AuthenticationCapability) -> &'static str { - match capability { - AuthenticationCapability::ProtectedResourceLinkage => "protected-resource-linkage", - AuthenticationCapability::SingleAuthorizationServer => "single-authorization-server", - AuthenticationCapability::IssuerOrigin => "issuer-origin", - AuthenticationCapability::AuthorizationEndpoint => "authorization-endpoint", - AuthenticationCapability::TokenEndpoint => "token-endpoint", - AuthenticationCapability::PushedAuthorizationEndpoint => "pushed-authorization-endpoint", - AuthenticationCapability::AtprotoScope => "atproto-scope", - AuthenticationCapability::CodeResponse => "code-response", - AuthenticationCapability::AuthorizationCodeGrant => "authorization-code-grant", - AuthenticationCapability::RefreshTokenGrant => "refresh-token-grant", - AuthenticationCapability::PublicClientAuth => "public-client-auth", - AuthenticationCapability::PrivateKeyJwtAuth => "private-key-jwt-auth", - AuthenticationCapability::Es256ClientAssertion => "es256-client-assertion", - AuthenticationCapability::S256Pkce => "s256-pkce", - AuthenticationCapability::Es256Dpop => "es256-dpop", - AuthenticationCapability::AuthorizationResponseIssuer => "authorization-response-issuer", - AuthenticationCapability::PushedAuthorizationRequired => "pushed-authorization-required", - AuthenticationCapability::ClientMetadataDocument => "client-metadata-document", - AuthenticationCapability::RequestUriRegistration => "request-uri-registration", - AuthenticationCapability::DpopNonce => "dpop-nonce", - } -} - -const fn remote_code_name(code: RemoteOAuthErrorCode) -> &'static str { - match code { - RemoteOAuthErrorCode::InvalidRequest => "invalid-request", - RemoteOAuthErrorCode::InvalidClient => "invalid-client", - RemoteOAuthErrorCode::InvalidGrant => "invalid-grant", - RemoteOAuthErrorCode::UnauthorizedClient => "unauthorized-client", - RemoteOAuthErrorCode::UnsupportedGrantType => "unsupported-grant-type", - RemoteOAuthErrorCode::InvalidClientMetadata => "invalid-client-metadata", - RemoteOAuthErrorCode::InvalidScope => "invalid-scope", - RemoteOAuthErrorCode::UseDpopNonce => "use-dpop-nonce", - RemoteOAuthErrorCode::InvalidDpopProof => "invalid-dpop-proof", - RemoteOAuthErrorCode::ServerError => "server-error", - RemoteOAuthErrorCode::TemporarilyUnavailable => "temporarily-unavailable", - RemoteOAuthErrorCode::Other => "other", - } -} - -enum RefreshRequest { - Active(AccountGenerationReference), - InFlight, -} - -pub struct DesktopAuthentication { - workflow: SharedWorkflow, - feed: OAuthHomeFeedReader, - refresh: AtprotoSessionRefresh, - state: SharedState, - pending: Arc>>, - active_attempt: Arc>>, - pending_start: Arc, - cancelled_start: Arc, - latest_restoration: Arc, - restoration_gate: Arc>, - refresh_gate: Arc>, +#[allow( + private_interfaces, + reason = "Test-only transport and vault substitutions keep the public facade's production defaults unchanged." +)] +pub struct DesktopAuthentication< + T = HardenedHttpClient, + V = MacOsKeychainVault, + C = HardenedHttpClient, + B = SystemBrowser, +> { + coordinator: Arc>, + browser: B, + feed: OAuthHomeFeedReader, + state: Arc>, + active_attempt: Arc>>, next_attempt: AtomicU64, } impl Default for DesktopAuthentication { fn default() -> Self { - let vault = SessionVault::new(KeyringSecretStore); - let session_service = OAuthSessionService::new(vault.clone()); + let coordinator = Arc::new(OAuthCoordinator::new()); + let feed_client = + HardenedHttpClient::new_get_only().expect("native certificate store is available"); Self { - workflow: Arc::new(AuthenticationApplication::new( - AtprotoOAuthAdapter::new(session_service.clone()), - session_service.clone(), - SystemBrowser, - NativeCallbackListener, - SystemDeadlineClock::default(), - )), - feed: OAuthHomeFeedReader::new_with_service(vault.clone(), session_service.clone()), - refresh: AtprotoSessionRefresh::new(vault, session_service), + feed: OAuthHomeFeedReader::new(Arc::clone(&coordinator), feed_client), + coordinator, + browser: SystemBrowser, state: Arc::new(Mutex::new(AuthenticationStateMachine::signed_out( Epoch::new(0), ))), - pending: Arc::new(Mutex::new(None)), active_attempt: Arc::new(Mutex::new(None)), - pending_start: Arc::new(AtomicU64::new(u64::MAX)), - cancelled_start: Arc::new(AtomicU64::new(u64::MAX)), - latest_restoration: Arc::new(AtomicU64::new(u64::MAX)), - restoration_gate: Arc::new(StdMutex::new(())), - refresh_gate: Arc::new(Mutex::new(())), next_attempt: AtomicU64::new(0), } } } -/// Application-owned notification delivery service. -/// -/// UI callers invoke [`NotificationService::notify`] through an Iced -/// [`Task`](iced::Task), keeping native delivery outside the UI update loop. -pub struct NotificationService { - notifier: Arc, +trait BrowserLauncher: Send + Sync { + fn launch_authorization( + &self, + url: ManualAuthorizationUrl, + ) -> Pin> + Send>>; } -impl NotificationService { - #[must_use] - pub fn system() -> Self { - Self { - notifier: Arc::new(SystemDesktopNotifier), - } - } - - #[must_use] - pub fn with_notifier(notifier: Arc) -> Self { - Self { notifier } - } - - pub async fn notify(&self, notification: DesktopNotification) -> Result<(), NotificationError> { - self.notifier.notify(¬ification) +impl BrowserLauncher for SystemBrowser { + fn launch_authorization( + &self, + url: ManualAuthorizationUrl, + ) -> Pin> + Send>> { + let browser = *self; + Box::pin(async move { browser.launch_authorization(url).await }) } } -impl DesktopAuthentication { +#[allow( + private_bounds, + reason = "Generic substitutions are crate-private deterministic test seams." +)] +impl DesktopAuthentication +where + T: OutboundHttp, + V: CredentialVault, + C: OutboundHttp, + B: BrowserLauncher + 'static, +{ + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] pub fn sign_in( self: Arc, handle: String, ) -> impl Future> + Send + 'static { - let workflow = Arc::clone(&self.workflow); - let state = Arc::clone(&self.state); - let pending = Arc::clone(&self.pending); - let active_attempt = Arc::clone(&self.active_attempt); - let sequence = self.next_attempt.fetch_add(1, Ordering::Relaxed); - self.pending_start.store(sequence, Ordering::Release); - let cancelled_start = Arc::clone(&self.cancelled_start); - supersede_restoration(&self.latest_restoration, &self.restoration_gate, sequence); async move { - let mut bytes = [0_u8; 16]; - getrandom::fill(&mut bytes).map_err(|_| { - AuthenticationError::new( - AuthenticationFailureStage::InputValidation, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::UpstreamUnavailable, - ) - })?; - let reference = AuthenticationErrorReference::from_bytes(bytes); - start_sign_in( - workflow, - state, - pending, - active_attempt, - cancelled_start, - sequence, - reference, - handle, - ) - .await - } - } - - pub fn restore_session( - self: Arc, - ) -> impl Future> + Send + 'static - { - let workflow = Arc::clone(&self.workflow); - let state = Arc::clone(&self.state); - let restoration_gate = Arc::clone(&self.restoration_gate); - let latest_restoration = Arc::clone(&self.latest_restoration); - let refresh = self.refresh.clone(); - let sequence = self.next_attempt.fetch_add(1, Ordering::Relaxed); - { - let _gate = self - .restoration_gate + let account = AccountHandle::new(handle)?; + let id = self.next_attempt.fetch_add(1, Ordering::Relaxed); + let attempt = AuthenticationAttemptId::new(format!("attempt-{id}"))?; + let epoch = self.state.lock().await.epoch(); + if self + .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - latest_restoration.store(sequence, Ordering::Release); - } - let attempt_id = AuthenticationAttemptId::new(format!("restore-{sequence}")) - .expect("fixed restoration attempt identifier is valid"); - async move { - let result = refresh_restored_session( - workflow.restore(attempt_id.clone()).await?, - &refresh, - attempt_id.clone(), - ) - .await; - apply_latest_restoration_result( - latest_restoration, - restoration_gate, - sequence, - state, - attempt_id, - result, - ) - .await + .await + .apply(AuthenticationEvent::StartAuthorization { + attempt_id: attempt.clone(), + epoch, + }) + == TransitionResult::Rejected + { + return Err(rejected_state()); + } + let prepared = + match self + .coordinator + .prepare_authorization( + attempt.clone(), + epoch, + account, + crate::authentication::RedirectUri::native(), + ) + .await + { + Ok(prepared) => prepared, + Err(error) => { + let _ = self.state.lock().await.apply( + AuthenticationEvent::AuthorizationPrepared { + attempt_id: attempt, + epoch, + result: Err(error), + }, + ); + return Err(rejected_state()); + } + }; + if self + .state + .lock() + .await + .apply(AuthenticationEvent::AuthorizationPrepared { + attempt_id: attempt.clone(), + epoch, + result: Ok(()), + }) + == TransitionResult::Rejected + { + self.coordinator.retire_authorization(&attempt).await; + return Err(rejected_state()); + } + let url = prepared.manual_url().clone(); + *self.active_attempt.lock().await = Some(ActiveAttempt { + attempt: attempt.clone(), + epoch, + url: url.clone(), + }); + match self.browser.launch_authorization(url.clone()).await { + Ok(()) => { + if self + .state + .lock() + .await + .apply(AuthenticationEvent::BrowserFinished { + attempt_id: attempt, + epoch, + result: Ok(()), + }) + == TransitionResult::Rejected + { + return Err(rejected_state()); + } + Ok(SignInOutcome::BrowserOpened) + } + Err(_) => Ok(SignInOutcome::ManualAuthorizationRequired( + url.reveal_to_browser().to_owned(), + )), + } } } - pub fn load_initial_feed( + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] + pub fn complete_sign_in( self: Arc, - ) -> impl Future> + Send + 'static { - let state = Arc::clone(&self.state); - let feed = self.feed.clone(); + callback: AuthorizationCallbackUrl, + ) -> impl Future> + Send + 'static { async move { - let active = state.lock().await.active_reference().ok_or_else(|| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - feed.load_initial(active).await + let active = self + .active_attempt + .lock() + .await + .take() + .ok_or_else(rejected_state)?; + let result = self.coordinator.complete_callback(callback).await; + let transition = + self.state + .lock() + .await + .apply(AuthenticationEvent::AuthorizationCompleted { + attempt_id: active.attempt, + epoch: active.epoch, + result, + }); + if transition == TransitionResult::Applied(AuthenticationPhase::Active) { + Ok(SignInOutcome::SignedIn) + } else { + Err(rejected_state()) + } } } - pub fn refresh_initial_feed( + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] + pub fn retire_sign_in( self: Arc, - ) -> impl Future> + Send + 'static { - let state = Arc::clone(&self.state); - let feed = self.feed.clone(); - let refresh = self.refresh.clone(); - let refresh_gate = Arc::clone(&self.refresh_gate); - let sequence = self.next_attempt.fetch_add(1, Ordering::Relaxed); - async move { refresh_initial_feed(state, feed, refresh, refresh_gate, sequence).await } - } - - pub fn continue_sign_in( - self: Arc, - ) -> impl Future> + Send + 'static { - let workflow = Arc::clone(&self.workflow); - let state = Arc::clone(&self.state); - let pending = Arc::clone(&self.pending); - let active_attempt = Arc::clone(&self.active_attempt); + ) -> impl Future> + Send + 'static { async move { - let flow = pending.lock().await.take().ok_or_else(|| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let attempt = flow.attempt.clone(); - match complete_pending(workflow, state, flow).await { - Ok(outcome) => { - clear_active_attempt(active_attempt, &attempt).await; - Ok(outcome) - } - Err(error) => { - let terminal = match error.category() { - AuthenticationErrorCategory::AuthorizationDenied => { - AuthorizationAttemptOutcome::Denied - } - AuthenticationErrorCategory::DeadlineElapsed => { - AuthorizationAttemptOutcome::TimedOut - } - AuthenticationErrorCategory::Cancelled => { - AuthorizationAttemptOutcome::Cancelled - } - _ => AuthorizationAttemptOutcome::Failed, - }; - Err(finish_active_attempt(active_attempt, &attempt, error, terminal).await) - } + if let Some(active) = self.active_attempt.lock().await.take() { + self.coordinator.retire_authorization(&active.attempt).await; + let _ = self + .state + .lock() + .await + .apply(AuthenticationEvent::TimedOut { + attempt_id: active.attempt, + epoch: active.epoch, + }); } + Ok(()) } } + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] pub fn retry_browser( self: Arc, ) -> impl Future> + Send + 'static { - let workflow = Arc::clone(&self.workflow); - let pending = Arc::clone(&self.pending); - let state = Arc::clone(&self.state); - let active_attempt = Arc::clone(&self.active_attempt); async move { - let url = pending + let active = self + .active_attempt .lock() .await .as_ref() - .map(|flow| flow.url.clone()) - .ok_or_else(|| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let attempt = active_attempt.lock().await.clone().ok_or_else(|| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - workflow.retry_browser(url).await?; - if state + .map(|active| (active.attempt.clone(), active.epoch, active.url.clone())) + .ok_or_else(rejected_state)?; + self.browser.launch_authorization(active.2).await?; + if self + .state .lock() .await .apply(AuthenticationEvent::BrowserFinished { - attempt_id: attempt.attempt, - epoch: attempt.epoch, + attempt_id: active.0, + epoch: active.1, result: Ok(()), }) - != TransitionResult::Applied(AuthenticationPhase::Authorizing) + == TransitionResult::Rejected { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); + return Err(rejected_state()); } Ok(()) } } + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] pub fn cancel_sign_in( self: Arc, ) -> impl Future> + Send + 'static { - let workflow = Arc::clone(&self.workflow); - let state = Arc::clone(&self.state); - let pending = Arc::clone(&self.pending); - let active_attempt = Arc::clone(&self.active_attempt); - let pending_start = self.pending_start.load(Ordering::Acquire); - let cancelled_start = Arc::clone(&self.cancelled_start); async move { - let Some(attempt) = active_attempt.lock().await.clone() else { - cancelled_start.store(pending_start, Ordering::Release); - return Ok(()); - }; - if state.lock().await.apply(AuthenticationEvent::Cancel { - attempt_id: attempt.attempt.clone(), - epoch: attempt.epoch, - }) != TransitionResult::Applied(AuthenticationPhase::Cancelling) - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let cancellation = workflow - .cancel(attempt.attempt.clone(), attempt.epoch) - .await; - let transition = state.lock().await.apply(cancellation.event); - if cancellation.invalidation.is_err() - || transition != TransitionResult::Applied(AuthenticationPhase::SignedOut) - { - return Err(cancellation - .invalidation - .err() - .unwrap_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))); - } - let mut pending = pending.lock().await; - if pending - .as_ref() - .is_some_and(|flow| flow.attempt == attempt.attempt) - { - pending.take(); + if let Some(active) = self.active_attempt.lock().await.take() { + if self.state.lock().await.apply(AuthenticationEvent::Cancel { + attempt_id: active.attempt.clone(), + epoch: active.epoch, + }) == TransitionResult::Rejected + { + return Err(rejected_state()); + } + self.coordinator.retire_authorization(&active.attempt).await; + if self + .state + .lock() + .await + .apply(AuthenticationEvent::CancellationInvalidated { + attempt_id: active.attempt, + epoch: active.epoch, + result: Ok(()), + }) + == TransitionResult::Rejected + { + return Err(rejected_state()); + } } - let _ = finish_active_attempt( - active_attempt, - &attempt.attempt, - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::Cancelled, - AuthenticationFailureReason::Cancelled, - ), - AuthorizationAttemptOutcome::Cancelled, - ) - .await; Ok(()) } } -} -async fn refresh_initial_feed( - state: SharedState, - feed: F, - refresh: R, - refresh_gate: Arc>, - sequence: u64, -) -> Result -where - R: SessionRefresh, - F: HomeFeedReader, -{ - let requested = { - let state = state.lock().await; - match state.active_reference() { - Some(active) => RefreshRequest::Active(active), - None if state.phase() == AuthenticationPhase::Refreshing => RefreshRequest::InFlight, - None => { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - )); - } - } - }; - let _flight = refresh_gate.lock().await; - let active = state.lock().await.active_reference().ok_or_else(|| { - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::RefreshRecoveryRequired, - AuthenticationFailureReason::StateRejected, - ) - })?; - if matches!(requested, RefreshRequest::InFlight) - || matches!(&requested, RefreshRequest::Active(requested) if requested != &active) + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] + pub fn restore_session( + self: Arc, + ) -> impl Future> + Send + 'static { - return feed.load_initial(active).await; - } - let attempt = AuthenticationAttemptId::new(format!("refresh-{sequence}")).map_err(|_| { - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - })?; - let (epoch, capability) = state.lock().await.begin_refresh(attempt.clone(), &active)?; - let result = refresh.refresh_session(attempt.clone(), capability).await; - let transition = state - .lock() - .await - .apply(AuthenticationEvent::RefreshFinished { - attempt_id: attempt, - epoch, - result, - }); - if transition != TransitionResult::Applied(AuthenticationPhase::Active) { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::RefreshRecoveryRequired, - AuthenticationFailureReason::StateRejected, - )); - } - let replacement = state.lock().await.active_reference().ok_or_else(|| { - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::RefreshRecoveryRequired, - AuthenticationFailureReason::StateRejected, - ) - })?; - feed.load_initial(replacement).await -} - -async fn refresh_restored_session( - restored: RestoreResult, - refresh: &R, - attempt_id: AuthenticationAttemptId, -) -> RestoreResult { - let RestoreResult { epoch, event } = restored; - let AuthenticationEvent::RestorationFinished { - result: Ok(RestorationStatus::Restored { session, .. }), - .. - } = event - else { - return RestoreResult { epoch, event }; - }; - let result = match refresh.refresh_session(attempt_id.clone(), session).await { - Ok(RefreshOutcome::Replaced(session) | RefreshOutcome::NotDispatched(session)) => { - RestorationStatus::restored(session) - } - Ok(RefreshOutcome::RecoveryRequired(recovery)) => { - RestorationStatus::refresh_recovery_required(recovery) - } - Err(error) => { - return RestoreResult { - epoch, - event: AuthenticationEvent::RestorationFinished { - attempt_id, + async move { + let id = self.next_attempt.fetch_add(1, Ordering::Relaxed); + let attempt = AuthenticationAttemptId::new(format!("restore-{id}"))?; + let epoch = self.state.lock().await.epoch(); + if self + .state + .lock() + .await + .apply(AuthenticationEvent::StartRestoration { + attempt_id: attempt.clone(), epoch, - result: Err(error), - }, - }; - } - }; - RestoreResult { - epoch, - event: AuthenticationEvent::RestorationFinished { - attempt_id, - epoch, - result: Ok(result), - }, - } -} - -fn apply_restoration_result( - state: &mut AuthenticationStateMachine, - attempt_id: AuthenticationAttemptId, - result: RestoreResult, -) -> Result { - if state.phase() != AuthenticationPhase::SignedOut { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - - *state = AuthenticationStateMachine::signed_out(result.epoch); - if state.apply(AuthenticationEvent::StartRestoration { - attempt_id: attempt_id.clone(), - epoch: result.epoch, - }) != TransitionResult::Applied(AuthenticationPhase::Restoring) - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - - match state.apply(result.event) { - TransitionResult::Applied(AuthenticationPhase::Active) => { - Ok(SessionRestorationOutcome::SignedIn) - } - TransitionResult::Applied(AuthenticationPhase::SignedOut) => { - Ok(SessionRestorationOutcome::SignedOut) - } - TransitionResult::Applied(AuthenticationPhase::RefreshRecoveryRequired) => { - Ok(SessionRestorationOutcome::RecoveryRequired) - } - TransitionResult::Applied(_) | TransitionResult::Rejected => Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), - } -} - -async fn apply_latest_restoration_result( - latest_restoration: Arc, - restoration_gate: Arc>, - sequence: u64, - state: SharedState, - attempt_id: AuthenticationAttemptId, - result: RestoreResult, -) -> Result { - let mut state = state.lock().await; - let _gate = restoration_gate - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if latest_restoration.load(Ordering::Acquire) != sequence { - return Ok(SessionRestorationOutcome::Superseded); - } - apply_restoration_result(&mut state, attempt_id, result) -} - -fn supersede_restoration( - latest_restoration: &AtomicU64, - restoration_gate: &StdMutex<()>, - sequence: u64, -) { - let _gate = restoration_gate - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - latest_restoration.store(sequence, Ordering::Release); -} -fn begin_authorization( - state: &mut AuthenticationStateMachine, - attempt_id: AuthenticationAttemptId, - epoch: Epoch, -) -> TransitionResult { - let event = if state.phase() == AuthenticationPhase::RetryableFailure { - AuthenticationEvent::Retry { attempt_id, epoch } - } else { - AuthenticationEvent::StartAuthorization { attempt_id, epoch } - }; - state.apply(event) -} - -async fn record_authorization_handoff( - state: &SharedState, - attempt_id: AuthenticationAttemptId, - epoch: Epoch, -) -> Result<(), AuthenticationError> { - if state - .lock() - .await - .apply(AuthenticationEvent::BrowserFinished { - attempt_id, - epoch, - result: Ok(()), - }) - != TransitionResult::Applied(AuthenticationPhase::Authorizing) - { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - )); - } - Ok(()) -} - -async fn retire_started_attempt( - workflow: SharedWorkflow, - state: SharedState, - active_attempt: Arc>>, - attempt: AuthenticationAttemptId, - epoch: Epoch, - error: AuthenticationError, -) -> Result { - match retire(workflow, state, attempt.clone(), epoch, error).await { - Ok(outcome) => Ok(outcome), - Err(error) => Err(finish_active_attempt( - active_attempt, - &attempt, - error, - AuthorizationAttemptOutcome::Failed, - ) - .await), - } -} - -#[expect( - clippy::too_many_arguments, - reason = "the Iced task boundary passes the independently owned concurrent state handles explicitly" -)] -async fn start_sign_in( - workflow: SharedWorkflow, - state: SharedState, - pending_store: Arc>>, - active_attempt: Arc>>, - cancelled_start: Arc, - sequence: u64, - reference: AuthenticationErrorReference, - handle: String, -) -> Result { - let started_at = Instant::now(); - let handle = AccountHandle::new(handle).map_err(|error| { - let error = error.with_reference(reference); - AuthenticationDiagnostics::record( - reference, - error, - AuthorizationAttemptOutcome::Failed, - started_at, - ); - error - })?; - let attempt = AuthenticationAttemptId::new(reference.to_string()).map_err(|error| { - let error = error.with_reference(reference); - AuthenticationDiagnostics::record( - reference, - error, - AuthorizationAttemptOutcome::Failed, - started_at, - ); - error - })?; - let recovery_epoch = { - let mut state = state.lock().await; - let epoch = state.epoch(); - if state.phase() == AuthenticationPhase::Active - && state.apply(AuthenticationEvent::InvalidateActiveSession { - attempt_id: attempt.clone(), - epoch, - }) != TransitionResult::Applied(AuthenticationPhase::RefreshRecoveryRequired) - { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - .with_reference(reference)); - } - if state.phase() == AuthenticationPhase::RefreshRecoveryRequired { - if state.apply(AuthenticationEvent::StartLogout { - attempt_id: attempt.clone(), - epoch, - }) != TransitionResult::Applied(AuthenticationPhase::LoggingOut) + }) + == TransitionResult::Rejected { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - .with_reference(reference)); + return Err(rejected_state()); } - Some(epoch) - } else { - None - } - }; - if let Some(recovery_epoch) = recovery_epoch { - let recovery = workflow.cleanup_recovery(recovery_epoch).await; - let mut state = state.lock().await; - match recovery.tombstone { - Ok(tombstone_epoch) => { - if state.apply(AuthenticationEvent::TombstoneInstalled { - attempt_id: attempt.clone(), - epoch: recovery_epoch, - tombstone_epoch, - result: Ok(()), - }) != TransitionResult::Applied(AuthenticationPhase::Tombstoned) - { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - .with_reference(reference)); + let snapshot = self.coordinator.restore_session(attempt.clone()).await?; + let outcome = snapshot.into_status(); + let transition = + self.state + .lock() + .await + .apply(AuthenticationEvent::RestorationFinished { + attempt_id: attempt, + epoch, + result: Ok(outcome), + }); + match transition { + TransitionResult::Applied(AuthenticationPhase::Active) => { + Ok(SessionRestorationOutcome::SignedIn) } - match recovery.cleanup { - Some(Ok(())) => { - if state.apply(AuthenticationEvent::LogoutCleanupFinished { - attempt_id: attempt.clone(), - epoch: tombstone_epoch, - result: Ok(()), - }) != TransitionResult::Applied(AuthenticationPhase::SignedOut) - { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - .with_reference(reference)); - } - } - Some(Err(error)) => { - let _ = state.apply(AuthenticationEvent::LogoutCleanupFinished { - attempt_id: attempt.clone(), - epoch: tombstone_epoch, - result: Err(error), - }); - return Err(error.with_reference(reference)); - } - None => { - return Err(AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - .with_reference(reference)); - } + TransitionResult::Applied(AuthenticationPhase::SignedOut) => { + Ok(SessionRestorationOutcome::SignedOut) } - } - Err(error) => { - let _ = state.apply(AuthenticationEvent::TombstoneInstalled { - attempt_id: attempt.clone(), - epoch: recovery_epoch, - tombstone_epoch: recovery_epoch, - result: Err(error), - }); - return Err(error.with_reference(reference)); + TransitionResult::Applied(AuthenticationPhase::RefreshRecoveryRequired) => { + Ok(SessionRestorationOutcome::RecoveryRequired) + } + _ => Err(rejected_state()), } } } - let epoch = { - let mut state = state.lock().await; - let epoch = state.epoch(); - if begin_authorization(&mut state, attempt.clone(), epoch) - != TransitionResult::Applied(AuthenticationPhase::Authorizing) - { - let error = AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ) - .with_reference(reference); - AuthenticationDiagnostics::record( - reference, - error, - AuthorizationAttemptOutcome::Failed, - started_at, - ); - return Err(error); - } - epoch - }; - *active_attempt.lock().await = Some(ActiveUiAttempt { - attempt: attempt.clone(), - epoch, - reference, - started_at, - }); - if cancelled_start.load(Ordering::Acquire) == sequence { - return cancel_started_attempt(workflow, state, active_attempt, attempt, epoch).await; - } - let timeout = AuthenticationTimeout::new(Duration::from_secs(300))?; - let started = match workflow - .start_sign_in(attempt.clone(), epoch, handle, timeout) - .await - { - Ok(started) => started, - Err(error) => { - state + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] + pub fn load_initial_feed( + self: Arc, + ) -> impl Future> + Send + 'static { + async move { + let active = self + .state .lock() .await - .apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: attempt.clone(), - epoch, - result: Err(error), - }); - return Err(finish_active_attempt( - active_attempt, - &attempt, - error, - AuthorizationAttemptOutcome::Failed, - ) - .await); + .active_reference() + .ok_or_else(rejected_state)?; + self.feed.load_initial(active).await } - }; - if state - .lock() - .await - .apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: attempt.clone(), - epoch, - result: Ok(()), - }) - != TransitionResult::Applied(AuthenticationPhase::Authorizing) - { - return retire_started_attempt( - workflow, - state, - active_attempt, - attempt, - epoch, - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ), - ) - .await; } - let (pending, url, outcome) = match started { - SignInStart::BrowserOpened(pending) => { - if let Err(error) = record_authorization_handoff(&state, attempt.clone(), epoch).await { - return retire_started_attempt( - workflow, - state, - active_attempt, - attempt, - epoch, - error, - ) + + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] + pub fn refresh_initial_feed( + self: Arc, + ) -> impl Future> + Send + 'static { + async move { + let active = self + .state + .lock() + .await + .active_reference() + .ok_or_else(rejected_state)?; + let id = self.next_attempt.fetch_add(1, Ordering::Relaxed); + let attempt = AuthenticationAttemptId::new(format!("refresh-{id}"))?; + let (epoch, verified) = self + .state + .lock() + .await + .begin_refresh(attempt.clone(), &active)?; + let result = self + .coordinator + .refresh_session(attempt.clone(), verified) .await; - } - let url = pending.manual_url().clone(); - (pending, url, SignInOutcome::BrowserOpened) - } - SignInStart::ManualAuthorization { url, pending } => { - if let Err(error) = record_authorization_handoff(&state, attempt.clone(), epoch).await { - return retire_started_attempt( - workflow, - state, - active_attempt, - attempt, + if self + .state + .lock() + .await + .apply(AuthenticationEvent::RefreshFinished { + attempt_id: attempt, epoch, - error, - ) - .await; + result, + }) + != TransitionResult::Applied(AuthenticationPhase::Active) + { + return Err(rejected_state()); } - let outcome = - SignInOutcome::ManualAuthorizationRequired(url.reveal_to_browser().to_owned()); - (pending, url, outcome) - } - }; - *pending_store.lock().await = Some(PendingUiFlow { - pending, - attempt: attempt.clone(), - epoch, - url, - }); - Ok(outcome) -} - -async fn complete_pending( - workflow: SharedWorkflow, - state: SharedState, - flow: PendingUiFlow, -) -> Result { - let PendingUiFlow { - pending, - attempt, - epoch, - .. - } = flow; - let callback = match workflow.await_authorization(pending).await { - Ok(callback) => callback, - Err(error) => return retire(workflow, state, attempt, epoch, error).await, - }; - let command = match callback { - ValidatedAuthorizationCallback::Success(command) => command, - ValidatedAuthorizationCallback::Denied { .. } => { - return retire( - workflow, - state, - attempt, - epoch, - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthorizationDenied, - AuthenticationFailureReason::StateRejected, - ), - ) - .await; + let replacement = self + .state + .lock() + .await + .active_reference() + .ok_or_else(rejected_state)?; + self.feed.load_initial(replacement).await } - }; - if state.lock().await.accept_callback(command.clone()) - != TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization) - { - return retire( - workflow, - state, - attempt, - epoch, - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ), - ) - .await; } - let completion = workflow.complete_callback(command).await; - let completed = match &completion.event { - AuthenticationEvent::AuthorizationCompleted { - result: Ok(completed), - .. - } => Ok(completed.clone()), - AuthenticationEvent::AuthorizationCompleted { - result: Err(error), .. - } => Err(*error), - _ => { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - }; - let transition = state.lock().await.apply(completion.event); - match completed { - Ok(completed) - if transition - == TransitionResult::Applied(AuthenticationPhase::PersistingAuthorization) => - { - let persistence = workflow - .persist_completed_authorization(attempt.clone(), epoch, completed) - .await; - let persistence_error = match &persistence { - AuthenticationEvent::PersistenceFinished { - result: Err(error), .. - } => Some(*error), - _ => None, + #[expect( + clippy::manual_async_fn, + reason = "Iced tasks require owned 'static futures from this facade." + )] + pub fn logout(self: Arc) -> impl Future + Send + 'static { + async move { + let active = self.state.lock().await.active_reference(); + let Some(active) = active else { + return LogoutReport { + revocation: crate::authentication::RevocationReport { + refresh: crate::authentication::RevocationOutcome::NotPresent, + access: crate::authentication::RevocationOutcome::NotPresent, + }, + local_cleanup: Ok(()), + }; }; - let transition = state.lock().await.apply(persistence); - if transition == TransitionResult::Applied(AuthenticationPhase::Active) { - Ok(SignInOutcome::SignedIn) - } else if let Some(error) = persistence_error { - retire_after_cancelling(workflow, state, attempt, epoch, error).await - } else { - Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )) - } - } - Err(error) if transition == TransitionResult::Applied(AuthenticationPhase::Cancelling) => { - retire_after_cancelling(workflow, state, attempt, epoch, error).await - } - _ => Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), - } -} - -async fn retire( - workflow: SharedWorkflow, - state: SharedState, - attempt: AuthenticationAttemptId, - epoch: Epoch, - error: AuthenticationError, -) -> Result { - let event = if error.category() == AuthenticationErrorCategory::DeadlineElapsed { - AuthenticationEvent::TimedOut { - attempt_id: attempt.clone(), - epoch, - } - } else { - AuthenticationEvent::Cancel { - attempt_id: attempt.clone(), - epoch, - } - }; - if state.lock().await.apply(event) != TransitionResult::Applied(AuthenticationPhase::Cancelling) - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - retire_after_cancelling(workflow, state, attempt, epoch, error).await -} - -async fn retire_after_cancelling( - workflow: SharedWorkflow, - state: SharedState, - attempt: AuthenticationAttemptId, - epoch: Epoch, - error: AuthenticationError, -) -> Result { - let cancellation = workflow.cancel(attempt, epoch).await; - let invalidation = cancellation.invalidation; - let transition = state.lock().await.apply(cancellation.event); - if invalidation.is_err() - || transition != TransitionResult::Applied(AuthenticationPhase::SignedOut) - { - return Err(invalidation.err().unwrap_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))); - } - Err(error) -} - -async fn clear_active_attempt( - active_attempt: Arc>>, - attempt: &AuthenticationAttemptId, -) { - let mut active = active_attempt.lock().await; - if active - .as_ref() - .is_some_and(|current| current.attempt == *attempt) - { - *active = None; - } -} - -async fn finish_active_attempt( - active_attempt: Arc>>, - expected_id: &AuthenticationAttemptId, - error: AuthenticationError, - outcome: AuthorizationAttemptOutcome, -) -> AuthenticationError { - let active = { - let mut slot = active_attempt.lock().await; - match slot.as_ref() { - Some(active) if active.attempt == *expected_id => slot.take(), - _ => None, - } - }; - match active { - Some(active) => { - let error = error.with_reference(active.reference); - AuthenticationDiagnostics::record(active.reference, error, outcome, active.started_at); - error - } - None => error, - } -} - -async fn cancel_started_attempt( - workflow: SharedWorkflow, - state: SharedState, - active_attempt: Arc>>, - attempt: AuthenticationAttemptId, - epoch: Epoch, -) -> Result { - if state.lock().await.apply(AuthenticationEvent::Cancel { - attempt_id: attempt.clone(), - - epoch, - }) != TransitionResult::Applied(AuthenticationPhase::Cancelling) - { - return Err(finish_active_attempt( - active_attempt, - &attempt, - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::StateRejected, - ), - AuthorizationAttemptOutcome::Failed, - ) - .await); - } - let cancellation = workflow.cancel(attempt.clone(), epoch).await; - let transition = state.lock().await.apply(cancellation.event); - if cancellation.invalidation.is_err() - || transition != TransitionResult::Applied(AuthenticationPhase::SignedOut) - { - let error = cancellation - .invalidation - .err() - .unwrap_or(AuthenticationError::new( - AuthenticationFailureStage::SessionInvalidation, - AuthenticationErrorCategory::AuthenticationUnavailable, - AuthenticationFailureReason::CleanupIncomplete, - )); - return Err(finish_active_attempt( - active_attempt, - &attempt, - error, - AuthorizationAttemptOutcome::Failed, - ) - .await); - } - Err(finish_active_attempt( - active_attempt, - &attempt, - AuthenticationError::new( - AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::Cancelled, - AuthenticationFailureReason::Cancelled, - ), - AuthorizationAttemptOutcome::Cancelled, - ) - .await) -} - -#[cfg(test)] -mod restoration_tests { - use super::*; - use crate::authentication::{ - AccountGenerationReference, Did, Generation, RestorationSnapshot, RestorationStatus, - VerifiedActiveSession, - }; - #[derive(Clone)] - struct CountingRefresh(Arc); - - impl SessionRefresh for CountingRefresh { - async fn refresh_session( - &self, - _: AuthenticationAttemptId, - active: VerifiedActiveSession, - ) -> Result { - self.0.fetch_add(1, Ordering::SeqCst); - tokio::task::yield_now().await; - let replacement = - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - active.reference().account().clone(), - Generation::new(active.reference().generation().value() + 1), - active.reference().epoch(), - )); - Ok(RefreshOutcome::Replaced(replacement)) - } - } - - #[derive(Clone, Default)] - struct CountingFeed(Arc); - - impl HomeFeedReader for CountingFeed { - async fn load_initial( - &self, - _: AccountGenerationReference, - ) -> Result { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(HomeFeed::default()) - } - } - - #[test] - fn retrying_authorization_begins_a_new_authorization_attempt() { - let epoch = Epoch::new(7); - let first = AuthenticationAttemptId::new("first-attempt").expect("valid attempt"); - let second = AuthenticationAttemptId::new("second-attempt").expect("valid attempt"); - let mut state = AuthenticationStateMachine::signed_out(epoch); - - assert_eq!( - state.apply(AuthenticationEvent::StartAuthorization { - attempt_id: first.clone(), - epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - state.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: first, - epoch, - result: Err(AuthenticationError::new( - AuthenticationFailureStage::PushedAuthorizationRequest, - AuthenticationErrorCategory::TransportUnavailable, - AuthenticationFailureReason::UpstreamUnavailable, - )), - }), - TransitionResult::Applied(AuthenticationPhase::RetryableFailure) - ); - - assert_eq!( - begin_authorization(&mut state, second, epoch), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - } - - #[tokio::test] - async fn manual_authorization_handoff_advances_to_awaiting_callback() { - let epoch = Epoch::new(7); - let attempt = - AuthenticationAttemptId::new("manual-handoff").expect("valid authentication attempt"); - let state = Arc::new(Mutex::new(AuthenticationStateMachine::signed_out(epoch))); - - assert_eq!( - state + let id = self.next_attempt.fetch_add(1, Ordering::Relaxed); + let attempt = AuthenticationAttemptId::new(format!("logout-{id}")) + .expect("fixed logout attempt identifier is valid"); + let epoch = self.state.lock().await.epoch(); + if self + .state .lock() .await - .apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt.clone(), + .apply(AuthenticationEvent::StartLogout { + attempt_id: attempt, epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - state - .lock() + }) + == TransitionResult::Rejected + { + return LogoutReport { + revocation: crate::authentication::RevocationReport { + refresh: crate::authentication::RevocationOutcome::NotPresent, + access: crate::authentication::RevocationOutcome::NotPresent, + }, + local_cleanup: Err(rejected_state()), + }; + } + self.coordinator + .revoke_and_delete( + crate::authentication::VerifiedActiveSession::from_verified_reference(active), + ) .await - .apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: attempt.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - - let issuer = crate::authentication::AttemptBoundAuthorizationServerIssuer::new( - attempt.clone(), - "https://issuer.example", - ) - .expect("valid issuer"); - let request_issuer = crate::authentication::AttemptBoundAuthorizationServerIssuer::new( - attempt.clone(), - "https://issuer.example", - ) - .expect("valid request issuer"); - let request = request_issuer.with_manual_url( - ManualAuthorizationUrl::new("https://issuer.example/authorize") - .expect("valid authorization URL"), - ); - let prepared = crate::authentication::PreparedAuthorization::new( - attempt.clone(), - issuer, - "state-canary", - crate::authentication::RedirectUri::for_selected_port(12345) - .expect("valid redirect URI"), - request, - ) - .expect("valid authorization preparation") - .bind_epoch(epoch) - .expect("preparation can bind once"); - let callback = - crate::authentication::command::CompleteAuthorizationCallback::from_inbound_callback( - &prepared, - "https://issuer.example", - "code-canary", - "state-canary", - ) - .expect("matching callback"); - record_authorization_handoff(&state, attempt, epoch) - .await - .expect("manual handoff reaches callback wait"); - - assert_eq!( - state.lock().await.accept_callback(callback), - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization) - ); - } - - #[tokio::test] - async fn simultaneous_refresh_requests_dispatch_once_then_share_the_generation() { - let active = - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - Did::new("did:plc:test") - .expect("valid DID") - .into_authenticated(), - Generation::new(1), - Epoch::new(3), - )); - let state = Arc::new(Mutex::new(AuthenticationStateMachine::signed_out( - Epoch::new(0), - ))); - let restore_attempt = - AuthenticationAttemptId::new("restore-active").expect("valid attempt"); - assert_eq!( - apply_restoration_result( - &mut *state.lock().await, - restore_attempt.clone(), - restore_result( - restore_attempt, - RestorationSnapshot::new(Epoch::new(3), RestorationStatus::restored(active)), - ), - ), - Ok(SessionRestorationOutcome::SignedIn) - ); - let refresh = CountingRefresh(Arc::default()); - let feed = CountingFeed::default(); - let gate = Arc::new(Mutex::new(())); - - let (first, second) = tokio::join!( - refresh_initial_feed( - Arc::clone(&state), - feed.clone(), - refresh.clone(), - Arc::clone(&gate), - 1 - ), - refresh_initial_feed(state, feed.clone(), refresh.clone(), gate, 2) - ); - - assert!(first.is_ok()); - assert!(second.is_ok()); - assert_eq!(refresh.0.load(Ordering::SeqCst), 1); - assert_eq!(feed.0.load(Ordering::SeqCst), 2); - } - - struct NotDispatchedRefresh; - - impl SessionRefresh for NotDispatchedRefresh { - async fn refresh_session( - &self, - _: AuthenticationAttemptId, - active: VerifiedActiveSession, - ) -> Result { - Ok(RefreshOutcome::NotDispatched(active)) } } - #[tokio::test] - async fn restoration_refreshes_before_reporting_an_active_session() { - let attempt = AuthenticationAttemptId::new("restore-refresh").expect("valid attempt"); - let active = - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - Did::new("did:plc:test") - .expect("valid DID") - .into_authenticated(), - Generation::new(1), - Epoch::new(3), - )); - let restored = restore_result( - attempt.clone(), - RestorationSnapshot::new(Epoch::new(3), RestorationStatus::restored(active)), - ); - - let result = - refresh_restored_session(restored, &NotDispatchedRefresh, attempt.clone()).await; - - assert!(matches!( - result.event, - AuthenticationEvent::RestorationFinished { - attempt_id, - result: Ok(RestorationStatus::Restored { .. }), - .. - } if attempt_id == attempt - )); - } - - fn restore_result( - attempt: AuthenticationAttemptId, - snapshot: RestorationSnapshot, - ) -> RestoreResult { - let epoch = snapshot.epoch(); - RestoreResult { - epoch, - event: AuthenticationEvent::RestorationFinished { - attempt_id: attempt, - epoch, - result: Ok(snapshot.into_status()), - }, + #[cfg(test)] + fn with_test_parts( + coordinator: Arc>, + browser: B, + feed_client: C, + ) -> Self { + Self { + feed: OAuthHomeFeedReader::new(Arc::clone(&coordinator), feed_client), + coordinator, + browser, + state: Arc::new(Mutex::new(AuthenticationStateMachine::signed_out( + Epoch::new(0), + ))), + active_attempt: Arc::new(Mutex::new(None)), + next_attempt: AtomicU64::new(0), } } - - #[test] - fn restoration_routes_exact_reducer_outcomes() { - let attempt = AuthenticationAttemptId::new("restore-test").expect("valid attempt"); - let active = - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - Did::new("did:plc:test") - .expect("valid DID") - .into_authenticated(), - Generation::new(1), - Epoch::new(3), - )); - let mut state = AuthenticationStateMachine::signed_out(Epoch::new(0)); - assert_eq!( - apply_restoration_result( - &mut state, - attempt.clone(), - restore_result( - attempt, - RestorationSnapshot::new(Epoch::new(3), RestorationStatus::restored(active)), - ), - ), - Ok(SessionRestorationOutcome::SignedIn) - ); - } - #[tokio::test] - async fn stale_restoration_completion_cannot_overwrite_newer_signed_out_state() { - let latest_restoration = Arc::new(AtomicU64::new(2)); - let restoration_gate = Arc::new(StdMutex::new(())); - let state = Arc::new(Mutex::new(AuthenticationStateMachine::signed_out( - Epoch::new(0), - ))); - let newer_attempt = - AuthenticationAttemptId::new("restore-2").expect("valid newer restoration attempt"); - assert_eq!( - apply_latest_restoration_result( - Arc::clone(&latest_restoration), - Arc::clone(&restoration_gate), - 2, - Arc::clone(&state), - newer_attempt.clone(), - restore_result( - newer_attempt, - RestorationSnapshot::new(Epoch::new(2), RestorationStatus::Unavailable), - ), - ) - .await, - Ok(SessionRestorationOutcome::SignedOut) - ); - - let active = - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - Did::new("did:plc:test") - .expect("valid DID") - .into_authenticated(), - Generation::new(1), - Epoch::new(1), - )); - let stale_attempt = - AuthenticationAttemptId::new("restore-1").expect("valid stale restoration attempt"); - assert_eq!( - apply_latest_restoration_result( - Arc::clone(&latest_restoration), - Arc::clone(&restoration_gate), - 1, - Arc::clone(&state), - stale_attempt.clone(), - restore_result( - stale_attempt, - RestorationSnapshot::new(Epoch::new(1), RestorationStatus::restored(active)), - ), - ) - .await, - Ok(SessionRestorationOutcome::Superseded) - ); - assert_eq!(state.lock().await.phase(), AuthenticationPhase::SignedOut); - } - - #[tokio::test] - async fn newer_registration_while_stale_completion_waits_cannot_apply() { - let latest_restoration = Arc::new(AtomicU64::new(1)); - let restoration_gate = Arc::new(StdMutex::new(())); - let state = Arc::new(Mutex::new(AuthenticationStateMachine::signed_out( - Epoch::new(0), - ))); - let state_guard = state.lock().await; - let attempt = AuthenticationAttemptId::new("restore-1").expect("valid attempt"); - let completion = tokio::spawn(apply_latest_restoration_result( - Arc::clone(&latest_restoration), - Arc::clone(&restoration_gate), - 1, - Arc::clone(&state), - attempt.clone(), - restore_result( - attempt, - RestorationSnapshot::new(Epoch::new(1), RestorationStatus::Unavailable), - ), - )); - tokio::task::yield_now().await; - supersede_restoration(&latest_restoration, &restoration_gate, 2); - drop(state_guard); - - assert_eq!( - completion.await.expect("completion task remains joinable"), - Ok(SessionRestorationOutcome::Superseded) - ); - assert_eq!(state.lock().await.phase(), AuthenticationPhase::SignedOut); - } - - #[test] - fn restoration_rejects_non_startup_state() { - let attempt = AuthenticationAttemptId::new("restore-rejected").expect("valid attempt"); - let mut state = AuthenticationStateMachine::signed_out(Epoch::new(2)); - assert_eq!( - state.apply(AuthenticationEvent::StartRestoration { - attempt_id: attempt.clone(), - epoch: Epoch::new(2), - }), - TransitionResult::Applied(AuthenticationPhase::Restoring) - ); - assert_eq!( - apply_restoration_result( - &mut state, - attempt.clone(), - restore_result( - attempt, - RestorationSnapshot::new(Epoch::new(2), RestorationStatus::Unavailable), - ), - ) - .expect_err("non-startup state rejects") - .category(), - AuthenticationErrorCategory::AuthenticationUnavailable - ); - } - - #[test] - fn restoration_routes_refresh_recovery_to_recovery_required() { - let attempt = AuthenticationAttemptId::new("restore-recovery").expect("valid attempt"); - let active = - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - Did::new("did:plc:test") - .expect("valid DID") - .into_authenticated(), - Generation::new(1), - Epoch::new(3), - )); - let mut state = AuthenticationStateMachine::signed_out(Epoch::new(0)); - assert_eq!( - apply_restoration_result( - &mut state, - attempt.clone(), - restore_result( - attempt, - RestorationSnapshot::new( - Epoch::new(3), - RestorationStatus::refresh_recovery_required( - active.into_refresh_recovery(), - ), - ), - ), - ), - Ok(SessionRestorationOutcome::RecoveryRequired) - ); - } } #[cfg(test)] -mod notification_tests { - use std::sync::{Arc, Mutex}; - - use crate::notification::{ - DesktopNotification, DesktopNotifier, NotificationCategory, NotificationError, - }; +mod oauth_end_to_end_tests; - #[derive(Default)] - struct RecordingNotifier(Mutex>); +fn rejected_state() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::StateTransition, + AuthenticationErrorCategory::AuthenticationUnavailable, + AuthenticationFailureReason::StateRejected, + ) +} - impl DesktopNotifier for RecordingNotifier { - fn notify(&self, notification: &DesktopNotification) -> Result<(), NotificationError> { - self.0 - .lock() - .expect("test notifier lock") - .push(notification.clone()); - Ok(()) +pub struct NotificationService { + notifier: Arc, +} +impl NotificationService { + #[must_use] + pub fn system() -> Self { + Self { + notifier: Arc::new(SystemDesktopNotifier), } } - - #[tokio::test] - async fn notification_service_composes_an_owned_port() { - let notifier = Arc::new(RecordingNotifier::default()); - let service = super::NotificationService::with_notifier( - Arc::clone(¬ifier) as Arc - ); - let notification = DesktopNotification::new( - NotificationCategory::Mention, - "Alice mentioned you", - "Hello from Bluesky", - Some("mention:1".to_owned()), - ); - - assert_eq!(service.notify(notification.clone()).await, Ok(())); - assert_eq!( - notifier.0.lock().expect("test notifier lock").as_slice(), - &[notification] - ); + #[must_use] + pub fn with_notifier(notifier: Arc) -> Self { + Self { notifier } + } + pub async fn notify(&self, notification: DesktopNotification) -> Result<(), NotificationError> { + self.notifier.notify(¬ification) } } diff --git a/src/application/oauth_end_to_end_tests.rs b/src/application/oauth_end_to_end_tests.rs new file mode 100644 index 0000000..0331dd7 --- /dev/null +++ b/src/application/oauth_end_to_end_tests.rs @@ -0,0 +1,347 @@ +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +use atrium_xrpc::http::{Response, StatusCode}; +use url::form_urlencoded; + +use super::{BrowserLauncher, DesktopAuthentication, SignInOutcome}; +use crate::authentication::command::AuthorizationCallbackUrl; +use crate::authentication::{AuthenticationPhase, ManualAuthorizationUrl}; +use crate::infrastructure::oauth::OAuthCoordinator; +use crate::infrastructure::session_vault::{CredentialVault, MemoryCredentialVault}; +use crate::infrastructure::transport::test_support::{ScriptedOutboundHttp, response}; + +const DID: &str = "did:plc:abcdefghijklmnopqrstuvwx"; +const ACCESS_TOKEN: &str = "access-token-canary"; +const REFRESH_TOKEN: &str = "replacement-refresh-canary"; + +type TestApplication = DesktopAuthentication< + ScriptedOutboundHttp, + MemoryCredentialVault, + ScriptedOutboundHttp, + Arc, +>; + +#[derive(Default)] +struct CapturedBrowser { + urls: Mutex>, +} + +impl CapturedBrowser { + fn urls(&self) -> Vec { + self.urls + .lock() + .map_or_else(|_| Vec::new(), |urls| urls.clone()) + } +} +impl BrowserLauncher for Arc { + fn launch_authorization( + &self, + url: ManualAuthorizationUrl, + ) -> Pin> + Send>> + { + let captured = url.reveal_to_browser().to_owned(); + self.urls + .lock() + .expect("captured browser lock") + .push(captured); + Box::pin(async { Ok(()) }) + } +} + +struct Fixture { + app: Arc, + browser: Arc, + http: ScriptedOutboundHttp, + vault: MemoryCredentialVault, +} + +impl Fixture { + fn new( + responses: impl IntoIterator, + ) -> Self { + let http = ScriptedOutboundHttp::new(responses); + let vault = MemoryCredentialVault::default(); + let browser = Arc::new(CapturedBrowser::default()); + let coordinator = Arc::new(OAuthCoordinator::with_http_and_vault( + http.clone(), + vault.clone(), + )); + let app = Arc::new(DesktopAuthentication::with_test_parts( + coordinator, + Arc::clone(&browser), + http.clone(), + )); + Self { + app, + browser, + http, + vault, + } + } + + async fn start(&self) { + assert!(matches!( + self.app.clone().sign_in(DID.to_owned()).await, + Ok(SignInOutcome::BrowserOpened) + )); + } + + fn approval_callback(&self) -> AuthorizationCallbackUrl { + let requests = self.http.requests(); + let par = requests.get(3).expect("PAR request is captured"); + let state = form_urlencoded::parse(&par.body) + .find_map(|(name, value)| (name == "state").then(|| value.into_owned())) + .expect("PAR state"); + AuthorizationCallbackUrl::parse(format!( + "com.nateledford.hitomi:/oauth/callback?code=code-canary&state={state}&iss=https%3A%2F%2Fauth.example.com" + )) + .expect("fixture callback") + } +} + +fn json(status: StatusCode, body: &str) -> crate::infrastructure::transport::OutboundResponse { + response( + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(body.as_bytes().to_vec()) + .expect("fixture response"), + ) +} + +fn json_with_nonce( + status: StatusCode, + nonce: &str, + body: &str, +) -> crate::infrastructure::transport::OutboundResponse { + response( + Response::builder() + .status(status) + .header("content-type", "application/json") + .header("dpop-nonce", nonce) + .body(body.as_bytes().to_vec()) + .expect("fixture response"), + ) +} +fn did_document() -> crate::infrastructure::transport::OutboundResponse { + json( + StatusCode::OK, + r##"{"@context":[],"id":"did:plc:abcdefghijklmnopqrstuvwx","alsoKnownAs":[],"verificationMethod":[],"service":[{"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}]}"##, + ) +} + +fn protected_resource() -> crate::infrastructure::transport::OutboundResponse { + json( + StatusCode::OK, + r#"{"resource":"https://pds.example.com","authorization_servers":["https://auth.example.com"]}"#, + ) +} + +fn authorization_server() -> crate::infrastructure::transport::OutboundResponse { + json( + StatusCode::OK, + r#"{"issuer":"https://auth.example.com","authorization_endpoint":"https://auth.example.com/authorize","token_endpoint":"https://auth.example.com/token","pushed_authorization_request_endpoint":"https://auth.example.com/par","scopes_supported":["atproto"],"response_types_supported":["code"],"grant_types_supported":["authorization_code","refresh_token"],"token_endpoint_auth_methods_supported":["none"],"code_challenge_methods_supported":["S256"],"dpop_signing_alg_values_supported":["ES256"],"token_endpoint_auth_signing_alg_values_supported":["ES256"],"require_pushed_authorization_requests":true,"require_request_uri_registration":true,"authorization_response_iss_parameter_supported":true,"client_id_metadata_document_supported":true}"#, + ) +} + +fn prepared_responses() -> Vec { + vec![ + did_document(), + protected_resource(), + authorization_server(), + json_with_nonce( + StatusCode::BAD_REQUEST, + "par-nonce", + r#"{"error":"use_dpop_nonce"}"#, + ), + json_with_nonce( + StatusCode::CREATED, + "par-fresh-nonce", + r#"{"request_uri":"urn:ietf:params:oauth:request_uri:fixture","expires_in":60}"#, + ), + ] +} + +fn successful_responses() -> Vec { + let mut responses = prepared_responses(); + responses.extend([ + json_with_nonce( + StatusCode::UNAUTHORIZED, + "token-nonce", + r#"{"error":"use_dpop_nonce"}"#, + ), + json_with_nonce( + StatusCode::OK, + "token-fresh-nonce", + r#"{"access_token":"access-token-canary","refresh_token":"replacement-refresh-canary","token_type":"DPoP","scope":"atproto","sub":"did:plc:abcdefghijklmnopqrstuvwx","expires_in":3600}"#, + ), + did_document(), + protected_resource(), + authorization_server(), + json( + StatusCode::OK, + r#"{"feed":[{"post":{"author":{"displayName":"Known","handle":"known.test"},"record":{"text":"known post"}}}]}"#, + ), + ]); + responses +} + +#[tokio::test] +async fn approval_returns_to_loaded_feed() { + let fixture = Fixture::new(successful_responses()); + fixture.start().await; + + let browser_urls = fixture.browser.urls(); + assert_eq!(browser_urls.len(), 1); + let browser_url = url::Url::parse(&browser_urls[0]).expect("captured browser URL"); + let pairs = browser_url.query_pairs().collect::>(); + assert_eq!(pairs.len(), 2); + assert!(pairs.iter().any(|(name, _)| name == "client_id")); + assert!(pairs.iter().any(|(name, value)| { + name == "request_uri" && value == "urn:ietf:params:oauth:request_uri:fixture" + })); + + let callback = fixture.approval_callback(); + assert!(matches!( + fixture.app.clone().complete_sign_in(callback).await, + Ok(SignInOutcome::SignedIn) + )); + assert_eq!( + fixture.app.state.lock().await.phase(), + AuthenticationPhase::Active + ); + + let stored = fixture + .vault + .load() + .expect("memory vault remains available") + .expect("refresh grant is persisted"); + let durable = String::from_utf8(stored.encode().expect("record encodes")).expect("JSON record"); + assert!(durable.contains(REFRESH_TOKEN)); + assert!(!durable.contains(ACCESS_TOKEN)); + + let feed = fixture + .app + .clone() + .load_initial_feed() + .await + .expect("authenticated timeline loads"); + assert_eq!(feed.posts().len(), 1); + assert_eq!(feed.posts()[0].handle(), "known.test"); + let timeline = fixture + .http + .requests() + .into_iter() + .last() + .expect("timeline request is captured"); + assert_eq!(timeline.method, atrium_xrpc::http::Method::GET); + assert!( + timeline + .url + .contains("/xrpc/app.bsky.feed.getTimeline?limit=50") + ); + assert!( + timeline + .headers + .get("authorization") + .is_some_and(|value| value.to_str().is_ok_and(|value| value.starts_with("DPoP "))) + ); + assert_eq!(fixture.http.request_count(), 11); + assert_eq!(fixture.http.remaining_response_count(), 0); +} + +#[tokio::test] +async fn callback_with_wrong_state_does_not_exchange_credentials() { + let fixture = Fixture::new(prepared_responses()); + fixture.start().await; + let callback = AuthorizationCallbackUrl::parse( + "com.nateledford.hitomi:/oauth/callback?code=code-canary&state=wrong&iss=https%3A%2F%2Fauth.example.com" + .to_owned(), + ) + .expect("fixture callback"); + + assert!( + fixture + .app + .clone() + .complete_sign_in(callback) + .await + .is_err() + ); + assert_eq!(fixture.http.request_count(), 5); + assert!(fixture.vault.is_empty()); +} + +#[tokio::test] +async fn callback_with_wrong_issuer_does_not_exchange_credentials() { + let fixture = Fixture::new(prepared_responses()); + fixture.start().await; + let callback = fixture.approval_callback(); + let wrong_issuer = AuthorizationCallbackUrl::parse(callback.as_str().replace( + "https%3A%2F%2Fauth.example.com", + "https%3A%2F%2Fother.example.com", + )) + .expect("fixture callback"); + + assert!( + fixture + .app + .clone() + .complete_sign_in(wrong_issuer) + .await + .is_err() + ); + assert_eq!(fixture.http.request_count(), 5); + assert!(fixture.vault.is_empty()); +} + +#[tokio::test] +async fn replayed_callback_does_not_repeat_token_exchange() { + let fixture = Fixture::new(successful_responses()); + fixture.start().await; + let callback = fixture.approval_callback(); + assert!( + fixture + .app + .clone() + .complete_sign_in(callback.clone()) + .await + .is_ok() + ); + assert_eq!(fixture.http.request_count(), 10); + + assert!( + fixture + .app + .clone() + .complete_sign_in(callback) + .await + .is_err() + ); + assert_eq!(fixture.http.request_count(), 10); +} + +#[tokio::test] +async fn callback_after_timeout_does_not_exchange_credentials() { + let fixture = Fixture::new(prepared_responses()); + fixture.start().await; + let callback = fixture.approval_callback(); + fixture + .app + .clone() + .retire_sign_in() + .await + .expect("retirement succeeds"); + + assert!( + fixture + .app + .clone() + .complete_sign_in(callback) + .await + .is_err() + ); + assert_eq!(fixture.http.request_count(), 5); + assert!(fixture.vault.is_empty()); +} diff --git a/src/authentication/command.rs b/src/authentication/command.rs index 3c445d7..b934711 100644 --- a/src/authentication/command.rs +++ b/src/authentication/command.rs @@ -3,188 +3,120 @@ use std::fmt; use super::error::{AuthenticationError, AuthenticationErrorCategory}; -use super::model::{AuthenticationAttemptId, Epoch, PreparedAuthorization}; -const MAX_CALLBACK_FIELD_BYTES: usize = 8 * 1024; +pub(crate) const MAX_AUTHORIZATION_CALLBACK_URL_BYTES: usize = 8 * 1024; -fn is_valid_callback_field(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_CALLBACK_FIELD_BYTES - && !value - .chars() - .any(|character| matches!(character, '\0'..='\x1f' | '\x7f')) -} - -/// A validated callback dispatched by the inbound callback adapter. -/// -/// The adapter must validate request bounds, callback grammar, state correlation, -/// and exact attempt-bound issuer equality before constructing this command. This -/// command is inbound-only: it neither starts nor stops a callback listener. +/// A bounded custom-scheme callback accepted at the macOS ingress boundary. +/// Query semantics remain coordinator-private until they can be correlated with +/// a pending authorization attempt. #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] -pub struct CompleteAuthorizationCallback { - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - issuer: String, - code: String, - state: String, -} - -impl fmt::Debug for CompleteAuthorizationCallback { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("CompleteAuthorizationCallback()") - } -} - -impl CompleteAuthorizationCallback { - /// Correlates a callback with the complete prepared authorization context. - /// - /// # Errors - /// - /// Returns a sanitized callback-rejected error when a callback field is - /// malformed or its issuer and state do not exactly match the preparation. - pub fn from_inbound_callback( - prepared: &PreparedAuthorization, - callback_issuer: &str, - code: impl Into, - state: impl Into, - ) -> Result { - let code = code.into(); - let state = state.into(); - if !is_valid_callback_field(callback_issuer) - || !is_valid_callback_field(&code) - || !is_valid_callback_field(&state) - || !prepared.matches_callback(callback_issuer, &state) - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, +pub struct AuthorizationCallbackUrl(String); + +impl AuthorizationCallbackUrl { + /// Parses the exact registered custom callback URI without exposing its + /// authorization data beyond the coordinator boundary. + pub fn parse(value: String) -> Result { + let rejected = || { + AuthenticationError::new( + crate::authentication::AuthenticationFailureStage::CallbackValidation, AuthenticationErrorCategory::CallbackRejected, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); + crate::authentication::AuthenticationFailureReason::CallbackRejected, + ) + }; + if value.len() > MAX_AUTHORIZATION_CALLBACK_URL_BYTES { + return Err(rejected()); + } + let url = url::Url::parse(&value).map_err(|_| rejected())?; + if url.scheme() != crate::release_identity::CALLBACK_SCHEME + || url.host().is_some() + || url.username() != "" + || url.password().is_some() + || url.fragment().is_some() + || url.path() != crate::release_identity::CALLBACK_PATH + || !query_is_valid_utf8(url.query().unwrap_or_default()) + { + return Err(rejected()); } - let epoch = prepared.epoch().ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CallbackRejected, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - Ok(Self { - attempt_id: prepared.attempt_id().clone(), - epoch, - issuer: callback_issuer.to_owned(), - code, - state, - }) + Ok(Self(value)) } - /// Returns the authentication attempt that this callback may complete. + /// Reveals the callback only to the application-owned OAuth coordinator. #[must_use] - pub fn attempt_id(&self) -> &AuthenticationAttemptId { - &self.attempt_id + pub(crate) fn as_str(&self) -> &str { + &self.0 } +} - /// Returns the epoch current when the prepared authorization was bound. - #[must_use] - pub const fn epoch(&self) -> Epoch { - self.epoch +fn query_is_valid_utf8(query: &str) -> bool { + let bytes = query.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + decoded.push(bytes[index]); + index += 1; + continue; + } + let Some((&high, &low)) = bytes.get(index + 1).zip(bytes.get(index + 2)) else { + return false; + }; + let (Some(high), Some(low)) = (hex_value(high), hex_value(low)) else { + return false; + }; + decoded.push((high << 4) | low); + index += 3; } + std::str::from_utf8(&decoded).is_ok() +} - /// Reveals the callback data only to the OAuth completion adapter. - #[must_use] - pub(crate) fn reveal_to_oauth_completion(&self) -> (&str, &str, &str) { - (&self.issuer, &self.code, &self.state) +const fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, } } -/// A terminal callback result validated against one prepared authorization. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum ValidatedAuthorizationCallback { - Success(CompleteAuthorizationCallback), - Denied { - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - }, +impl fmt::Debug for AuthorizationCallbackUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizationCallbackUrl()") + } } -impl ValidatedAuthorizationCallback { - #[must_use] - pub(crate) const fn denied(attempt_id: AuthenticationAttemptId, epoch: Epoch) -> Self { - Self::Denied { attempt_id, epoch } +impl fmt::Display for AuthorizationCallbackUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizationCallbackUrl()") } } - #[cfg(test)] mod tests { use super::*; - use crate::authentication::{ - AttemptBoundAuthorizationServerIssuer, AuthenticationAttemptId, Epoch, - ManualAuthorizationUrl, PreparedAuthorization, RedirectUri, - }; - - fn prepared(attempt: AuthenticationAttemptId, epoch: Epoch) -> PreparedAuthorization { - let issuer = - AttemptBoundAuthorizationServerIssuer::new(attempt.clone(), "https://issuer.example") - .expect("valid issuer"); - let request_issuer = - AttemptBoundAuthorizationServerIssuer::new(attempt.clone(), "https://issuer.example") - .expect("valid request issuer"); - let request = request_issuer.with_manual_url( - ManualAuthorizationUrl::new("https://issuer.example/authorize").expect("valid URL"), - ); - PreparedAuthorization::new( - attempt, - issuer, - "state-canary", - RedirectUri::for_selected_port(12345).expect("valid redirect"), - request, - ) - .expect("valid preparation") - .bind_epoch(epoch) - .expect("preparation can bind once") - } #[test] - fn callback_command_requires_a_bound_preparation_and_redacts_callback_data() { - let attempt = AuthenticationAttemptId::new("attempt-one").expect("valid attempt"); - let prepared = prepared(attempt.clone(), Epoch::new(7)); - let command = CompleteAuthorizationCallback::from_inbound_callback( - &prepared, - "https://issuer.example", - "code-canary", - "state-canary", + fn custom_scheme_callback_is_bounded_and_redacted() { + let callback = AuthorizationCallbackUrl::parse( + "com.nateledford.hitomi:/oauth/callback?code=canary".to_owned(), ) - .expect("matching callback command"); - assert_eq!(command.attempt_id(), &attempt); - assert_eq!(command.epoch(), Epoch::new(7)); - assert!(!format!("{command:?}").contains("code-canary")); - assert_eq!( - command.reveal_to_oauth_completion(), - ("https://issuer.example", "code-canary", "state-canary") - ); - - for (issuer, code, state) in [ - ( - "https://other-issuer.example", - String::from("other-code"), - String::from("state-canary"), - ), - ( - "https://issuer.example", - String::new(), - String::from("state-canary"), - ), - ( - "https://issuer.example", - String::from("code-canary"), - "s".repeat(MAX_CALLBACK_FIELD_BYTES + 1), - ), + .expect("exact callback scheme"); + assert!(!format!("{callback:?}").contains("canary")); + assert!(!format!("{callback}").contains("canary")); + for value in [ + "com.nateledford.hitomi://authority/oauth/callback", + "com.nateledford.hitomi:/wrong", + "https://example.test/oauth/callback", + "com.nateledford.hitomi:/oauth/callback?code=%", + "com.nateledford.hitomi:/oauth/callback?code=%ff", + "com.nateledford.hitomi:/oauth/callback#fragment", ] { - let error = CompleteAuthorizationCallback::from_inbound_callback( - &prepared, issuer, code, state, - ) - .expect_err("mismatched or malformed callbacks are rejected"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CallbackRejected - ); + assert!(AuthorizationCallbackUrl::parse(value.to_owned()).is_err()); } + assert!( + AuthorizationCallbackUrl::parse(format!( + "com.nateledford.hitomi:/oauth/callback?{}", + "x".repeat(MAX_AUTHORIZATION_CALLBACK_URL_BYTES) + )) + .is_err() + ); } } diff --git a/src/authentication/error.rs b/src/authentication/error.rs index df44649..d58c7ba 100644 --- a/src/authentication/error.rs +++ b/src/authentication/error.rs @@ -238,7 +238,7 @@ pub enum AuthenticationFailureReason { CallbackRejected, SubjectMismatch, AuthorityMismatch, - SessionArtifactInvalid, + StoredSessionInvalid, CredentialStoreUnavailable, CleanupIncomplete, RefreshAmbiguous, @@ -303,6 +303,7 @@ impl AuthenticationErrorCategory { pub struct AuthenticationErrorReference([u8; 16]); impl AuthenticationErrorReference { + #[cfg(test)] #[must_use] pub(crate) const fn from_bytes(bytes: [u8; 16]) -> Self { Self(bytes) diff --git a/src/authentication/mod.rs b/src/authentication/mod.rs index 792cd81..2992eea 100644 --- a/src/authentication/mod.rs +++ b/src/authentication/mod.rs @@ -3,9 +3,7 @@ pub mod command; mod error; mod model; -pub mod ports; mod state; -pub mod workflow; pub use error::{ AuthenticationCapability, AuthenticationError, AuthenticationErrorCategory, @@ -15,11 +13,10 @@ pub use error::{ }; pub use model::{ AccountGenerationReference, AccountHandle, AttemptBoundAuthorizationServerIssuer, - AttemptInvalidation, AuthenticatedAccount, AuthenticationAttemptId, AuthenticationDeadline, - AuthenticationOutcome, AuthenticationTimeout, AuthorizationRequest, CompletedAuthorization, - Did, Epoch, Generation, ManualAuthorizationUrl, PersistedActiveSession, PreparedAuthorization, - RedirectUri, RefreshOutcome, RefreshRecoveryRequired, RestorationSnapshot, RestorationStatus, - RevocationOutcome, RevocationReport, SessionArtifact, VerifiedActiveSession, + AuthenticatedAccount, AuthenticationAttemptId, Did, Epoch, Generation, LogoutReport, + ManualAuthorizationUrl, PersistedActiveSession, PreparedAuthorization, RedirectUri, + RefreshOutcome, RefreshRecoveryRequired, RestorationSnapshot, RestorationStatus, + RevocationOutcome, RevocationReport, VerifiedActiveSession, }; pub use state::{ AuthenticationEvent, AuthenticationPhase, AuthenticationStateMachine, TransitionResult, diff --git a/src/authentication/model.rs b/src/authentication/model.rs index 33d4a42..d99fbd9 100644 --- a/src/authentication/model.rs +++ b/src/authentication/model.rs @@ -1,7 +1,6 @@ //! Application-owned authentication data models. use std::fmt; -use std::time::Duration; use super::error::{AuthenticationError, AuthenticationErrorCategory}; @@ -354,15 +353,6 @@ impl PersistedActiveSession { )) } - #[cfg(test)] - #[must_use] - pub(crate) const fn from_committed_record( - verified: AccountGenerationReference, - session: VerifiedActiveSession, - ) -> Self { - Self { verified, session } - } - /// Returns whether the final CAS record names exactly this active session. #[must_use] pub(crate) fn is_exact_record(&self) -> bool { @@ -374,12 +364,6 @@ impl PersistedActiveSession { pub(crate) fn into_session(self) -> VerifiedActiveSession { self.session } - - /// Returns the promoted session for reducer validation. - #[must_use] - pub(crate) fn session(&self) -> &VerifiedActiveSession { - &self.session - } } /// An opaque metadata issuer bound to exactly one authentication attempt. @@ -418,20 +402,6 @@ impl AttemptBoundAuthorizationServerIssuer { ) -> bool { self.attempt_id == *attempt_id && self.issuer == callback_issuer } - - /// Attaches the attempt's manually openable authorization URL. - #[must_use] - pub fn with_manual_url(self, manual_url: ManualAuthorizationUrl) -> AuthorizationRequest { - AuthorizationRequest { - issuer: self, - manual_url, - } - } - - #[must_use] - pub(crate) fn is_bound_to(&self, attempt_id: &AuthenticationAttemptId) -> bool { - self.attempt_id == *attempt_id - } } impl fmt::Debug for AttemptBoundAuthorizationServerIssuer { @@ -440,134 +410,6 @@ impl fmt::Debug for AttemptBoundAuthorizationServerIssuer { } } -/// A monotonic deadline selected by the application rather than a runtime. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct AuthenticationDeadline(u64); - -impl AuthenticationDeadline { - /// Creates an opaque monotonic deadline from an adapter-provided coordinate. - #[must_use] - pub const fn new(value: u64) -> Self { - Self(value) - } - - /// Returns the opaque coordinate to the owning monotonic-clock adapter. - #[must_use] - pub(crate) const fn coordinate(self) -> u64 { - self.0 - } -} - -/// A bounded duration used when an authentication workflow requests a deadline. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct AuthenticationTimeout(Duration); - -impl AuthenticationTimeout { - /// Creates a nonzero workflow timeout. - /// - /// # Errors - /// - /// Returns a sanitized invalid-input error for a zero timeout. - pub fn new(value: Duration) -> Result { - if value.is_zero() { - return Err(invalid_input()); - } - Ok(Self(value)) - } - - /// Returns the duration to the time adapter that owns deadline calculation. - #[must_use] - pub const fn as_duration(self) -> Duration { - self.0 - } -} - -/// Opaque serialized session material transferred directly from the OAuth -/// adapter to the credential-vault adapter. -/// -/// It is intentionally not inspectable outside application-owned adapters and -/// never renders its bytes through formatting traits. -#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] -pub struct SessionArtifact(Vec); - -impl SessionArtifact { - /// Creates a bounded nonempty serialized session artifact. - pub(crate) fn new(bytes: Vec) -> Result { - if bytes.is_empty() || bytes.len() > 98_304 { - return Err(invalid_input()); - } - Ok(Self(bytes)) - } - - #[must_use] - pub(crate) fn as_bytes(&self) -> &[u8] { - &self.0 - } - - #[cfg(feature = "test-support")] - #[doc(hidden)] - #[must_use] - pub fn bytes_for_testing(&self) -> &[u8] { - &self.0 - } -} - -impl fmt::Debug for SessionArtifact { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("SessionArtifact()") - } -} - -/// An opaque completion result produced by the OAuth adapter. -/// -/// It transfers the completed session directly to the vault with its callback -/// epoch, preventing an unrelated or stale completion from selecting a new -/// active generation. -#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct CompletedAuthorization { - account: AuthenticatedAccount, - epoch: Epoch, - artifact: SessionArtifact, -} - -impl CompletedAuthorization { - /// Creates an opaque completion result at the OAuth adapter boundary. - pub(crate) const fn new( - account: AuthenticatedAccount, - epoch: Epoch, - artifact: SessionArtifact, - ) -> Self { - Self { - account, - epoch, - artifact, - } - } - - #[cfg(feature = "test-support")] - #[doc(hidden)] - pub fn for_testing(did: impl Into, epoch: Epoch) -> Result { - let did = Did::new(did)?; - let artifact = SessionArtifact::new(vec![1])?; - Ok(Self::new(did.into_authenticated(), epoch, artifact)) - } - /// Returns the account established by the completed authorization. - #[must_use] - pub(crate) fn account(&self) -> &AuthenticatedAccount { - &self.account - } - - #[must_use] - pub(crate) const fn epoch(&self) -> Epoch { - self.epoch - } - - #[must_use] - pub(crate) fn artifact(&self) -> &SessionArtifact { - &self.artifact - } -} - /// A callback URI selected by a bound callback listener. #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] pub enum RedirectUri { @@ -578,6 +420,7 @@ pub enum RedirectUri { } impl RedirectUri { + #[cfg(any(test, feature = "test-support"))] /// Creates the exact redirect for a listener-selected nonzero port. pub(crate) fn for_selected_port(port: u16) -> Result { if port == 0 { @@ -665,112 +508,23 @@ impl fmt::Display for ManualAuthorizationUrl { } } -/// A sensitive, ephemeral authorization request prepared for one browser attempt. -#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] -pub struct AuthorizationRequest { - issuer: AttemptBoundAuthorizationServerIssuer, - manual_url: ManualAuthorizationUrl, -} - -impl AuthorizationRequest { - /// Returns the redacted manual URL handle for explicit browser-bound revelation. - #[must_use] - pub fn manual_url(&self) -> &ManualAuthorizationUrl { - &self.manual_url - } - - /// Tests a callback against this request's exact attempt-bound issuer. - #[must_use] - pub fn matches_callback_issuer( - &self, - attempt_id: &AuthenticationAttemptId, - callback_issuer: &str, - ) -> bool { - self.issuer.matches_callback(attempt_id, callback_issuer) - } - #[must_use] - fn is_bound_to(&self, attempt_id: &AuthenticationAttemptId) -> bool { - self.issuer.is_bound_to(attempt_id) - } -} - -/// The complete, correlated authorization context for one callback attempt. +/// A redacted browser authorization URL paired with the current attempt by the +/// coordinator rather than by a workflow artifact. #[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] pub struct PreparedAuthorization { - attempt_id: AuthenticationAttemptId, - issuer: AttemptBoundAuthorizationServerIssuer, - expected_state: String, - redirect_uri: RedirectUri, - authorization: AuthorizationRequest, - epoch: Option, + manual_url: ManualAuthorizationUrl, } impl PreparedAuthorization { - /// Creates a callback-correlated authorization context. - /// - /// The OAuth adapter supplies all values after registering the selected - /// listener redirect; the constructor rejects cross-attempt composition. - pub fn new( - attempt_id: AuthenticationAttemptId, - issuer: AttemptBoundAuthorizationServerIssuer, - expected_state: impl Into, - redirect_uri: RedirectUri, - authorization: AuthorizationRequest, - ) -> Result { - let expected_state = expected_state.into(); - if expected_state.is_empty() - || contains_uri_control(&expected_state) - || !issuer.is_bound_to(&attempt_id) - || !authorization.is_bound_to(&attempt_id) - { - return Err(invalid_input()); - } - Ok(Self { - attempt_id, - issuer, - expected_state, - redirect_uri, - authorization, - epoch: None, - }) + #[must_use] + pub(crate) const fn new(manual_url: ManualAuthorizationUrl) -> Self { + Self { manual_url } } /// Returns the browser-safe authorization URL. #[must_use] pub fn manual_url(&self) -> &ManualAuthorizationUrl { - self.authorization.manual_url() - } - - #[must_use] - pub(crate) fn attempt_id(&self) -> &AuthenticationAttemptId { - &self.attempt_id - } - - /// Binds the workflow's current epoch before this context is handed to the - /// callback listener. It cannot be rebound to a different epoch. - pub fn bind_epoch(mut self, epoch: Epoch) -> Result { - if self.epoch.replace(epoch).is_some() { - return Err(invalid_input()); - } - Ok(self) - } - - #[must_use] - pub(crate) const fn epoch(&self) -> Option { - self.epoch - } - - /// Matches a callback's exact issuer and state to this preparation. - #[must_use] - pub(crate) fn matches_callback(&self, callback_issuer: &str, state: &str) -> bool { - self.issuer - .matches_callback(&self.attempt_id, callback_issuer) - && self.expected_state == state - } - - #[must_use] - pub(crate) fn redirect_uri(&self) -> &RedirectUri { - &self.redirect_uri + &self.manual_url } } @@ -786,18 +540,6 @@ impl fmt::Display for PreparedAuthorization { } } -impl fmt::Debug for AuthorizationRequest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("AuthorizationRequest()") - } -} - -impl fmt::Display for AuthorizationRequest { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("") - } -} - /// A restore-blocking refresh state that requires independent recovery. /// /// This is deliberately not a verified active capability. Its blocked coordinate @@ -828,20 +570,12 @@ pub enum RefreshOutcome { RecoveryRequired(RefreshRecoveryRequired), } -/// Durable result of invalidating a sign-in attempt. -#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum AttemptInvalidation { - /// The pending attempt was invalidated before it could become active. - Invalidated, - /// A concurrent promotion won; the workflow must immediately log out. - PromotionWon(VerifiedActiveSession), -} - /// The independently recorded outcome of one remote revocation request. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum RevocationOutcome { Succeeded, Unsupported, + NotPresent, Failed(AuthenticationError), } @@ -852,6 +586,14 @@ pub struct RevocationReport { pub access: RevocationOutcome, } +/// Logout result where local credential destruction—not remote confirmation—is +/// the user-visible sign-out authority. +#[derive(Clone, Debug)] +pub struct LogoutReport { + pub revocation: RevocationReport, + pub local_cleanup: Result<(), AuthenticationError>, +} + #[cfg(test)] mod refresh_outcome_tests { use super::*; @@ -957,28 +699,3 @@ impl RestorationStatus { } } } - -/// A mutually exclusive application outcome for one authentication operation. -#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum AuthenticationOutcome { - /// An authenticated account generation is active. - Active(VerifiedActiveSession), - /// A browser or UI boundary must explicitly open the sensitive request URL. - ManualAuthorizationRequired(AuthorizationRequest), - /// Refresh uncertainty blocks restoration and retry of an old credential. - RefreshRecoveryRequired(RefreshRecoveryRequired), - /// The operation failed with a sanitized category. - Failed(AuthenticationError), -} - -#[cfg(test)] -mod completion_artifact_tests { - use super::*; - - #[test] - fn completion_artifact_never_formats_its_credential_material() { - let artifact = SessionArtifact::new(b"credential-canary".to_vec()).expect("artifact"); - assert_eq!(format!("{artifact:?}"), "SessionArtifact()"); - assert!(!format!("{artifact:?}").contains("credential-canary")); - } -} diff --git a/src/authentication/ports.rs b/src/authentication/ports.rs deleted file mode 100644 index 21ec3ae..0000000 --- a/src/authentication/ports.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Outbound capabilities requested by authentication workflows. - -//! # Design and lifecycle -//! -//! These ports are deliberately generic, not object-safe: their `impl Future` -//! return types let each workflow depend on only the capability it needs without -//! a DI container or erased allocation. Implementations may perform I/O, but no -//! Tokio task, channel, cancellation token, Atrium value, HTTP value, or vendor -//! error may cross a contract. Fallible operations return only -//! [`AuthenticationError`]; outcome enums represent expected application states. -//! -//! Each capability has one external actor: `OAuth*` capabilities map to the -//! Atrium OAuth adapter, `Session*` capabilities to the credential-vault adapter -//! (except `SessionRefresh`, whose adapter owns the integrated refresh protocol), -//! `BrowserLauncher` to the system browser, `CallbackListenerLifecycle` to the -//! loopback listener, and `DeadlineClock` to the operating system monotonic clock. -//! `Cancellation` is the one workflow-local substitution supplied by the inbound -//! task owner, only where a workflow must observe cancellation. -//! -//! A sign-in workflow selects its deadline, starts and owns a listener lease, -//! then prepares and launches authorization. The inbound callback adapter later -//! validates and dispatches [`CompleteAuthorizationCallback`] independently of -//! listener lifecycle. On every terminal, cancelled, or superseded attempt, the -//! workflow owns stopping its lease. Callback completion consumes only a command -//! whose issuer was compared exactly before dispatch. -//! -//! Refresh dispatch is permitted only after its adapter's durable marker. A -//! `NotDispatched` outcome requires a proved, durable rollback; every uncertain, -//! cancelled, timed-out, or post-dispatch outcome is `RecoveryRequired`. A refresh -//! error is permitted only before a marker or dispatch could have occurred. -//! Persistence can promote only an independent completed authorization and has no -//! recovery-clear operation. Revocation is ordered after the workflow's local -//! sign-out/persistence decision and cannot reverse it. - -use std::future::Future; - -use super::command::{CompleteAuthorizationCallback, ValidatedAuthorizationCallback}; -use super::error::AuthenticationError; -use super::model::{ - AccountHandle, AttemptInvalidation, AuthenticationAttemptId, AuthenticationDeadline, - AuthenticationTimeout, CompletedAuthorization, Epoch, ManualAuthorizationUrl, - PersistedActiveSession, PreparedAuthorization, RedirectUri, RefreshOutcome, - RestorationSnapshot, RevocationReport, VerifiedActiveSession, -}; - -/// Capability supplied by the application-owned OAuth adapter to prepare an authorization request. -pub trait OAuthAuthorization { - /// Prepares a request after the listener has selected its exact redirect. - fn prepare_authorization( - &self, - attempt_id: AuthenticationAttemptId, - handle: AccountHandle, - redirect_uri: RedirectUri, - ) -> impl Future> + Send; -} - -/// Capability supplied by the application-owned OAuth adapter to complete a validated callback. -pub trait OAuthCompletion { - /// Completes only a command already validated and dispatched by the inbound adapter. - fn complete_authorization( - &self, - command: CompleteAuthorizationCallback, - ) -> impl Future> + Send; -} - -/// Retires an in-memory OAuth preparation that will not be completed. -/// -/// Implementations must be idempotent and remove only the exact attempt. -pub trait OAuthAttemptRetirement { - fn retire_authorization( - &self, - attempt_id: &AuthenticationAttemptId, - ) -> impl Future + Send { - let _ = attempt_id; - async {} - } -} - -/// Capability supplied by the credential-vault adapter to restore a stored session. -pub trait SessionRestoration { - /// Restores only an exact active generation, or returns a fail-closed status. - fn restore_session( - &self, - ) -> impl Future> + Send; -} - -/// Capability supplied by the Atrium refresh adapter to refresh one generation. -pub trait SessionRefresh { - /// Refreshes `active` under the exact durable refresh-marker protocol. - /// - /// `NotDispatched` proves the adapter durably restored the original active - /// pointer. `Replaced` proves it durably promoted the replacement. Every - /// uncertain or post-dispatch path is `RecoveryRequired`; `Err` is reserved - /// for a failure before the adapter can establish a durable outcome. - fn refresh_session( - &self, - attempt_id: AuthenticationAttemptId, - active: VerifiedActiveSession, - ) -> impl Future> + Send; -} - -/// Capability supplied by the OAuth adapter to revoke a session after local sign-out. -pub trait SessionRevocation { - /// Attempts remote revocation without changing the local persistence decision. - fn revoke_session( - &self, - session: VerifiedActiveSession, - ) -> impl Future + Send; -} - -/// Capability supplied by the credential-vault adapter to durably promote a completed authorization. -pub trait SessionPersistence { - /// Commits a new active generation from an independent completed authorization. - /// - /// This intentionally offers no generic clear or recovery-reset operation: - /// refresh recovery is resolved only by an explicit future recovery workflow. - fn persist_completed_authorization( - &self, - completed: CompletedAuthorization, - ) -> impl Future> + Send; -} - -/// Capability supplied by the credential vault to invalidate pending work and -/// advance a durable logout tombstone. -pub trait SessionInvalidation { - fn invalidate_attempt( - &self, - attempt_id: &AuthenticationAttemptId, - epoch: Epoch, - ) -> impl Future> + Send; - - fn install_logout_tombstone( - &self, - expected_epoch: Epoch, - ) -> impl Future> + Send; - - fn cleanup_tombstoned_epoch( - &self, - tombstone_epoch: Epoch, - ) -> impl Future> + Send; -} - -/// Capability supplied by the system-browser adapter. -pub trait BrowserLauncher { - /// Opens a prevalidated authorization URL without parsing or rewriting it. - fn launch_authorization( - &self, - url: ManualAuthorizationUrl, - ) -> impl Future> + Send; -} - -/// An owned loopback listener bound to one exact redirect. -/// -/// Dropping this value releases the socket. It is intentionally not `Clone`. -pub trait BoundCallbackListener: Send { - fn redirect_uri(&self) -> &RedirectUri; -} - -/// Capability supplied by the loopback callback adapter. -pub trait CallbackListenerLifecycle: Sync { - type Bound: BoundCallbackListener; - - fn start_listener( - &self, - attempt_id: AuthenticationAttemptId, - ) -> impl Future> + Send; - - fn accept_callback<'a>( - &'a self, - listener: &'a Self::Bound, - prepared: &'a PreparedAuthorization, - ) -> impl Future> + Send + 'a; -} - -/// Capability supplied by the operating system's monotonic clock. -pub trait DeadlineClock: Sync { - /// Selects a deadline for a workflow timeout without exposing runtime time types. - fn deadline_after(&self, timeout: AuthenticationTimeout) -> AuthenticationDeadline; - - /// Runs an operation until the selected deadline, dropping it on expiry. - fn run_until<'a, F>( - &'a self, - deadline: AuthenticationDeadline, - operation: F, - ) -> impl Future> + Send + 'a - where - F: Future + Send + 'a, - F::Output: Send + 'a; -} - -/// Workflow-local cancellation observation supplied by the caller that owns cancellation. -/// -/// Only workflows that can be substituted or cancelled accept this capability; it -/// is not a listener, OAuth, or persistence interface and carries no runtime token. -pub trait Cancellation { - /// Reports whether the owning workflow has requested cancellation. - fn is_cancelled(&self) -> bool; -} diff --git a/src/authentication/state.rs b/src/authentication/state.rs index b36f08d..f2c29e7 100644 --- a/src/authentication/state.rs +++ b/src/authentication/state.rs @@ -9,12 +9,10 @@ use std::collections::BTreeSet; -use super::command::CompleteAuthorizationCallback; use super::error::AuthenticationError; use super::model::{ - AccountGenerationReference, AuthenticationAttemptId, CompletedAuthorization, Epoch, - PersistedActiveSession, RefreshOutcome, RefreshRecoveryRequired, RestorationStatus, - VerifiedActiveSession, + AccountGenerationReference, AuthenticationAttemptId, Epoch, PersistedActiveSession, + RefreshOutcome, RefreshRecoveryRequired, RestorationStatus, VerifiedActiveSession, }; /// The observable lifecycle phase. It intentionally contains no credentials or @@ -24,8 +22,6 @@ pub enum AuthenticationPhase { SignedOut, Restoring, Authorizing, - CompletingAuthorization, - PersistingAuthorization, Active, Refreshing, RefreshRecoveryRequired, @@ -67,11 +63,6 @@ pub enum AuthenticationEvent { result: Result<(), AuthenticationError>, }, AuthorizationCompleted { - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - result: Result, - }, - PersistenceFinished { attempt_id: AuthenticationAttemptId, epoch: Epoch, result: Result, @@ -138,10 +129,6 @@ enum RetryTarget { Reauthentication, } -enum CancellationTarget { - SignedOut, - PersistingAuthorization { completed: CompletedAuthorization }, -} #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] enum AuthorizationStage { Preparing, @@ -163,21 +150,6 @@ enum AuthenticationState { retry: RetryTarget, stage: AuthorizationStage, }, - #[allow( - dead_code, - reason = "The crate-private callback adapter advances into this phase before OAuth completion wiring is added." - )] - CompletingAuthorization { - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - retry: RetryTarget, - }, - PersistingAuthorization { - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - retry: RetryTarget, - _completed: CompletedAuthorization, - }, Active { epoch: Epoch, session: VerifiedActiveSession, @@ -194,7 +166,6 @@ enum AuthenticationState { Cancelling { attempt_id: AuthenticationAttemptId, epoch: Epoch, - target: CancellationTarget, }, LoggingOut { attempt_id: AuthenticationAttemptId, @@ -219,8 +190,6 @@ impl AuthenticationState { Self::SignedOut { .. } => AuthenticationPhase::SignedOut, Self::Restoring { .. } => AuthenticationPhase::Restoring, Self::Authorizing { .. } => AuthenticationPhase::Authorizing, - Self::CompletingAuthorization { .. } => AuthenticationPhase::CompletingAuthorization, - Self::PersistingAuthorization { .. } => AuthenticationPhase::PersistingAuthorization, Self::Active { .. } => AuthenticationPhase::Active, Self::Refreshing { .. } => AuthenticationPhase::Refreshing, Self::RefreshRecoveryRequired { .. } => AuthenticationPhase::RefreshRecoveryRequired, @@ -236,8 +205,6 @@ impl AuthenticationState { Self::SignedOut { epoch } | Self::Restoring { epoch, .. } | Self::Authorizing { epoch, .. } - | Self::CompletingAuthorization { epoch, .. } - | Self::PersistingAuthorization { epoch, .. } | Self::Active { epoch, .. } | Self::Refreshing { epoch, .. } | Self::RefreshRecoveryRequired { epoch, .. } @@ -372,37 +339,6 @@ impl AuthenticationStateMachine { } } - pub fn accept_callback(&mut self, callback: CompleteAuthorizationCallback) -> TransitionResult { - let result_attempt = callback.attempt_id().clone(); - let result_epoch = callback.epoch(); - let fallback_epoch = self.state.epoch(); - let state = std::mem::replace( - &mut self.state, - AuthenticationState::SignedOut { - epoch: fallback_epoch, - }, - ); - match state { - AuthenticationState::Authorizing { - attempt_id, - epoch, - retry, - stage: AuthorizationStage::AwaitingCallback, - } if attempt_id == result_attempt && epoch == result_epoch => { - self.state = AuthenticationState::CompletingAuthorization { - attempt_id, - epoch, - retry, - }; - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization) - } - state => { - self.state = state; - TransitionResult::Rejected - } - } - } - fn transition( state: AuthenticationState, event: AuthenticationEvent, @@ -517,67 +453,41 @@ impl AuthenticationStateMachine { Err(error) => Self::authorization_failure(attempt_id, epoch, retry, error), }, ( - AuthenticationState::CompletingAuthorization { + AuthenticationState::Authorizing { attempt_id, epoch, - retry, + retry: _, + stage: AuthorizationStage::AwaitingCallback, }, AuthenticationEvent::AuthorizationCompleted { attempt_id: result_attempt, epoch: result_epoch, - result, + result: Ok(persisted), }, - ) if attempt_id == result_attempt && epoch == result_epoch => match result { - Ok(completed) => Ok(AuthenticationState::PersistingAuthorization { - attempt_id, - epoch, - retry, - _completed: completed, - }), - Err(_) => Ok(AuthenticationState::Cancelling { - attempt_id, + ) if attempt_id == result_attempt + && epoch == result_epoch + && persisted.is_exact_record() => + { + Ok(AuthenticationState::Active { epoch, - target: CancellationTarget::SignedOut, - }), - }, + session: persisted.into_session(), + }) + } ( - AuthenticationState::PersistingAuthorization { + AuthenticationState::Authorizing { attempt_id, epoch, - retry, - _completed: completed, + retry: _, + stage: AuthorizationStage::AwaitingCallback, }, - AuthenticationEvent::PersistenceFinished { + AuthenticationEvent::AuthorizationCompleted { attempt_id: result_attempt, epoch: result_epoch, - result, + result: Err(_), }, - ) if attempt_id == result_attempt && epoch == result_epoch => match result { - Ok(persisted) - if persisted.is_exact_record() - && Self::session_matches_completed( - persisted.session(), - &completed, - epoch, - ) => - { - Ok(AuthenticationState::Active { - epoch, - session: persisted.into_session(), - }) - } - Ok(_) => Err(AuthenticationState::PersistingAuthorization { - attempt_id, - epoch, - retry, - _completed: completed, - }), - Err(_) => Ok(AuthenticationState::Cancelling { - attempt_id, - epoch, - target: CancellationTarget::PersistingAuthorization { completed }, - }), - }, + ) if attempt_id == result_attempt && epoch == result_epoch => { + Ok(AuthenticationState::SignedOut { epoch }) + } ( AuthenticationState::Active { epoch, session }, AuthenticationEvent::StartRefresh { @@ -676,31 +586,6 @@ impl AuthenticationStateMachine { ( AuthenticationState::Authorizing { attempt_id, epoch, .. - } - | AuthenticationState::CompletingAuthorization { - attempt_id, epoch, .. - }, - AuthenticationEvent::Cancel { - attempt_id: result_attempt, - epoch: result_epoch, - } - | AuthenticationEvent::TimedOut { - attempt_id: result_attempt, - epoch: result_epoch, - }, - ) if attempt_id == result_attempt && epoch == result_epoch => { - Ok(AuthenticationState::Cancelling { - attempt_id, - epoch, - target: CancellationTarget::SignedOut, - }) - } - ( - AuthenticationState::PersistingAuthorization { - attempt_id, - epoch, - retry: _, - _completed: completed, }, AuthenticationEvent::Cancel { attempt_id: result_attempt, @@ -711,11 +596,7 @@ impl AuthenticationStateMachine { epoch: result_epoch, }, ) if attempt_id == result_attempt && epoch == result_epoch => { - Ok(AuthenticationState::Cancelling { - attempt_id, - epoch, - target: CancellationTarget::PersistingAuthorization { completed }, - }) + Ok(AuthenticationState::Cancelling { attempt_id, epoch }) } ( AuthenticationState::Restoring { attempt_id, epoch }, @@ -728,71 +609,27 @@ impl AuthenticationStateMachine { epoch: result_epoch, }, ) if attempt_id == result_attempt && epoch == result_epoch => { - Ok(AuthenticationState::Cancelling { - attempt_id, - epoch, - target: CancellationTarget::SignedOut, - }) + Ok(AuthenticationState::Cancelling { attempt_id, epoch }) } ( - AuthenticationState::Cancelling { - attempt_id, - epoch, - target, - }, + AuthenticationState::Cancelling { attempt_id, epoch }, AuthenticationEvent::CancellationInvalidated { attempt_id: result_attempt, epoch: result_epoch, result: Ok(()), }, - ) if attempt_id == result_attempt && epoch == result_epoch => match target { - CancellationTarget::SignedOut - | CancellationTarget::PersistingAuthorization { .. } => { - Ok(AuthenticationState::SignedOut { epoch }) - } - }, + ) if attempt_id == result_attempt && epoch == result_epoch => { + Ok(AuthenticationState::SignedOut { epoch }) + } ( - AuthenticationState::Cancelling { - attempt_id, - epoch, - target, - }, + AuthenticationState::Cancelling { attempt_id, epoch }, AuthenticationEvent::CancellationInvalidated { attempt_id: result_attempt, epoch: result_epoch, result: Err(_), }, ) if attempt_id == result_attempt && epoch == result_epoch => { - Ok(AuthenticationState::Cancelling { - attempt_id, - epoch, - target, - }) - } - // A successful persistence result that races cancellation means the - // promotion won. It must be reconciled through tombstone/logout, not - // represented as a completed cancellation. - ( - AuthenticationState::Cancelling { - attempt_id, - epoch, - target: CancellationTarget::PersistingAuthorization { completed }, - }, - AuthenticationEvent::PersistenceFinished { - attempt_id: result_attempt, - epoch: result_epoch, - result: Ok(persisted), - }, - ) if attempt_id == result_attempt - && epoch == result_epoch - && persisted.is_exact_record() - && Self::session_matches_completed(persisted.session(), &completed, epoch) => - { - Ok(AuthenticationState::LoggingOut { - attempt_id, - epoch, - session: Some(persisted.into_session()), - }) + Ok(AuthenticationState::Cancelling { attempt_id, epoch }) } ( AuthenticationState::RefreshRecoveryRequired { @@ -887,8 +724,6 @@ impl AuthenticationStateMachine { ( AuthenticationState::Restoring { epoch, .. } | AuthenticationState::Authorizing { epoch, .. } - | AuthenticationState::CompletingAuthorization { epoch, .. } - | AuthenticationState::PersistingAuthorization { epoch, .. } | AuthenticationState::Cancelling { epoch, .. }, AuthenticationEvent::StartLogout { attempt_id, @@ -993,10 +828,6 @@ impl AuthenticationStateMachine { } } - fn session_has_epoch(session: &VerifiedActiveSession, epoch: Epoch) -> bool { - session.reference().epoch() == epoch - } - fn same_active_record(left: &VerifiedActiveSession, right: &VerifiedActiveSession) -> bool { left.reference() == right.reference() } @@ -1009,15 +840,6 @@ impl AuthenticationStateMachine { && replacement.reference().epoch() == active.reference().epoch() && replacement.reference().generation() > active.reference().generation() } - - fn session_matches_completed( - session: &VerifiedActiveSession, - completed: &CompletedAuthorization, - epoch: Epoch, - ) -> bool { - Self::session_has_epoch(session, epoch) - && session.reference().account() == completed.account() - } } impl AuthenticationEvent { @@ -1032,7 +854,6 @@ impl AuthenticationEvent { | Self::AuthorizationPrepared { .. } | Self::BrowserFinished { .. } | Self::AuthorizationCompleted { .. } - | Self::PersistenceFinished { .. } | Self::RefreshFinished { .. } | Self::Cancel { .. } | Self::CancellationInvalidated { .. } @@ -1051,7 +872,6 @@ impl AuthenticationEvent { Self::AuthorizationPrepared { .. } => "authorization-prepared", Self::BrowserFinished { .. } => "browser-finished", Self::AuthorizationCompleted { .. } => "authorization-completed", - Self::PersistenceFinished { .. } => "persistence-finished", Self::StartRefresh { .. } => "start-refresh", Self::RefreshFinished { .. } => "refresh-finished", Self::Cancel { .. } => "cancel", @@ -1069,1159 +889,86 @@ impl AuthenticationEvent { #[cfg(test)] mod tests { use super::*; - use crate::authentication::AuthenticationErrorCategory; - use crate::authentication::model::{ - AccountGenerationReference, AuthenticatedAccount, Did, Generation, SessionArtifact, - }; + use crate::authentication::{Did, Generation}; fn attempt(value: &str) -> AuthenticationAttemptId { - AuthenticationAttemptId::new(value).expect("test attempt IDs are nonempty") - } - - fn active(epoch: Epoch) -> VerifiedActiveSession { - active_for("did:plc:alice", 1, epoch) + AuthenticationAttemptId::new(value).expect("valid attempt") } - fn active_for(did: &str, generation: u64, epoch: Epoch) -> VerifiedActiveSession { - let account: AuthenticatedAccount = Did::new(did) - .expect("test DID is valid") + fn persisted(epoch: Epoch) -> PersistedActiveSession { + let account = Did::new("did:plc:test") + .expect("valid DID") .into_authenticated(); - VerifiedActiveSession::from_verified_reference(AccountGenerationReference::new( - account, - Generation::new(generation), - epoch, - )) - } - - fn completed() -> CompletedAuthorization { - CompletedAuthorization::new( - Did::new("did:plc:alice") - .expect("test DID is valid") - .into_authenticated(), - Epoch::new(7), - SessionArtifact::new(vec![1]).expect("test artifact"), - ) - } - - fn persisted(session: VerifiedActiveSession) -> PersistedActiveSession { - PersistedActiveSession::committed(session) - } - - fn callback( - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - ) -> CompleteAuthorizationCallback { - let issuer = super::super::model::AttemptBoundAuthorizationServerIssuer::new( - attempt_id.clone(), - "https://issuer.example", - ) - .expect("test issuer is valid"); - let request_issuer = super::super::model::AttemptBoundAuthorizationServerIssuer::new( - attempt_id.clone(), - "https://issuer.example", - ) - .expect("test request issuer is valid"); - let request = request_issuer.with_manual_url( - super::super::model::ManualAuthorizationUrl::new("https://issuer.example/authorize") - .expect("test URL is valid"), - ); - let prepared = super::super::model::PreparedAuthorization::new( - attempt_id, - issuer, - "test-state", - super::super::model::RedirectUri::for_selected_port(12345) - .expect("test redirect is valid"), - request, - ) - .expect("test preparation is valid") - .bind_epoch(epoch) - .expect("test preparation can bind once"); - CompleteAuthorizationCallback::from_inbound_callback( - &prepared, - "https://issuer.example", - "test-code", - "test-state", - ) - .expect("test callback is valid") - } - - fn assert_applied( - machine: &mut AuthenticationStateMachine, - event: AuthenticationEvent, - phase: AuthenticationPhase, - ) { - let authorization_handoff = match &event { - AuthenticationEvent::StartAuthorization { attempt_id, epoch } => { - Some((attempt_id.clone(), *epoch)) - } - _ => None, - }; - assert_eq!(machine.apply(event), TransitionResult::Applied(phase)); - if let Some((attempt_id, epoch)) = authorization_handoff { - assert_eq!( - machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: attempt_id.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.apply(AuthenticationEvent::BrowserFinished { - attempt_id, - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - } - assert_eq!(machine.phase(), phase); + let reference = AccountGenerationReference::new(account, Generation::new(0), epoch); + PersistedActiveSession::committed(VerifiedActiveSession::from_verified_reference(reference)) } #[test] - fn table_driven_commands_allow_one_current_operation() { - let epoch = Epoch::new(4); - let cases = [ - ( - "startup restoration", - AuthenticationEvent::StartRestoration { - attempt_id: attempt("restore"), - epoch, - }, - AuthenticationPhase::Restoring, - ), - ( - "first authorization", - AuthenticationEvent::StartAuthorization { - attempt_id: attempt("authorize"), - epoch, - }, - AuthenticationPhase::Authorizing, - ), - ]; - - for (name, event, expected) in cases { - let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_eq!( - machine.apply(event), - TransitionResult::Applied(expected), - "{name}" - ); - assert_eq!(machine.phase(), expected, "{name}"); - } - + fn persisted_authorization_activates_only_the_awaiting_attempt() { + let epoch = Epoch::new(1); + let attempt = attempt("attempt-one"); let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: attempt("only-attempt"), - epoch, - }, - AuthenticationPhase::Authorizing, - ); assert_eq!( machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt("second-attempt"), + attempt_id: attempt.clone(), epoch, }), - TransitionResult::Rejected, - ); - assert_eq!(machine.phase(), AuthenticationPhase::Authorizing); - } - - #[test] - fn authorization_supersedes_in_progress_restoration() { - let epoch = Epoch::new(4); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartRestoration { - attempt_id: attempt("restore"), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: attempt("authorize"), - epoch, - }, - AuthenticationPhase::Authorizing, - ); - } - - #[test] - fn authorization_refresh_and_logout_follow_the_allowed_order() { - let epoch = Epoch::new(4); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let authorization = attempt("authorization"); - - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Authorizing, + TransitionResult::Applied(AuthenticationPhase::Authorizing) ); assert_eq!( - machine.accept_callback(callback(authorization.clone(), epoch)), - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization), - ); - assert_applied( - &mut machine, - AuthenticationEvent::AuthorizationCompleted { - attempt_id: authorization.clone(), - epoch, - result: Ok(completed()), - }, - AuthenticationPhase::PersistingAuthorization, - ); - assert_applied( - &mut machine, - AuthenticationEvent::PersistenceFinished { - attempt_id: authorization, - epoch, - result: Ok(persisted(active(epoch))), - }, - AuthenticationPhase::Active, - ); - - let refresh = attempt("refresh"); - assert_applied( - &mut machine, - AuthenticationEvent::StartRefresh { - attempt_id: refresh.clone(), - epoch, - }, - AuthenticationPhase::Refreshing, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RefreshFinished { - attempt_id: refresh, - epoch, - result: Ok(RefreshOutcome::NotDispatched(active(epoch))), - }, - AuthenticationPhase::Active, - ); - - let logout = attempt("logout"); - assert_applied( - &mut machine, - AuthenticationEvent::StartLogout { - attempt_id: logout.clone(), - epoch, - }, - AuthenticationPhase::LoggingOut, - ); - let tombstone_epoch = Epoch::new(5); - assert_applied( - &mut machine, - AuthenticationEvent::TombstoneInstalled { - attempt_id: logout.clone(), + machine.apply(AuthenticationEvent::AuthorizationPrepared { + attempt_id: attempt.clone(), epoch, - tombstone_epoch, result: Ok(()), - }, - AuthenticationPhase::Tombstoned, - ); - assert_applied( - &mut machine, - AuthenticationEvent::LogoutCleanupFinished { - attempt_id: logout, - epoch: tombstone_epoch, - result: Ok(()), - }, - AuthenticationPhase::SignedOut, - ); - assert_eq!(machine.epoch(), tombstone_epoch); - } - - #[test] - fn retry_requires_a_new_attempt_after_an_ordinary_failure() { - let epoch = Epoch::new(4); - let first = attempt("restore-one"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartRestoration { - attempt_id: first.clone(), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RestorationFinished { - attempt_id: first.clone(), - epoch, - result: Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), - }, - AuthenticationPhase::RetryableFailure, + }), + TransitionResult::Applied(AuthenticationPhase::Authorizing) ); assert_eq!( - machine.apply(AuthenticationEvent::Retry { - attempt_id: first.clone(), + machine.apply(AuthenticationEvent::BrowserFinished { + attempt_id: attempt.clone(), epoch, + result: Ok(()), }), - TransitionResult::Rejected, - "a replayed operation identifier cannot retry itself" - ); - let second = attempt("restore-two"); - assert_applied( - &mut machine, - AuthenticationEvent::Retry { - attempt_id: second.clone(), - epoch, - }, - AuthenticationPhase::Restoring, + TransitionResult::Applied(AuthenticationPhase::Authorizing) ); assert_eq!( - machine.apply(AuthenticationEvent::RestorationFinished { - attempt_id: first, + machine.apply(AuthenticationEvent::AuthorizationCompleted { + attempt_id: attempt, epoch, - result: Ok(RestorationStatus::Unavailable), + result: Ok(persisted(epoch)), }), - TransitionResult::Rejected, - "the pre-retry restoration cannot decide the new attempt" - ); - assert_eq!(machine.phase(), AuthenticationPhase::Restoring); - assert_applied( - &mut machine, - AuthenticationEvent::RestorationFinished { - attempt_id: second, - epoch, - result: Ok(RestorationStatus::Unavailable), - }, - AuthenticationPhase::SignedOut, + TransitionResult::Applied(AuthenticationPhase::Active) ); } #[test] - fn stale_and_replayed_browser_callback_persistence_restore_timeout_and_logout_results_are_rejected() - { - let epoch = Epoch::new(4); - let current = attempt("current"); + fn timed_out_authorization_rejects_completion() { + let epoch = Epoch::new(1); + let attempt = attempt("attempt-timeout"); let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: current.clone(), + let _ = machine.apply(AuthenticationEvent::StartAuthorization { + attempt_id: attempt.clone(), + epoch, + }); + assert_eq!( + machine.apply(AuthenticationEvent::TimedOut { + attempt_id: attempt.clone(), epoch, - }, - AuthenticationPhase::Authorizing, + }), + TransitionResult::Applied(AuthenticationPhase::Cancelling) ); - - for event in [ - AuthenticationEvent::BrowserFinished { - attempt_id: attempt("old"), + let _ = machine.apply(AuthenticationEvent::CancellationInvalidated { + attempt_id: attempt.clone(), + epoch, + result: Ok(()), + }); + assert_eq!( + machine.apply(AuthenticationEvent::AuthorizationCompleted { + attempt_id: attempt, epoch, - result: Ok(()), - }, - AuthenticationEvent::PersistenceFinished { - attempt_id: current.clone(), - epoch, - result: Ok(persisted(active(epoch))), - }, - AuthenticationEvent::RestorationFinished { - attempt_id: current.clone(), - epoch, - result: Ok(RestorationStatus::Unavailable), - }, - AuthenticationEvent::TimedOut { - attempt_id: attempt("old"), - epoch, - }, - ] { - assert_eq!(machine.apply(event), TransitionResult::Rejected); - assert_eq!(machine.phase(), AuthenticationPhase::Authorizing); - } - assert_eq!( - machine.accept_callback(callback(current.clone(), Epoch::new(3))), - TransitionResult::Rejected, - "a callback command with a stale epoch cannot complete the current attempt", - ); - assert_eq!(machine.phase(), AuthenticationPhase::Authorizing); - - assert_applied( - &mut machine, - AuthenticationEvent::StartLogout { - attempt_id: attempt("logout"), - epoch, - }, - AuthenticationPhase::LoggingOut, - ); - assert_eq!( - machine.apply(AuthenticationEvent::TombstoneInstalled { - attempt_id: attempt("old-logout"), - epoch, - tombstone_epoch: Epoch::new(5), - result: Ok(()), - }), - TransitionResult::Rejected, - ); - assert_eq!(machine.phase(), AuthenticationPhase::LoggingOut); - - let replayed = attempt("replayed-after-terminal"); - let mut reuse_machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut reuse_machine, - AuthenticationEvent::StartAuthorization { - attempt_id: replayed.clone(), - epoch, - }, - AuthenticationPhase::Authorizing, - ); - assert_applied( - &mut reuse_machine, - AuthenticationEvent::Cancel { - attempt_id: replayed.clone(), - epoch, - }, - AuthenticationPhase::Cancelling, - ); - assert_eq!( - reuse_machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: replayed, - epoch, - }), - TransitionResult::Rejected, - "a terminal attempt ID stays retired to prevent callback replay" - ); - } - - #[test] - fn rejected_callback_can_be_cancelled_to_release_its_authorization_attempt() { - let epoch = Epoch::new(4); - let authorization = attempt("authorization"); - let stale_callback = callback(attempt("stale-callback"), epoch); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - - assert_eq!( - machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - - assert_eq!( - machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), + result: Ok(persisted(epoch)), }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.apply(AuthenticationEvent::BrowserFinished { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.accept_callback(stale_callback), - TransitionResult::Rejected, - ); - assert_applied( - &mut machine, - AuthenticationEvent::Cancel { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Cancelling, - ); - assert_applied( - &mut machine, - AuthenticationEvent::CancellationInvalidated { - attempt_id: authorization, - epoch, - result: Ok(()), - }, - AuthenticationPhase::SignedOut, - ); - - assert_eq!( - machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt("fresh-authorization"), - epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing), - ); - } - #[test] - fn active_session_can_be_invalidated_before_reauthorization() { - let epoch = Epoch::new(4); - let invalidation = attempt("invalidate-active"); - let reauthorization = attempt("reauthorize"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let restored = active(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartRestoration { - attempt_id: attempt("restore-active"), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RestorationFinished { - attempt_id: attempt("restore-active"), - epoch, - result: Ok(RestorationStatus::restored(restored)), - }, - AuthenticationPhase::Active, - ); - assert_applied( - &mut machine, - AuthenticationEvent::InvalidateActiveSession { - attempt_id: invalidation.clone(), - epoch, - }, - AuthenticationPhase::RefreshRecoveryRequired, - ); - assert_applied( - &mut machine, - AuthenticationEvent::StartLogout { - attempt_id: invalidation.clone(), - epoch, - }, - AuthenticationPhase::LoggingOut, - ); - assert_applied( - &mut machine, - AuthenticationEvent::TombstoneInstalled { - attempt_id: invalidation.clone(), - epoch, - tombstone_epoch: Epoch::new(5), - result: Ok(()), - }, - AuthenticationPhase::Tombstoned, - ); - assert_applied( - &mut machine, - AuthenticationEvent::LogoutCleanupFinished { - attempt_id: invalidation, - epoch: Epoch::new(5), - result: Ok(()), - }, - AuthenticationPhase::SignedOut, - ); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: reauthorization, - epoch: Epoch::new(5), - }, - AuthenticationPhase::Authorizing, - ); - } - - #[test] - fn rejected_pre_browser_transition_can_be_cancelled_before_a_fresh_attempt() { - let epoch = Epoch::new(4); - let authorization = attempt("authorization"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - - assert_eq!( - machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.apply(AuthenticationEvent::BrowserFinished { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Rejected, - ); - assert_applied( - &mut machine, - AuthenticationEvent::Cancel { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Cancelling, - ); - assert_applied( - &mut machine, - AuthenticationEvent::CancellationInvalidated { - attempt_id: authorization, - epoch, - result: Ok(()), - }, - AuthenticationPhase::SignedOut, - ); - assert_eq!( - machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt("fresh-authorization"), - epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing), - ); - } - - #[test] - fn cancellation_waits_for_durable_invalidation_and_reconciles_a_winning_promotion_through_logout() - { - let epoch = Epoch::new(4); - let authorization = attempt("authorization"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Authorizing, - ); - assert_eq!( - machine.accept_callback(callback(authorization.clone(), epoch)), - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization), - ); - assert_applied( - &mut machine, - AuthenticationEvent::AuthorizationCompleted { - attempt_id: authorization.clone(), - epoch, - result: Ok(completed()), - }, - AuthenticationPhase::PersistingAuthorization, - ); - assert_applied( - &mut machine, - AuthenticationEvent::Cancel { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Cancelling, - ); - assert_applied( - &mut machine, - AuthenticationEvent::CancellationInvalidated { - attempt_id: authorization.clone(), - epoch, - result: Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), - }, - AuthenticationPhase::Cancelling, - ); - assert_applied( - &mut machine, - AuthenticationEvent::PersistenceFinished { - attempt_id: authorization.clone(), - epoch, - result: Ok(persisted(active(epoch))), - }, - AuthenticationPhase::LoggingOut, - ); - assert_eq!( - machine.apply(AuthenticationEvent::CancellationInvalidated { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Rejected, - "a promotion winner must be reconciled by logout, not cancellation" - ); - assert_applied( - &mut machine, - AuthenticationEvent::TombstoneInstalled { - attempt_id: authorization.clone(), - epoch, - tombstone_epoch: Epoch::new(5), - result: Ok(()), - }, - AuthenticationPhase::Tombstoned, - ); - assert_applied( - &mut machine, - AuthenticationEvent::LogoutCleanupFinished { - attempt_id: authorization, - epoch: Epoch::new(5), - result: Ok(()), - }, - AuthenticationPhase::SignedOut, - ); - } - - #[test] - fn restore_and_refresh_recovery_prohibit_old_work_but_allow_independent_reauthentication() { - let epoch = Epoch::new(4); - let restore = attempt("restore"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_eq!( - machine.apply(AuthenticationEvent::RestorationFinished { - attempt_id: restore.clone(), - epoch, - result: Ok(RestorationStatus::Unavailable), - }), - TransitionResult::Rejected, - "a restore result cannot arrive before its operation is current" - ); - assert_applied( - &mut machine, - AuthenticationEvent::StartRestoration { - attempt_id: restore.clone(), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RestorationFinished { - attempt_id: restore, - epoch, - result: Ok(RestorationStatus::refresh_recovery_required( - active(epoch).into_refresh_recovery(), - )), - }, - AuthenticationPhase::RefreshRecoveryRequired, - ); - assert_eq!( - machine.apply(AuthenticationEvent::StartRestoration { - attempt_id: attempt("restore-old"), - epoch, - }), - TransitionResult::Rejected, - ); - assert_eq!( - machine.apply(AuthenticationEvent::StartRefresh { - attempt_id: attempt("refresh-old"), - epoch, - }), - TransitionResult::Rejected, - ); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: attempt("reauthenticate"), - epoch: Epoch::new(5), - }, - AuthenticationPhase::Authorizing, - ); - - let refresh_attempt = attempt("refresh"); - let mut refresh_machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut refresh_machine, - AuthenticationEvent::StartRestoration { - attempt_id: attempt("restore-active"), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut refresh_machine, - AuthenticationEvent::RestorationFinished { - attempt_id: attempt("restore-active"), - epoch, - result: Ok(RestorationStatus::restored(active(epoch))), - }, - AuthenticationPhase::Active, - ); - assert_applied( - &mut refresh_machine, - AuthenticationEvent::StartRefresh { - attempt_id: refresh_attempt.clone(), - epoch, - }, - AuthenticationPhase::Refreshing, - ); - assert_applied( - &mut refresh_machine, - AuthenticationEvent::RefreshFinished { - attempt_id: refresh_attempt, - epoch, - result: Ok(RefreshOutcome::RecoveryRequired( - active(epoch).into_refresh_recovery(), - )), - }, - AuthenticationPhase::RefreshRecoveryRequired, - ); - assert_eq!( - refresh_machine.apply(AuthenticationEvent::StartRefresh { - attempt_id: attempt("retry-old-refresh"), - epoch, - }), - TransitionResult::Rejected, - "a recovery outcome never permits an automatic old-token refresh" - ); - } - - #[test] - fn refresh_error_and_unproven_no_dispatch_never_restore_the_prior_session() { - let epoch = Epoch::new(4); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let restore = attempt("restore"); - assert_applied( - &mut machine, - AuthenticationEvent::StartRestoration { - attempt_id: restore.clone(), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RestorationFinished { - attempt_id: restore, - epoch, - result: Ok(RestorationStatus::restored(active(epoch))), - }, - AuthenticationPhase::Active, - ); - - let refresh = attempt("ambiguous-refresh"); - assert_applied( - &mut machine, - AuthenticationEvent::StartRefresh { - attempt_id: refresh.clone(), - epoch, - }, - AuthenticationPhase::Refreshing, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RefreshFinished { - attempt_id: refresh, - epoch, - result: Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::TransportUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), - }, - AuthenticationPhase::RefreshRecoveryRequired, - ); - - let mut no_dispatch_machine = AuthenticationStateMachine::signed_out(epoch); - let restore = attempt("restore-again"); - assert_applied( - &mut no_dispatch_machine, - AuthenticationEvent::StartRestoration { - attempt_id: restore.clone(), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut no_dispatch_machine, - AuthenticationEvent::RestorationFinished { - attempt_id: restore, - epoch, - result: Ok(RestorationStatus::restored(active(epoch))), - }, - AuthenticationPhase::Active, - ); - let refresh = attempt("unproven-no-dispatch"); - assert_applied( - &mut no_dispatch_machine, - AuthenticationEvent::StartRefresh { - attempt_id: refresh.clone(), - epoch, - }, - AuthenticationPhase::Refreshing, - ); - assert_eq!( - no_dispatch_machine.apply(AuthenticationEvent::RefreshFinished { - attempt_id: refresh.clone(), - epoch, - result: Ok(RefreshOutcome::NotDispatched(active_for( - "did:plc:alice", - 2, - epoch, - ))), - }), - TransitionResult::Rejected, - ); - assert_eq!(no_dispatch_machine.phase(), AuthenticationPhase::Refreshing); - assert_applied( - &mut no_dispatch_machine, - AuthenticationEvent::RefreshFinished { - attempt_id: refresh, - epoch, - result: Ok(RefreshOutcome::NotDispatched(active(epoch))), - }, - AuthenticationPhase::Active, - ); - } - - #[test] - fn active_record_transitions_reject_same_epoch_cross_account_or_generation_results() { - let epoch = Epoch::new(4); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let restore = attempt("restore-active"); - assert_applied( - &mut machine, - AuthenticationEvent::StartRestoration { - attempt_id: restore.clone(), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_applied( - &mut machine, - AuthenticationEvent::RestorationFinished { - attempt_id: restore, - epoch, - result: Ok(RestorationStatus::restored(active(epoch))), - }, - AuthenticationPhase::Active, - ); - let refresh = attempt("refresh-active"); - assert_applied( - &mut machine, - AuthenticationEvent::StartRefresh { - attempt_id: refresh.clone(), - epoch, - }, - AuthenticationPhase::Refreshing, - ); - - for result in [ - RefreshOutcome::Replaced(active_for("did:plc:bob", 2, epoch)), - RefreshOutcome::Replaced(active(epoch)), - RefreshOutcome::NotDispatched(active_for("did:plc:bob", 1, epoch)), - RefreshOutcome::NotDispatched(active_for("did:plc:alice", 2, epoch)), - RefreshOutcome::RecoveryRequired( - active_for("did:plc:bob", 1, epoch).into_refresh_recovery(), - ), - ] { - assert_eq!( - machine.apply(AuthenticationEvent::RefreshFinished { - attempt_id: refresh.clone(), - epoch, - result: Ok(result), - }), - TransitionResult::Rejected, - ); - assert_eq!(machine.phase(), AuthenticationPhase::Refreshing); - } - - assert_applied( - &mut machine, - AuthenticationEvent::RefreshFinished { - attempt_id: refresh, - epoch, - result: Ok(RefreshOutcome::Replaced(active_for( - "did:plc:alice", - 2, - epoch, - ))), - }, - AuthenticationPhase::Active, - ); - } - - #[test] - fn restoration_and_persistence_bind_full_record_and_completed_account() { - let epoch = Epoch::new(4); - let mut restoration = AuthenticationStateMachine::signed_out(epoch); - let restore = attempt("restore-mismatch"); - assert_applied( - &mut restoration, - AuthenticationEvent::StartRestoration { - attempt_id: restore.clone(), - epoch, - }, - AuthenticationPhase::Restoring, - ); - assert_eq!( - restoration.apply(AuthenticationEvent::RestorationFinished { - attempt_id: restore, - epoch, - result: Ok(RestorationStatus::Restored { - verified: AccountGenerationReference::new( - Did::new("did:plc:alice") - .expect("test DID is valid") - .into_authenticated(), - Generation::new(1), - epoch, - ), - session: active_for("did:plc:bob", 1, epoch), - }), - }), - TransitionResult::Rejected, - ); - assert_eq!(restoration.phase(), AuthenticationPhase::Restoring); - - let mut persistence = AuthenticationStateMachine::signed_out(epoch); - let authorization = attempt("persist-account"); - assert_applied( - &mut persistence, - AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Authorizing, - ); - assert_eq!( - persistence.accept_callback(callback(authorization.clone(), epoch)), - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization), - ); - assert_applied( - &mut persistence, - AuthenticationEvent::AuthorizationCompleted { - attempt_id: authorization.clone(), - epoch, - result: Ok(completed()), - }, - AuthenticationPhase::PersistingAuthorization, - ); - assert_eq!( - persistence.apply(AuthenticationEvent::PersistenceFinished { - attempt_id: authorization.clone(), - epoch, - result: Ok(PersistedActiveSession::from_committed_record( - AccountGenerationReference::new( - Did::new("did:plc:alice") - .expect("test DID is valid") - .into_authenticated(), - Generation::new(2), - epoch, - ), - active(epoch), - )), - }), - TransitionResult::Rejected, - "a persistence result must name the exact active record it committed", - ); - assert_eq!( - persistence.phase(), - AuthenticationPhase::PersistingAuthorization - ); - assert_eq!( - persistence.apply(AuthenticationEvent::PersistenceFinished { - attempt_id: authorization, - epoch, - result: Ok(persisted(active_for("did:plc:bob", 1, epoch))), - }), - TransitionResult::Rejected, - ); - assert_eq!( - persistence.phase(), - AuthenticationPhase::PersistingAuthorization - ); - } - - #[test] - fn only_validated_callback_commands_can_advance_completion() { - let epoch = Epoch::new(4); - let authorization = attempt("callback-current"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - assert_applied( - &mut machine, - AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }, - AuthenticationPhase::Authorizing, - ); - assert_eq!( - machine.accept_callback(callback(attempt("callback-other"), epoch)), - TransitionResult::Rejected, - "the state machine derives correlation from the validated command", - ); - assert_eq!(machine.phase(), AuthenticationPhase::Authorizing); - assert_eq!( - machine.accept_callback(callback(authorization, epoch)), - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization), - ); - } - - #[test] - fn callback_requires_preparation_and_browser_handoff() { - let epoch = Epoch::new(4); - let authorization = attempt("ordered-callback"); - let command = callback(authorization.clone(), epoch); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - - assert_eq!( - machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: authorization.clone(), - epoch, - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.accept_callback(command.clone()), - TransitionResult::Rejected - ); - assert_eq!( - machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: authorization.clone(), - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.accept_callback(command.clone()), TransitionResult::Rejected ); - assert_eq!( - machine.apply(AuthenticationEvent::BrowserFinished { - attempt_id: authorization, - epoch, - result: Ok(()), - }), - TransitionResult::Applied(AuthenticationPhase::Authorizing) - ); - assert_eq!( - machine.accept_callback(command), - TransitionResult::Applied(AuthenticationPhase::CompletingAuthorization) - ); - } - - #[test] - fn begin_refresh_moves_capability_and_retains_only_coordinate() { - let epoch = Epoch::new(4); - let session = active(epoch); - let reference = session.reference().clone(); - let mut machine = AuthenticationStateMachine { - state: AuthenticationState::Active { epoch, session }, - retired_attempt_ids: BTreeSet::new(), - }; - - let (observed_epoch, handed_off) = machine - .begin_refresh(attempt("refresh-handoff"), &reference) - .expect("the exact active record can refresh"); - - assert_eq!(observed_epoch, epoch); - assert_eq!(handed_off.reference(), &reference); - assert_eq!(machine.phase(), AuthenticationPhase::Refreshing); - assert_eq!(machine.active_reference(), None); } } diff --git a/src/authentication/workflow.rs b/src/authentication/workflow.rs deleted file mode 100644 index 259bafc..0000000 --- a/src/authentication/workflow.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! Application use cases composed from narrow authentication ports. - -use super::command::{CompleteAuthorizationCallback, ValidatedAuthorizationCallback}; -use super::error::{AuthenticationError, AuthenticationErrorCategory}; -use super::model::{ - AccountHandle, AuthenticationAttemptId, AuthenticationDeadline, Epoch, ManualAuthorizationUrl, - PreparedAuthorization, RestorationSnapshot, RevocationReport, VerifiedActiveSession, -}; -use super::ports::{ - BoundCallbackListener, BrowserLauncher, CallbackListenerLifecycle, DeadlineClock, - OAuthAttemptRetirement, OAuthAuthorization, OAuthCompletion, SessionInvalidation, - SessionPersistence, SessionRestoration, SessionRevocation, -}; -use super::{AuthenticationEvent, AuthenticationTimeout}; - -/// Result of startup restoration correlated to the durable vault epoch. -pub struct RestoreResult { - pub epoch: Epoch, - pub event: AuthenticationEvent, -} - -/// The owned callback resources awaiting a correlated authorization response. -pub struct PendingAuthorization { - prepared: PreparedAuthorization, - listener: B, - deadline: AuthenticationDeadline, -} - -/// A started authorization whose browser was opened or must be opened manually. -pub enum SignInStart { - BrowserOpened(PendingAuthorization), - ManualAuthorization { - url: ManualAuthorizationUrl, - pending: PendingAuthorization, - }, -} -impl PendingAuthorization { - #[must_use] - pub fn manual_url(&self) -> &ManualAuthorizationUrl { - self.prepared.manual_url() - } -} - -/// Result of completing a validated callback. -pub struct CompletionResult { - pub event: AuthenticationEvent, -} - -/// Result of durably cancelling an authorization attempt. -pub struct CancellationResult { - pub event: AuthenticationEvent, - pub invalidation: Result<(), AuthenticationError>, -} - -/// Result of retrying the currently applicable workflow. -pub enum RetryResult { - Restoration(RestoreResult), - SignIn(SignInStart), -} - -/// Result of cleanup requested from refresh recovery. -pub struct RecoveryCleanupResult { - pub tombstone: Result, - pub cleanup: Option>, -} - -/// Result of logout; remote outcomes never decide local cleanup. -pub struct LogoutResult { - pub event: AuthenticationEvent, - pub revocation: RevocationReport, - pub local_cleanup: Result<(), AuthenticationError>, -} - -/// Authentication application service composed from independent port capabilities. -pub struct AuthenticationApplication { - oauth: O, - sessions: S, - browser: B, - listeners: L, - clock: C, -} - -impl AuthenticationApplication { - #[must_use] - pub const fn new(oauth: O, sessions: S, browser: B, listeners: L, clock: C) -> Self { - Self { - oauth, - sessions, - browser, - listeners, - clock, - } - } -} - -impl AuthenticationApplication -where - O: OAuthAuthorization + OAuthCompletion + OAuthAttemptRetirement + SessionRevocation + Sync, - S: SessionRestoration + SessionPersistence + SessionInvalidation + Sync, - B: BrowserLauncher + Sync, - L: CallbackListenerLifecycle + Sync, - C: DeadlineClock + Sync, -{ - pub async fn restore( - &self, - attempt_id: AuthenticationAttemptId, - ) -> Result { - let snapshot: RestorationSnapshot = self.sessions.restore_session().await?; - let epoch = snapshot.epoch(); - Ok(RestoreResult { - epoch, - event: AuthenticationEvent::RestorationFinished { - attempt_id, - epoch, - result: Ok(snapshot.into_status()), - }, - }) - } - - /// Binds the callback socket before preparing authorization. The returned pending - /// value owns that socket until callback completion, cancellation, or drop. - #[allow( - clippy::manual_async_fn, - reason = "The explicit Send future preserves Iced task compatibility." - )] - pub fn start_sign_in( - &self, - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - handle: AccountHandle, - timeout: AuthenticationTimeout, - ) -> impl Future, AuthenticationError>> + Send + '_ { - async move { - let deadline = self.clock.deadline_after(timeout); - let listener = self.listeners.start_listener(attempt_id.clone()).await?; - let redirect_uri = listener.redirect_uri().clone(); - let prepared = self - .oauth - .prepare_authorization(attempt_id, handle, redirect_uri) - .await? - .bind_epoch(epoch)?; - let pending = PendingAuthorization { - prepared, - listener, - deadline, - }; - let url = pending.prepared.manual_url().clone(); - match self.browser.launch_authorization(url.clone()).await { - Ok(()) => Ok(SignInStart::BrowserOpened(pending)), - Err(_) => Ok(SignInStart::ManualAuthorization { url, pending }), - } - } - } - /// Attempts to open an already-prepared authorization request in the system browser. - pub fn retry_browser( - &self, - url: ManualAuthorizationUrl, - ) -> impl Future> + Send + '_ { - self.browser.launch_authorization(url) - } - - /// Waits for a callback only until the application-owned authorization deadline. - #[allow( - clippy::manual_async_fn, - reason = "The explicit Send future preserves listener-drop ordering." - )] - pub fn await_authorization( - &self, - pending: PendingAuthorization, - ) -> impl Future> + Send + '_ - { - async move { - let PendingAuthorization { - prepared, - listener, - deadline, - } = pending; - let result = self - .clock - .run_until( - deadline, - self.listeners.accept_callback(&listener, &prepared), - ) - .await - .and_then(|result| result); - drop(listener); - result - } - } - - pub async fn complete_callback( - &self, - command: CompleteAuthorizationCallback, - ) -> CompletionResult { - let attempt_id = command.attempt_id().clone(); - let epoch = command.epoch(); - CompletionResult { - event: AuthenticationEvent::AuthorizationCompleted { - attempt_id, - epoch, - result: self.oauth.complete_authorization(command).await, - }, - } - } - - pub async fn persist_completed_authorization( - &self, - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - completed: super::model::CompletedAuthorization, - ) -> AuthenticationEvent { - AuthenticationEvent::PersistenceFinished { - attempt_id, - epoch, - result: self - .sessions - .persist_completed_authorization(completed) - .await, - } - } - - pub async fn cancel( - &self, - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - ) -> CancellationResult { - self.oauth.retire_authorization(&attempt_id).await; - let invalidation = self - .sessions - .invalidate_attempt(&attempt_id, epoch) - .await - .and_then(|outcome| match outcome { - super::model::AttemptInvalidation::Invalidated => Ok(()), - super::model::AttemptInvalidation::PromotionWon(_) => { - Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )) - } - }); - let event_result = invalidation; - CancellationResult { - event: AuthenticationEvent::CancellationInvalidated { - attempt_id, - epoch, - result: event_result, - }, - invalidation, - } - } - - pub async fn logout( - &self, - attempt_id: AuthenticationAttemptId, - epoch: Epoch, - session: VerifiedActiveSession, - ) -> LogoutResult { - let tombstone = self.sessions.install_logout_tombstone(epoch).await; - let revocation = self.oauth.revoke_session(session).await; - let local_cleanup = match tombstone { - Ok(tombstone_epoch) => { - self.sessions - .cleanup_tombstoned_epoch(tombstone_epoch) - .await - } - Err(error) => Err(error), - }; - LogoutResult { - event: AuthenticationEvent::LogoutCleanupFinished { - attempt_id, - epoch, - result: local_cleanup, - }, - revocation, - local_cleanup, - } - } -} - -impl AuthenticationApplication -where - S: SessionInvalidation, -{ - /// Removes a recovery-blocked durable session before a new authorization. - /// - /// This deliberately has no remote revocation: recovery has no trustworthy - /// active session capability. The caller applies its two lifecycle events. - pub async fn cleanup_recovery(&self, epoch: Epoch) -> RecoveryCleanupResult { - let tombstone = self.sessions.install_logout_tombstone(epoch).await; - let cleanup = match &tombstone { - Ok(tombstone_epoch) => Some( - self.sessions - .cleanup_tombstoned_epoch(*tombstone_epoch) - .await, - ), - Err(_) => None, - }; - RecoveryCleanupResult { tombstone, cleanup } - } -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use super::*; - use crate::authentication::{ - AttemptInvalidation, AuthenticationFailureReason, AuthenticationFailureStage, - }; - - #[derive(Clone, Copy)] - enum RecoveryMode { - TombstoneFails, - CleanupFails, - Succeeds, - } - - #[derive(Clone)] - struct RecoverySessions { - mode: RecoveryMode, - calls: Arc>>, - } - - impl RecoverySessions { - fn new(mode: RecoveryMode) -> Self { - Self { - mode, - calls: Arc::default(), - } - } - - fn error() -> AuthenticationError { - AuthenticationError::new( - AuthenticationFailureStage::SessionInvalidation, - AuthenticationErrorCategory::SessionCleanupIncomplete, - AuthenticationFailureReason::CleanupIncomplete, - ) - } - } - - impl SessionInvalidation for RecoverySessions { - async fn invalidate_attempt( - &self, - _: &AuthenticationAttemptId, - _: Epoch, - ) -> Result { - Ok(AttemptInvalidation::Invalidated) - } - - async fn install_logout_tombstone( - &self, - expected_epoch: Epoch, - ) -> Result { - self.calls - .lock() - .expect("test lock") - .push(("tombstone", expected_epoch)); - match self.mode { - RecoveryMode::TombstoneFails => Err(Self::error()), - RecoveryMode::CleanupFails | RecoveryMode::Succeeds => { - Ok(Epoch::new(expected_epoch.value() + 1)) - } - } - } - - async fn cleanup_tombstoned_epoch( - &self, - tombstone_epoch: Epoch, - ) -> Result<(), AuthenticationError> { - self.calls - .lock() - .expect("test lock") - .push(("cleanup", tombstone_epoch)); - match self.mode { - RecoveryMode::CleanupFails => Err(Self::error()), - RecoveryMode::TombstoneFails | RecoveryMode::Succeeds => Ok(()), - } - } - } - - #[tokio::test] - async fn recovery_cleanup_stops_before_cleanup_when_tombstone_fails() { - let sessions = RecoverySessions::new(RecoveryMode::TombstoneFails); - let calls = Arc::clone(&sessions.calls); - let workflow = AuthenticationApplication::new((), sessions, (), (), ()); - - let result = workflow.cleanup_recovery(Epoch::new(4)).await; - - assert_eq!(result.tombstone, Err(RecoverySessions::error())); - assert_eq!(result.cleanup, None); - assert_eq!( - *calls.lock().expect("test lock"), - vec![("tombstone", Epoch::new(4))] - ); - } - - #[tokio::test] - async fn recovery_cleanup_reports_failure_after_tombstone() { - let sessions = RecoverySessions::new(RecoveryMode::CleanupFails); - let calls = Arc::clone(&sessions.calls); - let workflow = AuthenticationApplication::new((), sessions, (), (), ()); - - let result = workflow.cleanup_recovery(Epoch::new(4)).await; - - assert_eq!(result.tombstone, Ok(Epoch::new(5))); - assert_eq!(result.cleanup, Some(Err(RecoverySessions::error()))); - assert_eq!( - *calls.lock().expect("test lock"), - vec![("tombstone", Epoch::new(4)), ("cleanup", Epoch::new(5))] - ); - } - - #[tokio::test] - async fn recovery_cleanup_tombstones_then_cleans_the_advanced_epoch() { - let sessions = RecoverySessions::new(RecoveryMode::Succeeds); - let calls = Arc::clone(&sessions.calls); - let workflow = AuthenticationApplication::new((), sessions, (), (), ()); - - let result = workflow.cleanup_recovery(Epoch::new(4)).await; - - assert_eq!(result.tombstone, Ok(Epoch::new(5))); - assert_eq!(result.cleanup, Some(Ok(()))); - assert_eq!( - *calls.lock().expect("test lock"), - vec![("tombstone", Epoch::new(4)), ("cleanup", Epoch::new(5))] - ); - } -} diff --git a/src/bin/oauth_release_qualification.rs b/src/bin/oauth_release_qualification.rs index 91a8aea..1afcbe2 100644 --- a/src/bin/oauth_release_qualification.rs +++ b/src/bin/oauth_release_qualification.rs @@ -12,9 +12,14 @@ use serde::Deserialize; use sha2::{Digest as _, Sha256}; const MAX_METADATA_BYTES: usize = 16_384; +const CLIENT_ID: &str = "https://hitomi.nateledford.com/client-metadata-v2.json"; +const CLIENT_NAME: &str = "Hitomi"; +const REDIRECT_URI: &str = "com.nateledford.hitomi:/oauth/callback"; #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct ClientMetadata { + client_name: String, client_id: String, redirect_uris: Vec, grant_types: Vec, @@ -74,39 +79,15 @@ fn parse_metadata(bytes: &[u8]) -> Result { } let metadata: ClientMetadata = serde_json::from_slice(bytes) .map_err(|_| "checked-in metadata is invalid JSON".to_owned())?; - let [redirect] = metadata.redirect_uris.as_slice() else { - return Err("checked-in metadata must have exactly one redirect URI".to_owned()); - }; - let client_id = url::Url::parse(&metadata.client_id) - .map_err(|_| "checked-in client ID is invalid".to_owned())?; - let redirect = - url::Url::parse(redirect).map_err(|_| "checked-in redirect URI is invalid".to_owned())?; - if client_id.scheme() != "https" - || client_id.host_str().is_none() - || client_id.port().is_some() - || !matches!(metadata.application_type.as_str(), "native") - || !matches!(metadata.token_endpoint_auth_method.as_str(), "none") + if metadata.client_id != CLIENT_ID + || metadata.redirect_uris.as_slice() != [REDIRECT_URI] + || metadata.client_name != CLIENT_NAME + || metadata.grant_types.as_slice() != ["authorization_code", "refresh_token"] + || metadata.response_types.as_slice() != ["code"] + || metadata.scope != "atproto" + || metadata.application_type != "native" + || metadata.token_endpoint_auth_method != "none" || !metadata.dpop_bound_access_tokens - || !metadata - .grant_types - .iter() - .any(|grant| grant == "authorization_code") - || !metadata - .response_types - .iter() - .any(|response| response == "code") - || !metadata - .scope - .split_ascii_whitespace() - .any(|scope| scope == "atproto") - || redirect.scheme() - != client_id - .host_str() - .unwrap_or_default() - .split('.') - .rev() - .collect::>() - .join(".") { return Err("checked-in metadata violates the public native-client contract".to_owned()); } @@ -191,6 +172,10 @@ fn package_url_schemes(package: &serde_json::Value) -> Result, Strin .collect() } +fn matches_exact_package_scheme(schemes: &[String], expected_scheme: &str) -> bool { + schemes == [expected_scheme] +} + fn package_scheme_probe(metadata: &ClientMetadata, info_plist: &str) -> Result<(), String> { let output = Command::new("plutil") .args(["-convert", "json", "-o", "-", info_plist]) @@ -210,7 +195,7 @@ fn package_scheme_probe(metadata: &ClientMetadata, info_plist: &str) -> Result<( .map_err(|_| "checked-in redirect URI is invalid".to_owned())? .scheme() .to_owned(); - if schemes != [expected_scheme] { + if !matches_exact_package_scheme(&schemes, &expected_scheme) { return Err("package URL schemes do not exactly match the metadata redirect".to_owned()); } println!( @@ -248,17 +233,62 @@ fn main() { #[cfg(test)] mod tests { - use super::{package_url_schemes, parse_metadata, valid_source_revision}; + use super::{ + MAX_METADATA_BYTES, matches_exact_package_scheme, package_url_schemes, parse_metadata, + valid_source_revision, + }; + + fn valid_metadata() -> serde_json::Value { + serde_json::json!({ + "client_id": "https://hitomi.nateledford.com/client-metadata-v2.json", + "client_name": "Hitomi", + "redirect_uris": ["com.nateledford.hitomi:/oauth/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "atproto", + "token_endpoint_auth_method": "none", + "application_type": "native", + "dpop_bound_access_tokens": true + }) + } #[test] - fn rejects_a_custom_scheme_that_does_not_match_the_client_hostname() { - let error = parse_metadata( - br#"{"client_id":"https://hitomi.nateledford.com/client-metadata-v2.json","redirect_uris":["com.example.hitomi:/oauth/callback"],"grant_types":["authorization_code"],"response_types":["code"],"scope":"atproto","token_endpoint_auth_method":"none","application_type":"native","dpop_bound_access_tokens":true}"#, - ) - .expect_err("unrelated custom scheme must fail"); + fn rejects_redirect_and_grant_contract_drift() { + for (field, value) in [ + ( + "redirect_uris", + serde_json::json!([ + "com.nateledford.hitomi:/oauth/callback", + "com.example.hitomi:/oauth/callback" + ]), + ), + ("redirect_uris", serde_json::json!([])), + ("grant_types", serde_json::json!(["authorization_code"])), + ] { + let mut metadata = valid_metadata(); + metadata[field] = value; + let bytes = serde_json::to_vec(&metadata).expect("fixture JSON"); + assert_eq!( + parse_metadata(&bytes).expect_err("drift must fail qualification"), + "checked-in metadata violates the public native-client contract" + ); + } + } + + #[test] + fn rejects_unknown_and_oversized_metadata() { + let mut metadata = valid_metadata(); + metadata["unexpected"] = serde_json::json!(true); + let bytes = serde_json::to_vec(&metadata).expect("fixture JSON"); assert_eq!( - error, - "checked-in metadata violates the public native-client contract" + parse_metadata(&bytes).expect_err("unknown fields must fail qualification"), + "checked-in metadata is invalid JSON" + ); + + let oversized = vec![b' '; MAX_METADATA_BYTES + 1]; + assert_eq!( + parse_metadata(&oversized).expect_err("oversized metadata must fail"), + "checked-in metadata exceeds its bound" ); } @@ -278,10 +308,26 @@ mod tests { {"CFBundleURLSchemes": ["com.nateledford.hitomi"]} ] }); + let schemes = package_url_schemes(&package).expect("package schemes"); - assert_eq!( - package_url_schemes(&package).expect("package schemes"), - ["com.nateledford.hitomi"] - ); + assert!(matches_exact_package_scheme( + &schemes, + "com.nateledford.hitomi" + )); + assert!(!matches_exact_package_scheme(&[], "com.nateledford.hitomi")); + assert!(!matches_exact_package_scheme( + &[ + "com.nateledford.hitomi".to_owned(), + "com.example.hitomi".to_owned() + ], + "com.nateledford.hitomi" + )); + assert!(!matches_exact_package_scheme( + &[ + "com.nateledford.hitomi".to_owned(), + "com.nateledford.hitomi".to_owned() + ], + "com.nateledford.hitomi" + )); } } diff --git a/src/infrastructure/browser.rs b/src/infrastructure/browser.rs index 2aece17..de3d6b2 100644 --- a/src/infrastructure/browser.rs +++ b/src/infrastructure/browser.rs @@ -1,6 +1,5 @@ //! System-browser adapter for a prevalidated manual authorization URL. -use crate::authentication::ports::BrowserLauncher; use crate::authentication::{ AuthenticationError, AuthenticationErrorCategory, ManualAuthorizationUrl, }; @@ -9,8 +8,8 @@ use crate::authentication::{ #[derive(Clone, Copy, Debug, Default)] pub struct SystemBrowser; -impl BrowserLauncher for SystemBrowser { - async fn launch_authorization( +impl SystemBrowser { + pub(crate) async fn launch_authorization( &self, url: ManualAuthorizationUrl, ) -> Result<(), AuthenticationError> { diff --git a/src/infrastructure/callback.rs b/src/infrastructure/callback.rs index 60f4d95..4d6150b 100644 --- a/src/infrastructure/callback.rs +++ b/src/infrastructure/callback.rs @@ -1,257 +1,68 @@ -//! IPv4 loopback callback adapter. -#![cfg_attr(not(test), allow(dead_code))] +//! Bounded callback ingress adapters. +//! +//! Production custom-scheme callbacks arrive through macOS LaunchServices and +//! are delivered in-process. The loopback HTTP server exists only for tests and +//! local development fixtures. +use std::sync::LazyLock; -use std::io::Write as _; -use std::net::{Shutdown, SocketAddr, TcpStream as StdTcpStream}; -use std::time::Duration; - -use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; use crate::authentication::command::{ - CompleteAuthorizationCallback, ValidatedAuthorizationCallback, -}; -use crate::authentication::ports::{BoundCallbackListener, CallbackListenerLifecycle}; -use crate::authentication::{ - AuthenticationAttemptId, AuthenticationError, AuthenticationErrorCategory, - PreparedAuthorization, RedirectUri, + AuthorizationCallbackUrl, MAX_AUTHORIZATION_CALLBACK_URL_BYTES, }; -const MAX_REQUEST_LINE_BYTES: usize = 8_192; -const MAX_HEADER_BYTES: usize = 16_384; -const MAX_HEADERS: usize = 32; -const MAX_QUERY_BYTES: usize = 8_192; -const MAX_PARAMETERS: usize = 8; -const CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(1); -const CONNECTION_WRITE_TIMEOUT: Duration = Duration::from_secs(1); -const MAX_REQUEST_BYTES: usize = MAX_REQUEST_LINE_BYTES + MAX_HEADER_BYTES + 4; - -const SUCCESS_RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\nAuthorization complete."; -const INVALID_RESPONSE: &[u8] = b"HTTP/1.1 400 Bad Request\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\nInvalid callback."; - -fn callback_rejected() -> AuthenticationError { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CallbackRejected, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) -} +const NATIVE_CALLBACK_CHANNEL_CAPACITY: usize = 16; -pub(crate) fn parse_callback_request( - prepared: &PreparedAuthorization, - request: &[u8], -) -> Result { - let request = std::str::from_utf8(request).map_err(|_| callback_rejected())?; - let (head, body) = request - .split_once("\r\n\r\n") - .ok_or_else(callback_rejected)?; - if !body.is_empty() || head.len() > MAX_REQUEST_LINE_BYTES + MAX_HEADER_BYTES { - return Err(callback_rejected()); - } - let mut lines = head.split("\r\n"); - let line = lines.next().ok_or_else(callback_rejected)?; - let headers = lines.collect::>(); - if line.len() > MAX_REQUEST_LINE_BYTES - || headers.len() > MAX_HEADERS - || headers.iter().any(|header| header.len() > MAX_HEADER_BYTES) - { - return Err(callback_rejected()); - } - let redirect = - url::Url::parse(prepared.redirect_uri().as_str()).map_err(|_| callback_rejected())?; - let expected_host = format!( - "{}:{}", - redirect.host_str().ok_or_else(callback_rejected)?, - redirect.port().ok_or_else(callback_rejected)? - ); - let host_count = headers - .iter() - .filter_map(|header| header.split_once(':')) - .filter(|(name, _)| name.eq_ignore_ascii_case("host")) - .count(); - let matching_host_count = headers - .iter() - .filter_map(|header| header.split_once(':')) - .filter(|(name, value)| name.eq_ignore_ascii_case("host") && value.trim() == expected_host) - .count(); - if host_count != 1 || matching_host_count != 1 { - return Err(callback_rejected()); - } - let mut parts = line.split_ascii_whitespace(); - let (Some("GET"), Some(target), Some("HTTP/1.1"), None) = - (parts.next(), parts.next(), parts.next(), parts.next()) - else { - return Err(callback_rejected()); - }; - if target.len() > MAX_QUERY_BYTES + "/oauth/callback?".len() { - return Err(callback_rejected()); - } - let url = - url::Url::parse(&format!("http://127.0.0.1{target}")).map_err(|_| callback_rejected())?; - if url.path() != "/oauth/callback" { - return Err(callback_rejected()); - } - let pairs = url.query_pairs().collect::>(); - if pairs.len() > MAX_PARAMETERS { - return Err(callback_rejected()); - } - let value = |name: &str| { - let values = pairs - .iter() - .filter_map(|(key, value)| (key == name).then_some(value.as_ref())) - .collect::>(); - match values.as_slice() { - [value] => Some(*value), - _ => None, - } - }; - let state = value("state").ok_or_else(callback_rejected)?; - let issuer = value("iss").ok_or_else(callback_rejected)?; - if !prepared.matches_callback(issuer, state) { - return Err(callback_rejected()); - } - match (value("code"), value("error")) { - (Some(code), None) - if pairs - .iter() - .all(|(key, _)| key == "state" || key == "iss" || key == "code") => - { - CompleteAuthorizationCallback::from_inbound_callback(prepared, issuer, code, state) - .map(ValidatedAuthorizationCallback::Success) - } - (None, Some(_)) - if pairs.iter().all(|(key, _)| { - key == "state" - || key == "iss" - || key == "error" - || key == "error_description" - || key == "error_uri" - }) => - { - Ok(ValidatedAuthorizationCallback::denied( - prepared.attempt_id().clone(), - prepared.epoch().ok_or_else(callback_rejected)?, - )) - } - _ => Err(callback_rejected()), - } -} +static NATIVE_CALLBACK_SENDER: LazyLock> = + LazyLock::new(|| broadcast::channel(NATIVE_CALLBACK_CHANNEL_CAPACITY).0); -const NATIVE_CALLBACK_SCHEME: &str = crate::release_identity::CALLBACK_SCHEME; -const NATIVE_CALLBACK_PATH: &str = crate::release_identity::CALLBACK_PATH; -const NATIVE_FORWARD_ADDRESS: &str = "127.0.0.1:46354"; - -fn parse_native_callback( - prepared: &PreparedAuthorization, - callback: &[u8], -) -> Result { - let callback = std::str::from_utf8(callback).map_err(|_| callback_rejected())?; - let url = url::Url::parse(callback).map_err(|_| callback_rejected())?; - if url.scheme() != NATIVE_CALLBACK_SCHEME - || url.host().is_some() - || url.fragment().is_some() - || url.path() != NATIVE_CALLBACK_PATH - || prepared.redirect_uri().as_str() - != format!("{NATIVE_CALLBACK_SCHEME}:{NATIVE_CALLBACK_PATH}") - { - return Err(callback_rejected()); - } - let pairs = url.query_pairs().collect::>(); - if pairs.len() > MAX_PARAMETERS { - return Err(callback_rejected()); - } - let value = |name: &str| { - let values = pairs - .iter() - .filter_map(|(key, value)| (key == name).then_some(value.as_ref())) - .collect::>(); - match values.as_slice() { - [value] => Some(*value), - _ => None, - } - }; - let state = value("state").ok_or_else(callback_rejected)?; - let issuer = value("iss").ok_or_else(callback_rejected)?; - if !prepared.matches_callback(issuer, state) { - return Err(callback_rejected()); - } - match (value("code"), value("error")) { - (Some(code), None) - if pairs - .iter() - .all(|(key, _)| key == "state" || key == "iss" || key == "code") => - { - CompleteAuthorizationCallback::from_inbound_callback(prepared, issuer, code, state) - .map(ValidatedAuthorizationCallback::Success) - } - (None, Some(_)) - if pairs.iter().all(|(key, _)| { - key == "state" - || key == "iss" - || key == "error" - || key == "error_description" - || key == "error_uri" - }) => - { - Ok(ValidatedAuthorizationCallback::denied( - prepared.attempt_id().clone(), - prepared.epoch().ok_or_else(callback_rejected)?, - )) - } - _ => Err(callback_rejected()), - } +fn native_callback_sender() -> &'static broadcast::Sender { + &NATIVE_CALLBACK_SENDER } - -/// Forwards a callback passed to a scheme-handler process to the active app. /// -/// The active listener independently validates the URI, issuer, state, and -/// authorization code before any token exchange. -pub fn forward_native_callback(callback: &str) -> bool { - if callback.len() > MAX_QUERY_BYTES { - return false; - } - let Ok(url) = url::Url::parse(callback) else { - return false; - }; - if url.scheme() != NATIVE_CALLBACK_SCHEME - || url.host().is_some() - || url.fragment().is_some() - || url.path() != NATIVE_CALLBACK_PATH - { +/// Each subscriber receives only callbacks delivered after it subscribes. A +/// callback with no active subscriber is deliberately dropped. +pub(crate) fn subscribe_native_callbacks() -> broadcast::Receiver { + native_callback_sender().subscribe() +} + +fn dispatch_native_callback(callback: &str) -> bool { + if callback.len() > MAX_AUTHORIZATION_CALLBACK_URL_BYTES { return false; } - let address = SocketAddr::from(([127, 0, 0, 1], 46_354)); - let Ok(mut stream) = StdTcpStream::connect_timeout(&address, CONNECTION_WRITE_TIMEOUT) else { + let Ok(callback) = AuthorizationCallbackUrl::parse(callback.to_owned()) else { return false; }; - stream - .write_all(callback.as_bytes()) - .and_then(|()| stream.shutdown(Shutdown::Write)) - .is_ok() + native_callback_sender().send(callback).is_ok() } /// Installs the macOS Apple-event bridge for custom-scheme callbacks. /// -/// LaunchServices delivers custom URL schemes as `kAEGetURL` Apple events, not -/// command-line arguments. The bridge forwards only the registered URI to the -/// active loopback listener; that listener performs the authorization checks. +/// LaunchServices delivers registered custom URL schemes as `kAEGetURL` Apple +/// events. The event handler only performs bounded decoding, syntax validation, +/// and nonblocking in-process delivery; OAuth correlation remains owned by the +/// coordinator. #[cfg(target_os = "macos")] pub fn install_native_callback_bridge() { + let _ = native_callback_sender(); macos_bridge::install(); } #[cfg(not(target_os = "macos"))] -pub fn install_native_callback_bridge() {} +pub fn install_native_callback_bridge() { + let _ = native_callback_sender(); +} #[cfg(target_os = "macos")] mod macos_bridge { - use std::ffi::CStr; - use std::sync::LazyLock; - use objc::declare::ClassDecl; use objc::runtime::{Class, Object, Sel}; use objc::{class, msg_send, sel, sel_impl}; + use std::ffi::c_char; + use std::slice; + use std::sync::LazyLock; - use super::forward_native_callback; + use super::{MAX_AUTHORIZATION_CALLBACK_URL_BYTES, dispatch_native_callback}; const INTERNET_EVENT_CLASS: u32 = 0x4755_524c; // 'GURL' const GET_URL_EVENT: u32 = 0x4755_524c; // 'GURL' @@ -292,139 +103,132 @@ mod macos_bridge { if value.is_null() { return; } - let utf8: *const std::ffi::c_char = unsafe { msg_send![value, UTF8String] }; + let utf8: *const c_char = unsafe { msg_send![value, UTF8String] }; if utf8.is_null() { return; } - let Ok(callback) = unsafe { CStr::from_ptr(utf8) }.to_str() else { + + // `UTF8String` is NUL-terminated. Bound the scan before constructing a + // Rust slice so an oversized Apple event cannot cause an unbounded copy. + let length = unsafe { strnlen(utf8, MAX_AUTHORIZATION_CALLBACK_URL_BYTES + 1) }; + if length > MAX_AUTHORIZATION_CALLBACK_URL_BYTES { + return; + } + let bytes = unsafe { slice::from_raw_parts(utf8.cast::(), length) }; + let Ok(callback) = std::str::from_utf8(bytes) else { return; }; - let _ = forward_native_callback(callback); + let _ = dispatch_native_callback(callback); } -} - -/// The sole owner of one bound loopback socket. -pub struct LoopbackBoundCallbackListener { - attempt_id: AuthenticationAttemptId, - socket: TcpListener, - redirect_uri: RedirectUri, -} -impl BoundCallbackListener for LoopbackBoundCallbackListener { - fn redirect_uri(&self) -> &RedirectUri { - &self.redirect_uri + unsafe extern "C" { + fn strnlen(string: *const c_char, max_length: usize) -> usize; } } -/// Stateless adapter that binds and accepts owned loopback listeners. -#[derive(Clone, Default)] -pub struct LoopbackCallbackListener; +#[cfg(any(test, feature = "test-support"))] +mod loopback { + use std::time::Duration; -async fn read_request(stream: &TcpStream) -> Result, ()> { - tokio::time::timeout(CONNECTION_READ_TIMEOUT, async { - let mut request = Vec::with_capacity(MAX_REQUEST_BYTES); - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - if request.len() == MAX_REQUEST_BYTES { - return Err(()); - } - stream.readable().await.map_err(|_| ())?; - let mut chunk = [0; 1024]; - let read = match stream.try_read(&mut chunk) { - Ok(read) => read, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue, - Err(_) => return Err(()), - }; - if read == 0 || read > MAX_REQUEST_BYTES - request.len() { - return Err(()); - } - request.extend_from_slice(&chunk[..read]); - } + use tokio::net::{TcpListener, TcpStream}; - Ok(request) - }) - .await - .map_err(|_| ())? -} + use crate::authentication::{ + AuthenticationError, AuthenticationErrorCategory, AuthenticationFailureReason, + AuthenticationFailureStage, RedirectUri, + }; -async fn respond_best_effort(stream: &TcpStream, response: &[u8]) { - let _ = tokio::time::timeout(CONNECTION_WRITE_TIMEOUT, async { - let mut written = 0; - while written < response.len() { - stream.writable().await.map_err(|_| ())?; - match stream.try_write(&response[written..]) { - Ok(0) | Err(_) => return Err(()), - Ok(count) => written += count, - } - } - Ok(()) - }) - .await; -} + const MAX_REQUEST_LINE_BYTES: usize = 8_192; + const MAX_HEADER_BYTES: usize = 16_384; + const MAX_HEADERS: usize = 32; + const MAX_QUERY_BYTES: usize = 8_192; + const CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(1); + const CONNECTION_WRITE_TIMEOUT: Duration = Duration::from_secs(1); + const MAX_REQUEST_BYTES: usize = MAX_REQUEST_LINE_BYTES + MAX_HEADER_BYTES + 4; + + const SUCCESS_RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\nAuthorization complete."; + const INVALID_RESPONSE: &[u8] = b"HTTP/1.1 400 Bad Request\r\nCache-Control: no-store\r\nPragma: no-cache\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\nInvalid callback."; + + fn callback_rejected() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::CallbackValidation, + AuthenticationErrorCategory::CallbackRejected, + AuthenticationFailureReason::CallbackRejected, + ) + } -impl CallbackListenerLifecycle for LoopbackCallbackListener { - type Bound = LoopbackBoundCallbackListener; - - async fn start_listener( - &self, - attempt_id: AuthenticationAttemptId, - ) -> Result { - let socket = TcpListener::bind("127.0.0.1:0").await.map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CallbackListenerUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let port = socket - .local_addr() - .map_err(|_| { + /// A one-attempt IPv4 loopback callback server for test and development + /// fixtures. Production never constructs this adapter. + pub struct LoopbackCallbackServer { + socket: TcpListener, + port: u16, + redirect_uri: RedirectUri, + } + impl LoopbackCallbackServer { + /// Binds an ephemeral IPv4 loopback port and derives its exact redirect. + pub async fn bind() -> Result { + let socket = TcpListener::bind("127.0.0.1:0").await.map_err(|_| { AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, + AuthenticationFailureStage::CallbackValidation, AuthenticationErrorCategory::CallbackListenerUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, + AuthenticationFailureReason::ListenerUnavailable, ) - })? - .port(); - Ok(LoopbackBoundCallbackListener { - attempt_id, - socket, - redirect_uri: RedirectUri::for_selected_port(port)?, - }) - } + })?; + let port = socket + .local_addr() + .map_err(|_| { + AuthenticationError::new( + AuthenticationFailureStage::CallbackValidation, + AuthenticationErrorCategory::CallbackListenerUnavailable, + AuthenticationFailureReason::ListenerUnavailable, + ) + })? + .port(); + Ok(Self { + socket, + port, + redirect_uri: RedirectUri::for_selected_port(port)?, + }) + } - #[allow( - clippy::manual_async_fn, - reason = "The trait binds callback borrows to the returned future." - )] - fn accept_callback<'a>( - &'a self, - listener: &'a Self::Bound, - prepared: &'a PreparedAuthorization, - ) -> impl Future> + Send + 'a - { - async move { - if listener.attempt_id != *prepared.attempt_id() - || listener.redirect_uri != *prepared.redirect_uri() - { - return Err(callback_rejected()); - } + #[must_use] + pub fn redirect_uri(&self) -> &RedirectUri { + &self.redirect_uri + } + + /// Returns the dynamically selected IPv4 loopback port. + #[must_use] + pub const fn local_port(&self) -> u16 { + self.port + } + /// Returns the test-only localhost client ID for this selected redirect. + #[must_use] + pub fn development_client_id(&self) -> String { + let mut client_id = + url::form_urlencoded::Serializer::new(String::from("http://localhost?")); + client_id + .append_pair("redirect_uri", self.redirect_uri.as_str()) + .append_pair("scope", "atproto") + .finish() + } + + pub async fn accept_callback(&self) -> Result { loop { - let (stream, address) = listener.socket.accept().await.map_err(|_| { + let (stream, address) = self.socket.accept().await.map_err(|_| { AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, + AuthenticationFailureStage::CallbackValidation, AuthenticationErrorCategory::CallbackListenerUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, + AuthenticationFailureReason::ListenerUnavailable, ) })?; if !address.ip().is_loopback() || !address.is_ipv4() { respond_best_effort(&stream, INVALID_RESPONSE).await; continue; } - let result = read_request(&stream) + match read_request(&stream) .await .map_err(|_| callback_rejected()) - .and_then(|request| parse_callback_request(prepared, &request)); - match result { + .and_then(|request| parse_request(&self.redirect_uri, &request)) + { Ok(callback) => { respond_best_effort(&stream, SUCCESS_RESPONSE).await; return Ok(callback); @@ -434,214 +238,151 @@ impl CallbackListenerLifecycle for LoopbackCallbackListener { } } } -} - -/// Callback listener receiving URLs forwarded by installed scheme handlers. -#[derive(Clone, Default)] -pub struct NativeCallbackListener; - -pub struct NativeBoundCallbackListener { - attempt_id: AuthenticationAttemptId, - socket: TcpListener, - redirect_uri: RedirectUri, -} - -impl BoundCallbackListener for NativeBoundCallbackListener { - fn redirect_uri(&self) -> &RedirectUri { - &self.redirect_uri - } -} -async fn read_native_callback(stream: &TcpStream) -> Result, ()> { - tokio::time::timeout(CONNECTION_READ_TIMEOUT, async { - let mut callback = Vec::with_capacity(MAX_QUERY_BYTES); - loop { - if callback.len() == MAX_QUERY_BYTES { - return Err(()); - } - stream.readable().await.map_err(|_| ())?; - let mut chunk = [0; 1024]; - match stream.try_read(&mut chunk) { - Ok(0) => return Ok(callback), - Ok(read) if read <= MAX_QUERY_BYTES - callback.len() => { - callback.extend_from_slice(&chunk[..read]); + async fn read_request(stream: &TcpStream) -> Result, ()> { + tokio::time::timeout(CONNECTION_READ_TIMEOUT, async { + let mut request = Vec::with_capacity(MAX_REQUEST_BYTES); + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + if request.len() == MAX_REQUEST_BYTES { + return Err(()); + } + stream.readable().await.map_err(|_| ())?; + let mut chunk = [0; 1024]; + let read = match stream.try_read(&mut chunk) { + Ok(read) => read, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(_) => return Err(()), + }; + if read == 0 || read > MAX_REQUEST_BYTES - request.len() { + return Err(()); } - Ok(_) => return Err(()), - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue, - Err(_) => return Err(()), + request.extend_from_slice(&chunk[..read]); } - } - }) - .await - .map_err(|_| ())? -} - -impl CallbackListenerLifecycle for NativeCallbackListener { - type Bound = NativeBoundCallbackListener; - - async fn start_listener( - &self, - attempt_id: AuthenticationAttemptId, - ) -> Result { - let socket = TcpListener::bind(NATIVE_FORWARD_ADDRESS) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::CallbackListener, - AuthenticationErrorCategory::CallbackListenerUnavailable, - crate::authentication::AuthenticationFailureReason::ListenerUnavailable, - ) - })?; - Ok(NativeBoundCallbackListener { - attempt_id, - socket, - redirect_uri: RedirectUri::native(), + Ok(request) }) + .await + .map_err(|_| ())? } - #[allow( - clippy::manual_async_fn, - reason = "The trait binds callback borrows to the returned future." - )] - fn accept_callback<'a>( - &'a self, - listener: &'a Self::Bound, - prepared: &'a PreparedAuthorization, - ) -> impl Future> + Send + 'a - { - async move { - if listener.attempt_id != *prepared.attempt_id() - || listener.redirect_uri != *prepared.redirect_uri() - { - return Err(callback_rejected()); - } - loop { - let (stream, address) = listener.socket.accept().await.map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::CallbackListener, - AuthenticationErrorCategory::CallbackListenerUnavailable, - crate::authentication::AuthenticationFailureReason::ListenerUnavailable, - ) - })?; - if !address.ip().is_loopback() || !address.is_ipv4() { - continue; - } - match read_native_callback(&stream) - .await - .map_err(|_| callback_rejected()) - .and_then(|callback| parse_native_callback(prepared, &callback)) - { - Ok(callback) => return Ok(callback), - Err(_) => continue, + async fn respond_best_effort(stream: &TcpStream, response: &[u8]) { + let _ = tokio::time::timeout(CONNECTION_WRITE_TIMEOUT, async { + let mut written = 0; + while written < response.len() { + stream.writable().await.map_err(|_| ())?; + match stream.try_write(&response[written..]) { + Ok(0) | Err(_) => return Err(()), + Ok(count) => written += count, } } + Ok(()) + }) + .await; + } + + fn parse_request( + redirect_uri: &RedirectUri, + request: &[u8], + ) -> Result { + let request = std::str::from_utf8(request).map_err(|_| callback_rejected())?; + let (head, body) = request + .split_once("\r\n\r\n") + .ok_or_else(callback_rejected)?; + if !body.is_empty() || head.len() > MAX_REQUEST_LINE_BYTES + MAX_HEADER_BYTES { + return Err(callback_rejected()); + } + let mut lines = head.split("\r\n"); + let line = lines.next().ok_or_else(callback_rejected)?; + let headers = lines.collect::>(); + if line.len() > MAX_REQUEST_LINE_BYTES + || headers.len() > MAX_HEADERS + || headers.iter().any(|header| header.len() > MAX_HEADER_BYTES) + { + return Err(callback_rejected()); } + let redirect = url::Url::parse(redirect_uri.as_str()).map_err(|_| callback_rejected())?; + let expected_host = format!( + "{}:{}", + redirect.host_str().ok_or_else(callback_rejected)?, + redirect.port().ok_or_else(callback_rejected)? + ); + let matching_host_count = headers + .iter() + .filter_map(|header| header.split_once(':')) + .filter(|(name, value)| { + name.eq_ignore_ascii_case("host") && value.trim() == expected_host + }) + .count(); + let host_count = headers + .iter() + .filter_map(|header| header.split_once(':')) + .filter(|(name, _)| name.eq_ignore_ascii_case("host")) + .count(); + if host_count != 1 || matching_host_count != 1 { + return Err(callback_rejected()); + } + let mut parts = line.split_ascii_whitespace(); + let (Some("GET"), Some(target), Some("HTTP/1.1"), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return Err(callback_rejected()); + }; + if target.len() > MAX_QUERY_BYTES + "/oauth/callback?".len() { + return Err(callback_rejected()); + } + let url = url::Url::parse(&format!("http://127.0.0.1{target}")) + .map_err(|_| callback_rejected())?; + if url.path() != "/oauth/callback" { + return Err(callback_rejected()); + } + Ok(url) } } +#[cfg(any(test, feature = "test-support"))] +pub use loopback::LoopbackCallbackServer; + #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; - use crate::authentication::{ - AttemptBoundAuthorizationServerIssuer, Epoch, ManualAuthorizationUrl, - }; static NATIVE_CALLBACK_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - - fn native_prepared() -> PreparedAuthorization { - let attempt = AuthenticationAttemptId::new("native-test").expect("valid attempt"); - let issuer = - AttemptBoundAuthorizationServerIssuer::new(attempt.clone(), "https://issuer.example") - .expect("valid issuer"); - let request_issuer = - AttemptBoundAuthorizationServerIssuer::new(attempt.clone(), "https://issuer.example") - .expect("valid request issuer"); - let request = request_issuer.with_manual_url( - ManualAuthorizationUrl::new("https://issuer.example/authorize").expect("valid URL"), - ); - PreparedAuthorization::new( - attempt, - issuer, - "state-canary", - RedirectUri::native(), - request, - ) - .expect("valid preparation") - .bind_epoch(Epoch::new(7)) - .expect("preparation binds once") + #[test] + fn native_callback_dispatch_rejects_non_registered_urls() { + assert!(!dispatch_native_callback( + "https://example.test/oauth/callback" + )); + assert!(!dispatch_native_callback( + "com.nateledford.hitomi://authority/oauth/callback" + )); } #[tokio::test] - async fn native_listener_binds_the_registered_callback_channel() { + async fn native_callback_bus_delivers_only_after_subscription() { let _guard = NATIVE_CALLBACK_TEST_LOCK.lock().await; - let listener = NativeCallbackListener - .start_listener( - AuthenticationAttemptId::new("native-bind-test").expect("valid attempt"), - ) + assert!(!dispatch_native_callback( + "com.nateledford.hitomi:/oauth/callback?state=before-subscription" + )); + let mut receiver = subscribe_native_callbacks(); + assert!(dispatch_native_callback( + "com.nateledford.hitomi:/oauth/callback?state=after-subscription" + )); + let callback = tokio::time::timeout(Duration::from_secs(1), receiver.recv()) .await - .expect("native callback channel is available"); - + .expect("callback delivery does not stall") + .expect("receiver remains subscribed"); assert_eq!( - listener.redirect_uri().as_str(), - "com.nateledford.hitomi:/oauth/callback" + callback.as_str(), + "com.nateledford.hitomi:/oauth/callback?state=after-subscription" ); } #[tokio::test] - async fn forwarded_secondary_process_callback_reaches_pending_native_attempt_once() { - let _guard = NATIVE_CALLBACK_TEST_LOCK.lock().await; - let listener = NativeCallbackListener - .start_listener(AuthenticationAttemptId::new("native-test").expect("valid attempt")) - .await - .expect("native listener"); - let prepared = native_prepared(); - let received = tokio::spawn(async move { - NativeCallbackListener - .accept_callback(&listener, &prepared) - .await - }); - - assert!(forward_native_callback( - "com.nateledford.hitomi:/oauth/callback?iss=https%3A%2F%2Fissuer.example&state=state-canary&code=code-canary" - )); - assert!(matches!( - tokio::time::timeout(std::time::Duration::from_secs(1), received) - .await - .expect("callback delivery does not stall") - .expect("receiver task") - .expect("callback validates"), - ValidatedAuthorizationCallback::Success(_) - )); - } - - #[test] - fn native_callback_requires_the_registered_uri_and_matching_state() { - let prepared = native_prepared(); - let callback = b"com.nateledford.hitomi:/oauth/callback?iss=https%3A%2F%2Fissuer.example&state=state-canary&code=code-canary"; - - assert!(matches!( - parse_native_callback(&prepared, callback), - Ok(ValidatedAuthorizationCallback::Success(_)) - )); - assert!(parse_native_callback( - &prepared, - b"com.nateledford.hitomi:/wrong?iss=https%3A%2F%2Fissuer.example&state=state-canary&code=code-canary" - ) - .is_err()); - assert!(parse_native_callback( - &prepared, - b"com.nateledford.hitomi:/oauth/callback?iss=https%3A%2F%2Fissuer.example&state=state-canary&code=code-canary#fragment" - ) - .is_err()); - } - - #[tokio::test] - async fn dropping_bound_listener_closes_its_selected_port() { - let adapter = LoopbackCallbackListener; - let listener = adapter - .start_listener(AuthenticationAttemptId::new("drop-test").expect("attempt")) + async fn dropping_loopback_server_releases_its_selected_port() { + let listener = LoopbackCallbackServer::bind() .await - .expect("listener"); + .expect("loopback listener binds"); let redirect = listener.redirect_uri().as_str().to_owned(); let port = url::Url::parse(&redirect) .expect("redirect URL") @@ -649,6 +390,35 @@ mod tests { .expect("selected port"); drop(listener); - assert!(TcpStream::connect(("127.0.0.1", port)).await.is_err()); + assert!( + tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_err() + ); + } + + #[tokio::test] + async fn loopback_client_id_uses_its_dynamic_redirect_and_atproto_scope() { + let listener = LoopbackCallbackServer::bind() + .await + .expect("loopback listener binds"); + let client_id = url::Url::parse(&listener.development_client_id()) + .expect("development client ID is a URL"); + let pairs = client_id.query_pairs().collect::>(); + + assert_eq!(client_id.scheme(), "http"); + assert_eq!(client_id.host_str(), Some("localhost")); + assert_eq!( + pairs + .iter() + .find_map(|(name, value)| (name == "redirect_uri").then_some(value.as_ref())), + Some(listener.redirect_uri().as_str()) + ); + assert_eq!( + pairs + .iter() + .find_map(|(name, value)| (name == "scope").then_some(value.as_ref())), + Some("atproto") + ); } } diff --git a/src/infrastructure/clock.rs b/src/infrastructure/clock.rs deleted file mode 100644 index 2f75f62..0000000 --- a/src/infrastructure/clock.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! Monotonic deadline adapter. - -use std::future::Future; -use std::time::Duration; - -use crate::authentication::ports::DeadlineClock; -use crate::authentication::{ - AuthenticationDeadline, AuthenticationError, AuthenticationErrorCategory, AuthenticationTimeout, -}; - -#[derive(Clone, Debug)] -pub struct SystemDeadlineClock { - origin: tokio::time::Instant, -} - -impl Default for SystemDeadlineClock { - fn default() -> Self { - Self { - origin: tokio::time::Instant::now(), - } - } -} - -impl DeadlineClock for SystemDeadlineClock { - fn deadline_after(&self, timeout: AuthenticationTimeout) -> AuthenticationDeadline { - let coordinate = self - .origin - .elapsed() - .saturating_add(timeout.as_duration()) - .as_millis(); - AuthenticationDeadline::new(coordinate.try_into().unwrap_or(u64::MAX)) - } - - #[allow( - clippy::manual_async_fn, - reason = "The trait binds the operation and clock to one future." - )] - fn run_until<'a, F>( - &'a self, - deadline: AuthenticationDeadline, - operation: F, - ) -> impl Future> + Send + 'a - where - F: Future + Send + 'a, - F::Output: Send + 'a, - { - async move { - let deadline = self.origin + Duration::from_millis(deadline.coordinate()); - if tokio::time::Instant::now() >= deadline { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::DeadlineElapsed, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - - tokio::select! { - biased; - _ = tokio::time::sleep_until(deadline) => Err(AuthenticationError::new(crate::authentication::AuthenticationFailureStage::AuthorizationWait, AuthenticationErrorCategory::DeadlineElapsed, crate::authentication::AuthenticationFailureReason::DeadlineElapsed)), - output = operation => Ok(output), - } - } - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::*; - - #[tokio::test(start_paused = true)] - async fn configured_deadline_allows_work_after_legacy_five_second_window() { - let clock = SystemDeadlineClock::default(); - let deadline = clock - .deadline_after(AuthenticationTimeout::new(Duration::from_secs(300)).expect("timeout")); - tokio::time::advance(Duration::from_secs(6)).await; - - assert_eq!(clock.run_until(deadline, async { 7_u8 }).await, Ok(7)); - } - - #[tokio::test(start_paused = true)] - async fn deadline_wins_an_exact_tie() { - let clock = SystemDeadlineClock::default(); - let deadline = clock - .deadline_after(AuthenticationTimeout::new(Duration::from_secs(1)).expect("timeout")); - let result = clock - .run_until(deadline, async { - tokio::time::sleep(Duration::from_secs(1)).await; - 7_u8 - }) - .await; - - assert_eq!( - result.expect_err("deadline wins").category(), - AuthenticationErrorCategory::DeadlineElapsed - ); - } -} diff --git a/src/infrastructure/feed.rs b/src/infrastructure/feed.rs index 0f64c6d..70faca1 100644 --- a/src/infrastructure/feed.rs +++ b/src/infrastructure/feed.rs @@ -1,4 +1,4 @@ -//! Restored-session home timeline adapter. +//! Coordinator-backed home timeline adapter. #[cfg(any(test, feature = "test-support"))] use atrium_api::app::bsky::feed::defs::{FeedViewPost, FeedViewPostReasonRefs, PostViewEmbedRefs}; @@ -7,45 +7,58 @@ use atrium_api::app::bsky::feed::post::Record; #[cfg(any(test, feature = "test-support"))] use atrium_api::app::bsky::richtext::facet::MainFeaturesItem; -use super::session_vault::{KeyringSecretStore, SessionVault}; +use std::sync::Arc; + use crate::authentication::{ - AccountGenerationReference, AuthenticationError, AuthenticationErrorCategory, SessionArtifact, + AccountGenerationReference, AuthenticationError, AuthenticationErrorCategory, }; use crate::feed::{FeedPost, FeedSegment, HomeFeed, HomeFeedReader}; +use crate::infrastructure::oauth::OAuthCoordinator; +use crate::infrastructure::session_vault::{CredentialVault, MacOsKeychainVault}; +use crate::infrastructure::transport::{HardenedHttpClient, OutboundHttp}; use atrium_xrpc::http::StatusCode; use serde::Deserialize; -pub trait RestoredFeedClient: Clone + Send + Sync + 'static { - fn load( - &self, - session: SessionArtifact, - active: AccountGenerationReference, - ) -> impl Future> + Send; -} - -#[derive(Clone, Default)] -pub struct AtprotoRestoredFeedClient { - service: crate::infrastructure::oauth::session_agent::OAuthSessionService, +/// Loads one bounded home page through the coordinator's exact installed access +/// grant. The feed adapter cannot read durable refresh material. +#[derive(Clone)] +pub(crate) struct OAuthHomeFeedReader< + T = HardenedHttpClient, + V = MacOsKeychainVault, + C = HardenedHttpClient, +> { + coordinator: Arc>, + client: C, } -impl AtprotoRestoredFeedClient { - pub(crate) fn new( - service: crate::infrastructure::oauth::session_agent::OAuthSessionService, - ) -> Self { - Self { service } +impl OAuthHomeFeedReader +where + T: OutboundHttp, + V: CredentialVault, + C: OutboundHttp, +{ + #[must_use] + pub(crate) fn new(coordinator: Arc>, client: C) -> Self { + Self { + coordinator, + client, + } } } -impl RestoredFeedClient for AtprotoRestoredFeedClient { - async fn load( +impl HomeFeedReader for OAuthHomeFeedReader +where + T: OutboundHttp, + V: CredentialVault, + C: OutboundHttp, +{ + async fn load_initial( &self, - _stored: SessionArtifact, active: AccountGenerationReference, ) -> Result { - let http = crate::infrastructure::transport::HardenedHttpClient::new_get_only()?; let response = self - .service - .send_timeline(active.account().did().as_str(), &http) + .coordinator + .send_timeline(&active, &self.client) .await?; if response.status == StatusCode::UNAUTHORIZED { return Err(AuthenticationError::new( @@ -121,45 +134,6 @@ impl CandidatePost { } } -type DefaultRestoredFeedClient = AtprotoRestoredFeedClient; - -/// Loads one bounded home page from the exact currently-active vault session. -#[derive(Clone)] -pub struct OAuthHomeFeedReader< - T: super::session_vault::SecretStore = KeyringSecretStore, - C: RestoredFeedClient = DefaultRestoredFeedClient, -> { - vault: SessionVault, - client: C, -} - -impl OAuthHomeFeedReader { - pub(crate) fn new_with_service( - vault: SessionVault, - service: crate::infrastructure::oauth::session_agent::OAuthSessionService, - ) -> Self { - Self::with_client(vault, DefaultRestoredFeedClient::new(service)) - } -} - -impl OAuthHomeFeedReader { - pub fn with_client(vault: SessionVault, client: C) -> Self { - Self { vault, client } - } -} - -impl HomeFeedReader - for OAuthHomeFeedReader -{ - async fn load_initial( - &self, - active: AccountGenerationReference, - ) -> Result { - let stored = self.vault.load_active_at(&active).await?; - self.client.load(stored, active).await - } -} - #[cfg(any(test, feature = "test-support"))] fn map_page(posts: &[FeedViewPost]) -> Result { let mut retained = Vec::with_capacity(posts.len()); @@ -406,16 +380,33 @@ mod tests { } #[tokio::test] - async fn reader_fails_closed_before_network_without_an_exact_active_vault_record() { + async fn reader_fails_closed_before_network_without_an_exact_installed_agent() { + use std::sync::Arc; + use super::OAuthHomeFeedReader; use crate::authentication::{AccountGenerationReference, Did, Epoch, Generation}; use crate::feed::HomeFeedReader; - use crate::infrastructure::session_vault::{MemorySecretStore, SessionVault}; + use crate::infrastructure::oauth::OAuthCoordinator; + use crate::infrastructure::session_vault::MemoryCredentialVault; + use crate::infrastructure::transport::{ + HardenedHttpClient, OutboundHttp, OutboundRequest, OutboundResponse, TransportError, + }; + + #[derive(Clone)] + struct NeverDispatch; + + impl OutboundHttp for NeverDispatch { + async fn send(&self, _: OutboundRequest) -> Result { + panic!("missing session agent must prevent feed dispatch") + } + } - let reader = OAuthHomeFeedReader::new_with_service( - SessionVault::new(MemorySecretStore::default()), - crate::infrastructure::oauth::OAuthSessionService::default(), + let coordinator = Arc::new( + OAuthCoordinator::::with_vault( + MemoryCredentialVault::default(), + ), ); + let reader = OAuthHomeFeedReader::new(coordinator, NeverDispatch); let active = AccountGenerationReference::new( Did::new("did:plc:z72i7hdynmk6r22z27h6tvur") .expect("static DID") diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 3a3cd31..145f155 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -2,7 +2,6 @@ pub mod browser; pub mod callback; -pub mod clock; pub mod feed; pub mod notification; pub mod oauth; diff --git a/src/infrastructure/oauth.rs b/src/infrastructure/oauth.rs index f3fd911..e8e61b5 100644 --- a/src/infrastructure/oauth.rs +++ b/src/infrastructure/oauth.rs @@ -1,170 +1,147 @@ -//! Candidate AT Protocol OAuth adapter. +//! Application-owned AT Protocol OAuth coordinator. //! -//! This module owns the replacement implementation. It intentionally never -//! delegates an authorization attempt to the temporary Atrium implementation. +//! Protocol discovery, PAR, callback correlation, token exchange, refresh and +//! Keychain ordering live here. Upstream crates provide only PKCE and DPoP +//! primitives; no upstream workflow or credential store is used. pub(crate) mod identity; pub(crate) mod proof; pub(crate) mod protocol; pub(crate) mod session; pub(crate) mod session_agent; -pub(crate) use session_agent::OAuthSessionService; + +use std::time::{SystemTime, UNIX_EPOCH}; use atproto_identity::key::{KeyData, KeyType, generate_key}; use base64::Engine as _; use sha2::{Digest as _, Sha256}; -use std::collections::BTreeMap; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::Mutex; use url::Url; -use crate::authentication::command::CompleteAuthorizationCallback; -use crate::authentication::ports::{ - OAuthAttemptRetirement, OAuthAuthorization, OAuthCompletion, SessionRefresh, SessionRevocation, -}; +use crate::authentication::command::AuthorizationCallbackUrl; use crate::authentication::{ - AccountHandle, AttemptBoundAuthorizationServerIssuer, AuthenticationAttemptId, - AuthenticationError, AuthenticationErrorCategory, AuthenticationFailureReason, - AuthenticationFailureStage, CompletedAuthorization, Did, ManualAuthorizationUrl, - ParRequestDiagnostic, PreparedAuthorization, RedirectUri, RefreshOutcome, RevocationOutcome, - RevocationReport, VerifiedActiveSession, + AccountGenerationReference, AccountHandle, AuthenticationAttemptId, AuthenticationError, + AuthenticationErrorCategory, AuthenticationFailureReason, AuthenticationFailureStage, Did, + Epoch, Generation, LogoutReport, ManualAuthorizationUrl, PersistedActiveSession, + PreparedAuthorization, RedirectUri, RefreshOutcome, RefreshRecoveryRequired, + RestorationSnapshot, RestorationStatus, RevocationOutcome, RevocationReport, + VerifiedActiveSession, }; -use crate::infrastructure::session_vault::{KeyringSecretStore, SessionVault}; - -#[derive(Default)] -pub(crate) struct AtprotoOAuthAdapter { - pending: Arc>>, - service: session_agent::OAuthSessionService, -} +use crate::infrastructure::session_vault::{CredentialVault, MacOsKeychainVault}; +use crate::infrastructure::transport::{HardenedHttpClient, OutboundHttp, OutboundResponse}; -impl AtprotoOAuthAdapter { - pub(crate) fn new(service: session_agent::OAuthSessionService) -> Self { - Self { - pending: Arc::default(), - service, - } - } -} - -struct PendingAttempt { - expected_did: String, - client_id: String, - issuer: String, - resource: String, - redirect_uri: RedirectUri, - state: String, - pkce_verifier: String, - dpop_key: KeyData, - dpop_nonce: String, - token_endpoint: String, - expires_at_unix_seconds: i64, - http: crate::infrastructure::transport::HardenedHttpClient, -} +use self::session::{RefreshGrantV1, StoredSessionStateV1, StoredSessionV1}; +use self::session_agent::SessionAgent; -fn par_rejected() -> AuthenticationError { - AuthenticationError::new( - AuthenticationFailureStage::PushedAuthorizationRequest, - AuthenticationErrorCategory::ProtocolRejected, - AuthenticationFailureReason::MalformedResponse, - ) +/// `T` is the application-owned outbound transport, injected by deterministic +/// tests while production uses `HardenedHttpClient`. +pub(crate) struct OAuthCoordinator { + http: T, + vault: V, + pending: Mutex>, + agent: Mutex>, + refresh: Mutex<()>, } -fn random_b64(bytes: usize) -> Result { - let mut value = vec![0; bytes]; - getrandom::fill(&mut value).map_err(|_| par_rejected())?; - Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value)) +impl Default for OAuthCoordinator { + fn default() -> Self { + Self::new() + } } -fn random_key() -> Result { - generate_key(KeyType::P256Private).map_err(|_| par_rejected()) +impl OAuthCoordinator { + #[must_use] + pub(crate) fn new() -> Self { + Self::with_vault(MacOsKeychainVault::new()) + } } -fn now_unix_seconds() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|duration| i64::try_from(duration.as_secs()).ok()) - .ok_or_else(par_rejected) +impl OAuthCoordinator +where + V: CredentialVault, +{ + #[must_use] + pub(crate) fn with_vault(vault: V) -> Self { + Self::with_http_and_vault( + HardenedHttpClient::new().expect("native certificate store is available"), + vault, + ) + } } -/// Returns the public client identifier for an authorization attempt. -fn client_id_for(redirect_uri: &RedirectUri) -> Result { - match redirect_uri { - RedirectUri::Native => Ok(crate::release_identity::CLIENT_ID.to_owned()), - RedirectUri::Loopback(_) => { - #[cfg(feature = "test-support")] - { - Ok(format!( - "http://localhost?redirect_uri={}&scope=atproto", - url::form_urlencoded::byte_serialize(redirect_uri.as_str().as_bytes()) - .collect::(), - )) - } - - #[cfg(not(feature = "test-support"))] - { - Err(par_rejected()) - } +impl OAuthCoordinator +where + T: OutboundHttp, + V: CredentialVault, +{ + #[must_use] + pub(crate) fn with_http_and_vault(http: T, vault: V) -> Self { + Self { + http, + vault, + pending: Mutex::new(None), + agent: Mutex::new(None), + refresh: Mutex::new(()), } } -} -fn authorization_request_fields<'a>( - client_id: &'a str, - redirect_uri: &'a RedirectUri, - state: &'a str, - challenge: &'a str, - login_hint: &'a str, - resource: &'a str, -) -> [(&'static str, &'a str); 9] { - [ - ("client_id", client_id), - ("response_type", "code"), - ("redirect_uri", redirect_uri.as_str()), - ("state", state), - ("scope", "atproto"), - ("code_challenge", challenge), - ("code_challenge_method", "S256"), - ("login_hint", login_hint), - ("resource", resource), - ] -} + async fn vault_load(&self) -> Result, AuthenticationError> { + let vault = self.vault.clone(); + tokio::task::spawn_blocking(move || vault.load()) + .await + .map_err(|_| vault_error())? + .map_err(|_| vault_error()) + } -fn sha256_prefix(value: &str) -> [u8; 8] { - let digest = Sha256::digest(value.as_bytes()); - let mut prefix = [0_u8; 8]; - prefix.copy_from_slice(&digest[..8]); - prefix -} + async fn vault_replace(&self, record: StoredSessionV1) -> Result<(), AuthenticationError> { + let vault = self.vault.clone(); + tokio::task::spawn_blocking(move || vault.replace(&record)) + .await + .map_err(|_| vault_error())? + .map_err(|_| vault_error()) + } -fn par_request_diagnostic(client_id: &str, issuer: &str, endpoint: &str) -> ParRequestDiagnostic { - ParRequestDiagnostic::from_sha256_prefixes( - sha256_prefix(client_id), - sha256_prefix(issuer), - sha256_prefix(endpoint), - ) -} + async fn vault_delete(&self) -> Result<(), AuthenticationError> { + let vault = self.vault.clone(); + tokio::task::spawn_blocking(move || vault.delete()) + .await + .map_err(|_| vault_error())? + .map_err(|_| vault_error()) + } + + async fn purge_legacy(&self) -> Result<(), AuthenticationError> { + let vault = self.vault.clone(); + tokio::task::spawn_blocking(move || vault.purge_legacy()) + .await + .map_err(|_| vault_error())? + .map_err(|_| vault_error()) + } -impl OAuthAuthorization for AtprotoOAuthAdapter { - async fn prepare_authorization( + /// Prepares PAR and records one exact callback correlation before browser launch. + pub(crate) async fn prepare_authorization( &self, attempt_id: AuthenticationAttemptId, - handle: AccountHandle, + epoch: Epoch, + account: AccountHandle, redirect_uri: RedirectUri, ) -> Result { + if self.pending.lock().await.is_some() { + return Err(callback_rejected()); + } + self.purge_legacy().await?; let client_id = client_id_for(&redirect_uri)?; - let discovered = protocol::discover_authorization_server(&handle).await?; - let state = random_b64(16)?; + let discovered = + protocol::discover_authorization_server(self.http.clone(), &account).await?; + let state = random_b64(32)?; let (pkce_verifier, challenge) = atproto_oauth::pkce::generate(); - let key = random_key()?; + let dpop_key = generate_key(KeyType::P256Private) + .map_err(|_| local_failure(AuthenticationFailureStage::PushedAuthorizationRequest))?; let endpoint = discovered .metadata .pushed_authorization_request_endpoint .as_deref() .ok_or_else(par_rejected)?; - let par_diagnostic = - par_request_diagnostic(&client_id, &discovered.metadata.issuer, endpoint); + let diagnostic = par_request_diagnostic(&client_id, &discovered.metadata.issuer, endpoint); let par = protocol::post_par_form( &discovered.http, endpoint, @@ -173,13 +150,13 @@ impl OAuthAuthorization for AtprotoOAuthAdapter { &redirect_uri, &state, &challenge, - handle.as_str(), + account.as_str(), &discovered.identity.pds, ), - &key, + &dpop_key, ) .await - .map_err(|error| error.with_par_request_diagnostic(par_diagnostic))?; + .map_err(|error| error.with_par_request_diagnostic(diagnostic))?; let authorization_endpoint = discovered .metadata .authorization_endpoint @@ -191,81 +168,69 @@ impl OAuthAuthorization for AtprotoOAuthAdapter { .query_pairs_mut() .append_pair("client_id", &client_id) .append_pair("request_uri", &par.response.request_uri); - let issuer = AttemptBoundAuthorizationServerIssuer::new( - attempt_id.clone(), - discovered.metadata.issuer.clone(), - )?; - let authorization = issuer - .clone() - .with_manual_url(ManualAuthorizationUrl::new(authorization_url.to_string())?); - let prepared = PreparedAuthorization::new( - attempt_id.clone(), - issuer, - state.clone(), - redirect_uri.clone(), - authorization, - )?; - let pending = PendingAttempt { + let prepared = + PreparedAuthorization::new(ManualAuthorizationUrl::new(authorization_url.to_string())?); + let expires_at = now_unix_seconds()? + .checked_add(i64::try_from(par.response.expires_in).map_err(|_| par_rejected())?) + .ok_or_else(par_rejected)?; + *self.pending.lock().await = Some(PendingAttempt { + attempt_id, + epoch, expected_did: discovered.identity.did, client_id, issuer: discovered.metadata.issuer, resource: discovered.identity.pds, redirect_uri, state, - http: discovered.http.clone(), pkce_verifier, - dpop_key: key, + dpop_key, dpop_nonce: par.nonce, token_endpoint: discovered .metadata .token_endpoint .ok_or_else(par_rejected)?, - expires_at_unix_seconds: now_unix_seconds()? - .checked_add(i64::try_from(par.response.expires_in).map_err(|_| par_rejected())?) - .ok_or_else(par_rejected)?, - }; - let mut pending_attempts = self.pending.lock().await; - if pending_attempts.insert(attempt_id, pending).is_some() { - return Err(par_rejected()); - } + revocation_endpoint: discovered.metadata.revocation_endpoint, + expires_at, + }); Ok(prepared) } -} -impl OAuthCompletion for AtprotoOAuthAdapter { - async fn complete_authorization( + /// Parses and correlates a custom-scheme callback against the one live attempt. + pub(crate) async fn complete_callback( &self, - command: CompleteAuthorizationCallback, - ) -> Result { - let (issuer, code, state) = command.reveal_to_oauth_completion(); + callback: AuthorizationCallbackUrl, + ) -> Result { + let fields = callback_fields(callback.as_str())?; let pending = self .pending .lock() .await - .remove(command.attempt_id()) - .ok_or_else(|| { - AuthenticationError::new( - AuthenticationFailureStage::CallbackValidation, - AuthenticationErrorCategory::CallbackRejected, - AuthenticationFailureReason::CallbackRejected, - ) - })?; - if pending.issuer != issuer - || pending.state != state - || now_unix_seconds()? >= pending.expires_at_unix_seconds + .take() + .ok_or_else(callback_rejected)?; + if fields.state != pending.state + || fields.issuer != pending.issuer + || now_unix_seconds()? >= pending.expires_at { - return Err(AuthenticationError::new( - AuthenticationFailureStage::CallbackValidation, - AuthenticationErrorCategory::CallbackRejected, - AuthenticationFailureReason::CallbackRejected, - )); + return Err(callback_rejected()); + } + let result = self.exchange_and_persist(pending, fields.code).await; + if result.is_err() { + self.agent.lock().await.take(); } + result + } + + async fn exchange_and_persist( + &self, + pending: PendingAttempt, + code: String, + ) -> Result { let token_result = protocol::post_token_form_json::<_, protocol::TokenResponse>( - &pending.http, + &self.http, &pending.token_endpoint, &[ ("grant_type", "authorization_code"), - ("code", code), + ("code", &code), ("redirect_uri", pending.redirect_uri.as_str()), ("client_id", &pending.client_id), ("code_verifier", &pending.pkce_verifier), @@ -275,288 +240,642 @@ impl OAuthCompletion for AtprotoOAuthAdapter { AuthenticationFailureStage::TokenExchange, ) .await?; - let token_nonce = token_result.nonce; + let nonce = token_result.nonce; let tokens = token_result.response.validate(&pending.expected_did)?; - let subject = AccountHandle::new(pending.expected_did.clone())?; - let verified = protocol::discover_authorization_server(&subject).await?; + let verified_subject = AccountHandle::new(pending.expected_did.clone())?; + let verified = + protocol::discover_authorization_server(self.http.clone(), &verified_subject).await?; if verified.identity.did != pending.expected_did || verified.identity.pds != pending.resource || verified.metadata.issuer != pending.issuer { - return Err(par_rejected()); + return Err(authority_mismatch()); } let scalar: [u8; 32] = pending .dpop_key .bytes() .try_into() .map_err(|_| par_rejected())?; - let mut persisted = session::OAuthSessionV1::new(session::OAuthSessionInput { - client_id: pending.client_id, - issuer: pending.issuer, - resource: pending.resource.clone(), - subject: pending.expected_did.clone(), - scope: tokens.scope, - refresh_token: tokens.refresh_token, - dpop_private_scalar: scalar, - })?; - persisted.record_nonce(&pending.token_endpoint, token_nonce)?; - let artifact = persisted.encode()?; - self.service.stage( + let mut grant = RefreshGrantV1::new( + pending.client_id, + pending.issuer, + pending.resource.clone(), + pending.token_endpoint, + pending.revocation_endpoint, + pending.expected_did.clone(), + tokens.scope, + tokens.refresh_token, + scalar, + )?; + let token_endpoint = grant.token_endpoint().to_owned(); + grant.record_nonce(&token_endpoint, nonce)?; + let record = StoredSessionV1::active(0, pending.epoch.value(), grant)?; + // The Keychain commit is intentionally before installing the process grant. + self.vault_replace(record).await?; + let agent = SessionAgent::new( pending.expected_did.clone(), - command.epoch(), + 0, + pending.epoch.value(), tokens.access_token, pending.resource, pending.dpop_key, - )?; - Ok(CompletedAuthorization::new( + ); + *self.agent.lock().await = Some(agent); + let reference = AccountGenerationReference::new( Did::new(pending.expected_did)?.into_authenticated(), - command.epoch(), - artifact, + Generation::new(0), + pending.epoch, + ); + Ok(PersistedActiveSession::committed( + VerifiedActiveSession::from_verified_reference(reference), )) } -} -impl OAuthAttemptRetirement for AtprotoOAuthAdapter { - async fn retire_authorization(&self, attempt_id: &AuthenticationAttemptId) { - self.pending.lock().await.remove(attempt_id); + pub(crate) async fn retire_authorization(&self, attempt_id: &AuthenticationAttemptId) { + let mut pending = self.pending.lock().await; + if pending + .as_ref() + .is_some_and(|pending| &pending.attempt_id == attempt_id) + { + pending.take(); + } } -} -impl SessionRevocation for AtprotoOAuthAdapter { - async fn revoke_session(&self, _active: VerifiedActiveSession) -> RevocationReport { - self.service.clear(); - RevocationReport { - refresh: RevocationOutcome::Unsupported, - access: RevocationOutcome::Unsupported, + /// Restores only durable state; an access token is never reconstructed from Keychain. + pub(crate) async fn restore_session( + &self, + _attempt_id: AuthenticationAttemptId, + ) -> Result { + match self.vault_load().await? { + None => Ok(RestorationSnapshot::new( + Epoch::new(0), + RestorationStatus::Unavailable, + )), + Some(record) => match record.state() { + StoredSessionStateV1::Active { + generation, + epoch, + grant, + } => { + let reference = reference(grant.subject(), *generation, *epoch)?; + Ok(RestorationSnapshot::new( + Epoch::new(*epoch), + RestorationStatus::restored( + VerifiedActiveSession::from_verified_reference(reference), + ), + )) + } + StoredSessionStateV1::RefreshInFlight { + generation, + epoch, + operation_id, + grant, + } => { + let recovery = RefreshRecoveryRequired::from_blocked_reference(reference( + grant.subject(), + *generation, + *epoch, + )?); + let _ = operation_id; + Ok(RestorationSnapshot::new( + Epoch::new(*epoch), + RestorationStatus::refresh_recovery_required(recovery), + )) + } + StoredSessionStateV1::RefreshInDoubt { + generation, + epoch, + subject, + .. + } => { + let recovery = RefreshRecoveryRequired::from_blocked_reference(reference( + subject, + *generation, + *epoch, + )?); + Ok(RestorationSnapshot::new( + Epoch::new(*epoch), + RestorationStatus::refresh_recovery_required(recovery), + )) + } + StoredSessionStateV1::Tombstone { epoch } => { + self.vault_delete().await?; + Ok(RestorationSnapshot::new( + Epoch::new(*epoch), + RestorationStatus::Unavailable, + )) + } + }, } } -} -/// Refresh adapter for the application-owned AT Protocol session. -#[derive(Clone)] -pub(crate) struct AtprotoSessionRefresh { - vault: SessionVault, - service: session_agent::OAuthSessionService, -} - -impl AtprotoSessionRefresh { - pub(crate) fn new( - vault: SessionVault, - service: session_agent::OAuthSessionService, - ) -> Self { - Self { vault, service } - } -} -impl SessionRefresh for AtprotoSessionRefresh { - async fn refresh_session( + pub(crate) async fn refresh_session( &self, attempt_id: AuthenticationAttemptId, active: VerifiedActiveSession, ) -> Result { - let reference = active.reference().clone(); - let artifact = self.vault.begin_refresh(&reference, &attempt_id).await?; - let decoded = match session::OAuthSessionV1::decode(&artifact) { - Ok(session) => session, - Err(error) => { - let restored = self.vault.rollback_refresh(&reference, &attempt_id).await?; - let _ = restored; - return Err(error); - } + let _single_flight = self.refresh.lock().await; + let active_reference = active.reference().clone(); + let Some(current) = self.vault_load().await? else { + return Err(vault_error()); }; - let material = match decoded.refresh_material() { - Ok(material) => material, - Err(error) => { - self.vault.rollback_refresh(&reference, &attempt_id).await?; - return Err(error); - } - }; - let issuer = material.issuer; - let resource = material.resource; - let subject = material.subject; - let client_id = material.client_id; - let refresh_token = material.refresh_token; - let scalar = material.private_scalar; - let resolved_subject = match AccountHandle::new(subject.to_owned()) { - Ok(subject) => subject, - Err(error) => { - self.vault.rollback_refresh(&reference, &attempt_id).await?; - return Err(error); - } - }; - let (_, resolved_identity) = match protocol::resolve_identity(&resolved_subject).await { - Ok(identity) => identity, - Err(error) => { - self.vault.rollback_refresh(&reference, &attempt_id).await?; - return Err(error); - } - }; - if resolved_identity.did != subject || resolved_identity.pds != resource { - self.vault.rollback_refresh(&reference, &attempt_id).await?; - return Err(AuthenticationError::new( - AuthenticationFailureStage::SessionRefresh, - AuthenticationErrorCategory::ProtocolRejected, - AuthenticationFailureReason::AuthorityMismatch, - )); - } - let (http, metadata) = match protocol::token_endpoint_for_issuer(issuer, resource).await { - Ok(value) => value, - Err(error) => { - self.vault.rollback_refresh(&reference, &attempt_id).await?; - return Err(error); + let (generation, epoch, grant) = match current.state() { + StoredSessionStateV1::Active { + generation, + epoch, + grant, + } if *generation == active_reference.generation().value() + && *epoch == active_reference.epoch().value() + && grant.subject() == active_reference.account().did().as_str() => + { + (*generation, *epoch, grant.clone()) } - }; - let endpoint = match metadata.token_endpoint.as_deref() { - Some(endpoint) => endpoint, - None => { - self.vault.rollback_refresh(&reference, &attempt_id).await?; - return Err(par_rejected()); + _ => { + return Ok(RefreshOutcome::RecoveryRequired( + active.into_refresh_recovery(), + )); } }; - let cached_nonce = decoded.nonce_for(endpoint); - let key = KeyData::new(KeyType::P256Private, scalar.to_vec()); - let token_result = match protocol::post_token_form_json::<_, protocol::TokenResponse>( + self.vault_replace(StoredSessionV1::refresh_in_flight( + generation, + epoch, + attempt_id.as_str().to_owned(), + grant.clone(), + )?) + .await?; + let key = grant.dpop_key()?; + let http = HardenedHttpClient::new().map_err(|_| vault_error())?; + let cached_nonce = grant.nonce_for(grant.token_endpoint()); + let token_result = protocol::post_refresh_token_form_json( &http, - endpoint, + grant.token_endpoint(), &[ ("grant_type", "refresh_token"), - ("client_id", client_id), - ("refresh_token", refresh_token), + ("client_id", grant.client_id()), + ("refresh_token", grant.refresh_token()), ], &key, cached_nonce.as_deref().map(String::as_str), - AuthenticationFailureStage::SessionRefresh, ) - .await - { - Ok(tokens) => tokens, - Err(_) => { - self.vault - .mark_refresh_in_doubt(&reference, &attempt_id) + .await; + let token_result = match token_result { + Ok(result) => result, + Err(protocol::RefreshTokenError::NotDispatched) => { + self.vault_replace(StoredSessionV1::active(generation, epoch, grant.clone())?) .await?; - return Ok(RefreshOutcome::RecoveryRequired( - active.into_refresh_recovery(), - )); + return Ok(RefreshOutcome::NotDispatched(active)); } - }; - let token_nonce = token_result.nonce; - let tokens = match token_result.response.validate(subject) { - Ok(tokens) => tokens, - Err(_) => { - self.vault - .mark_refresh_in_doubt(&reference, &attempt_id) - .await?; + Err(protocol::RefreshTokenError::OutcomeUnknown) => { + self.vault_replace(StoredSessionV1::refresh_in_doubt( + generation, + epoch, + attempt_id.as_str().to_owned(), + grant.subject().to_owned(), + grant.issuer().to_owned(), + grant.resource().to_owned(), + )?) + .await?; return Ok(RefreshOutcome::RecoveryRequired( active.into_refresh_recovery(), )); } - }; - let mut replacement_session = - match session::OAuthSessionV1::new(session::OAuthSessionInput { - client_id: client_id.to_owned(), - issuer: issuer.to_owned(), - resource: resource.to_owned(), - subject: subject.to_owned(), - scope: tokens.scope, - refresh_token: tokens.refresh_token, - dpop_private_scalar: scalar, - }) { - Ok(session) => session, - Err(_) => { - self.vault - .mark_refresh_in_doubt(&reference, &attempt_id) - .await?; + Err(protocol::RefreshTokenError::Rejected(error)) => { + if error.reason() + == AuthenticationFailureReason::RemoteRejected( + crate::authentication::RemoteOAuthErrorCode::InvalidGrant, + ) + { + self.agent.lock().await.take(); + self.vault_delete().await?; return Ok(RefreshOutcome::RecoveryRequired( active.into_refresh_recovery(), )); } - }; - let replacement = match replacement_session - .record_nonce(endpoint, token_nonce) - .and_then(|()| replacement_session.encode()) - { - Ok(replacement) => replacement, - Err(_) => { - self.vault - .mark_refresh_in_doubt(&reference, &attempt_id) + self.vault_replace(StoredSessionV1::active(generation, epoch, grant.clone())?) .await?; - return Ok(RefreshOutcome::RecoveryRequired( - active.into_refresh_recovery(), - )); + return Ok(RefreshOutcome::NotDispatched(active)); } }; - let promoted = match self - .vault - .commit_refresh_replacement(&reference, &attempt_id, replacement) - .await - { - Ok(promoted) => promoted, + let nonce = token_result.nonce; + let tokens = match token_result.response.validate(grant.subject()) { + Ok(tokens) => tokens, Err(_) => { - self.vault - .mark_refresh_in_doubt(&reference, &attempt_id) - .await?; + self.vault_replace(StoredSessionV1::refresh_in_doubt( + generation, + epoch, + attempt_id.as_str().to_owned(), + grant.subject().to_owned(), + grant.issuer().to_owned(), + grant.resource().to_owned(), + )?) + .await?; return Ok(RefreshOutcome::RecoveryRequired( active.into_refresh_recovery(), )); } }; - self.service.install( - subject.to_owned(), + let mut replacement = RefreshGrantV1::new( + grant.client_id().to_owned(), + grant.issuer().to_owned(), + grant.resource().to_owned(), + grant.token_endpoint().to_owned(), + grant.revocation_endpoint().map(ToOwned::to_owned), + grant.subject().to_owned(), + tokens.scope, + tokens.refresh_token, + key.bytes().try_into().map_err(|_| vault_error())?, + )?; + let token_endpoint = replacement.token_endpoint().to_owned(); + replacement.record_nonce(&token_endpoint, nonce)?; + let next_generation = generation.checked_add(1).ok_or_else(vault_error)?; + if self + .vault_replace(StoredSessionV1::active( + next_generation, + epoch, + replacement, + )?) + .await + .is_err() + { + self.vault_replace(StoredSessionV1::refresh_in_doubt( + generation, + epoch, + attempt_id.as_str().to_owned(), + grant.subject().to_owned(), + grant.issuer().to_owned(), + grant.resource().to_owned(), + )?) + .await?; + return Ok(RefreshOutcome::RecoveryRequired( + active.into_refresh_recovery(), + )); + } + *self.agent.lock().await = Some(SessionAgent::new( + grant.subject().to_owned(), + next_generation, + epoch, tokens.access_token, - resource.to_owned(), + grant.resource().to_owned(), key, - )?; - Ok(RefreshOutcome::Replaced(promoted)) + )); + let replacement = VerifiedActiveSession::from_verified_reference(reference( + grant.subject(), + next_generation, + epoch, + )?); + Ok(RefreshOutcome::Replaced(replacement)) + } + + pub(crate) async fn send_timeline( + &self, + reference: &AccountGenerationReference, + client: &C, + ) -> Result { + let mut agent = self.agent.lock().await; + let agent = agent + .as_mut() + .filter(|agent| agent.matches(reference)) + .ok_or_else(session_agent::unavailable)?; + agent.send_timeline(client).await + } + + /// Local credential removal is authoritative; remote revocation is best effort. + pub(crate) async fn revoke_and_delete(&self, _active: VerifiedActiveSession) -> LogoutReport { + let record = self.vault_load().await; + let access_token = self + .agent + .lock() + .await + .take() + .map(|agent| agent.access_token_for_revocation()); + let tombstone_epoch = record + .as_ref() + .ok() + .and_then(|record| record.as_ref()) + .map_or(0, |record| match record.state() { + StoredSessionStateV1::Active { epoch, .. } + | StoredSessionStateV1::RefreshInFlight { epoch, .. } + | StoredSessionStateV1::RefreshInDoubt { epoch, .. } + | StoredSessionStateV1::Tombstone { epoch } => *epoch, + }); + let local_cleanup = match self + .vault_replace(StoredSessionV1::tombstone(tombstone_epoch)) + .await + { + Ok(()) => self.vault_delete().await, + Err(error) => Err(error), + }; + let revocation = match &record { + Ok(Some(record)) => match record.state() { + StoredSessionStateV1::Active { grant, .. } + | StoredSessionStateV1::RefreshInFlight { grant, .. } => { + match grant.revocation_endpoint() { + None => RevocationReport { + refresh: RevocationOutcome::Unsupported, + access: access_token + .as_ref() + .map_or(RevocationOutcome::NotPresent, |_| { + RevocationOutcome::Unsupported + }), + }, + Some(endpoint) => match (grant.dpop_key(), HardenedHttpClient::new()) { + (Ok(key), Ok(http)) => { + let nonce = grant.nonce_for(endpoint); + let refresh = protocol::post_revocation_form( + &http, + endpoint, + grant.client_id(), + grant.refresh_token(), + "refresh_token", + &key, + nonce.as_deref().map(String::as_str), + ) + .await; + let access = match access_token.as_deref() { + Some(token) => { + protocol::post_revocation_form( + &http, + endpoint, + grant.client_id(), + token, + "access_token", + &key, + nonce.as_deref().map(String::as_str), + ) + .await + } + None => { + return LogoutReport { + revocation: RevocationReport { + refresh: map_revocation(refresh), + access: RevocationOutcome::NotPresent, + }, + local_cleanup, + }; + } + }; + RevocationReport { + refresh: map_revocation(refresh), + access: map_revocation(access), + } + } + (Err(error), _) | (_, Err(error)) => RevocationReport { + refresh: RevocationOutcome::Failed(error), + access: RevocationOutcome::NotPresent, + }, + }, + } + } + _ => RevocationReport { + refresh: RevocationOutcome::NotPresent, + access: RevocationOutcome::NotPresent, + }, + }, + Ok(None) => RevocationReport { + refresh: RevocationOutcome::NotPresent, + access: RevocationOutcome::NotPresent, + }, + Err(error) => RevocationReport { + refresh: RevocationOutcome::Failed(*error), + access: RevocationOutcome::NotPresent, + }, + }; + LogoutReport { + revocation, + local_cleanup, + } } } -#[cfg(test)] -mod tests { - use super::*; +struct PendingAttempt { + attempt_id: AuthenticationAttemptId, + epoch: Epoch, + expected_did: String, + client_id: String, + issuer: String, + resource: String, + redirect_uri: RedirectUri, + state: String, + pkce_verifier: String, + dpop_key: KeyData, + dpop_nonce: String, + token_endpoint: String, + revocation_endpoint: Option, + expires_at: i64, +} - #[test] - fn native_callback_always_uses_the_release_client_identity() { - assert_eq!( - client_id_for(&RedirectUri::native()).expect("native callback is configured"), - "https://hitomi.nateledford.com/client-metadata-v2.json" - ); +struct CallbackFields { + issuer: String, + state: String, + code: String, +} + +fn callback_fields(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| callback_rejected())?; + let mut issuer = None; + let mut state = None; + let mut code = None; + let mut error = None; + for (key, value) in url.query_pairs() { + if value.is_empty() || value.len() > 4096 || !value.is_ascii() { + return Err(callback_rejected()); + } + match key.as_ref() { + "iss" => { + if issuer.replace(value.into_owned()).is_some() { + return Err(callback_rejected()); + } + } + "state" => { + if state.replace(value.into_owned()).is_some() { + return Err(callback_rejected()); + } + } + "code" => { + if code.replace(value.into_owned()).is_some() { + return Err(callback_rejected()); + } + } + "error" => { + if error.replace(value.into_owned()).is_some() { + return Err(callback_rejected()); + } + } + _ => return Err(callback_rejected()), + } } + if error.is_some() { + return Err(AuthenticationError::new( + AuthenticationFailureStage::CallbackValidation, + AuthenticationErrorCategory::AuthorizationDenied, + AuthenticationFailureReason::CallbackRejected, + )); + } + match (issuer, state, code) { + (Some(issuer), Some(state), Some(code)) => Ok(CallbackFields { + issuer, + state, + code, + }), + _ => Err(callback_rejected()), + } +} +fn reference( + subject: &str, + generation: u64, + epoch: u64, +) -> Result { + Ok(AccountGenerationReference::new( + Did::new(subject.to_owned())?.into_authenticated(), + Generation::new(generation), + Epoch::new(epoch), + )) +} + +fn local_failure(stage: AuthenticationFailureStage) -> AuthenticationError { + AuthenticationError::new( + stage, + AuthenticationErrorCategory::AuthenticationUnavailable, + AuthenticationFailureReason::UpstreamUnavailable, + ) +} +fn vault_error() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::SessionPersistence, + AuthenticationErrorCategory::CredentialStoreUnavailable, + AuthenticationFailureReason::CredentialStoreUnavailable, + ) +} +fn callback_rejected() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::CallbackValidation, + AuthenticationErrorCategory::CallbackRejected, + AuthenticationFailureReason::CallbackRejected, + ) +} +fn authority_mismatch() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::TokenExchange, + AuthenticationErrorCategory::ProtocolRejected, + AuthenticationFailureReason::AuthorityMismatch, + ) +} +fn par_rejected() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::PushedAuthorizationRequest, + AuthenticationErrorCategory::ProtocolRejected, + AuthenticationFailureReason::MalformedResponse, + ) +} + +fn map_revocation(result: Result<(), AuthenticationError>) -> RevocationOutcome { + match result { + Ok(()) => RevocationOutcome::Succeeded, + Err(error) => RevocationOutcome::Failed(error), + } +} +fn random_b64(bytes: usize) -> Result { + let mut value = vec![0; bytes]; + getrandom::fill(&mut value) + .map_err(|_| local_failure(AuthenticationFailureStage::PushedAuthorizationRequest))?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(value)) +} +fn now_unix_seconds() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_secs()).ok()) + .ok_or_else(|| local_failure(AuthenticationFailureStage::PushedAuthorizationRequest)) +} +fn client_id_for(redirect_uri: &RedirectUri) -> Result { + match redirect_uri { + RedirectUri::Native => Ok(crate::release_identity::CLIENT_ID.to_owned()), + RedirectUri::Loopback(_) => { + #[cfg(feature = "test-support")] + { + Ok(format!( + "http://localhost?redirect_uri={}&scope=atproto", + url::form_urlencoded::byte_serialize(redirect_uri.as_str().as_bytes()) + .collect::() + )) + } + #[cfg(not(feature = "test-support"))] + { + Err(par_rejected()) + } + } + } +} +fn authorization_request_fields<'a>( + client_id: &'a str, + redirect_uri: &'a RedirectUri, + state: &'a str, + challenge: &'a str, + login_hint: &'a str, + resource: &'a str, +) -> [(&'static str, &'a str); 9] { + [ + ("client_id", client_id), + ("response_type", "code"), + ("redirect_uri", redirect_uri.as_str()), + ("state", state), + ("scope", "atproto"), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("login_hint", login_hint), + ("resource", resource), + ] +} +fn par_request_diagnostic( + client_id: &str, + issuer: &str, + endpoint: &str, +) -> crate::authentication::ParRequestDiagnostic { + fn prefix(value: &str) -> [u8; 8] { + let digest = Sha256::digest(value.as_bytes()); + let mut prefix = [0; 8]; + prefix.copy_from_slice(&digest[..8]); + prefix + } + crate::authentication::ParRequestDiagnostic::from_sha256_prefixes( + prefix(client_id), + prefix(issuer), + prefix(endpoint), + ) +} + +#[cfg(test)] +mod tests { + use super::*; #[test] - fn loopback_callback_identity_is_test_support_only() { - let loopback = RedirectUri::for_selected_port(17_001).expect("valid loopback redirect"); - #[cfg(feature = "test-support")] + fn native_callback_uses_release_identity() { assert_eq!( - client_id_for(&loopback).expect("test client identity"), - "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A17001%2Foauth%2Fcallback&scope=atproto" + client_id_for(&RedirectUri::native()).expect("client ID"), + crate::release_identity::CLIENT_ID ); - #[cfg(not(feature = "test-support"))] - assert!(client_id_for(&loopback).is_err()); } - #[test] - fn authorization_request_binds_the_discovered_pds_resource() { - let redirect = RedirectUri::native(); - let fields = authorization_request_fields( - "https://client.example/metadata.json", - &redirect, - "state", - "challenge", - "alice.example", - "https://pds.example/", - ); - + fn authorization_request_binds_discovered_resource() { assert_eq!( - fields, + authorization_request_fields( + "client", + &RedirectUri::native(), + "state", + "challenge", + "did:plc:alice", + "https://pds.example/" + ), [ - ("client_id", "https://client.example/metadata.json"), + ("client_id", "client"), ("response_type", "code"), ("redirect_uri", "com.nateledford.hitomi:/oauth/callback"), ("state", "state"), ("scope", "atproto"), ("code_challenge", "challenge"), ("code_challenge_method", "S256"), - ("login_hint", "alice.example"), - ("resource", "https://pds.example/"), + ("login_hint", "did:plc:alice"), + ("resource", "https://pds.example/") ] ); } diff --git a/src/infrastructure/oauth/identity.rs b/src/infrastructure/oauth/identity.rs index 7dab311..a06a897 100644 --- a/src/infrastructure/oauth/identity.rs +++ b/src/infrastructure/oauth/identity.rs @@ -129,15 +129,15 @@ async fn resolve_handle_http( async fn resolve_handle_dns(handle: &str) -> Result { let resolver = TokioResolver::builder_tokio() - .map_err(|_| transport_error(TransportError))? + .map_err(|_| transport_error(TransportError::NotDispatched))? .build() - .map_err(|_| transport_error(TransportError))?; + .map_err(|_| transport_error(TransportError::NotDispatched))?; let lookup = timeout( Duration::from_secs(5), resolver.txt_lookup(format!("_atproto.{handle}")), ) .await - .map_err(|_| transport_error(TransportError))? + .map_err(|_| transport_error(TransportError::OutcomeUnknown))? .map_err(|_| rejected(AuthenticationFailureReason::IdentityUnresolved))?; let mut dids = BTreeSet::new(); let mut bytes = 0usize; diff --git a/src/infrastructure/oauth/protocol.rs b/src/infrastructure/oauth/protocol.rs index d3add22..aa50b5e 100644 --- a/src/infrastructure/oauth/protocol.rs +++ b/src/infrastructure/oauth/protocol.rs @@ -14,7 +14,7 @@ use crate::authentication::{ bounded_response_body_len, }; use crate::infrastructure::transport::{ - HardenedHttpClient, OutboundHttp, OutboundRequest, OutboundResponse, RemoteHttpsUrl, + OutboundHttp, OutboundRequest, OutboundResponse, RemoteHttpsUrl, }; #[derive(Clone, Deserialize)] @@ -117,16 +117,20 @@ fn rejected( pub(super) use super::identity::ResolvedIdentity; -pub(super) struct DiscoveredAuthorizationServer { +pub(super) struct DiscoveredAuthorizationServer { pub(super) identity: ResolvedIdentity, pub(super) metadata: AuthorizationServerMetadata, - pub(super) http: HardenedHttpClient, + pub(super) http: T, } -pub(super) async fn discover_authorization_server( +pub(super) async fn discover_authorization_server( + http: T, handle: &AccountHandle, -) -> Result { - let (http, identity) = resolve_identity(handle).await?; +) -> Result, AuthenticationError> +where + T: OutboundHttp, +{ + let identity = resolve_identity(&http, handle).await?; let protected_url = well_known_url( &identity.pds, "oauth-protected-resource", @@ -168,18 +172,14 @@ pub(super) async fn discover_authorization_server( }) } -pub(super) async fn resolve_identity( +pub(super) async fn resolve_identity( + http: &T, handle: &AccountHandle, -) -> Result<(HardenedHttpClient, ResolvedIdentity), AuthenticationError> { - let http = HardenedHttpClient::new().map_err(|_| { - AuthenticationError::new( - AuthenticationFailureStage::IdentityResolution, - AuthenticationErrorCategory::TransportUnavailable, - AuthenticationFailureReason::UpstreamUnavailable, - ) - })?; - let identity = super::identity::resolve(&http, handle).await?; - Ok((http, identity)) +) -> Result +where + T: OutboundHttp, +{ + super::identity::resolve(http, handle).await } pub(super) async fn fetch_json( @@ -660,6 +660,81 @@ where parse_token_response(response, stage) } +/// Refresh dispatch classification. A request-construction failure or proven +/// connect failure cannot consume a rotating token; every later failure is +/// deliberately ambiguous. +pub(super) enum RefreshTokenError { + NotDispatched, + OutcomeUnknown, + Rejected(AuthenticationError), +} + +async fn send_refresh( + client: &T, + uri: &str, + fields: &[(&str, &str)], + key: &KeyData, + nonce: Option<&str>, + stage: AuthenticationFailureStage, +) -> Result { + let proof = proof::authorization(key, "POST", uri, nonce, stage) + .map_err(|_| RefreshTokenError::NotDispatched)?; + let body = { + let mut encoded = url::form_urlencoded::Serializer::new(String::new()); + for (name, value) in fields { + encoded.append_pair(name, value); + } + encoded.finish().into_bytes() + }; + let mut headers = HeaderMap::new(); + headers.insert("accept", HeaderValue::from_static("application/json")); + headers.insert( + "content-type", + HeaderValue::from_static("application/x-www-form-urlencoded"), + ); + headers.insert( + "dpop", + HeaderValue::from_str(&proof).map_err(|_| RefreshTokenError::NotDispatched)?, + ); + let request = OutboundRequest::new( + Method::POST, + RemoteHttpsUrl::parse(uri).map_err(|_| RefreshTokenError::NotDispatched)?, + headers, + body, + 32 * 1024, + ) + .map_err(|_| RefreshTokenError::NotDispatched)?; + client.send(request).await.map_err(|error| match error { + crate::infrastructure::transport::TransportError::NotDispatched => { + RefreshTokenError::NotDispatched + } + crate::infrastructure::transport::TransportError::OutcomeUnknown => { + RefreshTokenError::OutcomeUnknown + } + }) +} + +pub(super) async fn post_refresh_token_form_json( + client: &T, + uri: &str, + fields: &[(&str, &str)], + key: &KeyData, + cached_nonce: Option<&str>, +) -> Result, RefreshTokenError> +where + T: OutboundHttp, +{ + let stage = AuthenticationFailureStage::SessionRefresh; + let response = send_refresh(client, uri, fields, key, cached_nonce, stage).await?; + if let Some(nonce) = + dpop_nonce_challenge(&response, stage).map_err(RefreshTokenError::Rejected)? + { + let retry = send_refresh(client, uri, fields, key, Some(&nonce), stage).await?; + return parse_token_response(retry, stage).map_err(RefreshTokenError::Rejected); + } + parse_token_response(response, stage).map_err(RefreshTokenError::Rejected) +} + fn parse_token_response( response: OutboundResponse, stage: AuthenticationFailureStage, @@ -688,37 +763,45 @@ where Ok(TokenResult { response, nonce }) } -pub(super) async fn token_endpoint_for_issuer( - issuer: &str, - resource: &str, -) -> Result<(HardenedHttpClient, AuthorizationServerMetadata), AuthenticationError> { - let resource_metadata_url = well_known_url( - resource, - "oauth-protected-resource", - AuthenticationFailureStage::ProtectedResourceMetadata, - AuthenticationCapability::ProtectedResourceLinkage, - )?; - let http = HardenedHttpClient::new()?; - let protected: ProtectedResourceMetadata = fetch_json( - &http, - resource_metadata_url.as_str(), - AuthenticationFailureStage::ProtectedResourceMetadata, - ) - .await?; - let metadata_url = well_known_url( - issuer, - "oauth-authorization-server", - AuthenticationFailureStage::AuthorizationServerMetadata, - AuthenticationCapability::IssuerOrigin, - )?; - let metadata: AuthorizationServerMetadata = fetch_json( - &http, - metadata_url.as_str(), - AuthenticationFailureStage::AuthorizationServerMetadata, - ) - .await?; - validate_metadata(resource, protected, issuer, metadata.clone())?; - Ok((http, metadata)) +pub(super) async fn post_revocation_form( + client: &T, + uri: &str, + client_id: &str, + token: &str, + token_type_hint: &str, + key: &KeyData, + cached_nonce: Option<&str>, +) -> Result<(), AuthenticationError> +where + T: OutboundHttp, +{ + let stage = AuthenticationFailureStage::SessionRevocation; + let fields = [ + ("token", token), + ("token_type_hint", token_type_hint), + ("client_id", client_id), + ]; + let response = send_refresh(client, uri, &fields, key, cached_nonce, stage) + .await + .map_err(|_| { + AuthenticationError::new( + stage, + AuthenticationErrorCategory::TransportUnavailable, + AuthenticationFailureReason::UpstreamUnavailable, + ) + })?; + if let Some(nonce) = dpop_nonce_challenge(&response, stage)? { + send_refresh(client, uri, &fields, key, Some(&nonce), stage) + .await + .map_err(|_| { + AuthenticationError::new( + stage, + AuthenticationErrorCategory::TransportUnavailable, + AuthenticationFailureReason::UpstreamUnavailable, + ) + })?; + } + Ok(()) } fn well_known_url( @@ -783,34 +866,11 @@ fn simple_https_url(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::infrastructure::transport::test_support::{ + ScriptedOutboundHttp, response as outbound, + }; use atproto_identity::key::{KeyType, generate_key}; use atrium_xrpc::http::Response; - use std::sync::{Arc, Mutex}; - - #[derive(Clone)] - struct ResponseSequence(Arc>>); - - impl OutboundHttp for ResponseSequence { - async fn send( - &self, - _: OutboundRequest, - ) -> Result { - self.0 - .lock() - .map_err(|_| crate::infrastructure::transport::TransportError)? - .pop() - .ok_or(crate::infrastructure::transport::TransportError) - } - } - - fn outbound(response: Response>) -> OutboundResponse { - let (parts, body) = response.into_parts(); - OutboundResponse { - status: parts.status, - headers: parts.headers, - body, - } - } #[tokio::test] async fn par_retries_once_with_the_server_nonce() { @@ -832,10 +892,7 @@ mod tests { .to_vec(), ) .expect("success response"); - let client = ResponseSequence(Arc::new(Mutex::new(vec![ - outbound(success), - outbound(challenge), - ]))); + let client = ScriptedOutboundHttp::new([outbound(challenge), outbound(success)]); let key = generate_key(KeyType::P256Private).expect("P-256 key"); let result = post_par_form( @@ -849,13 +906,7 @@ mod tests { assert_eq!(result.nonce, "fresh-nonce"); assert_eq!(result.response.expires_in, 60); - assert!( - client - .0 - .lock() - .expect("response sequence is available") - .is_empty() - ); + assert_eq!(client.remaining_response_count(), 0); } #[tokio::test] @@ -872,10 +923,7 @@ mod tests { .header("dpop-nonce", "fresh-nonce") .body(br#"{"result":"ok"}"#.to_vec()) .expect("success response"); - let client = ResponseSequence(Arc::new(Mutex::new(vec![ - outbound(success), - outbound(challenge), - ]))); + let client = ScriptedOutboundHttp::new([outbound(challenge), outbound(success)]); let key = generate_key(KeyType::P256Private).expect("P-256 key"); let result = post_token_form_json::<_, serde_json::Value>( @@ -891,13 +939,7 @@ mod tests { assert_eq!(result.nonce, "fresh-nonce"); assert_eq!(result.response["result"], "ok"); - assert!( - client - .0 - .lock() - .expect("response sequence is available") - .is_empty() - ); + assert_eq!(client.remaining_response_count(), 0); } fn metadata() -> AuthorizationServerMetadata { diff --git a/src/infrastructure/oauth/session.rs b/src/infrastructure/oauth/session.rs index e9ee2ff..3538c47 100644 --- a/src/infrastructure/oauth/session.rs +++ b/src/infrastructure/oauth/session.rs @@ -1,4 +1,7 @@ -//! Redacted, application-owned durable OAuth session schema. +//! Durable, redacted OAuth refresh-session record. +//! +//! The Keychain record deliberately contains no access token, authorization code, +//! callback value, or PKCE verifier. Those values are process-local and short-lived. use std::collections::BTreeMap; use std::fmt; @@ -11,23 +14,33 @@ use zeroize::Zeroizing; use crate::authentication::{ AuthenticationError, AuthenticationErrorCategory, AuthenticationFailureReason, - AuthenticationFailureStage, Did, SessionArtifact, + AuthenticationFailureStage, Did, }; -const MAX_ARTIFACT_BYTES: usize = 98_304; +pub(crate) const MAX_STORED_SESSION_BYTES: usize = 16 * 1024; const MAX_NONCES: usize = 4; const MAX_NONCE_BYTES: usize = 512; -fn invalid_artifact() -> AuthenticationError { +fn invalid_record() -> AuthenticationError { AuthenticationError::new( AuthenticationFailureStage::SessionValidation, AuthenticationErrorCategory::CredentialCorrupt, - AuthenticationFailureReason::SessionArtifactInvalid, + AuthenticationFailureReason::StoredSessionInvalid, ) } #[derive(Clone, Deserialize, Serialize)] -struct Redacted(Zeroizing); +pub(crate) struct Redacted(Zeroizing); + +impl Redacted { + pub(crate) fn new(value: String) -> Self { + Self(value.into()) + } + + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} impl fmt::Debug for Redacted { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -35,98 +48,57 @@ impl fmt::Debug for Redacted { } } -/// Version-one application-owned OAuth session artifact. +/// Durable material needed to refresh or revoke a DPoP-bound OAuth session. #[derive(Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields)] -pub(crate) struct OAuthSessionV1 { - schema_version: u8, +pub(crate) struct RefreshGrantV1 { client_id: String, issuer: String, resource: String, + token_endpoint: String, + revocation_endpoint: Option, subject: String, scope: String, refresh_token: Redacted, dpop_private_scalar: Redacted, - nonces: BTreeMap, + authorization_server_nonces: BTreeMap, } -impl fmt::Debug for OAuthSessionV1 { +impl fmt::Debug for RefreshGrantV1 { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("OAuthSessionV1()") + formatter.write_str("RefreshGrantV1()") } } -pub(crate) struct RefreshMaterial<'a> { - pub(crate) issuer: &'a str, - pub(crate) resource: &'a str, - pub(crate) subject: &'a str, - pub(crate) client_id: &'a str, - pub(crate) refresh_token: &'a str, - pub(crate) private_scalar: [u8; 32], -} - -pub(crate) struct OAuthSessionInput { - pub(crate) client_id: String, - pub(crate) issuer: String, - pub(crate) resource: String, - pub(crate) subject: String, - pub(crate) scope: String, - pub(crate) refresh_token: String, - pub(crate) dpop_private_scalar: [u8; 32], -} - -impl OAuthSessionV1 { - pub(crate) const SCHEMA_VERSION: u8 = 1; - - pub(crate) fn new(input: OAuthSessionInput) -> Result { - let session = Self { - schema_version: Self::SCHEMA_VERSION, - client_id: input.client_id, - issuer: input.issuer, - resource: input.resource, - subject: input.subject, - scope: input.scope, - refresh_token: Redacted(input.refresh_token.into()), - dpop_private_scalar: Redacted( - base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(input.dpop_private_scalar) - .into(), +impl RefreshGrantV1 { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + client_id: String, + issuer: String, + resource: String, + token_endpoint: String, + revocation_endpoint: Option, + subject: String, + scope: String, + refresh_token: String, + dpop_private_scalar: [u8; 32], + ) -> Result { + let grant = Self { + client_id, + issuer, + resource, + token_endpoint, + revocation_endpoint, + subject, + scope, + refresh_token: Redacted::new(refresh_token), + dpop_private_scalar: Redacted::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dpop_private_scalar), ), - nonces: BTreeMap::new(), + authorization_server_nonces: BTreeMap::new(), }; - session.validate()?; - Ok(session) - } - - pub(crate) fn refresh_material(&self) -> Result, AuthenticationError> { - let scalar = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&self.dpop_private_scalar.0) - .map_err(|_| invalid_artifact())?; - let private_scalar: [u8; 32] = scalar.try_into().map_err(|_| invalid_artifact())?; - Ok(RefreshMaterial { - issuer: &self.issuer, - resource: &self.resource, - subject: &self.subject, - client_id: &self.client_id, - refresh_token: &self.refresh_token.0, - private_scalar, - }) - } - - pub(crate) fn decode(artifact: &SessionArtifact) -> Result { - if artifact.as_bytes().len() > MAX_ARTIFACT_BYTES { - return Err(invalid_artifact()); - } - let session: Self = - serde_json::from_slice(artifact.as_bytes()).map_err(|_| invalid_artifact())?; - session.validate()?; - Ok(session) - } - - pub(crate) fn encode(&self) -> Result { - self.validate()?; - let bytes = serde_json::to_vec(self).map_err(|_| invalid_artifact())?; - SessionArtifact::new(bytes).map_err(|_| invalid_artifact()) + grant.validate()?; + Ok(grant) } pub(crate) fn record_nonce( @@ -134,54 +106,246 @@ impl OAuthSessionV1 { endpoint: &str, nonce: String, ) -> Result<(), AuthenticationError> { - let origin = endpoint_origin(endpoint).ok_or_else(invalid_artifact)?; - if self.nonces.len() == MAX_NONCES && !self.nonces.contains_key(&origin) { - let Some(oldest) = self.nonces.keys().next().cloned() else { - return Err(invalid_artifact()); + let origin = endpoint_origin(endpoint).ok_or_else(invalid_record)?; + if self.authorization_server_nonces.len() == MAX_NONCES + && !self.authorization_server_nonces.contains_key(&origin) + { + let Some(oldest) = self.authorization_server_nonces.keys().next().cloned() else { + return Err(invalid_record()); }; - self.nonces.remove(&oldest); + self.authorization_server_nonces.remove(&oldest); } - self.nonces.insert(origin, Redacted(nonce.into())); + self.authorization_server_nonces + .insert(origin, Redacted::new(nonce)); self.validate() } pub(crate) fn nonce_for(&self, endpoint: &str) -> Option> { endpoint_origin(endpoint) - .and_then(|origin| self.nonces.get(&origin)) + .and_then(|origin| self.authorization_server_nonces.get(&origin)) .map(|nonce| nonce.0.clone()) } + pub(crate) fn client_id(&self) -> &str { + &self.client_id + } + pub(crate) fn issuer(&self) -> &str { + &self.issuer + } + pub(crate) fn resource(&self) -> &str { + &self.resource + } + pub(crate) fn token_endpoint(&self) -> &str { + &self.token_endpoint + } + pub(crate) fn revocation_endpoint(&self) -> Option<&str> { + self.revocation_endpoint.as_deref() + } + pub(crate) fn subject(&self) -> &str { + &self.subject + } + pub(crate) fn refresh_token(&self) -> &str { + self.refresh_token.expose() + } + + pub(crate) fn dpop_key(&self) -> Result { + let scalar = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(self.dpop_private_scalar.expose()) + .map_err(|_| invalid_record())?; + if scalar.len() != 32 { + return Err(invalid_record()); + } + Ok(KeyData::new(KeyType::P256Private, scalar)) + } + fn validate(&self) -> Result<(), AuthenticationError> { - if self.schema_version != Self::SCHEMA_VERSION - || !valid_client_id(&self.client_id) + if !valid_client_id(&self.client_id) || !simple_https_url(&self.issuer) || !simple_https_url(&self.resource) + || !simple_https_url(&self.token_endpoint) + || self + .revocation_endpoint + .as_deref() + .is_some_and(|url| !simple_https_url(url)) || Did::new(self.subject.clone()).is_err() - || self.refresh_token.0.is_empty() + || self.refresh_token.expose().is_empty() || !self .scope .split_ascii_whitespace() .any(|scope| scope == "atproto") + || self.authorization_server_nonces.len() > MAX_NONCES + || self + .authorization_server_nonces + .iter() + .any(|(origin, nonce)| { + !simple_https_origin(origin) + || nonce.expose().is_empty() + || nonce.expose().len() > MAX_NONCE_BYTES + || !nonce.expose().is_ascii() + }) { - return Err(invalid_artifact()); + return Err(invalid_record()); } - let scalar = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&self.dpop_private_scalar.0) - .map_err(|_| invalid_artifact())?; - if scalar.len() != 32 || to_public(&KeyData::new(KeyType::P256Private, scalar)).is_err() { - return Err(invalid_artifact()); + let key = self.dpop_key()?; + to_public(&key).map_err(|_| invalid_record())?; + Ok(()) + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) enum StoredSessionStateV1 { + Active { + generation: u64, + epoch: u64, + grant: RefreshGrantV1, + }, + RefreshInFlight { + generation: u64, + epoch: u64, + operation_id: String, + grant: RefreshGrantV1, + }, + RefreshInDoubt { + generation: u64, + epoch: u64, + operation_id: String, + subject: String, + issuer: String, + resource: String, + }, + Tombstone { + epoch: u64, + }, +} + +impl fmt::Debug for StoredSessionStateV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("StoredSessionStateV1()") + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StoredSessionV1 { + schema_version: u8, + state: StoredSessionStateV1, +} + +impl fmt::Debug for StoredSessionV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("StoredSessionV1()") + } +} + +impl StoredSessionV1 { + pub(crate) const SCHEMA_VERSION: u8 = 1; + + pub(crate) fn active( + generation: u64, + epoch: u64, + grant: RefreshGrantV1, + ) -> Result { + Self::new(StoredSessionStateV1::Active { + generation, + epoch, + grant, + }) + } + + pub(crate) fn refresh_in_flight( + generation: u64, + epoch: u64, + operation_id: String, + grant: RefreshGrantV1, + ) -> Result { + Self::new(StoredSessionStateV1::RefreshInFlight { + generation, + epoch, + operation_id, + grant, + }) + } + + pub(crate) fn refresh_in_doubt( + generation: u64, + epoch: u64, + operation_id: String, + subject: String, + issuer: String, + resource: String, + ) -> Result { + Self::new(StoredSessionStateV1::RefreshInDoubt { + generation, + epoch, + operation_id, + subject, + issuer, + resource, + }) + } + + pub(crate) fn tombstone(epoch: u64) -> Self { + Self { + schema_version: Self::SCHEMA_VERSION, + state: StoredSessionStateV1::Tombstone { epoch }, } - if self.nonces.len() > MAX_NONCES - || self.nonces.iter().any(|(origin, nonce)| { - !simple_https_origin(origin) - || nonce.0.is_empty() - || nonce.0.len() > MAX_NONCE_BYTES - || !nonce.0.is_ascii() - }) - { - return Err(invalid_artifact()); + } + + fn new(state: StoredSessionStateV1) -> Result { + let stored = Self { + schema_version: Self::SCHEMA_VERSION, + state, + }; + stored.validate()?; + Ok(stored) + } + + pub(crate) fn state(&self) -> &StoredSessionStateV1 { + &self.state + } + + pub(crate) fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_STORED_SESSION_BYTES { + return Err(invalid_record()); + } + let stored: Self = serde_json::from_slice(bytes).map_err(|_| invalid_record())?; + stored.validate()?; + Ok(stored) + } + + pub(crate) fn encode(&self) -> Result, AuthenticationError> { + self.validate()?; + let bytes = serde_json::to_vec(self).map_err(|_| invalid_record())?; + if bytes.len() > MAX_STORED_SESSION_BYTES { + return Err(invalid_record()); + } + Ok(bytes) + } + + fn validate(&self) -> Result<(), AuthenticationError> { + if self.schema_version != Self::SCHEMA_VERSION { + return Err(invalid_record()); + } + match &self.state { + StoredSessionStateV1::Active { grant, .. } + | StoredSessionStateV1::RefreshInFlight { grant, .. } => grant.validate(), + StoredSessionStateV1::RefreshInDoubt { + operation_id, + subject, + issuer, + resource, + .. + } if !operation_id.is_empty() + && Did::new(subject.clone()).is_ok() + && simple_https_url(issuer) + && simple_https_url(resource) => + { + Ok(()) + } + StoredSessionStateV1::Tombstone { .. } => Ok(()), + _ => Err(invalid_record()), } - Ok(()) } } @@ -250,24 +414,43 @@ fn valid_client_id(value: &str) -> bool { #[cfg(test)] mod tests { - use super::{OAuthSessionInput, OAuthSessionV1}; + use super::{RefreshGrantV1, StoredSessionV1}; + + fn grant() -> RefreshGrantV1 { + RefreshGrantV1::new( + crate::release_identity::CLIENT_ID.to_owned(), + "https://auth.example/".to_owned(), + "https://pds.example/".to_owned(), + "https://auth.example/token".to_owned(), + None, + "did:plc:abcdefghijklmnopqrstuvwx".to_owned(), + "atproto".to_owned(), + "refresh-canary".to_owned(), + [7; 32], + ) + .expect("valid grant") + } #[test] - fn durable_v1_artifact_never_serializes_an_access_token() { - let artifact = OAuthSessionV1::new(OAuthSessionInput { - client_id: crate::release_identity::CLIENT_ID.to_owned(), - issuer: "https://auth.example/".to_owned(), - resource: "https://pds.example/".to_owned(), - subject: "did:plc:abcdefghijklmnopqrstuvwx".to_owned(), - scope: "atproto".to_owned(), - refresh_token: "refresh-token".to_owned(), - dpop_private_scalar: [7; 32], - }) - .expect("session") - .encode() - .expect("artifact"); - let serialized = std::str::from_utf8(artifact.as_bytes()).expect("JSON"); - assert!(!serialized.contains("access-token")); - assert!(!serialized.contains("access_token")); + fn durable_record_round_trips_without_access_tokens() { + let bytes = StoredSessionV1::active(3, 7, grant()) + .expect("active") + .encode() + .expect("encode"); + assert!(bytes.len() <= super::MAX_STORED_SESSION_BYTES); + assert!(!String::from_utf8_lossy(&bytes).contains("access-token-canary")); + assert!(StoredSessionV1::decode(&bytes).is_ok()); + } + + #[test] + fn durable_record_rejects_unknown_corrupt_and_oversized_values() { + assert!( + StoredSessionV1::decode( + br#"{\"schema_version\":1,\"state\":{\"Tombstone\":{\"epoch\":1}},\"extra\":true}"# + ) + .is_err() + ); + assert!(StoredSessionV1::decode(b"not-json").is_err()); + assert!(StoredSessionV1::decode(&vec![b'x'; super::MAX_STORED_SESSION_BYTES + 1]).is_err()); } } diff --git a/src/infrastructure/oauth/session_agent.rs b/src/infrastructure/oauth/session_agent.rs index 93f0b2c..e8bff52 100644 --- a/src/infrastructure/oauth/session_agent.rs +++ b/src/infrastructure/oauth/session_agent.rs @@ -1,119 +1,70 @@ -//! Application-owned, process-memory OAuth access-token owner. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; +//! Process-memory access-token session agent. +//! +//! The coordinator owns exactly one of these. Its access token is never durable. use atproto_identity::key::KeyData; use atrium_xrpc::http::{HeaderMap, HeaderValue, Method, StatusCode}; use secrecy::{ExposeSecret, SecretString}; -use crate::authentication::ports::{SessionInvalidation, SessionPersistence, SessionRestoration}; use crate::authentication::{ - AttemptInvalidation, AuthenticationAttemptId, AuthenticationError, AuthenticationErrorCategory, - AuthenticationFailureReason, AuthenticationFailureStage, CompletedAuthorization, Epoch, - PersistedActiveSession, RestorationSnapshot, + AccountGenerationReference, AuthenticationError, AuthenticationErrorCategory, + AuthenticationFailureReason, AuthenticationFailureStage, }; -use crate::infrastructure::session_vault::{KeyringSecretStore, SessionVault}; use crate::infrastructure::transport::{ OutboundHttp, OutboundRequest, OutboundResponse, RemoteHttpsUrl, }; -struct SessionAgent { +pub(crate) struct SessionAgent { + subject: String, + generation: u64, + epoch: u64, access_token: SecretString, resource: String, dpop_key: KeyData, resource_nonce: Option, } -/// The one application-scoped owner of durable OAuth sessions and process-only -/// access grants. The vault never receives an access token. -#[derive(Clone)] -pub(crate) struct OAuthSessionService { - vault: SessionVault, - agents: Arc>>, - staged: Arc>>, -} - -impl Default for OAuthSessionService { - fn default() -> Self { - Self::new(SessionVault::new(KeyringSecretStore)) - } -} - -impl OAuthSessionService { - pub(crate) fn new(vault: SessionVault) -> Self { - Self { - vault, - agents: Arc::default(), - staged: Arc::default(), - } - } - - fn unavailable() -> AuthenticationError { - AuthenticationError::new( - AuthenticationFailureStage::FeedRequest, - AuthenticationErrorCategory::AccessTokenRejected, - AuthenticationFailureReason::AccessTokenRejected, - ) - } - - /// Holds a validated ephemeral grant until the matching completion commits. - pub(crate) fn stage( - &self, +impl SessionAgent { + pub(crate) fn new( subject: String, - epoch: Epoch, + generation: u64, + epoch: u64, access_token: String, resource: String, dpop_key: KeyData, - ) -> Result<(), AuthenticationError> { - self.staged.lock().map_err(|_| Self::unavailable())?.insert( - (subject, epoch.value()), - SessionAgent { - access_token: SecretString::from(access_token), - resource, - dpop_key, - resource_nonce: None, - }, - ); - Ok(()) + ) -> Self { + Self { + subject, + generation, + epoch, + access_token: SecretString::from(access_token), + resource, + dpop_key, + resource_nonce: None, + } } - /// Installs a validated grant after refresh has durably promoted its session. - pub(crate) fn install( - &self, - subject: String, - access_token: String, - resource: String, - dpop_key: KeyData, - ) -> Result<(), AuthenticationError> { - self.agents.lock().map_err(|_| Self::unavailable())?.insert( - subject, - SessionAgent { - access_token: SecretString::from(access_token), - resource, - dpop_key, - resource_nonce: None, - }, - ); - Ok(()) + pub(crate) fn matches(&self, reference: &AccountGenerationReference) -> bool { + self.subject == reference.account().did().as_str() + && self.generation == reference.generation().value() + && self.epoch == reference.epoch().value() + } + + pub(crate) fn access_token_for_revocation(&self) -> String { + self.access_token.expose_secret().to_owned() } - /// Builds the replay-safe home-timeline request with the access grant held - /// only in process memory. - fn timeline_request(&self, subject: &str) -> Result { - let agents = self.agents.lock().map_err(|_| Self::unavailable())?; - let agent = agents.get(subject).ok_or_else(Self::unavailable)?; + fn timeline_request(&self) -> Result { let uri = format!( "{}/xrpc/app.bsky.feed.getTimeline?limit=50", - agent.resource.trim_end_matches('/') + self.resource.trim_end_matches('/') ); let proof = crate::infrastructure::oauth::proof::resource( - &agent.dpop_key, + &self.dpop_key, "GET", &uri, - agent.access_token.expose_secret(), - agent - .resource_nonce + self.access_token.expose_secret(), + self.resource_nonce .as_ref() .map(ExposeSecret::expose_secret), AuthenticationFailureStage::FeedRequest, @@ -121,116 +72,48 @@ impl OAuthSessionService { let mut headers = HeaderMap::new(); headers.insert( "authorization", - HeaderValue::from_str(&format!("DPoP {}", agent.access_token.expose_secret())) - .map_err(|_| Self::unavailable())?, + HeaderValue::from_str(&format!("DPoP {}", self.access_token.expose_secret())) + .map_err(|_| unavailable())?, ); headers.insert( "dpop", - HeaderValue::from_str(&proof).map_err(|_| Self::unavailable())?, + HeaderValue::from_str(&proof).map_err(|_| unavailable())?, ); OutboundRequest::new( Method::GET, - RemoteHttpsUrl::parse(&uri).map_err(|_| Self::unavailable())?, + RemoteHttpsUrl::parse(&uri).map_err(|_| unavailable())?, headers, Vec::new(), 128 * 1024, ) - .map_err(|_| Self::unavailable()) + .map_err(|_| unavailable()) } - /// Sends the replay-safe home timeline request and performs one exact DPoP - /// nonce retry. No agent lock is held while network I/O is in progress. pub(crate) async fn send_timeline( - &self, - subject: &str, + &mut self, client: &T, ) -> Result { let response = client - .send(self.timeline_request(subject)?) + .send(self.timeline_request()?) .await - .map_err(|_| Self::unavailable())?; + .map_err(|_| unavailable())?; let Some(nonce) = resource_nonce_challenge(&response)? else { return Ok(response); }; - { - let mut agents = self.agents.lock().map_err(|_| Self::unavailable())?; - let agent = agents.get_mut(subject).ok_or_else(Self::unavailable)?; - agent.resource_nonce = Some(SecretString::from(nonce)); - } + self.resource_nonce = Some(SecretString::from(nonce)); client - .send(self.timeline_request(subject)?) + .send(self.timeline_request()?) .await - .map_err(|_| Self::unavailable()) - } - - /// Clears access grants during local logout before remote revocation. - pub(crate) fn clear(&self) { - if let Ok(mut agents) = self.agents.lock() { - agents.clear(); - } - if let Ok(mut staged) = self.staged.lock() { - staged.clear(); - } + .map_err(|_| unavailable()) } } -impl SessionPersistence for OAuthSessionService { - async fn persist_completed_authorization( - &self, - completed: CompletedAuthorization, - ) -> Result { - let subject = completed.account().did().as_str().to_owned(); - let epoch = completed.epoch(); - let agent = self - .staged - .lock() - .map_err(|_| Self::unavailable())? - .remove(&(subject.clone(), epoch.value())) - .ok_or_else(Self::unavailable)?; - let persisted = self - .vault - .persist_completed_authorization(completed) - .await?; - self.agents - .lock() - .map_err(|_| Self::unavailable())? - .insert(subject, agent); - Ok(persisted) - } -} - -impl SessionRestoration for OAuthSessionService { - async fn restore_session(&self) -> Result { - self.vault.restore_session().await - } -} - -impl SessionInvalidation for OAuthSessionService { - async fn invalidate_attempt( - &self, - attempt_id: &AuthenticationAttemptId, - epoch: Epoch, - ) -> Result { - if let Ok(mut staged) = self.staged.lock() { - staged.retain(|(_, staged_epoch), _| *staged_epoch != epoch.value()); - } - self.vault.invalidate_attempt(attempt_id, epoch).await - } - - async fn install_logout_tombstone( - &self, - expected_epoch: Epoch, - ) -> Result { - self.clear(); - self.vault.install_logout_tombstone(expected_epoch).await - } - - async fn cleanup_tombstoned_epoch( - &self, - tombstone_epoch: Epoch, - ) -> Result<(), AuthenticationError> { - self.vault.cleanup_tombstoned_epoch(tombstone_epoch).await - } +pub(crate) fn unavailable() -> AuthenticationError { + AuthenticationError::new( + AuthenticationFailureStage::FeedRequest, + AuthenticationErrorCategory::AccessTokenRejected, + AuthenticationFailureReason::AccessTokenRejected, + ) } fn resource_nonce_challenge( @@ -258,110 +141,10 @@ fn resource_nonce_challenge( .filter_map(|value| value.to_str().ok()) .collect::>(); let [nonce] = values.as_slice() else { - return Err(OAuthSessionService::unavailable()); + return Err(unavailable()); }; if nonce.is_empty() || nonce.len() > 512 || !nonce.is_ascii() { - return Err(OAuthSessionService::unavailable()); + return Err(unavailable()); } Ok(Some((*nonce).to_owned())) } - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use atproto_identity::key::{KeyType, generate_key}; - use atrium_xrpc::http::{HeaderMap, HeaderValue, Method}; - - use super::OAuthSessionService; - use crate::infrastructure::transport::{ - OutboundHttp, OutboundRequest, OutboundResponse, TransportError, - }; - - #[derive(Clone)] - struct Responses { - responses: Arc>>, - requests: Arc>>, - } - - impl OutboundHttp for Responses { - async fn send(&self, request: OutboundRequest) -> Result { - self.requests - .lock() - .map_err(|_| TransportError)? - .push(request); - self.responses - .lock() - .map_err(|_| TransportError)? - .pop() - .ok_or(TransportError) - } - } - - fn response( - status: atrium_xrpc::http::StatusCode, - nonce: &str, - body: &[u8], - ) -> OutboundResponse { - let mut headers = HeaderMap::new(); - headers.insert("content-type", HeaderValue::from_static("application/json")); - headers.insert( - "dpop-nonce", - HeaderValue::from_str(nonce).expect("valid nonce"), - ); - OutboundResponse { - status, - headers, - body: body.to_vec(), - } - } - - #[tokio::test] - async fn resource_request_retries_once_for_exact_nonce_challenge() { - let service = OAuthSessionService::default(); - service - .install( - "did:plc:abcdefghijklmnopqrstuvwx".to_owned(), - "access-token".to_owned(), - "https://pds.example/".to_owned(), - generate_key(KeyType::P256Private).expect("P-256 key"), - ) - .expect("installs"); - let client = Responses { - responses: Arc::new(Mutex::new(vec![ - response( - atrium_xrpc::http::StatusCode::OK, - "fresh", - br#"{"feed":[]}"#, - ), - response( - atrium_xrpc::http::StatusCode::UNAUTHORIZED, - "challenge", - br#"{"error":"use_dpop_nonce"}"#, - ), - ])), - requests: Arc::default(), - }; - - let result = service - .send_timeline("did:plc:abcdefghijklmnopqrstuvwx", &client) - .await - .expect("retries once"); - - assert_eq!(result.status, atrium_xrpc::http::StatusCode::OK); - assert!(client.responses.lock().expect("sequence").is_empty()); - let requests = client.requests.lock().expect("requests"); - assert_eq!(requests.len(), 2); - for request in requests.iter() { - assert_eq!(request.method(), &Method::GET); - assert_eq!( - request - .headers() - .get("authorization") - .and_then(|value| value.to_str().ok()), - Some("DPoP access-token") - ); - assert!(request.headers().contains_key("dpop")); - } - } -} diff --git a/src/infrastructure/session_vault.rs b/src/infrastructure/session_vault.rs index 14aaad7..5902c0d 100644 --- a/src/infrastructure/session_vault.rs +++ b/src/infrastructure/session_vault.rs @@ -1,1421 +1,202 @@ -//! OS credential-vault access. Secrets never fall back to filesystem storage. - -use std::collections::BTreeMap; -use std::fmt; -use std::sync::{Arc, Mutex}; - -use crate::authentication::ports::{SessionInvalidation, SessionPersistence, SessionRestoration}; -use crate::authentication::{ - AccountGenerationReference, AttemptInvalidation, AuthenticationAttemptId, AuthenticationError, - AuthenticationErrorCategory, CompletedAuthorization, Did, Epoch, Generation, - PersistedActiveSession, RestorationSnapshot, RestorationStatus, SessionArtifact, - VerifiedActiveSession, +//! One-record macOS Keychain persistence for OAuth refresh credentials. +//! +//! This adapter never provides a fallback store. Access tokens are deliberately +//! absent from [`StoredSessionV1`] and remain process-memory-only. + +use std::sync::Arc; +#[cfg(any(test, feature = "test-support"))] +use std::sync::Mutex; + +#[cfg(target_os = "macos")] +use security_framework::passwords::{ + PasswordOptions, delete_generic_password_options, generic_password, + set_generic_password_options, }; -/// Fixed service namespace for all development session artifacts. -use base64::Engine as _; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use crate::infrastructure::oauth::session::StoredSessionV1; -/// Complete versioned session payload written only as a vault artifact. -#[derive(Clone, Deserialize, Serialize)] -pub struct SessionEnvelopeV1 { - pub schema_version: u8, - pub did: String, - pub generation: u64, - pub epoch: u64, - pub artifact_base64url: String, -} +const KEYCHAIN_SERVICE: &str = "com.nateledford.hitomi.oauth.session.v1"; +const KEYCHAIN_ACCOUNT: &str = "active"; +const LEGACY_KEYCHAIN_SERVICE: &str = "hitomi.oauth.session.v1"; +const ITEM_NOT_FOUND: i32 = -25_300; -impl fmt::Debug for SessionEnvelopeV1 { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("SessionEnvelopeV1()") - } +/// Opaque failure from the OS credential service or a malformed existing record. +/// Security.framework detail is intentionally not retained because it can expose +/// local Keychain state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CredentialVaultError { + Unavailable, + Corrupt, } -impl SessionEnvelopeV1 { - pub const SCHEMA_VERSION: u8 = 1; - pub const MAX_ENVELOPE_BYTES: usize = 98_304; - - fn validate_metadata(&self) -> Result<(), AuthenticationError> { - if self.schema_version != Self::SCHEMA_VERSION - || self.did.trim().is_empty() - || self.artifact_base64url.is_empty() - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - )); - } - Ok(()) - } - - pub fn encode(&self) -> Result, AuthenticationError> { - self.validate_metadata()?; - let json = serde_json::to_vec(self).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - ) - })?; - if json.len() > Self::MAX_ENVELOPE_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - )); - } - Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(json) - .into_bytes()) - } - - pub fn decode(encoded: &[u8]) -> Result { - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(encoded) - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - ) - })?; - if bytes.len() > Self::MAX_ENVELOPE_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - )); - } - let envelope: Self = serde_json::from_slice(&bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - ) - })?; - envelope.validate_metadata()?; - Ok(envelope) - } - - fn artifact(&self) -> Result { - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&self.artifact_base64url) - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - ) - })?; - SessionArtifact::new(bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - ) - }) - } - - #[must_use] - pub fn sha256(encoded: &[u8]) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(encoded)) - } +/// The sole durable credential boundary. All production invocations happen on a +pub(crate) trait CredentialVault: Clone + Send + Sync + 'static { + fn load(&self) -> Result, CredentialVaultError>; + fn replace(&self, value: &StoredSessionV1) -> Result<(), CredentialVaultError>; + fn delete(&self) -> Result<(), CredentialVaultError>; + fn purge_legacy(&self) -> Result<(), CredentialVaultError>; } -pub const SERVICE_NAME: &str = "hitomi.oauth.session.v1"; - -pub const CHUNK_BYTES: usize = 1_536; -pub const MAX_CHUNKS: usize = 64; -pub const MAX_CATALOG_ARTIFACTS: usize = 64; -pub const MAX_METADATA_BYTES: usize = 2_048; -const MAX_ENCODED_BYTES: usize = MAX_CHUNKS * CHUNK_BYTES; -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CatalogV1 { - pub schema_version: u8, - pub revision: u64, - pub artifacts: Vec, +/// Direct, non-synchronizing generic-password Keychain item. +#[derive(Clone, Debug)] +pub(crate) struct MacOsKeychainVault { + service: Arc, + account: Arc, } -#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub enum DurablePointerV1 { - Active { - did: String, - generation: u64, - epoch: u64, - }, - RefreshInFlight { - did: String, - generation: u64, - epoch: u64, - operation_id: String, - }, - RefreshInDoubt { - did: String, - generation: u64, - epoch: u64, - operation_id: String, - }, - Tombstone { - epoch: u64, - }, -} - -impl DurablePointerV1 { - fn decode(bytes: &[u8]) -> Result { - if bytes.len() > MAX_METADATA_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - serde_json::from_slice(bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - }) +impl MacOsKeychainVault { + #[must_use] + pub(crate) fn new() -> Self { + Self::for_service_account(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) } -} -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct SessionManifestV1 { - pub schema_version: u8, - pub did: String, - pub generation: u64, - pub epoch: u64, - - pub chunk_count: u8, - pub encoded_len: usize, - pub sha256: String, -} -impl CatalogV1 { - const SCHEMA_VERSION: u8 = 1; - - fn empty() -> Self { + fn for_service_account(service: impl Into, account: impl Into) -> Self { Self { - schema_version: Self::SCHEMA_VERSION, - revision: 0, - artifacts: Vec::new(), - } - } - - fn decode(bytes: &[u8]) -> Result { - if bytes.len() > MAX_METADATA_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); + service: service.into().into(), + account: account.into().into(), } - let catalog: Self = serde_json::from_slice(bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - if catalog.schema_version != Self::SCHEMA_VERSION - || catalog.artifacts.len() > MAX_CATALOG_ARTIFACTS - || catalog.artifacts.iter().any(String::is_empty) - || { - let mut unique = catalog.artifacts.clone(); - unique.sort_unstable(); - unique.windows(2).any(|pair| pair[0] == pair[1]) - } - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - Ok(catalog) } - fn encode(&self) -> Result, AuthenticationError> { - let bytes = serde_json::to_vec(self).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - if bytes.len() > MAX_METADATA_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - Ok(bytes) + #[cfg(target_os = "macos")] + fn options(&self) -> PasswordOptions { + let mut options = PasswordOptions::new_generic_password(&self.service, &self.account); + options.set_access_synchronized(Some(false)); + options } } -fn digest_name(value: &str) -> String { - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(Sha256::digest(value.as_bytes())) -} - -pub fn generation_manifest_key(did: &str, epoch: u64, generation: u64) -> String { - format!( - "generation-{}-{epoch}-{generation}-manifest", - digest_name(did) - ) -} - -pub fn generation_chunk_key(did: &str, epoch: u64, generation: u64, index: u8) -> String { - format!( - "generation-{}-{epoch}-{generation}-chunk-{index:02}", - digest_name(did) - ) -} - -/// Blocking secret-store operations kept behind the vault adapter. -pub trait SecretStore: Send + Sync + 'static { - fn get(&self, user: &str) -> Result>, AuthenticationError>; - - fn set(&self, user: &str, bytes: &[u8]) -> Result<(), AuthenticationError>; - fn delete(&self, user: &str) -> Result<(), AuthenticationError>; -} - -/// Platform credential-store implementation using the fixed namespace. -#[derive(Clone, Copy, Debug, Default)] -pub struct KeyringSecretStore; - -impl KeyringSecretStore { - fn entry(user: &str) -> Result { - keyring::Entry::new(SERVICE_NAME, user).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - }) +impl Default for MacOsKeychainVault { + fn default() -> Self { + Self::new() } } -impl SecretStore for KeyringSecretStore { - fn get(&self, user: &str) -> Result>, AuthenticationError> { - match Self::entry(user)?.get_secret() { - Ok(bytes) => Ok(Some(bytes)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(_) => Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), +#[cfg(target_os = "macos")] +impl CredentialVault for MacOsKeychainVault { + fn load(&self) -> Result, CredentialVaultError> { + match generic_password(self.options()) { + Ok(bytes) => StoredSessionV1::decode(&bytes) + .map(Some) + .map_err(|_| CredentialVaultError::Corrupt), + Err(error) if error.code() == ITEM_NOT_FOUND => Ok(None), + Err(_) => Err(CredentialVaultError::Unavailable), } } - fn set(&self, user: &str, bytes: &[u8]) -> Result<(), AuthenticationError> { - Self::entry(user)?.set_secret(bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - }) + fn replace(&self, value: &StoredSessionV1) -> Result<(), CredentialVaultError> { + let bytes = value.encode().map_err(|_| CredentialVaultError::Corrupt)?; + set_generic_password_options(&bytes, self.options()) + .map_err(|_| CredentialVaultError::Unavailable) } - fn delete(&self, user: &str) -> Result<(), AuthenticationError> { - match Self::entry(user)?.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(_) => Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), + fn delete(&self) -> Result<(), CredentialVaultError> { + match delete_generic_password_options(self.options()) { + Ok(()) => Ok(()), + Err(error) if error.code() == ITEM_NOT_FOUND => Ok(()), + Err(_) => Err(CredentialVaultError::Unavailable), } } -} - -/// Deterministic memory-only store for contract tests. -#[derive(Clone, Default)] -pub struct MemorySecretStore(Arc>>>); - -impl SecretStore for MemorySecretStore { - fn get(&self, user: &str) -> Result>, AuthenticationError> { - Ok(self - .0 - .lock() - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? - .get(user) - .cloned()) - } - - fn set(&self, user: &str, bytes: &[u8]) -> Result<(), AuthenticationError> { - self.0 - .lock() - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? - .insert(user.to_owned(), bytes.to_vec()); - Ok(()) - } - - fn delete(&self, user: &str) -> Result<(), AuthenticationError> { - self.0 - .lock() - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? - .remove(user); - Ok(()) - } -} -/// Async façade that moves every potentially blocking credential operation off -/// the GUI and Tokio runtime worker threads and serializes commits. -#[derive(Clone)] -pub struct SessionVault { - store: Arc, - commit: Arc>, - promotion: Arc>, -} - -impl SessionVault { - #[must_use] - pub fn new(store: T) -> Self { - Self { - store: Arc::new(store), - - commit: Arc::new(tokio::sync::Mutex::new(())), - promotion: Arc::new(tokio::sync::Mutex::new(())), + fn purge_legacy(&self) -> Result<(), CredentialVaultError> { + let mut search = security_framework::item::ItemSearchOptions::new(); + search + .class(security_framework::item::ItemClass::generic_password()) + .service(LEGACY_KEYCHAIN_SERVICE); + match search.delete() { + Ok(()) => Ok(()), + Err(error) if error.code() == ITEM_NOT_FOUND => Ok(()), + Err(_) => Err(CredentialVaultError::Unavailable), } } +} - pub async fn get(&self, user: String) -> Result>, AuthenticationError> { - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.get(&user)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? - } - - pub async fn commit(&self, user: String, bytes: Vec) -> Result<(), AuthenticationError> { - let _guard = self.commit.lock().await; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.set(&user, &bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? - } - - pub async fn delete(&self, user: String) -> Result<(), AuthenticationError> { - let _guard = self.commit.lock().await; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.delete(&user)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? - } - - pub async fn store_generation( - &self, - envelope: &SessionEnvelopeV1, - ) -> Result { - let encoded = envelope.encode()?; - let chunk_count = encoded.len().div_ceil(CHUNK_BYTES); - if chunk_count == 0 || chunk_count > MAX_CHUNKS { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let _guard = self.commit.lock().await; - let store = Arc::clone(&self.store); - let catalog_bytes = tokio::task::spawn_blocking(move || store.get("catalog")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - let mut catalog = match catalog_bytes { - Some(bytes) => CatalogV1::decode(&bytes)?, - None => { - let catalog = CatalogV1::empty(); - let bytes = catalog.encode()?; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.set("catalog", &bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - catalog - } - }; - let did = envelope.did.clone(); - let epoch = envelope.epoch; - let generation = envelope.generation; - let manifest = SessionManifestV1 { - schema_version: 1, - did, - generation, - epoch, - chunk_count: chunk_count as u8, - encoded_len: encoded.len(), - sha256: SessionEnvelopeV1::sha256(&encoded), - }; - let metadata = serde_json::to_vec(&manifest).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - if metadata.len() > MAX_METADATA_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let manifest_key = - generation_manifest_key(&manifest.did, manifest.epoch, manifest.generation); - let mut artifact_keys = (0..manifest.chunk_count) - .map(|index| { - generation_chunk_key(&manifest.did, manifest.epoch, manifest.generation, index) - }) - .collect::>(); - artifact_keys.push(manifest_key); - let existing_keys = catalog - .artifacts - .iter() - .collect::>(); - let new_keys = artifact_keys - .iter() - .filter(|key| !existing_keys.contains(key)) - .collect::>(); - if catalog.artifacts.len() + new_keys.len() > MAX_CATALOG_ARTIFACTS { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - catalog.artifacts.extend(new_keys.into_iter().cloned()); - catalog.revision = catalog - .revision - .checked_add(1) - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - let catalog_bytes = catalog.encode()?; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.set("catalog", &catalog_bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - for (index, chunk) in encoded.chunks(CHUNK_BYTES).enumerate() { - let store = Arc::clone(&self.store); - let key = generation_chunk_key( - &manifest.did, - manifest.epoch, - manifest.generation, - index as u8, - ); - let bytes = chunk.to_vec(); - tokio::task::spawn_blocking(move || store.set(&key, &bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - } - let store = Arc::clone(&self.store); - let key = generation_manifest_key(&manifest.did, manifest.epoch, manifest.generation); - tokio::task::spawn_blocking(move || store.set(&key, &metadata)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - Ok(manifest) - } - - pub async fn read_generation( - &self, - did: String, - epoch: u64, - generation: u64, - ) -> Result { - let _guard = self.commit.lock().await; - self.read_generation_unlocked(did, epoch, generation).await - } - - /// Loads a session only if the durable active pointer remains at `active`. - pub(crate) async fn load_active_at( - &self, - active: &AccountGenerationReference, - ) -> Result { - let _guard = self.commit.lock().await; - let expected = DurablePointerV1::Active { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - }; - if self.read_pointer_unlocked().await? != expected { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let envelope = self - .read_generation_unlocked( - active.account().did().as_str().to_owned(), - active.epoch().value(), - active.generation().value(), - ) - .await?; - if self.read_pointer_unlocked().await? != expected { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - envelope.artifact() - } - - /// Durably arms one exact refresh before any refresh-token request may be sent. - pub(crate) async fn begin_refresh( - &self, - active: &AccountGenerationReference, - operation_id: &AuthenticationAttemptId, - ) -> Result { - let _guard = self.commit.lock().await; - let expected = DurablePointerV1::Active { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - }; - if self.read_pointer_unlocked().await? != expected { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let envelope = self - .read_generation_unlocked( - active.account().did().as_str().to_owned(), - active.epoch().value(), - active.generation().value(), - ) - .await?; - self.write_pointer_unlocked(DurablePointerV1::RefreshInFlight { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - operation_id: operation_id.as_str().to_owned(), - }) - .await?; - envelope.artifact() - } - - /// Restores the original active pointer only when the exact refresh marker - /// proves that the refresh grant was never dispatched. - pub(crate) async fn rollback_refresh( - &self, - active: &AccountGenerationReference, - operation_id: &AuthenticationAttemptId, - ) -> Result { - let _guard = self.commit.lock().await; - let marker = DurablePointerV1::RefreshInFlight { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - operation_id: operation_id.as_str().to_owned(), - }; - if self.read_pointer_unlocked().await? != marker { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - self.write_pointer_unlocked(DurablePointerV1::Active { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - }) - .await?; - Ok(VerifiedActiveSession::from_verified_reference( - active.clone(), - )) +#[cfg(not(target_os = "macos"))] +impl CredentialVault for MacOsKeychainVault { + fn load(&self) -> Result, CredentialVaultError> { + Err(CredentialVaultError::Unavailable) } - - /// Converts only an exact in-flight marker into its restart-safe blocked state. - pub(crate) async fn mark_refresh_in_doubt( - &self, - active: &AccountGenerationReference, - operation_id: &AuthenticationAttemptId, - ) -> Result<(), AuthenticationError> { - let _guard = self.commit.lock().await; - let marker = DurablePointerV1::RefreshInFlight { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - operation_id: operation_id.as_str().to_owned(), - }; - if self.read_pointer_unlocked().await? != marker { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - self.write_pointer_unlocked(DurablePointerV1::RefreshInDoubt { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - operation_id: operation_id.as_str().to_owned(), - }) - .await + fn replace(&self, _: &StoredSessionV1) -> Result<(), CredentialVaultError> { + Err(CredentialVaultError::Unavailable) } - - async fn write_pointer_unlocked( - &self, - pointer: DurablePointerV1, - ) -> Result<(), AuthenticationError> { - let bytes = serde_json::to_vec(&pointer).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.set("pointer", &bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? + fn delete(&self) -> Result<(), CredentialVaultError> { + Err(CredentialVaultError::Unavailable) } - - /// Persists and promotes one replacement only while its exact refresh marker - /// remains current. Artifacts written before a lost CAS are unreachable and - /// therefore safe for later catalog cleanup. - pub(crate) async fn commit_refresh_replacement( - &self, - active: &AccountGenerationReference, - operation_id: &AuthenticationAttemptId, - replacement: SessionArtifact, - ) -> Result { - let marker = DurablePointerV1::RefreshInFlight { - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - operation_id: operation_id.as_str().to_owned(), - }; - { - let _guard = self.commit.lock().await; - if self.read_pointer_unlocked().await? != marker { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - } - let generation = - active - .generation() - .value() - .checked_add(1) - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::SessionRefresh, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::SessionArtifactInvalid, - ))?; - self.store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: active.account().did().as_str().to_owned(), - generation, - epoch: active.epoch().value(), - artifact_base64url: base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(replacement.as_bytes()), - }) - .await?; - let replacement_reference = AccountGenerationReference::new( - active.account().clone(), - Generation::new(generation), - active.epoch(), - ); - let _guard = self.commit.lock().await; - if self.read_pointer_unlocked().await? != marker { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - self.write_pointer_unlocked(DurablePointerV1::Active { - did: active.account().did().as_str().to_owned(), - generation, - epoch: active.epoch().value(), - }) - .await?; - Ok(VerifiedActiveSession::from_verified_reference( - replacement_reference, - )) - } - - async fn read_pointer_unlocked(&self) -> Result { - let store = Arc::clone(&self.store); - let bytes = tokio::task::spawn_blocking(move || store.get("pointer")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?? - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - DurablePointerV1::decode(&bytes) - } - - async fn read_generation_unlocked( - &self, - did: String, - epoch: u64, - generation: u64, - ) -> Result { - let manifest_key = generation_manifest_key(&did, epoch, generation); - let store = Arc::clone(&self.store); - let manifest_bytes = tokio::task::spawn_blocking(move || store.get(&manifest_key)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?? - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - if manifest_bytes.len() > MAX_METADATA_BYTES { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let manifest: SessionManifestV1 = - serde_json::from_slice(&manifest_bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let Some(chunk_capacity) = usize::from(manifest.chunk_count).checked_mul(CHUNK_BYTES) - else { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - }; - if manifest.schema_version != 1 - || manifest.did != did - || manifest.epoch != epoch - || manifest.generation != generation - || manifest.chunk_count == 0 - || usize::from(manifest.chunk_count) > MAX_CHUNKS - || manifest.encoded_len > MAX_ENCODED_BYTES - || manifest.encoded_len > chunk_capacity - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let mut encoded = Vec::with_capacity(manifest.encoded_len); - for index in 0..manifest.chunk_count { - let store = Arc::clone(&self.store); - let key = generation_chunk_key(&manifest.did, epoch, generation, index); - let chunk = tokio::task::spawn_blocking(move || store.get(&key)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?? - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - if chunk.len() > CHUNK_BYTES - || chunk.len() > manifest.encoded_len.saturating_sub(encoded.len()) - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - encoded.extend_from_slice(&chunk); - } - if encoded.len() != manifest.encoded_len - || SessionEnvelopeV1::sha256(&encoded) != manifest.sha256 - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - SessionEnvelopeV1::decode(&encoded) + fn purge_legacy(&self) -> Result<(), CredentialVaultError> { + Err(CredentialVaultError::Unavailable) } +} - pub async fn promote_active( - &self, - did: String, - generation: u64, - expected_epoch: u64, - ) -> Result { - let _guard = self.commit.lock().await; - let store = Arc::clone(&self.store); - let current = tokio::task::spawn_blocking(move || store.get("pointer")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - if let Some(bytes) = current.as_deref() { - let pointer = DurablePointerV1::decode(bytes)?; - let epoch = match pointer { - DurablePointerV1::Active { epoch, .. } - | DurablePointerV1::RefreshInFlight { epoch, .. } - | DurablePointerV1::RefreshInDoubt { epoch, .. } - | DurablePointerV1::Tombstone { epoch } => epoch, - }; - if epoch != expected_epoch { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - } - let pointer = DurablePointerV1::Active { - did, - generation, - epoch: expected_epoch, - }; - let bytes = serde_json::to_vec(&pointer).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.set("pointer", &bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - Ok(pointer) - } +#[cfg(any(test, feature = "test-support"))] +#[derive(Clone, Default)] +pub struct MemoryCredentialVault(Arc>>); - pub async fn install_tombstone( - &self, - expected_epoch: u64, - ) -> Result { - let _guard = self.commit.lock().await; - let store = Arc::clone(&self.store); - let previous = tokio::task::spawn_blocking(move || store.get("pointer")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - if let Some(bytes) = previous.as_deref() { - let pointer: DurablePointerV1 = serde_json::from_slice(bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let observed_epoch = match pointer { - DurablePointerV1::Active { epoch, .. } - | DurablePointerV1::RefreshInFlight { epoch, .. } - | DurablePointerV1::RefreshInDoubt { epoch, .. } - | DurablePointerV1::Tombstone { epoch } => epoch, - }; - if observed_epoch != expected_epoch { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - } - let next_epoch = expected_epoch - .checked_add(1) - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - let pointer = DurablePointerV1::Tombstone { epoch: next_epoch }; - let bytes = serde_json::to_vec(&pointer).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - let store = Arc::clone(&self.store); - tokio::task::spawn_blocking(move || store.set("pointer", &bytes)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })??; - Ok(pointer) +#[cfg(any(test, feature = "test-support"))] +impl MemoryCredentialVault { + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.lock().is_ok_and(|record| record.is_none()) } - pub async fn cleanup_tombstone(&self, epoch: u64) -> Result<(), AuthenticationError> { - let _guard = self.commit.lock().await; - let store = Arc::clone(&self.store); - let pointer_bytes = tokio::task::spawn_blocking(move || store.get("pointer")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?? - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - let pointer: DurablePointerV1 = serde_json::from_slice(&pointer_bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - if pointer != (DurablePointerV1::Tombstone { epoch }) { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let store = Arc::clone(&self.store); - let catalog = tokio::task::spawn_blocking(move || store.get("catalog")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::SessionCleanupIncomplete, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?? - .map(|bytes| CatalogV1::decode(&bytes)) - .transpose()? - .unwrap_or_else(CatalogV1::empty); - for artifact in catalog.artifacts { - let store = Arc::clone(&self.store); - let deleted = tokio::task::spawn_blocking(move || store.delete(&artifact)) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::SessionCleanupIncomplete, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - deleted.map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::SessionCleanupIncomplete, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; + pub fn delete_for_testing(&self) { + if let Ok(mut record) = self.0.lock() { + *record = None; } - let store = Arc::clone(&self.store); - let deleted = tokio::task::spawn_blocking(move || store.delete("catalog")) - .await - .map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::SessionCleanupIncomplete, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - deleted.map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::SessionCleanupIncomplete, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })?; - Ok(()) } -} -impl SessionPersistence for SessionVault { - async fn persist_completed_authorization( - &self, - completed: CompletedAuthorization, - ) -> Result { - let epoch = completed.epoch(); - let _promotion_guard = self.promotion.lock().await; - let current = self.get("pointer".to_owned()).await?; - let generation = match current { - None => 1, - Some(bytes) => { - match serde_json::from_slice::(&bytes).map_err(|_| { - AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ) - })? { - DurablePointerV1::Active { - generation, - epoch: observed, - .. - } - | DurablePointerV1::RefreshInFlight { - generation, - epoch: observed, - .. - } - | DurablePointerV1::RefreshInDoubt { - generation, - epoch: observed, - .. - } if observed == epoch.value() => { - generation.checked_add(1).ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))? - } - DurablePointerV1::Tombstone { epoch: observed } - if observed == epoch.value() => - { - 1 - } - _ => { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - } - } - }; - let did = completed.account().did().as_str().to_owned(); - self.store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: did.clone(), - generation, - epoch: epoch.value(), - artifact_base64url: base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(completed.artifact().as_bytes()), - }) - .await?; - self.promote_active(did, generation, epoch.value()).await?; - let reference = AccountGenerationReference::new( - completed.account().clone(), - Generation::new(generation), - Epoch::new(epoch.value()), - ); - Ok(PersistedActiveSession::committed( - VerifiedActiveSession::from_verified_reference(reference), - )) - } + pub fn purge_legacy_for_testing(&self) {} } -impl SessionInvalidation for SessionVault { - async fn invalidate_attempt( - &self, - _attempt_id: &AuthenticationAttemptId, - _epoch: Epoch, - ) -> Result { - // No attempt record is durable in this vault schema. Returning a - // promoted session without exact durable attempt correlation would let - // a stale callback claim another attempt's active generation. - Ok(AttemptInvalidation::Invalidated) +#[cfg(any(test, feature = "test-support"))] +impl CredentialVault for MemoryCredentialVault { + fn load(&self) -> Result, CredentialVaultError> { + self.0 + .lock() + .map(|record| record.clone()) + .map_err(|_| CredentialVaultError::Unavailable) } - async fn install_logout_tombstone( - &self, - expected_epoch: Epoch, - ) -> Result { - match self.install_tombstone(expected_epoch.value()).await? { - DurablePointerV1::Tombstone { epoch } => Ok(Epoch::new(epoch)), - _ => Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )), - } + fn replace(&self, value: &StoredSessionV1) -> Result<(), CredentialVaultError> { + *self + .0 + .lock() + .map_err(|_| CredentialVaultError::Unavailable)? = Some(value.clone()); + Ok(()) } - async fn cleanup_tombstoned_epoch( - &self, - tombstone_epoch: Epoch, - ) -> Result<(), AuthenticationError> { - self.cleanup_tombstone(tombstone_epoch.value()).await + fn delete(&self) -> Result<(), CredentialVaultError> { + *self + .0 + .lock() + .map_err(|_| CredentialVaultError::Unavailable)? = None; + Ok(()) } -} -impl SessionRestoration for SessionVault { - async fn restore_session(&self) -> Result { - let _guard = self.commit.lock().await; - let Some(bytes) = self.get("pointer".to_owned()).await? else { - return Ok(RestorationSnapshot::new( - Epoch::new(0), - RestorationStatus::Unavailable, - )); - }; - let pointer = DurablePointerV1::decode(&bytes)?; - match pointer.clone() { - DurablePointerV1::Active { - did, - generation, - epoch, - } => { - let envelope = self - .read_generation_unlocked(did.clone(), epoch, generation) - .await?; - if envelope.did != did - || envelope.generation != generation - || envelope.epoch != epoch - { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialCorrupt, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let final_pointer = - self.get("pointer".to_owned()) - .await? - .ok_or(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - ))?; - let final_pointer = DurablePointerV1::decode(&final_pointer)?; - if final_pointer != pointer { - return Err(AuthenticationError::new( - crate::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - crate::authentication::AuthenticationFailureReason::StateRejected, - )); - } - let reference = AccountGenerationReference::new( - Did::new(did)?.into_authenticated(), - Generation::new(generation), - Epoch::new(epoch), - ); - Ok(RestorationSnapshot::new( - Epoch::new(epoch), - RestorationStatus::restored(VerifiedActiveSession::from_verified_reference( - reference, - )), - )) - } - DurablePointerV1::RefreshInDoubt { - did, - generation, - epoch, - .. - } - | DurablePointerV1::RefreshInFlight { - did, - generation, - epoch, - .. - } => { - let reference = AccountGenerationReference::new( - Did::new(did)?.into_authenticated(), - Generation::new(generation), - Epoch::new(epoch), - ); - let recovery = VerifiedActiveSession::from_verified_reference(reference) - .into_refresh_recovery(); - Ok(RestorationSnapshot::new( - Epoch::new(epoch), - RestorationStatus::refresh_recovery_required(recovery), - )) - } - DurablePointerV1::Tombstone { epoch } => Ok(RestorationSnapshot::new( - Epoch::new(epoch), - RestorationStatus::Unavailable, - )), - } + fn purge_legacy(&self) -> Result<(), CredentialVaultError> { + Ok(()) } } #[cfg(test)] mod tests { - use super::*; - use crate::authentication::{ - AccountGenerationReference, AuthenticationAttemptId, Did, Epoch, Generation, - }; - - fn reference(generation: u64, epoch: u64) -> AccountGenerationReference { - AccountGenerationReference::new( - Did::new("did:plc:z72i7hdynmk6r22z27h6tvur") - .expect("static DID") - .into_authenticated(), - Generation::new(generation), - Epoch::new(epoch), - ) - } - - #[tokio::test] - async fn concurrent_refreshes_arm_exactly_one_durable_operation() { - let vault = SessionVault::new(MemorySecretStore::default()); - let active = reference(4, 9); - vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: active.account().did().as_str().to_owned(), - generation: active.generation().value(), - epoch: active.epoch().value(), - artifact_base64url: "AQ".to_owned(), - }) - .await - .expect("generation stores"); - vault - .promote_active( - active.account().did().as_str().to_owned(), - active.generation().value(), - active.epoch().value(), - ) - .await - .expect("active pointer stores"); - let first = AuthenticationAttemptId::new("refresh-first").expect("valid attempt"); - let second = AuthenticationAttemptId::new("refresh-second").expect("valid attempt"); - - let (first, second) = tokio::join!( - vault.begin_refresh(&active, &first), - vault.begin_refresh(&active, &second) - ); - - assert_eq!(usize::from(first.is_ok()) + usize::from(second.is_ok()), 1); - } - - #[tokio::test] - async fn active_load_fails_closed_for_every_mismatched_coordinate() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .promote_active("did:plc:z72i7hdynmk6r22z27h6tvur".into(), 4, 9) - .await - .expect("pointer stores"); - - assert!(vault.load_active_at(&reference(3, 9)).await.is_err()); - assert!(vault.load_active_at(&reference(4, 8)).await.is_err()); - } + use super::{CredentialVault, MemoryCredentialVault}; + use crate::infrastructure::oauth::session::StoredSessionV1; - #[tokio::test] - async fn active_load_fails_closed_for_tombstones() { - let vault = SessionVault::new(MemorySecretStore::default()); + #[test] + fn memory_vault_replaces_and_deletes_its_one_record() { + let vault = MemoryCredentialVault::default(); + assert!(vault.load().expect("load").is_none()); vault - .promote_active("did:plc:z72i7hdynmk6r22z27h6tvur".into(), 4, 9) - .await - .expect("pointer stores"); - vault.install_tombstone(9).await.expect("tombstone stores"); - assert!(vault.load_active_at(&reference(4, 9)).await.is_err()); + .replace(&StoredSessionV1::tombstone(4)) + .expect("replace"); + assert!(vault.load().expect("load").is_some()); + vault.delete().expect("delete"); + assert!(vault.load().expect("load").is_none()); + } + + #[test] + fn memory_legacy_cleanup_is_idempotent() { + let vault = MemoryCredentialVault::default(); + vault.purge_legacy().expect("first cleanup"); + vault.purge_legacy().expect("second cleanup"); } } diff --git a/src/infrastructure/transport.rs b/src/infrastructure/transport.rs index c5202a0..813193e 100644 --- a/src/infrastructure/transport.rs +++ b/src/infrastructure/transport.rs @@ -49,7 +49,7 @@ impl OutboundRequest { max_response_bytes: usize, ) -> Result { if max_response_bytes == 0 || body.len() > MAX_RESPONSE_BODY_BYTES { - return Err(TransportError); + return Err(TransportError::not_dispatched()); } Ok(Self { method, @@ -67,16 +67,6 @@ impl OutboundRequest { self.allow_credential_free_redirects = true; self } - - #[cfg(test)] - pub(crate) const fn method(&self) -> &Method { - &self.method - } - - #[cfg(test)] - pub(crate) const fn headers(&self) -> &HeaderMap { - &self.headers - } } /// A bounded response from Hitomi's outbound HTTP boundary. @@ -86,9 +76,26 @@ pub(crate) struct OutboundResponse { pub(crate) body: Vec, } -/// Sanitized failure from the hardened outbound HTTP boundary. -#[derive(Debug)] -pub(crate) struct TransportError; +/// Sanitized dispatch outcome from the hardened outbound HTTP boundary. +/// +/// `NotDispatched` proves no request reached the remote peer. Every error after +/// an attempt that may have reached the peer is deliberately `OutcomeUnknown`, +/// so credential rotation code never reuses a potentially consumed grant. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TransportError { + NotDispatched, + OutcomeUnknown, +} + +impl TransportError { + pub(crate) const fn not_dispatched() -> Self { + Self::NotDispatched + } + + pub(crate) const fn outcome_unknown() -> Self { + Self::OutcomeUnknown + } +} impl fmt::Display for TransportError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -107,6 +114,81 @@ pub(crate) trait OutboundHttp: Clone + Send + Sync + 'static { ) -> impl Future> + Send; } +#[cfg(any(test, feature = "test-support"))] +pub(crate) mod test_support { + use std::collections::VecDeque; + use std::sync::{Arc, Mutex}; + + use super::{OutboundHttp, OutboundRequest, OutboundResponse, TransportError}; + + /// Deterministic outbound fixture shared by OAuth and feed tests. + #[derive(Clone)] + pub(crate) struct ScriptedOutboundHttp { + responses: Arc>>, + requests: Arc>>, + } + + #[derive(Clone)] + pub(crate) struct CapturedOutboundRequest { + pub(crate) method: atrium_xrpc::http::Method, + pub(crate) url: String, + pub(crate) headers: atrium_xrpc::http::HeaderMap, + pub(crate) body: Vec, + } + + pub(crate) fn response(response: atrium_xrpc::http::Response>) -> OutboundResponse { + let (parts, body) = response.into_parts(); + OutboundResponse { + status: parts.status, + headers: parts.headers, + body, + } + } + + impl ScriptedOutboundHttp { + pub(crate) fn new(responses: impl IntoIterator) -> Self { + Self { + responses: Arc::new(Mutex::new(responses.into_iter().collect())), + requests: Arc::new(Mutex::new(Vec::new())), + } + } + + pub(crate) fn request_count(&self) -> usize { + self.requests.lock().map_or(0, |requests| requests.len()) + } + + pub(crate) fn requests(&self) -> Vec { + self.requests + .lock() + .map_or_else(|_| Vec::new(), |requests| requests.clone()) + } + + pub(crate) fn remaining_response_count(&self) -> usize { + self.responses.lock().map_or(0, |responses| responses.len()) + } + } + + impl OutboundHttp for ScriptedOutboundHttp { + async fn send(&self, request: OutboundRequest) -> Result { + let captured = CapturedOutboundRequest { + method: request.method, + url: request.url.as_url().to_string(), + headers: request.headers, + body: request.body, + }; + self.requests + .lock() + .map_err(|_| TransportError::outcome_unknown())? + .push(captured); + self.responses + .lock() + .map_err(|_| TransportError::outcome_unknown())? + .pop_front() + .ok_or_else(TransportError::not_dispatched) + } + } +} + fn is_public_address(address: IpAddr) -> bool { let address = match address { IpAddr::V6(address) => address @@ -393,33 +475,36 @@ impl OutboundHttp for HardenedHttpClient { max_response_bytes, } = request; if self.get_only && method != Method::GET { - return Err(TransportError); + return Err(TransportError::not_dispatched()); } let mut request = hyper::Request::builder() .method(method) .uri(url.as_url().as_str()) .body(Full::new(Bytes::from(body))) - .map_err(|_| TransportError)?; + .map_err(|_| TransportError::not_dispatched())?; *request.headers_mut() = headers; - enforce_identity_request_encoding(request.headers_mut()).map_err(|_| TransportError)?; - let response = self - .client - .request(request) - .await - .map_err(|_| TransportError)?; + enforce_identity_request_encoding(request.headers_mut()) + .map_err(|_| TransportError::not_dispatched())?; + let response = self.client.request(request).await.map_err(|error| { + if error.is_connect() { + TransportError::not_dispatched() + } else { + TransportError::outcome_unknown() + } + })?; if !allow_credential_free_redirects && !accepts_response_headers(response.status(), response.headers()) { - return Err(TransportError); + return Err(TransportError::outcome_unknown()); } let (parts, mut body) = response.into_parts(); let mut bytes = Vec::new(); while let Some(frame) = body.frame().await { - let frame = frame.map_err(|_| TransportError)?; + let frame = frame.map_err(|_| TransportError::outcome_unknown())?; if let Some(data) = frame.data_ref() { let remaining = max_response_bytes.saturating_sub(bytes.len()); if data.len() > remaining { - return Err(TransportError); + return Err(TransportError::outcome_unknown()); } bytes.extend_from_slice(data); } @@ -440,29 +525,33 @@ impl HttpClient for HardenedHttpClient { let (parts, body) = request.into_parts(); let request = OutboundRequest::new( parts.method, - RemoteHttpsUrl::parse(&parts.uri.to_string()).map_err(|_| TransportError)?, + RemoteHttpsUrl::parse(&parts.uri.to_string()) + .map_err(|_| TransportError::not_dispatched())?, parts.headers, body, MAX_RESPONSE_BODY_BYTES, )?; let response = self.send(request).await?; let mut builder = XrpcResponse::builder().status(response.status); - let headers = builder.headers_mut().ok_or(TransportError)?; + let headers = builder + .headers_mut() + .ok_or_else(TransportError::outcome_unknown)?; *headers = response.headers; - builder - .body(response.body) - .map_err(|_| Box::new(TransportError) as _) + builder.body(response.body).map_err(|_| { + Box::new(TransportError::outcome_unknown()) + as Box + }) } } #[cfg(test)] mod tests { use super::{ - HardenedHttpClient, accepts_response_headers, authorization_state_from_request, + HardenedHttpClient, OutboundHttp, OutboundRequest, RemoteHttpsUrl, TransportError, + accepts_response_headers, authorization_state_from_request, enforce_identity_request_encoding, is_public_address, }; - use atrium_xrpc::HttpClient; - use hyper::{HeaderMap, StatusCode, header}; + use hyper::{HeaderMap, Method, StatusCode, header}; use std::net::{IpAddr, Ipv6Addr}; #[test] fn rejects_ipv4_mapped_private_and_loopback_addresses() { @@ -515,12 +604,32 @@ mod tests { ); } + #[test] + fn request_policy_rejection_is_not_dispatched() { + let result = OutboundRequest::new( + Method::GET, + RemoteHttpsUrl::parse("https://example.com/").expect("static URL"), + HeaderMap::new(), + vec![0; super::MAX_RESPONSE_BODY_BYTES + 1], + 1, + ); + assert!(matches!(result, Err(TransportError::NotDispatched))); + } + #[tokio::test] async fn feed_transport_rejects_post_before_network_dispatch() { let client = HardenedHttpClient::new_get_only().expect("native certificates available"); - let request = atrium_xrpc::http::Request::post("https://example.com/token") - .body(Vec::new()) - .expect("static request"); - assert!(client.send_http(request).await.is_err()); + let request = OutboundRequest::new( + Method::POST, + RemoteHttpsUrl::parse("https://example.com/token").expect("static URL"), + HeaderMap::new(), + Vec::new(), + 1, + ) + .expect("bounded request"); + assert!(matches!( + client.send(request).await, + Err(TransportError::NotDispatched) + )); } } diff --git a/src/main.rs b/src/main.rs index c705485..b90f029 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,13 @@ fn main() -> iced::Result { - let callback = std::env::args() - .skip(1) - .find(|argument| argument.starts_with("com.nateledford.hitomi:")); let instance = match single_instance::SingleInstance::new("hitomi-oauth-v1") { Ok(instance) => instance, Err(_) => return Ok(()), }; - - if let Some(callback) = callback { - if !instance.is_single() { - let _ = hitomi::infrastructure::callback::forward_native_callback(&callback); - } - return Ok(()); - } - if !instance.is_single() { eprintln!("Hitomi is already running."); return Ok(()); } + hitomi::infrastructure::callback::install_native_callback_bridge(); let _instance = instance; hitomi::run() diff --git a/src/ui.rs b/src/ui.rs index 606c7c5..2aad047 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,8 +6,11 @@ use iced::{ Background, Border, Color, Element, Length, Subscription, Task, Theme, clipboard, font, }; +use iced::futures::SinkExt; + use crate::application::{DesktopAuthentication, SessionRestorationOutcome}; use crate::authentication::AuthenticationError; +use crate::authentication::command::AuthorizationCallbackUrl; mod alerts; #[cfg(feature = "test-support")] @@ -156,6 +159,7 @@ pub(crate) enum Message { Login(login::Message), IconFontLoaded(Result<(), font::Error>), Home(home::Message), + NativeOAuthCallback(AuthorizationCallbackUrl), SpinnerTick(Instant), CopyDiagnostic(&'static str), #[cfg(feature = "test-support")] @@ -233,12 +237,33 @@ fn icon_font_task() -> Task { ) } +fn native_callback_stream() -> impl iced::futures::Stream { + iced::stream::channel(16, async |mut output| { + let mut callbacks = crate::infrastructure::callback::subscribe_native_callbacks(); + loop { + match callbacks.recv().await { + Ok(callback) => { + if output.send(callback).await.is_err() { + return; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, + } + } + }) +} + pub(crate) fn subscription(state: &Hitomi) -> Subscription { - if state.screen.needs_spinner_ticks() { + let spinner = if state.screen.needs_spinner_ticks() { iced::time::every(Duration::from_millis(33)).map(Message::SpinnerTick) } else { Subscription::none() - } + }; + Subscription::batch([ + spinner, + Subscription::run(native_callback_stream).map(Message::NativeOAuthCallback), + ]) } pub(crate) fn update(state: &mut Hitomi, message: Message) -> Task { @@ -317,6 +342,23 @@ pub(crate) fn update(state: &mut Hitomi, message: Message) -> Task { } Message::SignInAgain => Task::none(), Message::CopyDiagnostic(diagnostic) => clipboard::write(diagnostic.to_owned()), + Message::NativeOAuthCallback(callback) => match &mut state.screen { + Screen::Login(login) => { + let (transition, task) = login::update( + login, + &state.authentication, + login::Message::CallbackReceived(callback), + ); + if matches!(transition, login::Transition::Authenticated) { + let (home, task) = home::enter(Arc::clone(&state.authentication)); + state.screen = Screen::Home(home); + task.map(Message::Home) + } else { + task.map(Message::Login) + } + } + _ => Task::none(), + }, Message::Login(message) => match &mut state.screen { Screen::Login(login) => { let (transition, task) = login::update(login, &state.authentication, message); @@ -334,11 +376,16 @@ pub(crate) fn update(state: &mut Hitomi, message: Message) -> Task { Screen::Home(home) => { let (transition, task) = home::update(home, Arc::clone(&state.authentication), message); - if matches!(transition, home::Transition::SessionUnavailable) { - state.screen = Screen::SessionUnavailable; - Task::none() - } else { - task.map(Message::Home) + match transition { + home::Transition::SessionUnavailable => { + state.screen = Screen::SessionUnavailable; + Task::none() + } + home::Transition::SignedOut => { + state.screen = Screen::Login(login::State::default()); + Task::none() + } + home::Transition::Stay => task.map(Message::Home), } } _ => Task::none(), @@ -583,6 +630,20 @@ mod tests { assert!(matches!(state.screen, Screen::SessionUnavailable)); } + #[test] + fn native_callbacks_are_ignored_off_the_login_screen() { + let mut state = restoring(); + let callback = AuthorizationCallbackUrl::parse( + "com.nateledford.hitomi:/oauth/callback?state=canary".to_owned(), + ) + .expect("callback syntax is valid"); + + let task = update(&mut state, Message::NativeOAuthCallback(callback)); + + assert_eq!(task.units(), 0); + assert!(matches!(state.screen, Screen::RestoringSession)); + } + #[test] fn interactive_sign_in_enters_loading_home_with_a_task() { let mut state = restoring(); diff --git a/src/ui/home.rs b/src/ui/home.rs index 2f094be..e15407e 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -9,7 +9,7 @@ use super::{ panel_style, readout_style, skeleton, spinner, }; use crate::application::DesktopAuthentication; -use crate::authentication::{AuthenticationError, AuthenticationErrorCategory}; +use crate::authentication::{AuthenticationError, AuthenticationErrorCategory, LogoutReport}; use crate::feed::{FeedSegment, HomeFeed}; const REFRESHING_SESSION_DIAGNOSTIC: &str = "Hitomi alert\nSeverity: Warning\nTitle: Session expired\nMessage: Your saved session expired. Hitomi is securely refreshing it now."; @@ -19,9 +19,11 @@ const RETRYABLE_ERROR_DIAGNOSTIC: &str = "Hitomi alert\nSeverity: Danger\nTitle: pub(super) enum Phase { Loading, RefreshingSession, + SigningOut, Ready(HomeFeed), Empty, RetryableError, + SignOutFailed, } #[derive(Clone, Debug)] @@ -40,6 +42,11 @@ pub(crate) enum Message { generation: u64, result: Result, }, + SignOut, + SignOutFinished { + generation: u64, + report: LogoutReport, + }, Retry, CopyDiagnostic(&'static str), } @@ -47,6 +54,7 @@ pub(crate) enum Message { pub(super) enum Transition { Stay, SessionUnavailable, + SignedOut, } pub(super) fn enter(authentication: Arc) -> (State, Task) { @@ -72,6 +80,13 @@ fn refresh_task(authentication: Arc, generation: u64) -> ) } +fn sign_out_task(authentication: Arc, generation: u64) -> Task { + Task::perform( + async move { authentication.logout().await }, + move |report| Message::SignOutFinished { generation, report }, + ) +} + #[cfg(feature = "test-support")] pub(super) fn smoke_retryable_error() -> State { State { @@ -94,6 +109,24 @@ pub(super) fn update( message: Message, ) -> (Transition, Task) { match message { + Message::SignOut if !matches!(state.phase, Phase::SigningOut) => { + state.generation = state.generation.wrapping_add(1); + state.phase = Phase::SigningOut; + ( + Transition::Stay, + sign_out_task(authentication, state.generation), + ) + } + Message::SignOut => (Transition::Stay, Task::none()), + Message::SignOutFinished { generation, report } if generation == state.generation => { + if report.local_cleanup.is_ok() { + (Transition::SignedOut, Task::none()) + } else { + state.phase = Phase::SignOutFailed; + (Transition::Stay, Task::none()) + } + } + Message::SignOutFinished { .. } => (Transition::Stay, Task::none()), Message::Loaded { generation, .. } if generation != state.generation => { (Transition::Stay, Task::none()) } @@ -160,7 +193,10 @@ pub(super) fn update( } pub(super) fn needs_spinner_ticks(state: &State) -> bool { - matches!(state.phase, Phase::Loading | Phase::RefreshingSession) + matches!( + state.phase, + Phase::Loading | Phase::RefreshingSession | Phase::SigningOut + ) } pub(super) fn view( @@ -173,8 +209,21 @@ pub(super) fn view( text("Latest from your timeline") .size(16) .color(MUTED_SIGNAL), + button::action( + "Sign out", + button::Spec::new(button::Tone::Neutral).emphasis(button::Emphasis::Outline), + if matches!(state.phase, Phase::SigningOut) { + button::State::Loading { + label: "Signing out…", + } + } else { + button::State::Enabled(Message::SignOut) + }, + icons_ready, + spinner_phase, + ), ] - .spacing(4); + .spacing(8); let body: Element<'_, Message> = match &state.phase { Phase::Loading => column![ alerts::view( @@ -205,6 +254,16 @@ pub(super) fn view( icons_ready, spinner_phase, ), + Phase::SigningOut => loading_readout( + Alert::new( + AlertKind::Info, + "Signing out", + "Removing this account from Hitomi.", + ), + PAPER_SIGNAL, + icons_ready, + spinner_phase, + ), Phase::Empty => readout("Your home feed is quiet right now.").into(), Phase::RetryableError => column![ alerts::view( @@ -228,6 +287,27 @@ pub(super) fn view( ] .spacing(16) .into(), + Phase::SignOutFailed => column![ + alerts::view( + Alert::new( + AlertKind::Danger, + "Sign out incomplete", + "Hitomi could not remove this account locally. Try again.", + ), + None, + icons_ready, + spinner_phase, + ), + button::action( + "Try signing out again", + button::Spec::new(button::Tone::Neutral).emphasis(button::Emphasis::Outline), + button::State::Enabled(Message::SignOut), + icons_ready, + spinner_phase, + ), + ] + .spacing(16) + .into(), Phase::Ready(feed) => { let mut posts = column![].width(Length::Fill); for (index, post) in feed.posts().iter().enumerate() { @@ -304,7 +384,10 @@ fn readout(message: &str) -> iced::widget::Container<'_, Message> { #[cfg(test)] mod tests { use super::{Message, Phase, State, Transition, update}; - use crate::authentication::{AuthenticationError, AuthenticationErrorCategory}; + use crate::authentication::{ + AuthenticationError, AuthenticationErrorCategory, LogoutReport, RevocationOutcome, + RevocationReport, + }; use crate::feed::HomeFeed; use std::sync::Arc; @@ -378,6 +461,37 @@ mod tests { assert_eq!(task.units(), 0); } + #[test] + fn local_logout_completes_despite_remote_revocation_failure() { + let mut state = State { + generation: 3, + phase: Phase::SigningOut, + }; + let remote_failure = || { + AuthenticationError::new( + crate::authentication::AuthenticationFailureStage::SessionRevocation, + AuthenticationErrorCategory::TransportUnavailable, + crate::authentication::AuthenticationFailureReason::UpstreamUnavailable, + ) + }; + let (transition, task) = update( + &mut state, + Arc::default(), + Message::SignOutFinished { + generation: 3, + report: LogoutReport { + revocation: RevocationReport { + refresh: RevocationOutcome::Failed(remote_failure()), + access: RevocationOutcome::Failed(remote_failure()), + }, + local_cleanup: Ok(()), + }, + }, + ); + assert!(matches!(transition, Transition::SignedOut)); + assert_eq!(task.units(), 0); + } + #[test] fn retry_advances_the_load_generation() { let mut state = State { diff --git a/src/ui/login.rs b/src/ui/login.rs index ba6470a..e292e44 100644 --- a/src/ui/login.rs +++ b/src/ui/login.rs @@ -9,7 +9,10 @@ use super::{ panel_style, }; use crate::application::{DesktopAuthentication, SignInOutcome}; +use crate::authentication::command::AuthorizationCallbackUrl; use crate::authentication::{AuthenticationError, AuthenticationErrorCategory}; + +const AUTHORIZATION_DEADLINE: Duration = Duration::from_secs(300); const MANUAL_RECOVERY_DIAGNOSTIC: &str = "Hitomi alert\nSeverity: Warning\nTitle: Browser needs attention\nMessage: Hitomi couldn’t open your browser. Copy the authorization link, approve Hitomi there, then return here."; #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -17,6 +20,7 @@ enum AuthorizationUiState { Ready, OpeningBrowser, WaitingForApproval, + CompletingAuthorization, ManualRecovery, Cancelling, } @@ -62,14 +66,18 @@ pub(crate) enum Message { generation: u64, result: Result, }, - ContinueSignInFinished { + CallbackReceived(AuthorizationCallbackUrl), + CallbackFinished { generation: u64, result: Result, }, + AuthorizationDeadlineElapsed { + generation: u64, + }, + AuthorizationRetired, CopyAuthorizationLink, CopyDiagnostic, CopyAlertDiagnostic(&'static str), - WaitForManualApproval, RetryBrowser, RetryBrowserFinished { generation: u64, @@ -91,7 +99,8 @@ pub(super) fn update( ) -> (Transition, Task) { match message { Message::SignInFinished { generation, .. } - | Message::ContinueSignInFinished { generation, .. } + | Message::CallbackFinished { generation, .. } + | Message::AuthorizationDeadlineElapsed { generation } | Message::RetryBrowserFinished { generation, .. } if generation != state.operation_generation => {} Message::HandleChanged(handle) => { @@ -123,14 +132,9 @@ pub(super) fn update( } => { state.state = AuthorizationUiState::WaitingForApproval; state.status = Some("Waiting for approval in your browser…".to_owned()); - let authentication = Arc::clone(authentication); - let generation = state.operation_generation; return ( Transition::Stay, - Task::perform( - async move { authentication.continue_sign_in().await }, - move |result| Message::ContinueSignInFinished { generation, result }, - ), + authorization_deadline_task(state.operation_generation), ); } Message::SignInFinished { @@ -140,27 +144,72 @@ pub(super) fn update( state.state = AuthorizationUiState::ManualRecovery; state.manual_authorization_url = Some(url); state.status = Some("We could not open your browser. Copy the authorization link, then return here after approving Hitomi.".to_owned()); + return ( + Transition::Stay, + authorization_deadline_task(state.operation_generation), + ); } Message::SignInFinished { result: Ok(SignInOutcome::SignedIn), .. } - | Message::ContinueSignInFinished { + | Message::CallbackFinished { result: Ok(SignInOutcome::SignedIn), .. } => { state.manual_authorization_url = None; return (Transition::Authenticated, Task::none()); } - Message::ContinueSignInFinished { result: Ok(_), .. } => { + Message::CallbackFinished { result: Ok(_), .. } => { state.state = AuthorizationUiState::Ready; state.status = Some("Authorization could not be completed. Start again.".to_owned()); state.manual_authorization_url = None; } + Message::CallbackReceived(callback) + if matches!( + state.state, + AuthorizationUiState::WaitingForApproval | AuthorizationUiState::ManualRecovery + ) => + { + state.state = AuthorizationUiState::CompletingAuthorization; + state.status = Some("Completing authorization…".to_owned()); + state.manual_authorization_url = None; + let authentication = Arc::clone(authentication); + let generation = state.operation_generation; + return ( + Transition::Stay, + Task::perform( + async move { authentication.complete_sign_in(callback).await }, + move |result| Message::CallbackFinished { generation, result }, + ), + ); + } + Message::CallbackReceived(_) => {} + Message::AuthorizationDeadlineElapsed { .. } + if matches!( + state.state, + AuthorizationUiState::WaitingForApproval + | AuthorizationUiState::ManualRecovery + | AuthorizationUiState::CompletingAuthorization + ) => + { + state.operation_generation = state.operation_generation.wrapping_add(1); + state.state = AuthorizationUiState::Ready; + state.status = Some("Authorization expired. Start again.".to_owned()); + state.manual_authorization_url = None; + let authentication = Arc::clone(authentication); + return ( + Transition::Stay, + Task::perform(async move { authentication.retire_sign_in().await }, |_| { + Message::AuthorizationRetired + }), + ); + } + Message::AuthorizationDeadlineElapsed { .. } | Message::AuthorizationRetired => {} Message::SignInFinished { result: Err(error), .. } - | Message::ContinueSignInFinished { + | Message::CallbackFinished { result: Err(error), .. } => { state.state = AuthorizationUiState::Ready; @@ -185,20 +234,6 @@ pub(super) fn update( Message::CopyAlertDiagnostic(diagnostic) => { return (Transition::Stay, clipboard::write(diagnostic.to_owned())); } - Message::WaitForManualApproval => { - state.state = AuthorizationUiState::WaitingForApproval; - state.status = Some("Waiting for approval in your browser…".to_owned()); - state.manual_authorization_url = None; - let authentication = Arc::clone(authentication); - let generation = state.operation_generation; - return ( - Transition::Stay, - Task::perform( - async move { authentication.continue_sign_in().await }, - move |result| Message::ContinueSignInFinished { generation, result }, - ), - ); - } Message::RetryBrowser => { state.state = AuthorizationUiState::OpeningBrowser; state.status = Some("Retrying your browser…".to_owned()); @@ -216,15 +251,6 @@ pub(super) fn update( state.state = AuthorizationUiState::WaitingForApproval; state.status = Some("Waiting for approval in your browser…".to_owned()); state.manual_authorization_url = None; - let authentication = Arc::clone(authentication); - let generation = state.operation_generation; - return ( - Transition::Stay, - Task::perform( - async move { authentication.continue_sign_in().await }, - move |result| Message::ContinueSignInFinished { generation, result }, - ), - ); } Message::RetryBrowserFinished { result: Err(_), .. } => { state.state = AuthorizationUiState::ManualRecovery; @@ -356,11 +382,22 @@ fn sign_in_error_diagnostic(error: AuthenticationError) -> String { ) } +fn authorization_deadline_task(generation: u64) -> Task { + Task::perform( + async move { + tokio::time::sleep(AUTHORIZATION_DEADLINE).await; + generation + }, + |generation| Message::AuthorizationDeadlineElapsed { generation }, + ) +} + pub(super) fn needs_spinner_ticks(state: &State) -> bool { matches!( state.state, AuthorizationUiState::OpeningBrowser | AuthorizationUiState::WaitingForApproval + | AuthorizationUiState::CompletingAuthorization | AuthorizationUiState::Cancelling ) } @@ -468,13 +505,6 @@ pub(super) fn view( icons_ready, spinner_phase, ), - button::action( - "I've opened the link", - button::Spec::new(button::Tone::Neutral).emphasis(button::Emphasis::Outline), - button::State::Enabled(Message::WaitForManualApproval), - icons_ready, - spinner_phase, - ), button::action( "Retry browser", button::Spec::new(button::Tone::Neutral) @@ -483,7 +513,7 @@ pub(super) fn view( button::State::Enabled(Message::RetryBrowser), icons_ready, spinner_phase, - ) + ), ] .spacing(8) .align_x(Alignment::Start), @@ -530,7 +560,6 @@ mod tests { let _ = update(&mut state, &authentication, Message::SignIn); assert_eq!(state.state, AuthorizationUiState::OpeningBrowser); let generation = state.operation_generation; - let _ = update( &mut state, &authentication, @@ -547,6 +576,45 @@ mod tests { ); } + #[test] + fn callback_is_ignored_unless_an_authorization_is_waiting() { + let (mut state, authentication) = login(); + let callback = AuthorizationCallbackUrl::parse( + "com.nateledford.hitomi:/oauth/callback?state=canary".to_owned(), + ) + .expect("valid custom-scheme callback"); + + let (_, task) = update( + &mut state, + &authentication, + Message::CallbackReceived(callback), + ); + + assert_eq!(task.units(), 0); + assert_eq!(state.state, AuthorizationUiState::Ready); + } + + #[test] + fn authorization_deadline_invalidates_the_waiting_generation() { + let (mut state, authentication) = login(); + state.state = AuthorizationUiState::WaitingForApproval; + state.operation_generation = 9; + + let (_, task) = update( + &mut state, + &authentication, + Message::AuthorizationDeadlineElapsed { generation: 9 }, + ); + + assert_eq!(state.state, AuthorizationUiState::Ready); + assert_eq!(state.operation_generation, 10); + assert_eq!( + state.status.as_deref(), + Some("Authorization expired. Start again.") + ); + assert_eq!(task.units(), 1); + } + #[test] fn retrying_a_failed_sign_in_dispatches_a_new_sign_in_task() { let (mut state, authentication) = login(); diff --git a/tests/authentication_core.rs b/tests/authentication_core.rs index 19ae8a9..6624901 100644 --- a/tests/authentication_core.rs +++ b/tests/authentication_core.rs @@ -16,34 +16,23 @@ fn public_identifiers_are_validated_and_ordered() { } #[test] -fn authorization_request_only_reveals_a_validated_url_at_the_browser_boundary() { - let attempt = AuthenticationAttemptId::new("attempt-1").expect("nonempty attempt ID"); - let issuer = AttemptBoundAuthorizationServerIssuer::new(attempt, "https://issuer.example") - .expect("nonempty issuer"); +fn manual_authorization_url_reveals_only_at_the_browser_boundary() { let url = ManualAuthorizationUrl::new("https://issuer.example/authorize?state=synthetic-canary") .expect("HTTP(S) URL"); - let request = issuer.with_manual_url(url); assert_eq!( - request.manual_url().reveal_to_browser(), + url.reveal_to_browser(), "https://issuer.example/authorize?state=synthetic-canary" ); assert!(ManualAuthorizationUrl::new("").is_err()); assert!(ManualAuthorizationUrl::new("issuer.example/authorize").is_err()); assert!(ManualAuthorizationUrl::new("https://issuer.example/has space").is_err()); - let debug = format!("{request:?}"); - let display = request.to_string(); + let debug = format!("{url:?}"); + let display = url.to_string(); assert!(!debug.contains("synthetic-canary")); assert!(!display.contains("synthetic-canary")); - assert!(!format!("{:?}", request.manual_url()).contains("synthetic-canary")); - assert!( - !request - .manual_url() - .to_string() - .contains("synthetic-canary") - ); } #[test] diff --git a/tests/authentication_workflows.rs b/tests/authentication_workflows.rs deleted file mode 100644 index 7b44274..0000000 --- a/tests/authentication_workflows.rs +++ /dev/null @@ -1,197 +0,0 @@ -use std::sync::{Arc, Mutex}; - -use hitomi::authentication::ports::{ - BoundCallbackListener, BrowserLauncher, CallbackListenerLifecycle, DeadlineClock, - OAuthAttemptRetirement, OAuthAuthorization, OAuthCompletion, SessionInvalidation, - SessionPersistence, SessionRestoration, SessionRevocation, -}; -use hitomi::authentication::workflow::AuthenticationApplication; -use hitomi::authentication::{ - AttemptInvalidation, AuthenticationAttemptId, AuthenticationDeadline, AuthenticationError, - AuthenticationErrorCategory, AuthenticationEvent, AuthenticationTimeout, - CompletedAuthorization, Epoch, ManualAuthorizationUrl, PersistedActiveSession, - PreparedAuthorization, RedirectUri, RestorationSnapshot, RestorationStatus, RevocationReport, - VerifiedActiveSession, -}; - -#[derive(Default)] -struct FakeOAuth; - -impl OAuthAuthorization for FakeOAuth { - async fn prepare_authorization( - &self, - _: AuthenticationAttemptId, - _: hitomi::authentication::AccountHandle, - _: hitomi::authentication::RedirectUri, - ) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} - -impl OAuthCompletion for FakeOAuth { - async fn complete_authorization( - &self, - _: hitomi::authentication::command::CompleteAuthorizationCallback, - ) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} - -impl OAuthAttemptRetirement for FakeOAuth { - async fn retire_authorization(&self, _: &AuthenticationAttemptId) {} -} - -impl SessionRevocation for FakeOAuth { - async fn revoke_session(&self, _: VerifiedActiveSession) -> RevocationReport { - unreachable!("cancellation must not revoke") - } -} - -#[derive(Clone, Default)] -struct FakeSessions(Arc>>); - -impl SessionRestoration for FakeSessions { - async fn restore_session(&self) -> Result { - Ok(RestorationSnapshot::new( - Epoch::new(0), - RestorationStatus::Unavailable, - )) - } -} - -impl SessionPersistence for FakeSessions { - async fn persist_completed_authorization( - &self, - _: CompletedAuthorization, - ) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} - -impl SessionInvalidation for FakeSessions { - async fn invalidate_attempt( - &self, - attempt: &AuthenticationAttemptId, - epoch: Epoch, - ) -> Result { - self.0 - .lock() - .expect("record lock") - .push((attempt.clone(), epoch)); - Ok(AttemptInvalidation::Invalidated) - } - - async fn install_logout_tombstone(&self, _: Epoch) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } - - async fn cleanup_tombstoned_epoch(&self, _: Epoch) -> Result<(), AuthenticationError> { - Ok(()) - } -} - -struct FakeBrowser; -impl BrowserLauncher for FakeBrowser { - async fn launch_authorization( - &self, - _: ManualAuthorizationUrl, - ) -> Result<(), AuthenticationError> { - Ok(()) - } -} - -struct FakeBound(RedirectUri); - -impl BoundCallbackListener for FakeBound { - fn redirect_uri(&self) -> &RedirectUri { - &self.0 - } -} - -struct FakeListener; -impl CallbackListenerLifecycle for FakeListener { - type Bound = FakeBound; - - async fn start_listener( - &self, - _: AuthenticationAttemptId, - ) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CallbackListenerUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } - - async fn accept_callback( - &self, - _: &Self::Bound, - _: &PreparedAuthorization, - ) -> Result - { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CallbackRejected, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} - -struct FakeClock; -impl DeadlineClock for FakeClock { - fn deadline_after(&self, _: AuthenticationTimeout) -> AuthenticationDeadline { - AuthenticationDeadline::new(1) - } - - async fn run_until<'a, F>( - &'a self, - _: AuthenticationDeadline, - operation: F, - ) -> Result - where - F: Future + Send + 'a, - F::Output: Send + 'a, - { - Ok(operation.await) - } -} - -#[tokio::test] -async fn cancellation_is_durably_recorded_before_its_event_is_returned() { - let sessions = FakeSessions::default(); - let application = AuthenticationApplication::new( - FakeOAuth, - sessions.clone(), - FakeBrowser, - FakeListener, - FakeClock, - ); - let attempt = AuthenticationAttemptId::new("attempt-canary").expect("attempt"); - let result = application.cancel(attempt.clone(), Epoch::new(4)).await; - - assert_eq!( - sessions.0.lock().expect("record lock").as_slice(), - &[(attempt.clone(), Epoch::new(4))] - ); - assert!(matches!( - result.event, - AuthenticationEvent::CancellationInvalidated { attempt_id, epoch, result: Ok(()) } - if attempt_id == attempt && epoch == Epoch::new(4) - )); -} diff --git a/tests/behavior.rs b/tests/behavior.rs deleted file mode 100644 index 030eb20..0000000 --- a/tests/behavior.rs +++ /dev/null @@ -1,493 +0,0 @@ -mod support; -use cucumber::{World, given, then, when}; -use hitomi::authentication::ports::{ - BoundCallbackListener, BrowserLauncher, CallbackListenerLifecycle, DeadlineClock, - OAuthAttemptRetirement, OAuthAuthorization, OAuthCompletion, SessionInvalidation, - SessionPersistence, SessionRestoration, SessionRevocation, -}; -use hitomi::authentication::workflow::AuthenticationApplication; -use hitomi::authentication::{ - AccountHandle, AttemptBoundAuthorizationServerIssuer, AttemptInvalidation, - AuthenticationAttemptId, AuthenticationDeadline, AuthenticationError, - AuthenticationErrorCategory, AuthenticationEvent, AuthenticationStateMachine, - AuthenticationTimeout, CompletedAuthorization, Epoch, ManualAuthorizationUrl, - PersistedActiveSession, PreparedAuthorization, RedirectUri, RestorationSnapshot, - RestorationStatus, RevocationReport, TransitionResult, VerifiedActiveSession, -}; -use std::{ - env, - path::PathBuf, - sync::{Arc, Mutex}, -}; - -#[derive(Default)] -struct FakeOAuth; -impl OAuthAuthorization for FakeOAuth { - async fn prepare_authorization( - &self, - attempt: AuthenticationAttemptId, - _: AccountHandle, - redirect_uri: hitomi::authentication::RedirectUri, - ) -> Result { - let issuer = - AttemptBoundAuthorizationServerIssuer::new(attempt.clone(), "https://issuer.example")?; - let authorization = issuer.clone().with_manual_url(ManualAuthorizationUrl::new( - "https://issuer.example/authorize", - )?); - PreparedAuthorization::new( - attempt, - issuer, - "behavior-state", - redirect_uri, - authorization, - ) - } -} -impl OAuthCompletion for FakeOAuth { - async fn complete_authorization( - &self, - command: hitomi::authentication::command::CompleteAuthorizationCallback, - ) -> Result { - CompletedAuthorization::for_testing("did:plc:behavior", command.epoch()) - } -} - -impl OAuthAttemptRetirement for FakeOAuth { - async fn retire_authorization(&self, _: &AuthenticationAttemptId) {} -} -impl SessionRevocation for FakeOAuth { - async fn revoke_session(&self, _: VerifiedActiveSession) -> RevocationReport { - RevocationReport { - refresh: hitomi::authentication::RevocationOutcome::Unsupported, - access: hitomi::authentication::RevocationOutcome::Unsupported, - } - } -} -#[derive(Clone, Default)] -struct FakeSessions(Arc>>); -impl SessionRestoration for FakeSessions { - async fn restore_session(&self) -> Result { - Ok(RestorationSnapshot::new( - Epoch::new(0), - RestorationStatus::Unavailable, - )) - } -} -impl SessionPersistence for FakeSessions { - async fn persist_completed_authorization( - &self, - _: CompletedAuthorization, - ) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} -impl SessionInvalidation for FakeSessions { - async fn invalidate_attempt( - &self, - a: &AuthenticationAttemptId, - e: Epoch, - ) -> Result { - self.0.lock().expect("record lock").push((a.clone(), e)); - Ok(AttemptInvalidation::Invalidated) - } - async fn install_logout_tombstone(&self, _: Epoch) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } - async fn cleanup_tombstoned_epoch(&self, _: Epoch) -> Result<(), AuthenticationError> { - Ok(()) - } -} - -struct FailingBrowser; -impl BrowserLauncher for FailingBrowser { - async fn launch_authorization( - &self, - _: ManualAuthorizationUrl, - ) -> Result<(), AuthenticationError> { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::BrowserUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} -struct FakeBrowser; -impl BrowserLauncher for FakeBrowser { - async fn launch_authorization( - &self, - _: ManualAuthorizationUrl, - ) -> Result<(), AuthenticationError> { - Ok(()) - } -} -struct FakeBound(RedirectUri); -impl BoundCallbackListener for FakeBound { - fn redirect_uri(&self) -> &RedirectUri { - &self.0 - } -} - -struct FakeListener; -impl CallbackListenerLifecycle for FakeListener { - type Bound = FakeBound; - - async fn start_listener( - &self, - _: AuthenticationAttemptId, - ) -> Result { - Ok(FakeBound(RedirectUri::for_testing(17_001)?)) - } - - async fn accept_callback( - &self, - _: &Self::Bound, - _: &PreparedAuthorization, - ) -> Result - { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CallbackRejected, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} -struct FakeClock; -impl DeadlineClock for FakeClock { - fn deadline_after(&self, _: AuthenticationTimeout) -> AuthenticationDeadline { - AuthenticationDeadline::new(1) - } - - async fn run_until<'a, F>( - &'a self, - _: AuthenticationDeadline, - operation: F, - ) -> Result - where - F: Future + Send + 'a, - F::Output: Send + 'a, - { - Ok(operation.await) - } -} - -fn prepared(attempt: &AuthenticationAttemptId, epoch: Epoch) -> PreparedAuthorization { - let issuer = - AttemptBoundAuthorizationServerIssuer::new(attempt.clone(), "https://issuer.example") - .expect("issuer"); - let authorization = issuer.clone().with_manual_url( - ManualAuthorizationUrl::new("https://issuer.example/authorize").expect("url"), - ); - PreparedAuthorization::new( - attempt.clone(), - issuer, - "behavior-state", - RedirectUri::for_testing(17_001).expect("redirect"), - authorization, - ) - .expect("prepared") - .bind_epoch(epoch) - .expect("epoch") -} - -#[derive(Debug, Default, World)] -struct BehaviorWorld { - recorded: bool, - restoration_signed_out: bool, - restoration_retryable: bool, - sign_in_active: bool, - manual_authorization_remains: bool, - timeout_signed_out: bool, - replay_rejected: bool, - late_callback_rejected: bool, -} -#[given("a deterministic authentication application")] -async fn application(world: &mut BehaviorWorld) { - let sessions = FakeSessions::default(); - let app = AuthenticationApplication::new( - FakeOAuth, - sessions.clone(), - FakeBrowser, - FakeListener, - FakeClock, - ); - let attempt = AuthenticationAttemptId::new("behavior-cancel").expect("attempt"); - let result = app.cancel(attempt.clone(), Epoch::new(1)).await; - world.recorded = matches!( - result.event, - AuthenticationEvent::CancellationInvalidated { result: Ok(()), .. } - ) && sessions.0.lock().expect("record lock").as_slice() - == [(attempt, Epoch::new(1))]; -} -#[when("the authorization is cancelled")] -async fn cancelled(_: &mut BehaviorWorld) {} -#[then("cancellation is recorded before completion")] -async fn recorded(world: &mut BehaviorWorld) { - assert!(world.recorded); -} - -#[when("no stored session is available")] -async fn no_stored_session_is_available(world: &mut BehaviorWorld) { - let app = AuthenticationApplication::new( - FakeOAuth, - FakeSessions::default(), - FakeBrowser, - FakeListener, - FakeClock, - ); - let result = app - .restore(AuthenticationAttemptId::new("behavior-restore").expect("attempt")) - .await - .expect("restoration succeeds"); - world.restoration_signed_out = result.epoch == Epoch::new(0) - && matches!( - result.event, - AuthenticationEvent::RestorationFinished { - epoch, - result: Ok(RestorationStatus::Unavailable), - .. - } if epoch == Epoch::new(0) - ); -} - -#[then("restoration reports signed out")] -async fn restoration_reports_signed_out(world: &mut BehaviorWorld) { - assert!(world.restoration_signed_out); -} - -#[when("the credential vault is unavailable")] -async fn credential_vault_is_unavailable(world: &mut BehaviorWorld) { - let app = AuthenticationApplication::new( - FakeOAuth, - support::authentication::Sessions::with_restore_mode( - support::authentication::RestoreMode::VaultUnavailable, - ), - FakeBrowser, - FakeListener, - FakeClock, - ); - let result = app - .restore(AuthenticationAttemptId::new("behavior-vault").expect("attempt")) - .await; - world.restoration_retryable = matches!(result, Err(error) if error.is_retryable()); -} - -#[then("restoration reports a retryable failure")] -async fn restoration_reports_retryable_failure(world: &mut BehaviorWorld) { - assert!(world.restoration_retryable); -} - -#[when("a first-time authorization completes")] -async fn first_time_authorization_completes(world: &mut BehaviorWorld) { - let attempt = AuthenticationAttemptId::new("behavior-first-sign-in").expect("attempt"); - let epoch = Epoch::new(1); - let app = AuthenticationApplication::new( - FakeOAuth, - support::authentication::Sessions::with_persistence_mode( - support::authentication::PersistenceMode::Succeeds, - ), - FakeBrowser, - FakeListener, - FakeClock, - ); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let _ = machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt.clone(), - epoch, - }); - let prepared = prepared(&attempt, epoch); - let _ = machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: attempt.clone(), - epoch, - result: Ok(()), - }); - let _ = machine.apply(AuthenticationEvent::BrowserFinished { - attempt_id: attempt.clone(), - epoch, - result: Ok(()), - }); - let callback = - hitomi::authentication::command::CompleteAuthorizationCallback::from_inbound_callback( - &prepared, - "https://issuer.example", - "behavior-code", - "behavior-state", - ) - .expect("callback"); - let _ = machine.accept_callback(callback.clone()); - let completed = match app.complete_callback(callback).await.event { - AuthenticationEvent::AuthorizationCompleted { - result: Ok(completed), - .. - } => completed, - _ => panic!("completion must succeed"), - }; - let _ = machine.apply(AuthenticationEvent::AuthorizationCompleted { - attempt_id: attempt.clone(), - epoch, - result: Ok(completed.clone()), - }); - let persistence = app - .persist_completed_authorization(attempt, epoch, completed) - .await; - let _ = machine.apply(persistence); - world.sign_in_active = matches!( - machine.phase(), - hitomi::authentication::AuthenticationPhase::Active - ); -} - -#[then("the session is active")] -async fn session_is_active(world: &mut BehaviorWorld) { - assert!(world.sign_in_active); -} - -#[when("the system browser cannot open")] -async fn system_browser_cannot_open(world: &mut BehaviorWorld) { - let app = AuthenticationApplication::new( - FakeOAuth, - FakeSessions::default(), - FailingBrowser, - FakeListener, - FakeClock, - ); - let started = app - .start_sign_in( - AuthenticationAttemptId::new("behavior-manual").expect("attempt"), - Epoch::new(1), - AccountHandle::new("behavior.example").expect("handle"), - AuthenticationTimeout::new(std::time::Duration::from_secs(1)).expect("timeout"), - ) - .await - .expect("preparation"); - world.manual_authorization_remains = matches!( - started, - hitomi::authentication::workflow::SignInStart::ManualAuthorization { url, .. } - if url == ManualAuthorizationUrl::new("https://issuer.example/authorize").expect("manual URL") - ); -} - -#[when("the TimedOut event is applied")] -async fn authorization_times_out(world: &mut BehaviorWorld) { - let attempt = AuthenticationAttemptId::new("behavior-timeout").expect("attempt"); - let epoch = Epoch::new(1); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let _ = machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt.clone(), - epoch, - }); - let _ = machine.apply(AuthenticationEvent::TimedOut { - attempt_id: attempt, - epoch, - }); - world.timeout_signed_out = !matches!( - machine.phase(), - hitomi::authentication::AuthenticationPhase::Active - ); -} - -#[then("the authentication phase is not active")] -async fn authentication_phase_is_not_active(world: &mut BehaviorWorld) { - assert!(world.timeout_signed_out); -} -#[then("a manual authorization remains")] -async fn manual_authorization_remains(world: &mut BehaviorWorld) { - assert!(world.manual_authorization_remains); -} - -#[when("a callback is replayed")] -async fn callback_is_replayed(world: &mut BehaviorWorld) { - let attempt = AuthenticationAttemptId::new("behavior-replay").expect("attempt"); - let epoch = Epoch::new(1); - let prepared = prepared(&attempt, epoch); - let callback = - hitomi::authentication::command::CompleteAuthorizationCallback::from_inbound_callback( - &prepared, - "https://issuer.example", - "behavior-code", - "behavior-state", - ) - .expect("callback"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let _ = machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt.clone(), - epoch, - }); - let _ = machine.apply(AuthenticationEvent::AuthorizationPrepared { - attempt_id: attempt, - epoch, - result: Ok(()), - }); - let _ = machine.accept_callback(callback.clone()); - world.replay_rejected = matches!( - machine.accept_callback(callback), - TransitionResult::Rejected - ); -} - -#[then("the replay is rejected")] -async fn replay_is_rejected(world: &mut BehaviorWorld) { - assert!(world.replay_rejected); -} - -#[when("a timed out callback arrives late")] -async fn timed_out_callback_arrives_late(world: &mut BehaviorWorld) { - let attempt = AuthenticationAttemptId::new("behavior-late").expect("attempt"); - let epoch = Epoch::new(1); - let prepared = prepared(&attempt, epoch); - let callback = - hitomi::authentication::command::CompleteAuthorizationCallback::from_inbound_callback( - &prepared, - "https://issuer.example", - "behavior-code", - "behavior-state", - ) - .expect("callback"); - let mut machine = AuthenticationStateMachine::signed_out(epoch); - let _ = machine.apply(AuthenticationEvent::StartAuthorization { - attempt_id: attempt.clone(), - epoch, - }); - let _ = machine.apply(AuthenticationEvent::TimedOut { - attempt_id: attempt, - epoch, - }); - world.late_callback_rejected = matches!( - machine.accept_callback(callback), - TransitionResult::Rejected - ); -} - -#[then("the late callback is rejected")] -async fn late_callback_is_rejected(world: &mut BehaviorWorld) { - assert!(world.late_callback_rejected); -} -#[given("a valid behavior runner")] -async fn valid(_: &mut BehaviorWorld) {} -#[when("it executes a passing scenario")] -async fn passing(_: &mut BehaviorWorld) {} -#[then("it reports success")] -async fn success(_: &mut BehaviorWorld) {} -#[given("a failing behavior step")] -async fn fail(_: &mut BehaviorWorld) { - panic!("synthetic behavior-runner failure") -} -fn feature_directory() -> PathBuf { - env::var_os("HITOMI_FEATURES_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("tests/features")) -} -#[tokio::main(flavor = "current_thread")] -async fn main() { - BehaviorWorld::cucumber() - .fail_on_skipped() - .run_and_exit(feature_directory()) - .await; -} diff --git a/tests/behavior_runner_contract.rs b/tests/behavior_runner_contract.rs deleted file mode 100644 index 58ec8bf..0000000 --- a/tests/behavior_runner_contract.rs +++ /dev/null @@ -1,70 +0,0 @@ -mod support; - -use std::process::ExitStatus; -use support::BehaviorFeatureDirectory; - -const VALID_FEATURE: &str = "\ -Feature: Valid behavior runner\n\ -\n\ - Scenario: A matching scenario succeeds\n\ - Given a valid behavior runner\n\ - When it executes a passing scenario\n\ - Then it reports success\n"; - -const UNDEFINED_STEP_FEATURE: &str = "\ -Feature: Undefined steps fail closed\n\ -\n\ - Scenario: An undefined step is rejected\n\ - Given an undefined behavior step\n"; - -const SKIPPED_STEP_FEATURE: &str = "\ -Feature: Skipped steps fail closed\n\ -\n\ - Scenario: An unmatched successor is skipped\n\ - Given a valid behavior runner\n\ - When it executes a skipped behavior step\n"; - -const MALFORMED_GHERKIN_FEATURE: &str = "This is not valid Gherkin.\n"; - -const FAILING_SCENARIO_FEATURE: &str = "\ -Feature: Failing scenarios fail closed\n\ -\n\ - Scenario: A failing step rejects the scenario\n\ - Given a failing behavior step\n"; - -fn behavior_status(feature: &str) -> ExitStatus { - let directory = - BehaviorFeatureDirectory::new(feature).expect("contract fixture directory must be created"); - let status = directory - .run_behavior() - .expect("behavior runner subprocess must start"); - directory - .cleanup() - .expect("contract fixture directory must be removed"); - status -} - -#[test] -fn valid_features_succeed() { - assert!(behavior_status(VALID_FEATURE).success()); -} - -#[test] -fn undefined_steps_fail_closed() { - assert!(!behavior_status(UNDEFINED_STEP_FEATURE).success()); -} - -#[test] -fn skipped_steps_fail_closed() { - assert!(!behavior_status(SKIPPED_STEP_FEATURE).success()); -} - -#[test] -fn malformed_gherkin_fails_closed() { - assert!(!behavior_status(MALFORMED_GHERKIN_FEATURE).success()); -} - -#[test] -fn failing_scenarios_fail_closed() { - assert!(!behavior_status(FAILING_SCENARIO_FEATURE).success()); -} diff --git a/tests/features/oauth_authentication.feature b/tests/features/oauth_authentication.feature deleted file mode 100644 index 47d3ee3..0000000 --- a/tests/features/oauth_authentication.feature +++ /dev/null @@ -1,41 +0,0 @@ -Feature: Authentication workflow - - Scenario: Cancellation is durable - Given a deterministic authentication application - When the authorization is cancelled - Then cancellation is recorded before completion - - Scenario: Unavailable session restoration is signed out - Given a deterministic authentication application - When no stored session is available - Then restoration reports signed out - - Scenario: Unavailable vault is retryable - Given a deterministic authentication application - When the credential vault is unavailable - Then restoration reports a retryable failure - - Scenario: First sign-in activates a persisted session - Given a deterministic authentication application - When a first-time authorization completes - Then the session is active - - Scenario: Browser failure preserves manual authorization - Given a deterministic authentication application - When the system browser cannot open - Then a manual authorization remains - - Scenario: TimedOut event does not activate a session - Given a deterministic authentication application - When the TimedOut event is applied - Then the authentication phase is not active - - Scenario: Replayed callback cannot advance authentication - Given a deterministic authentication application - When a callback is replayed - Then the replay is rejected - - Scenario: Late callback cannot resurrect a timed out attempt - Given a deterministic authentication application - When a timed out callback arrives late - Then the late callback is rejected diff --git a/tests/feed_adapter.rs b/tests/feed_adapter.rs index 8b9cd79..b0565bb 100644 --- a/tests/feed_adapter.rs +++ b/tests/feed_adapter.rs @@ -1,45 +1,8 @@ -#[allow( - clippy::missing_panics_doc, - clippy::missing_errors_doc, - reason = "test support exposes fixture controls" -)] -mod support; - use atrium_api::app::bsky::feed::defs::FeedViewPost; use serde_json::json; -use hitomi::authentication::{ - AccountGenerationReference, AuthenticationError, AuthenticationErrorCategory, - AuthenticationFailureReason, AuthenticationFailureStage, SessionArtifact, -}; -use hitomi::feed::{FeedSegment, HomeFeed, HomeFeedReader}; -use hitomi::infrastructure::feed::{ - OAuthHomeFeedReader, RestoredFeedClient, map_timeline_for_test, -}; -use hitomi::infrastructure::session_vault::{MemorySecretStore, SessionEnvelopeV1, SessionVault}; -use support::owned_session::{SUBJECT, artifact_base64url, artifact_bytes}; - -#[derive(Clone)] -struct FixtureLoader { - expected: Vec, -} - -impl RestoredFeedClient for FixtureLoader { - async fn load( - &self, - stored: SessionArtifact, - _active: AccountGenerationReference, - ) -> Result { - if stored.bytes_for_testing() != self.expected { - return Err(AuthenticationError::new( - AuthenticationFailureStage::SessionValidation, - AuthenticationErrorCategory::CredentialCorrupt, - AuthenticationFailureReason::SessionArtifactInvalid, - )); - } - Ok(HomeFeed::default()) - } -} +use hitomi::feed::FeedSegment; +use hitomi::infrastructure::feed::map_timeline_for_test; fn post(record: serde_json::Value) -> FeedViewPost { serde_json::from_value(json!({ @@ -82,30 +45,3 @@ fn representative_timeline_record_maps_to_owned_feed_values() { ); assert!(!feed.posts()[0].has_image()); } - -#[tokio::test] -async fn reader_loads_the_exact_owned_vault_artifact() { - let vault = SessionVault::new(MemorySecretStore::default()); - let expected = artifact_bytes(); - vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: SUBJECT.into(), - generation: 1, - epoch: 0, - artifact_base64url: artifact_base64url(), - }) - .await - .expect("session persists"); - vault - .promote_active(SUBJECT.into(), 1, 0) - .await - .expect("pointer promotes"); - - let reader = OAuthHomeFeedReader::with_client(vault, FixtureLoader { expected }); - let feed = reader - .load_initial(AccountGenerationReference::for_test(SUBJECT, 1, 0).expect("test reference")) - .await - .expect("reader receives exact owned artifact"); - assert!(feed.is_empty()); -} diff --git a/tests/loopback_callback.rs b/tests/loopback_callback.rs index ce1cc21..9e7e9e4 100644 --- a/tests/loopback_callback.rs +++ b/tests/loopback_callback.rs @@ -1,14 +1,19 @@ -use hitomi::authentication::AuthenticationAttemptId; -use hitomi::authentication::ports::CallbackListenerLifecycle; -use hitomi::infrastructure::callback::LoopbackCallbackListener; +#![cfg(feature = "test-support")] + +use hitomi::infrastructure::callback::LoopbackCallbackServer; #[tokio::test] async fn dropping_bound_listener_releases_its_owned_loopback_endpoint() { - let listener = LoopbackCallbackListener; - let attempt = AuthenticationAttemptId::new("loopback-test").expect("valid attempt"); - let bound = listener - .start_listener(attempt) + let server = LoopbackCallbackServer::bind() .await - .expect("loopback listener binds"); - drop(bound); + .expect("loopback server binds"); + let port = server.local_port(); + assert_ne!(port, 0); + drop(server); + + assert!( + tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .is_err() + ); } diff --git a/tests/platform_keychain.rs b/tests/platform_keychain.rs new file mode 100644 index 0000000..dcfbd17 --- /dev/null +++ b/tests/platform_keychain.rs @@ -0,0 +1,43 @@ +use security_framework::passwords::{ + PasswordOptions, delete_generic_password_options, generic_password, + set_generic_password_options, +}; + +const SERVICE: &str = "dev.hitomi.keychain.probe"; + +fn options(account: &str) -> PasswordOptions { + let mut options = PasswordOptions::new_generic_password(SERVICE, account); + options.set_access_synchronized(Some(false)); + options +} + +fn namespace() -> String { + let mut bytes = [0_u8; 16]; + getrandom::fill(&mut bytes).expect("CSPRNG available for native test namespace"); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) +} + +#[test] +#[ignore = "destructive namespaced Keychain probe; run serially on a disposable local profile"] +fn native_keychain_creates_reads_replaces_and_deletes_binary_item() { + let account = namespace(); + let first = [0_u8, 1, 2, 0xff]; + let replacement = [0xaa, 0, 0x55]; + + let _ = delete_generic_password_options(options(&account)); + set_generic_password_options(&first, options(&account)).expect("create Keychain item"); + assert_eq!( + generic_password(options(&account)).expect("read Keychain item"), + first + ); + + set_generic_password_options(&replacement, options(&account)).expect("replace Keychain item"); + assert_eq!( + generic_password(options(&account)).expect("read replacement Keychain item"), + replacement + ); + + delete_generic_password_options(options(&account)).expect("delete Keychain item"); + let missing = generic_password(options(&account)).expect_err("deleted item is absent"); + assert_eq!(missing.code(), -25_300); +} diff --git a/tests/platform_keyring.rs b/tests/platform_keyring.rs deleted file mode 100644 index 662e48f..0000000 --- a/tests/platform_keyring.rs +++ /dev/null @@ -1,351 +0,0 @@ -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use keyring::{Entry, Error}; -use std::{ - sync::{Arc, mpsc}, - thread, -}; - -const SERVICE: &str = "dev.hitomi.credential-vault-probe"; -const BASIC_RAW_BYTES: usize = 96; -const MAX_ENCODED_PROBE_BYTES: usize = 65_536; -const CONCURRENT_WORKERS: usize = 4; - -const ROLES: [&str; 7] = [ - "binary", - "text", - "size", - "concurrent-0", - "concurrent-1", - "concurrent-2", - "concurrent-3", -]; - -struct ProbeNamespace { - id: String, -} - -impl ProbeNamespace { - fn new() -> Result { - let mut random = [0_u8; 16]; - getrandom::fill(&mut random).map_err(|_| "could not create a probe namespace")?; - - let id = random - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - Ok(Self { id }) - } - - fn username(&self, role: &str) -> String { - format!("probe-{}-{role}", self.id) - } -} - -/// Attempts deletion again during unwinding, so every named probe entry has a -/// cleanup path even if a vault operation returns an expected denial or lock error. -struct CleanupGuard { - namespace: ProbeNamespace, -} - -impl CleanupGuard { - fn new(namespace: ProbeNamespace) -> Self { - Self { namespace } - } - - fn remove_all(&self) -> Result<(), &'static str> { - for role in ROLES { - let entry = Entry::new(SERVICE, &self.namespace.username(role)) - .map_err(|_| "could not open a probe entry for cleanup")?; - match entry.delete_credential() { - Ok(()) | Err(Error::NoEntry) => {} - Err(_) => return Err("could not remove a probe entry"), - } - } - Ok(()) - } - - fn verify_empty(&self) -> Result<(), &'static str> { - for role in ROLES { - let entry = Entry::new(SERVICE, &self.namespace.username(role)) - .map_err(|_| "could not reopen a probe entry after cleanup")?; - if !matches!(entry.get_secret(), Err(Error::NoEntry)) { - return Err("a probe entry remained after cleanup"); - } - } - Ok(()) - } -} - -impl Drop for CleanupGuard { - fn drop(&mut self) { - // Drop cannot report an error, but it must try the same namespace-scoped - // cleanup after any early return or failed assertion. - let _ = self.remove_all(); - } -} - -fn synthetic_bytes(length: usize) -> Vec { - (0..length) - .map(|index| (index as u8).wrapping_mul(37).wrapping_add(11)) - .collect() -} - -fn encoded_bytes(length: usize) -> String { - URL_SAFE_NO_PAD.encode(synthetic_bytes(length)) -} - -fn encoded_length(raw_length: usize) -> usize { - (raw_length / 3) * 4 - + match raw_length % 3 { - 0 => 0, - 1 => 2, - 2 => 3, - _ => unreachable!("a remainder modulo three is at most two"), - } -} - -fn raw_length_for_encoded_limit(limit: usize) -> Result { - let raw_length = (limit / 4) * 3; - if encoded_length(raw_length) != limit { - return Err("encoded probe limit must be divisible by four"); - } - Ok(raw_length) -} - -fn open(namespace: &ProbeNamespace, role: &str) -> Result { - Entry::new(SERVICE, &namespace.username(role)).map_err(|_| "credential store unavailable") -} - -fn verify_absent(entry: &Entry) -> Result<(), &'static str> { - match entry.get_secret() { - Err(Error::NoEntry) => Ok(()), - Ok(_) => Err("unexpected pre-existing probe entry"), - Err(_) => Err("credential store denied availability read"), - } -} - -fn binary_round_trip(namespace: &ProbeNamespace) -> Result<(), &'static str> { - let entry = open(namespace, "binary")?; - let expected = synthetic_bytes(BASIC_RAW_BYTES); - entry - .set_secret(&expected) - .map_err(|_| "credential store denied binary write")?; - let actual = entry - .get_secret() - .map_err(|_| "credential store denied binary read")?; - if actual != expected { - return Err("binary round trip changed synthetic bytes"); - } - Ok(()) -} - -fn text_round_trip(namespace: &ProbeNamespace) -> Result<(), &'static str> { - let entry = open(namespace, "text")?; - let expected = encoded_bytes(BASIC_RAW_BYTES); - if expected.len() != 128 { - return Err("base64url overhead calculation changed"); - } - entry - .set_password(&expected) - .map_err(|_| "credential store denied encoded-text write")?; - let actual = entry - .get_password() - .map_err(|_| "credential store denied encoded-text read")?; - if actual != expected { - return Err("encoded-text round trip changed synthetic bytes"); - } - Ok(()) -} - -struct SizeProbeResult { - largest_successful_encoded_bytes: usize, - reached_probe_ceiling: bool, -} - -fn write_and_read_text(entry: &Entry, raw_length: usize) -> Result<(), ()> { - let expected = encoded_bytes(raw_length); - if entry.set_password(&expected).is_err() { - return Err(()); - } - match entry.get_password() { - Ok(actual) if actual == expected => Ok(()), - Ok(_) | Err(_) => Err(()), - } -} - -fn maximum_encoded_entry_size(namespace: &ProbeNamespace) -> Result { - let entry = open(namespace, "size")?; - let upper_raw = raw_length_for_encoded_limit(MAX_ENCODED_PROBE_BYTES)?; - - if write_and_read_text(&entry, upper_raw).is_ok() { - return Ok(SizeProbeResult { - largest_successful_encoded_bytes: MAX_ENCODED_PROBE_BYTES, - reached_probe_ceiling: true, - }); - } - - let mut low = 0; - let mut high = upper_raw; - while low < high { - let midpoint = low + (high - low).div_ceil(2); - if write_and_read_text(&entry, midpoint).is_ok() { - low = midpoint; - } else { - high = midpoint - 1; - } - } - - if low == 0 || write_and_read_text(&entry, low).is_err() { - return Err("credential store rejected every encoded probe size"); - } - - Ok(SizeProbeResult { - largest_successful_encoded_bytes: encoded_length(low), - reached_probe_ceiling: false, - }) -} - -/// Joins every worker before reporting whether any of them failed or panicked. -/// -/// This prevents callers from beginning cleanup while a worker can still write -/// to its namespace. -fn join_workers( - workers: Vec>>, -) -> Result<(), &'static str> { - let mut worker_failed = false; - for worker in workers { - if !matches!(worker.join(), Ok(Ok(()))) { - worker_failed = true; - } - } - - if worker_failed { - Err("concurrent probe worker failed") - } else { - Ok(()) - } -} - -fn concurrent_access(namespace: &ProbeNamespace) -> Result<(), &'static str> { - let (ready_tx, ready_rx) = mpsc::channel(); - let (start_tx, start_rx) = mpsc::channel(); - let start_rx = Arc::new(std::sync::Mutex::new(start_rx)); - let mut workers = Vec::with_capacity(CONCURRENT_WORKERS); - - for index in 0..CONCURRENT_WORKERS { - let namespace = ProbeNamespace { - id: namespace.id.clone(), - }; - let ready_tx = ready_tx.clone(); - let start_rx = Arc::clone(&start_rx); - workers.push(thread::spawn(move || -> Result<(), &'static str> { - let entry = open(&namespace, &format!("concurrent-{index}")); - ready_tx - .send(entry.is_ok()) - .map_err(|_| "concurrent probe could not signal readiness")?; - start_rx - .lock() - .map_err(|_| "concurrent probe start coordination failed")? - .recv() - .map_err(|_| "concurrent probe did not receive a start signal")?; - - let entry = entry?; - let expected = encoded_bytes(BASIC_RAW_BYTES + index); - entry - .set_password(&expected) - .map_err(|_| "credential store denied concurrent write")?; - let actual = entry - .get_password() - .map_err(|_| "credential store denied concurrent read")?; - if actual != expected { - return Err("concurrent access changed synthetic bytes"); - } - match entry.delete_credential() { - Ok(()) | Err(Error::NoEntry) => Ok(()), - Err(_) => Err("credential store denied concurrent cleanup"), - } - })); - } - drop(ready_tx); - - let mut all_entries_opened = true; - let mut coordination_failed = false; - for _ in 0..CONCURRENT_WORKERS { - match ready_rx.recv() { - Ok(entry_opened) => all_entries_opened &= entry_opened, - Err(_) => coordination_failed = true, - } - } - for _ in 0..CONCURRENT_WORKERS { - if start_tx.send(()).is_err() { - coordination_failed = true; - } - } - - let worker_result = join_workers(workers); - if worker_result.is_err() { - worker_result - } else if coordination_failed { - Err("concurrent probe coordination failed") - } else if all_entries_opened { - Ok(()) - } else { - Err("credential store denied concurrent entry creation") - } -} - -#[test] -fn worker_failures_and_panics_are_reported_only_after_every_worker_is_joined() { - use std::sync::atomic::{AtomicUsize, Ordering}; - - let completed = Arc::new(AtomicUsize::new(0)); - let workers = (0..3) - .map(|index| { - let completed = Arc::clone(&completed); - thread::spawn(move || { - completed.fetch_add(1, Ordering::SeqCst); - match index { - 1 => Err("synthetic worker failure"), - 2 => panic!("synthetic worker panic"), - _ => Ok(()), - } - }) - }) - .collect(); - - assert_eq!(join_workers(workers), Err("concurrent probe worker failed")); - assert_eq!(completed.load(Ordering::SeqCst), 3); -} - -#[test] -#[ignore = "destructive, namespaced native credential-vault probe; run serially and only on a disposable local user profile"] -fn probes_native_credential_vault_without_emitting_synthetic_data() -> Result<(), &'static str> { - let cleanup = CleanupGuard::new(ProbeNamespace::new()?); - let result = (|| { - cleanup.remove_all()?; - - let availability = open(&cleanup.namespace, "binary")?; - verify_absent(&availability)?; - binary_round_trip(&cleanup.namespace)?; - text_round_trip(&cleanup.namespace)?; - - let size = maximum_encoded_entry_size(&cleanup.namespace)?; - if size.largest_successful_encoded_bytes == 0 { - return Err("size probe recorded no successful encoded entry"); - } - if !size.reached_probe_ceiling - && size.largest_successful_encoded_bytes >= MAX_ENCODED_PROBE_BYTES - { - return Err("size probe ceiling accounting was inconsistent"); - } - - concurrent_access(&cleanup.namespace) - })(); - - let cleanup_result = cleanup.remove_all().and_then(|()| cleanup.verify_empty()); - match (result, cleanup_result) { - (Ok(()), Ok(())) => Ok(()), - (Err(_), _) => Err("native credential-vault probe failed; scoped cleanup was attempted"), - (Ok(()), Err(_)) => Err("native credential-vault probe cleanup could not be verified"), - } -} diff --git a/tests/session_vault.rs b/tests/session_vault.rs old mode 100644 new mode 100755 index ebd67e2..18a9f6d --- a/tests/session_vault.rs +++ b/tests/session_vault.rs @@ -1,650 +1,19 @@ -#[allow( - dead_code, - reason = "shared fixture module exposes more support than this vault target uses" -)] -mod support; +#![cfg(feature = "test-support")] -use std::sync::{ - Arc, - atomic::{AtomicBool, AtomicUsize, Ordering}, -}; +use hitomi::infrastructure::session_vault::MemoryCredentialVault; -use hitomi::authentication::ports::{SessionInvalidation, SessionRestoration}; -use hitomi::authentication::{ - AttemptInvalidation, AuthenticationAttemptId, AuthenticationError, AuthenticationErrorCategory, - Epoch, RestorationStatus, -}; -use hitomi::infrastructure::session_vault::{ - DurablePointerV1, MemorySecretStore, SecretStore, SessionEnvelopeV1, SessionManifestV1, - SessionVault, generation_chunk_key, generation_manifest_key, -}; -use support::owned_session::artifact_base64url; - -#[derive(Clone, Default)] -struct FailingDeleteStore(MemorySecretStore); - -impl SecretStore for FailingDeleteStore { - fn get(&self, user: &str) -> Result>, AuthenticationError> { - self.0.get(user) - } - - fn set(&self, user: &str, bytes: &[u8]) -> Result<(), AuthenticationError> { - self.0.set(user, bytes) - } - - fn delete(&self, _user: &str) -> Result<(), AuthenticationError> { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } -} - -#[derive(Clone, Default)] -struct PointerSwapStore { - inner: MemorySecretStore, - pointer_reads: Arc, -} - -impl SecretStore for PointerSwapStore { - fn get(&self, user: &str) -> Result>, AuthenticationError> { - if user == "pointer" && self.pointer_reads.fetch_add(1, Ordering::SeqCst) == 1 { - return Ok(Some( - serde_json::to_vec(&DurablePointerV1::Tombstone { epoch: 5 }) - .expect("test pointer serializes"), - )); - } - self.inner.get(user) - } - - fn set(&self, user: &str, bytes: &[u8]) -> Result<(), AuthenticationError> { - self.inner.set(user, bytes) - } - - fn delete(&self, user: &str) -> Result<(), AuthenticationError> { - self.inner.delete(user) - } -} - -#[derive(Clone)] -struct FailOnceSetStore { - inner: MemorySecretStore, - failed_key: String, - failed: Arc, -} - -impl FailOnceSetStore { - fn new(failed_key: String) -> Self { - Self { - inner: MemorySecretStore::default(), - failed_key, - failed: Arc::new(AtomicBool::new(false)), - } - } -} - -impl SecretStore for FailOnceSetStore { - fn get(&self, user: &str) -> Result>, AuthenticationError> { - self.inner.get(user) - } - - fn set(&self, user: &str, bytes: &[u8]) -> Result<(), AuthenticationError> { - if user == self.failed_key && !self.failed.swap(true, Ordering::SeqCst) { - return Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )); - } - self.inner.set(user, bytes) - } - - fn delete(&self, user: &str) -> Result<(), AuthenticationError> { - self.inner.delete(user) - } -} - -#[allow(dead_code, reason = "fixture is shared by vault regression cases")] -fn fixture_session_artifact() -> String { - artifact_base64url() -} -#[tokio::test] -async fn vault_commits_reads_and_deletes_binary_artifacts() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .commit("catalog".to_owned(), vec![0, 1, 2, 255]) - .await - .expect("memory vault commit succeeds"); - assert_eq!( - vault - .get("catalog".to_owned()) - .await - .expect("read succeeds"), - Some(vec![0, 1, 2, 255]) - ); - vault - .delete("catalog".to_owned()) - .await - .expect("delete succeeds"); - assert_eq!( - vault - .get("catalog".to_owned()) - .await - .expect("read succeeds"), - None - ); -} - -#[tokio::test] -async fn restoration_accepts_an_intact_session_envelope_at_its_durable_epoch() { - let vault = SessionVault::new(MemorySecretStore::default()); - let session = fixture_session_artifact(); - vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: "did:plc:z72i7hdynmk6r22z27h6tvur".to_owned(), - generation: 1, - epoch: 3, - artifact_base64url: session, - }) - .await - .expect("generation persists"); - vault - .promote_active("did:plc:z72i7hdynmk6r22z27h6tvur".to_owned(), 1, 3) - .await - .expect("pointer promotes"); - let snapshot = vault.restore_session().await.expect("restoration succeeds"); - assert_eq!(snapshot.epoch(), Epoch::new(3)); - assert!(matches!( - snapshot.into_status(), - RestorationStatus::Restored { .. } - )); -} - -#[tokio::test] -async fn active_promotion_and_tombstone_reject_stale_epochs() { - let vault = SessionVault::new(MemorySecretStore::default()); - let active = vault - .promote_active("did:plc:test".to_owned(), 1, 0) - .await - .expect("initial promotion succeeds"); - assert!(matches!(active, DurablePointerV1::Active { epoch: 0, .. })); - let tombstone = vault - .install_tombstone(0) - .await - .expect("current logout succeeds"); - assert!(matches!( - tombstone, - DurablePointerV1::Tombstone { epoch: 1 } - )); - assert!(vault.install_tombstone(0).await.is_err()); -} - -#[tokio::test] -async fn independent_vault_facades_reject_a_stale_writer() { - let store = MemorySecretStore::default(); - let first = SessionVault::new(store.clone()); - let stale = SessionVault::new(store); - - first - .promote_active("did:plc:test".to_owned(), 1, 0) - .await - .expect("initial promotion succeeds"); - first - .install_tombstone(0) - .await - .expect("current logout succeeds"); - - assert!( - stale - .promote_active("did:plc:test".to_owned(), 2, 0) - .await - .is_err() - ); -} - -#[tokio::test] -async fn invalidation_never_returns_an_active_session_without_attempt_correlation() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .promote_active("did:plc:test".to_owned(), 1, 0) - .await - .expect("promotion succeeds"); - let result = vault - .invalidate_attempt( - &AuthenticationAttemptId::new("different-attempt").expect("attempt"), - Epoch::new(0), - ) - .await - .expect("invalidation succeeds"); - assert!(matches!(result, AttemptInvalidation::Invalidated)); -} - -#[tokio::test] -async fn restoration_preserves_missing_and_tombstoned_pointer_epochs() { - let vault = SessionVault::new(MemorySecretStore::default()); - let missing = vault.restore_session().await.expect("empty restore"); - assert_eq!(missing.epoch(), Epoch::new(0)); - assert!(matches!( - missing.into_status(), - RestorationStatus::Unavailable - )); - vault - .promote_active("did:plc:test".to_owned(), 1, 0) - .await - .expect("promotion succeeds"); - vault.install_tombstone(0).await.expect("logout succeeds"); - let tombstone = vault.restore_session().await.expect("tombstone restore"); - assert_eq!(tombstone.epoch(), Epoch::new(1)); - assert!(matches!( - tombstone.into_status(), - RestorationStatus::Unavailable - )); +#[test] +fn replacement_memory_vault_starts_empty_and_deletes_idempotently() { + let vault = MemoryCredentialVault::default(); + assert!(vault.is_empty()); + vault.delete_for_testing(); + vault.delete_for_testing(); + assert!(vault.is_empty()); } -#[tokio::test] -async fn restoration_preserves_refresh_recovery_epoch() { - let vault = SessionVault::new(MemorySecretStore::default()); - let pointer = DurablePointerV1::RefreshInFlight { - did: "did:plc:test".to_owned(), - generation: 4, - epoch: 7, - operation_id: "opaque-operation".to_owned(), - }; - vault - .commit( - "pointer".to_owned(), - serde_json::to_vec(&pointer).expect("fixture pointer serializes"), - ) - .await - .expect("fixture pointer persists"); - let snapshot = vault - .restore_session() - .await - .expect("recovery restores safely"); - assert_eq!(snapshot.epoch(), Epoch::new(7)); - assert!(matches!( - snapshot.into_status(), - RestorationStatus::RefreshRecoveryRequired { .. } - )); -} - -#[tokio::test] -async fn restoration_treats_refresh_in_doubt_as_recovery_required() { - let vault = SessionVault::new(MemorySecretStore::default()); - let pointer = DurablePointerV1::RefreshInDoubt { - did: "did:plc:test".to_owned(), - generation: 4, - epoch: 7, - operation_id: "opaque-operation".to_owned(), - }; - vault - .commit( - "pointer".to_owned(), - serde_json::to_vec(&pointer).expect("fixture pointer serializes"), - ) - .await - .expect("fixture pointer persists"); - - let snapshot = vault - .restore_session() - .await - .expect("recovery restores safely"); - assert_eq!(snapshot.epoch(), Epoch::new(7)); - assert!(matches!( - snapshot.into_status(), - RestorationStatus::RefreshRecoveryRequired { .. } - )); -} - -#[tokio::test] -async fn restoration_rejects_a_pointer_changed_during_final_revalidation() { - let vault = SessionVault::new(PointerSwapStore::default()); - let did = "did:plc:z72i7hdynmk6r22z27h6tvur"; - vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: did.to_owned(), - generation: 1, - epoch: 4, - artifact_base64url: fixture_session_artifact(), - }) - .await - .expect("generation persists"); - vault - .commit( - "pointer".to_owned(), - serde_json::to_vec(&DurablePointerV1::Active { - did: did.to_owned(), - generation: 1, - epoch: 4, - }) - .expect("fixture pointer serializes"), - ) - .await - .expect("fixture pointer persists"); - - let error = vault - .restore_session() - .await - .expect_err("changed pointer must not restore"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::AuthenticationUnavailable - ); -} - -#[tokio::test] -async fn restoration_rejects_an_envelope_mismatched_with_its_pointer() { - let vault = SessionVault::new(MemorySecretStore::default()); - let stored_did = "did:plc:z72i7hdynmk6r22z27h6tvur"; - let pointer_did = "did:plc:different"; - vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: stored_did.to_owned(), - generation: 1, - epoch: 4, - artifact_base64url: fixture_session_artifact(), - }) - .await - .expect("generation persists"); - let stored_manifest = generation_manifest_key(stored_did, 4, 1); - let manifest_bytes = vault - .get(stored_manifest) - .await - .expect("manifest reads") - .expect("manifest exists"); - let mut manifest: SessionManifestV1 = - serde_json::from_slice(&manifest_bytes).expect("manifest parses"); - manifest.did = pointer_did.to_owned(); - for index in 0..manifest.chunk_count { - let chunk = vault - .get(generation_chunk_key(stored_did, 4, 1, index)) - .await - .expect("chunk reads") - .expect("chunk exists"); - vault - .commit(generation_chunk_key(pointer_did, 4, 1, index), chunk) - .await - .expect("mismatched chunk persists"); - } - vault - .commit( - generation_manifest_key(pointer_did, 4, 1), - serde_json::to_vec(&manifest).expect("manifest serializes"), - ) - .await - .expect("mismatched manifest persists"); - vault - .commit( - "pointer".to_owned(), - serde_json::to_vec(&DurablePointerV1::Active { - did: pointer_did.to_owned(), - generation: 1, - epoch: 4, - }) - .expect("pointer serializes"), - ) - .await - .expect("pointer persists"); - - let error = vault - .restore_session() - .await - .expect_err("mismatched envelope must not restore"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CredentialCorrupt - ); -} - -#[tokio::test] -async fn restoration_rejects_a_corrupt_pointer() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .commit("pointer".to_owned(), b"not-json".to_vec()) - .await - .expect("write corrupt fixture"); - let error = vault.restore_session().await.expect_err("corrupt pointer"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CredentialCorrupt - ); -} -#[tokio::test] -async fn tombstone_cleanup_requires_the_current_epoch() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .promote_active("did:plc:test".to_owned(), 1, 0) - .await - .expect("promotion succeeds"); - vault.install_tombstone(0).await.expect("logout succeeds"); - assert!(vault.cleanup_tombstone(0).await.is_err()); - vault - .cleanup_tombstone(1) - .await - .expect("current cleanup succeeds"); -} - -#[tokio::test] -async fn tombstone_cleanup_removes_cataloged_generation_artifacts() { - let vault = SessionVault::new(MemorySecretStore::default()); - let did = "did:plc:z72i7hdynmk6r22z27h6tvur"; - vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: did.to_owned(), - generation: 1, - epoch: 0, - artifact_base64url: fixture_session_artifact(), - }) - .await - .expect("generation persists"); - vault - .promote_active(did.to_owned(), 1, 0) - .await - .expect("pointer promotes"); - vault.install_tombstone(0).await.expect("logout succeeds"); - vault - .cleanup_tombstone(1) - .await - .expect("current cleanup succeeds"); - - for key in [ - "catalog".to_owned(), - generation_chunk_key(did, 0, 1, 0), - generation_manifest_key(did, 0, 1), - ] { - assert_eq!( - vault.get(key).await.expect("artifact read succeeds"), - None, - "cleanup removes every cataloged generation artifact" - ); - } -} - -#[tokio::test] -async fn cleanup_maps_store_deletion_failure_to_retryable_cleanup_outcome() { - let vault = SessionVault::new(FailingDeleteStore::default()); - vault - .promote_active("did:plc:test".to_owned(), 1, 0) - .await - .expect("promotion succeeds"); - vault.install_tombstone(0).await.expect("logout succeeds"); - let error = vault - .cleanup_tombstone(1) - .await - .expect_err("delete failure must retain tombstone"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::SessionCleanupIncomplete - ); - assert!( - vault - .get("pointer".to_owned()) - .await - .expect("pointer read succeeds") - .is_some(), - "failed cleanup retains the durable tombstone" - ); -} - -#[tokio::test] -async fn restoration_rejects_oversized_catalog_metadata() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .commit("catalog".to_owned(), vec![b' '; 2_049]) - .await - .expect("oversized catalog fixture persists"); - - let error = vault - .store_generation(&SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: "did:plc:test".to_owned(), - generation: 1, - epoch: 1, - artifact_base64url: fixture_session_artifact(), - }) - .await - .expect_err("oversized catalog must not deserialize"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CredentialCorrupt - ); -} - -#[tokio::test] -async fn restoration_rejects_oversized_manifest_metadata() { - let vault = SessionVault::new(MemorySecretStore::default()); - let did = "did:plc:test"; - vault - .commit(generation_manifest_key(did, 1, 1), vec![b' '; 2_049]) - .await - .expect("oversized manifest fixture persists"); - vault - .commit( - "pointer".to_owned(), - serde_json::to_vec(&DurablePointerV1::Active { - did: did.to_owned(), - generation: 1, - epoch: 1, - }) - .expect("pointer serializes"), - ) - .await - .expect("pointer persists"); - - let error = vault - .restore_session() - .await - .expect_err("oversized manifest must not deserialize"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CredentialCorrupt - ); -} - -#[tokio::test] -async fn interrupted_generation_writes_are_resumable() { - let did = "did:plc:z72i7hdynmk6r22z27h6tvur"; - let artifact_keys = [ - "catalog".to_owned(), - generation_chunk_key(did, 1, 1, 0), - generation_manifest_key(did, 1, 1), - ]; - - for failed_key in artifact_keys { - let vault = SessionVault::new(FailOnceSetStore::new(failed_key)); - let envelope = SessionEnvelopeV1 { - schema_version: SessionEnvelopeV1::SCHEMA_VERSION, - did: did.to_owned(), - generation: 1, - epoch: 1, - artifact_base64url: fixture_session_artifact(), - }; - assert!(vault.store_generation(&envelope).await.is_err()); - vault - .store_generation(&envelope) - .await - .expect("retry resumes the same generation"); - vault - .promote_active(did.to_owned(), 1, 1) - .await - .expect("retried generation promotes"); - assert!(matches!( - vault - .restore_session() - .await - .expect("retried generation restores") - .into_status(), - RestorationStatus::Restored { .. } - )); - } -} - -#[tokio::test] -async fn restoration_rejects_an_oversized_chunk_before_assembly() { - let vault = SessionVault::new(MemorySecretStore::default()); - let did = "did:plc:test"; - let manifest = SessionManifestV1 { - schema_version: 1, - did: did.to_owned(), - generation: 1, - epoch: 1, - chunk_count: 1, - encoded_len: 1, - sha256: String::new(), - }; - vault - .commit( - generation_manifest_key(did, 1, 1), - serde_json::to_vec(&manifest).expect("manifest serializes"), - ) - .await - .expect("manifest fixture persists"); - vault - .commit(generation_chunk_key(did, 1, 1, 0), vec![0; 1_537]) - .await - .expect("oversized chunk fixture persists"); - vault - .commit( - "pointer".to_owned(), - serde_json::to_vec(&DurablePointerV1::Active { - did: did.to_owned(), - generation: 1, - epoch: 1, - }) - .expect("pointer serializes"), - ) - .await - .expect("pointer persists"); - - let error = vault - .restore_session() - .await - .expect_err("oversized chunk must not assemble"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CredentialCorrupt - ); -} - -#[tokio::test] -async fn restoration_rejects_an_oversized_pointer_before_deserialization() { - let vault = SessionVault::new(MemorySecretStore::default()); - vault - .commit("pointer".to_owned(), vec![b' '; 2_049]) - .await - .expect("oversized pointer fixture persists"); - - let error = vault - .restore_session() - .await - .expect_err("oversized pointer must not deserialize"); - assert_eq!( - error.category(), - AuthenticationErrorCategory::CredentialCorrupt - ); +#[test] +fn replacement_legacy_cleanup_is_idempotent() { + let vault = MemoryCredentialVault::default(); + vault.purge_legacy_for_testing(); + vault.purge_legacy_for_testing(); } diff --git a/tests/support/authentication.rs b/tests/support/authentication.rs deleted file mode 100644 index f231331..0000000 --- a/tests/support/authentication.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Deterministic authentication test adapters. -//! -//! This module intentionally exposes only controllable test modes. Production -//! authentication never imports these adapters. - -use std::sync::{Arc, Mutex}; - -use hitomi::authentication::ports::{SessionInvalidation, SessionPersistence, SessionRestoration}; -use hitomi::authentication::{ - AttemptInvalidation, AuthenticationAttemptId, AuthenticationError, AuthenticationErrorCategory, - CompletedAuthorization, Epoch, PersistedActiveSession, RestorationSnapshot, RestorationStatus, -}; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum RestoreMode { - #[default] - Unavailable, - VaultUnavailable, -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum PersistenceMode { - #[default] - Unavailable, - Succeeds, -} -#[derive(Clone, Default)] -pub struct Sessions { - invalidations: Arc>>, - restore_mode: RestoreMode, - persistence_mode: PersistenceMode, -} -impl Sessions { - pub fn with_restore_mode(restore_mode: RestoreMode) -> Self { - Self { - invalidations: Arc::default(), - restore_mode, - persistence_mode: PersistenceMode::Unavailable, - } - } - pub fn with_persistence_mode(persistence_mode: PersistenceMode) -> Self { - Self { - invalidations: Arc::default(), - restore_mode: RestoreMode::Unavailable, - persistence_mode, - } - } -} -impl SessionRestoration for Sessions { - async fn restore_session(&self) -> Result { - match self.restore_mode { - RestoreMode::Unavailable => Ok(RestorationSnapshot::new( - Epoch::new(0), - RestorationStatus::Unavailable, - )), - RestoreMode::VaultUnavailable => Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::CredentialStoreUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )), - } - } -} -impl SessionPersistence for Sessions { - async fn persist_completed_authorization( - &self, - _: CompletedAuthorization, - ) -> Result { - match self.persistence_mode { - PersistenceMode::Unavailable => Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )), - PersistenceMode::Succeeds => { - #[cfg(feature = "test-support")] - { - PersistedActiveSession::for_testing("did:plc:behavior", Epoch::new(1)) - } - #[cfg(not(feature = "test-support"))] - { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } - } - } - } -} -impl SessionInvalidation for Sessions { - async fn invalidate_attempt( - &self, - attempt: &AuthenticationAttemptId, - epoch: Epoch, - ) -> Result { - self.invalidations - .lock() - .expect("test lock") - .push((attempt.clone(), epoch)); - Ok(AttemptInvalidation::Invalidated) - } - async fn install_logout_tombstone(&self, _: Epoch) -> Result { - Err(AuthenticationError::new( - hitomi::authentication::AuthenticationFailureStage::StateTransition, - AuthenticationErrorCategory::AuthenticationUnavailable, - hitomi::authentication::AuthenticationFailureReason::StateRejected, - )) - } - async fn cleanup_tombstoned_epoch(&self, _: Epoch) -> Result<(), AuthenticationError> { - Ok(()) - } -} diff --git a/tests/support/mod.rs b/tests/support/mod.rs deleted file mode 100644 index b235cc9..0000000 --- a/tests/support/mod.rs +++ /dev/null @@ -1,88 +0,0 @@ -#[allow( - dead_code, - reason = "behavior-only shared fakes are compiled by every integration target" -)] -pub mod authentication; -#[allow( - dead_code, - reason = "owned session fixtures are compiled independently by each integration target" -)] -pub mod owned_session; - -use std::{ - env, - ffi::OsString, - fs, io, - path::PathBuf, - process::{Command, ExitStatus}, - sync::atomic::{AtomicUsize, Ordering}, -}; - -#[allow( - dead_code, - reason = "shared integration-test helpers are compiled independently by each test target" -)] -static NEXT_BEHAVIOR_FIXTURE: AtomicUsize = AtomicUsize::new(0); - -#[allow( - dead_code, - reason = "shared integration-test helpers are compiled independently by each test target" -)] -pub struct BehaviorFeatureDirectory { - path: PathBuf, -} - -#[allow( - dead_code, - reason = "shared integration-test helpers are compiled independently by each test target" -)] -impl BehaviorFeatureDirectory { - pub fn new(feature: &str) -> io::Result { - let sequence = NEXT_BEHAVIOR_FIXTURE.fetch_add(1, Ordering::Relaxed); - let path = env::temp_dir().join(format!( - "hitomi-behavior-contract-{}-{sequence}", - std::process::id() - )); - fs::create_dir(&path)?; - if let Err(error) = fs::write(path.join("contract.feature"), feature) { - let _ = fs::remove_dir_all(&path); - return Err(error); - } - Ok(Self { path }) - } - - pub fn run_behavior(&self) -> io::Result { - Command::new(cargo_executable()) - .arg("test") - .arg("--quiet") - .arg("--features") - .arg("test-support") - .arg("--test") - .arg("behavior") - .current_dir(env!("CARGO_MANIFEST_DIR")) - .env("CARGO_TERM_COLOR", "never") - .env("HITOMI_FEATURES_DIR", &self.path) - .output() - .map(|output| output.status) - } - - pub fn cleanup(&self) -> io::Result<()> { - fs::remove_dir_all(&self.path) - } -} - -impl Drop for BehaviorFeatureDirectory { - fn drop(&mut self) { - let _ = self.cleanup(); - } -} - -#[allow( - dead_code, - reason = "shared integration-test helpers are compiled independently by each test target" -)] -fn cargo_executable() -> OsString { - option_env!("CARGO") - .map(OsString::from) - .unwrap_or_else(|| OsString::from("cargo")) -} diff --git a/tests/support/owned_session.rs b/tests/support/owned_session.rs deleted file mode 100644 index 638e6b6..0000000 --- a/tests/support/owned_session.rs +++ /dev/null @@ -1,28 +0,0 @@ -use base64::Engine as _; - -pub const SUBJECT: &str = "did:plc:z72i7hdynmk6r22z27h6tvur"; - -pub fn artifact_base64url() -> String { - let scalar = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1_u8; 32]); - let session = serde_json::json!({ - "schema_version": 1, - "client_id": "https://client.example.test", - "issuer": "https://issuer.example.test", - "resource": "https://pds.example.test", - "subject": SUBJECT, - "scope": "atproto transition:generic", - "access_token": "fixture-access-token", - "refresh_token": "fixture-refresh-token", - "expires_at_unix_seconds": null, - "dpop_private_scalar": scalar, - "nonces": {} - }); - base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(serde_json::to_vec(&session).expect("static owned session fixture serializes")) -} - -pub fn artifact_bytes() -> Vec { - base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(artifact_base64url()) - .expect("owned session fixture base64 decodes") -}