BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

build: vendored chat scope

+13982 -35
+53
patches/jacquard-oauth-0.11-chat-scope.patch
··· 1 + --- upstream/src/scopes.rs 2 + +++ vendor/src/scopes.rs 3 + @@ -153,6 +153,8 @@ 4 + pub enum TransitionScope { 5 + /// Generic transition operations 6 + Generic, 7 + + /// Bluesky chat / DM transition operations 8 + + ChatBsky, 9 + /// Email transition operations 10 + Email, 11 + } 12 + @@ -709,6 +711,7 @@ 13 + fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> { 14 + let scope = match suffix { 15 + Some("generic") => TransitionScope::Generic, 16 + + Some("chat.bsky") => TransitionScope::ChatBsky, 17 + Some("email") => TransitionScope::Email, 18 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 19 + None => return Err(ParseError::MissingResource), 20 + @@ -855,6 +858,7 @@ 21 + Scope::Atproto => "atproto".to_string(), 22 + Scope::Transition(scope) => match scope { 23 + TransitionScope::Generic => "transition:generic".to_string(), 24 + + TransitionScope::ChatBsky => "transition:chat.bsky".to_string(), 25 + TransitionScope::Email => "transition:email".to_string(), 26 + }, 27 + Scope::OpenId => "openid".to_string(), 28 + @@ -1396,6 +1400,9 @@ 29 + fn test_transition_scope_parsing() { 30 + let scope = Scope::parse("transition:generic").unwrap(); 31 + assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); 32 + + 33 + + let scope = Scope::parse("transition:chat.bsky").unwrap(); 34 + + assert_eq!(scope, Scope::Transition(TransitionScope::ChatBsky)); 35 + 36 + let scope = Scope::parse("transition:email").unwrap(); 37 + assert_eq!(scope, Scope::Transition(TransitionScope::Email)); 38 + @@ -1864,13 +1871,14 @@ 39 + 40 + // Test with transition scopes 41 + let scopes = vec![ 42 + + Scope::Transition(TransitionScope::ChatBsky), 43 + Scope::Transition(TransitionScope::Email), 44 + Scope::Transition(TransitionScope::Generic), 45 + Scope::Atproto, 46 + ]; 47 + assert_eq!( 48 + Scope::serialize_multiple(&scopes), 49 + - "atproto transition:email transition:generic" 50 + + "atproto transition:chat.bsky transition:email transition:generic" 51 + ); 52 + 53 + // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed)
-2
src-tauri/Cargo.lock
··· 3601 3601 [[package]] 3602 3602 name = "jacquard-oauth" 3603 3603 version = "0.11.0" 3604 - source = "registry+https://github.com/rust-lang/crates.io-index" 3605 - checksum = "ca0b3a8b765b3d3f1890233f9f62dda1b1524d1dc9967c310e5b009b93443776" 3606 3604 dependencies = [ 3607 3605 "base64 0.22.1", 3608 3606 "bytes",
+2
src-tauri/Cargo.toml
··· 41 41 # [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] 42 42 # tauri-plugin-updater = "2" 43 43 44 + [patch.crates-io] 45 + jacquard-oauth = { path = "vendor/jacquard-oauth" } 44 46 45 47 [lints.clippy] 46 48 bool_comparison = "deny"
+5 -1
src-tauri/src/auth.rs
··· 32 32 const CLIENT_METADATA_URL: &str = "https://lazurite.stormlightlabs.org/client-metadata.json"; 33 33 const CLIENT_SITE_URL: &str = "https://lazurite.stormlightlabs.org"; 34 34 const LOOPBACK_CALLBACK_PATH: &str = "/callback"; 35 - const LOOPBACK_SCOPE: &str = "atproto transition:generic"; 35 + const LOOPBACK_SCOPE: &str = "atproto transition:generic transition:chat.bsky"; 36 36 const LOGIN_TYPEAHEAD_LIMIT: usize = 6; 37 37 const LOGIN_TYPEAHEAD_CLIENT: &str = "lazurite-desktop"; 38 38 const LOGIN_TYPEAHEAD_PRIMARY_URL: &str = "https://typeahead.waow.tech"; ··· 784 784 }; 785 785 use crate::db::DbPool; 786 786 use jacquard::common::deps::fluent_uri::Uri; 787 + use jacquard::oauth::scopes::{Scope, TransitionScope}; 787 788 use reqwest::StatusCode; 788 789 use rusqlite::{params, Connection}; 789 790 use std::sync::{Arc, Mutex}; ··· 905 906 Some("https://lazurite.stormlightlabs.org") 906 907 ); 907 908 assert_eq!(metadata.redirect_uris[0].as_str(), "http://127.0.0.1/callback"); 909 + assert!(metadata.scopes.contains(&Scope::Atproto)); 910 + assert!(metadata.scopes.contains(&Scope::Transition(TransitionScope::Generic))); 911 + assert!(metadata.scopes.contains(&Scope::Transition(TransitionScope::ChatBsky))); 908 912 } 909 913 910 914 #[test]
+86 -32
src-tauri/src/conversations.rs
··· 7 7 use jacquard::api::chat_bsky::convo::send_message::SendMessage; 8 8 use jacquard::api::chat_bsky::convo::update_read::UpdateRead; 9 9 use jacquard::api::chat_bsky::convo::MessageInput; 10 + use jacquard::common::error::{ClientError, ClientErrorKind}; 11 + use jacquard::oauth::authstore::ClientAuthStore; 12 + use jacquard::oauth::scopes::{Scope, TransitionScope}; 10 13 use jacquard::types::did::Did; 11 14 use jacquard::xrpc::{CallOptions, XrpcClient}; 12 15 use jacquard::IntoStatic; 16 + use reqwest::StatusCode; 13 17 use serde_json::Value; 14 18 use std::sync::Arc; 15 19 use tauri_plugin_log::log; 16 20 17 21 const CHAT_PROXY: &str = "did:web:api.bsky.chat#bsky_chat"; 22 + const CHAT_SCOPE_MISSING_MESSAGE: &str = 23 + "This account was authenticated without DM access. Sign out and sign back in to enable messages."; 18 24 19 25 async fn get_session(state: &AppState) -> Result<Arc<LazuriteOAuthSession>> { 20 - let did = state 21 - .active_session 22 - .read() 23 - .map_err(|error| { 24 - log::error!("active_session poisoned: {error}"); 25 - AppError::StatePoisoned("active_session") 26 - })? 27 - .as_ref() 28 - .ok_or_else(|| { 29 - log::error!("no active account"); 30 - AppError::Validation("no active account".into()) 31 - })? 32 - .did 33 - .clone(); 26 + let did = active_did(state)?; 34 27 35 28 state 36 29 .sessions ··· 47 40 }) 48 41 } 49 42 43 + fn active_did(state: &AppState) -> Result<String> { 44 + Ok(state 45 + .active_session 46 + .read() 47 + .map_err(|error| { 48 + log::error!("active_session poisoned: {error}"); 49 + AppError::StatePoisoned("active_session") 50 + })? 51 + .as_ref() 52 + .ok_or_else(|| { 53 + log::error!("no active account"); 54 + AppError::Validation("no active account".into()) 55 + })? 56 + .did 57 + .clone()) 58 + } 59 + 60 + async fn ensure_chat_scope(state: &AppState) -> Result<()> { 61 + let did = active_did(state)?; 62 + let parsed_did = Did::new(&did).map_err(|_| AppError::validation("invalid active account DID"))?; 63 + let account = state 64 + .auth_store 65 + .get_account(&did)? 66 + .ok_or_else(|| { 67 + log::error!("active account missing from auth store"); 68 + AppError::validation("no active account") 69 + })?; 70 + let session_id = account.session_id.ok_or_else(|| { 71 + log::error!("active account missing session id"); 72 + AppError::validation("no active account session") 73 + })?; 74 + let session_data = state 75 + .auth_store 76 + .get_session(&parsed_did, &session_id) 77 + .await 78 + .map_err(AppError::from)? 79 + .ok_or_else(|| { 80 + log::error!("persisted session missing for active account"); 81 + AppError::validation("no active account session") 82 + })?; 83 + 84 + if session_data 85 + .scopes 86 + .iter() 87 + .any(|scope| matches!(scope, Scope::Transition(TransitionScope::ChatBsky))) 88 + { 89 + return Ok(()); 90 + } 91 + 92 + log::warn!("active session is missing transition:chat.bsky scope"); 93 + Err(AppError::validation(CHAT_SCOPE_MISSING_MESSAGE)) 94 + } 95 + 50 96 fn chat_opts() -> CallOptions<'static> { 51 97 CallOptions { atproto_proxy: Some(CHAT_PROXY.into()), ..Default::default() } 52 98 } 53 99 100 + fn map_chat_error(error: ClientError, default_message: &'static str, context: &'static str) -> AppError { 101 + if let ClientErrorKind::Http { status } = error.kind() { 102 + if *status == StatusCode::FORBIDDEN { 103 + log::warn!("{context} forbidden, likely missing DM scope"); 104 + return AppError::validation(CHAT_SCOPE_MISSING_MESSAGE); 105 + } 106 + } 107 + 108 + log::error!("{context}: {error}"); 109 + AppError::validation(default_message) 110 + } 111 + 54 112 pub async fn list_convos(cursor: Option<String>, limit: Option<u32>, state: &AppState) -> Result<Value> { 113 + ensure_chat_scope(state).await?; 55 114 let session = get_session(state).await?; 56 115 let mut req = ListConvos::new().limit(limit.map(|n| n as i64)); 57 116 if let Some(c) = &cursor { ··· 61 120 let output = session 62 121 .send_with_opts(req.build(), chat_opts()) 63 122 .await 64 - .map_err(|error| { 65 - log::error!("listConvos error: {error}"); 66 - AppError::validation("Could not load conversations.") 67 - })? 123 + .map_err(|error| map_chat_error(error, "Could not load conversations.", "listConvos error"))? 68 124 .into_output() 69 125 .map_err(|error| { 70 126 log::error!("listConvos output error: {error}"); ··· 79 135 return Err(AppError::validation("members must not be empty")); 80 136 } 81 137 138 + ensure_chat_scope(state).await?; 82 139 let session = get_session(state).await?; 83 140 let dids: Result<Vec<Did<'static>>> = members 84 141 .iter() ··· 93 150 let output = session 94 151 .send_with_opts(req, chat_opts()) 95 152 .await 96 - .map_err(|error| { 97 - log::error!("getConvoForMembers error: {error}"); 98 - AppError::validation("Could not open this conversation.") 99 - })? 153 + .map_err(|error| map_chat_error(error, "Could not open this conversation.", "getConvoForMembers error"))? 100 154 .into_output() 101 155 .map_err(|error| { 102 156 log::error!("getConvoForMembers output error: {error}"); ··· 113 167 return Err(AppError::validation("convo_id must not be empty")); 114 168 } 115 169 170 + ensure_chat_scope(state).await?; 116 171 let session = get_session(state).await?; 117 172 let mut req = GetMessages::new() 118 173 .convo_id(convo_id.as_str()) ··· 124 179 let output = session 125 180 .send_with_opts(req.build(), chat_opts()) 126 181 .await 127 - .map_err(|error| { 128 - log::error!("getMessages error: {error}"); 129 - AppError::validation("Could not load messages.") 130 - })? 182 + .map_err(|error| map_chat_error(error, "Could not load messages.", "getMessages error"))? 131 183 .into_output() 132 184 .map_err(|error| { 133 185 log::error!("getMessages output error: {error}"); ··· 145 197 return Err(AppError::validation("message text must not be empty")); 146 198 } 147 199 200 + ensure_chat_scope(state).await?; 148 201 let session = get_session(state).await?; 149 202 let msg = MessageInput { text: text.into(), facets: None, embed: None, ..Default::default() }; 150 203 let req = SendMessage::new().convo_id(convo_id.as_str()).message(msg).build(); ··· 152 205 let output = session 153 206 .send_with_opts(req, chat_opts()) 154 207 .await 155 - .map_err(|error| { 156 - log::error!("sendMessage error: {error}"); 157 - AppError::validation("Could not send this message.") 158 - })? 208 + .map_err(|error| map_chat_error(error, "Could not send this message.", "sendMessage error"))? 159 209 .into_output() 160 210 .map_err(|error| { 161 211 log::error!("sendMessage output error: {error}"); ··· 170 220 return Err(AppError::validation("convo_id must not be empty")); 171 221 } 172 222 223 + ensure_chat_scope(state).await?; 173 224 let session = get_session(state).await?; 174 225 let req = UpdateRead { 175 226 convo_id: convo_id.as_str().into(), ··· 181 232 .send_with_opts(req, chat_opts()) 182 233 .await 183 234 .map_err(|error| { 184 - log::error!("updateRead error: {error}"); 185 - AppError::validation("Could not update the read status for this conversation.") 235 + map_chat_error( 236 + error, 237 + "Could not update the read status for this conversation.", 238 + "updateRead error", 239 + ) 186 240 })? 187 241 .into_output() 188 242 .map_err(|error| {
+1
src-tauri/vendor/jacquard-oauth/.cargo-ok
··· 1 + {"v":1}
+6
src-tauri/vendor/jacquard-oauth/.cargo_vcs_info.json
··· 1 + { 2 + "git": { 3 + "sha1": "dd2e2bbf6bcbfd5e9cf1727bddb828a3f0038802" 4 + }, 5 + "path_in_vcs": "crates/jacquard-oauth" 6 + }
+4298
src-tauri/vendor/jacquard-oauth/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 3 4 + 5 + [[package]] 6 + name = "adler2" 7 + version = "2.0.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 10 + 11 + [[package]] 12 + name = "adler32" 13 + version = "1.2.0" 14 + source = "registry+https://github.com/rust-lang/crates.io-index" 15 + checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" 16 + 17 + [[package]] 18 + name = "aho-corasick" 19 + version = "1.1.4" 20 + source = "registry+https://github.com/rust-lang/crates.io-index" 21 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 22 + dependencies = [ 23 + "memchr", 24 + ] 25 + 26 + [[package]] 27 + name = "aliasable" 28 + version = "0.1.3" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" 31 + 32 + [[package]] 33 + name = "alloc-no-stdlib" 34 + version = "2.0.4" 35 + source = "registry+https://github.com/rust-lang/crates.io-index" 36 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" 37 + 38 + [[package]] 39 + name = "alloc-stdlib" 40 + version = "0.2.2" 41 + source = "registry+https://github.com/rust-lang/crates.io-index" 42 + checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" 43 + dependencies = [ 44 + "alloc-no-stdlib", 45 + ] 46 + 47 + [[package]] 48 + name = "allocator-api2" 49 + version = "0.2.21" 50 + source = "registry+https://github.com/rust-lang/crates.io-index" 51 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" 52 + 53 + [[package]] 54 + name = "android_system_properties" 55 + version = "0.1.5" 56 + source = "registry+https://github.com/rust-lang/crates.io-index" 57 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 58 + dependencies = [ 59 + "libc", 60 + ] 61 + 62 + [[package]] 63 + name = "anyhow" 64 + version = "1.0.102" 65 + source = "registry+https://github.com/rust-lang/crates.io-index" 66 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 67 + 68 + [[package]] 69 + name = "ascii" 70 + version = "1.1.0" 71 + source = "registry+https://github.com/rust-lang/crates.io-index" 72 + checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" 73 + 74 + [[package]] 75 + name = "async-compression" 76 + version = "0.4.41" 77 + source = "registry+https://github.com/rust-lang/crates.io-index" 78 + checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" 79 + dependencies = [ 80 + "compression-codecs", 81 + "compression-core", 82 + "pin-project-lite", 83 + "tokio", 84 + ] 85 + 86 + [[package]] 87 + name = "atomic-polyfill" 88 + version = "1.0.3" 89 + source = "registry+https://github.com/rust-lang/crates.io-index" 90 + checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" 91 + dependencies = [ 92 + "critical-section", 93 + ] 94 + 95 + [[package]] 96 + name = "atomic-waker" 97 + version = "1.1.2" 98 + source = "registry+https://github.com/rust-lang/crates.io-index" 99 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 100 + 101 + [[package]] 102 + name = "autocfg" 103 + version = "1.5.0" 104 + source = "registry+https://github.com/rust-lang/crates.io-index" 105 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" 106 + 107 + [[package]] 108 + name = "base-x" 109 + version = "0.2.11" 110 + source = "registry+https://github.com/rust-lang/crates.io-index" 111 + checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" 112 + 113 + [[package]] 114 + name = "base16ct" 115 + version = "0.2.0" 116 + source = "registry+https://github.com/rust-lang/crates.io-index" 117 + checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" 118 + 119 + [[package]] 120 + name = "base256emoji" 121 + version = "1.0.2" 122 + source = "registry+https://github.com/rust-lang/crates.io-index" 123 + checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" 124 + dependencies = [ 125 + "const-str", 126 + "match-lookup", 127 + ] 128 + 129 + [[package]] 130 + name = "base64" 131 + version = "0.13.1" 132 + source = "registry+https://github.com/rust-lang/crates.io-index" 133 + checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" 134 + 135 + [[package]] 136 + name = "base64" 137 + version = "0.22.1" 138 + source = "registry+https://github.com/rust-lang/crates.io-index" 139 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 140 + 141 + [[package]] 142 + name = "base64ct" 143 + version = "1.8.3" 144 + source = "registry+https://github.com/rust-lang/crates.io-index" 145 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 146 + 147 + [[package]] 148 + name = "bitflags" 149 + version = "2.11.0" 150 + source = "registry+https://github.com/rust-lang/crates.io-index" 151 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" 152 + 153 + [[package]] 154 + name = "block-buffer" 155 + version = "0.10.4" 156 + source = "registry+https://github.com/rust-lang/crates.io-index" 157 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 158 + dependencies = [ 159 + "generic-array", 160 + ] 161 + 162 + [[package]] 163 + name = "bon" 164 + version = "3.9.1" 165 + source = "registry+https://github.com/rust-lang/crates.io-index" 166 + checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" 167 + dependencies = [ 168 + "bon-macros", 169 + "rustversion", 170 + ] 171 + 172 + [[package]] 173 + name = "bon-macros" 174 + version = "3.9.1" 175 + source = "registry+https://github.com/rust-lang/crates.io-index" 176 + checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" 177 + dependencies = [ 178 + "darling", 179 + "ident_case", 180 + "prettyplease", 181 + "proc-macro2", 182 + "quote", 183 + "rustversion", 184 + "syn", 185 + ] 186 + 187 + [[package]] 188 + name = "borrow-or-share" 189 + version = "0.2.4" 190 + source = "registry+https://github.com/rust-lang/crates.io-index" 191 + checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" 192 + 193 + [[package]] 194 + name = "borsh" 195 + version = "1.6.1" 196 + source = "registry+https://github.com/rust-lang/crates.io-index" 197 + checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" 198 + dependencies = [ 199 + "bytes", 200 + "cfg_aliases", 201 + ] 202 + 203 + [[package]] 204 + name = "brotli" 205 + version = "3.5.0" 206 + source = "registry+https://github.com/rust-lang/crates.io-index" 207 + checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" 208 + dependencies = [ 209 + "alloc-no-stdlib", 210 + "alloc-stdlib", 211 + "brotli-decompressor", 212 + ] 213 + 214 + [[package]] 215 + name = "brotli-decompressor" 216 + version = "2.5.1" 217 + source = "registry+https://github.com/rust-lang/crates.io-index" 218 + checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" 219 + dependencies = [ 220 + "alloc-no-stdlib", 221 + "alloc-stdlib", 222 + ] 223 + 224 + [[package]] 225 + name = "buf_redux" 226 + version = "0.8.4" 227 + source = "registry+https://github.com/rust-lang/crates.io-index" 228 + checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" 229 + dependencies = [ 230 + "memchr", 231 + "safemem", 232 + ] 233 + 234 + [[package]] 235 + name = "bumpalo" 236 + version = "3.20.2" 237 + source = "registry+https://github.com/rust-lang/crates.io-index" 238 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" 239 + 240 + [[package]] 241 + name = "byteorder" 242 + version = "1.5.0" 243 + source = "registry+https://github.com/rust-lang/crates.io-index" 244 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 245 + 246 + [[package]] 247 + name = "bytes" 248 + version = "1.11.1" 249 + source = "registry+https://github.com/rust-lang/crates.io-index" 250 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" 251 + dependencies = [ 252 + "serde", 253 + ] 254 + 255 + [[package]] 256 + name = "cbor4ii" 257 + version = "0.2.14" 258 + source = "registry+https://github.com/rust-lang/crates.io-index" 259 + checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" 260 + dependencies = [ 261 + "serde", 262 + ] 263 + 264 + [[package]] 265 + name = "cc" 266 + version = "1.2.57" 267 + source = "registry+https://github.com/rust-lang/crates.io-index" 268 + checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" 269 + dependencies = [ 270 + "find-msvc-tools", 271 + "shlex", 272 + ] 273 + 274 + [[package]] 275 + name = "cfg-if" 276 + version = "1.0.4" 277 + source = "registry+https://github.com/rust-lang/crates.io-index" 278 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 279 + 280 + [[package]] 281 + name = "cfg_aliases" 282 + version = "0.2.1" 283 + source = "registry+https://github.com/rust-lang/crates.io-index" 284 + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 285 + 286 + [[package]] 287 + name = "chrono" 288 + version = "0.4.44" 289 + source = "registry+https://github.com/rust-lang/crates.io-index" 290 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" 291 + dependencies = [ 292 + "iana-time-zone", 293 + "js-sys", 294 + "num-traits", 295 + "serde", 296 + "wasm-bindgen", 297 + "windows-link", 298 + ] 299 + 300 + [[package]] 301 + name = "chunked_transfer" 302 + version = "1.5.0" 303 + source = "registry+https://github.com/rust-lang/crates.io-index" 304 + checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" 305 + 306 + [[package]] 307 + name = "ciborium" 308 + version = "0.2.2" 309 + source = "registry+https://github.com/rust-lang/crates.io-index" 310 + checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" 311 + dependencies = [ 312 + "ciborium-io", 313 + "ciborium-ll", 314 + "serde", 315 + ] 316 + 317 + [[package]] 318 + name = "ciborium-io" 319 + version = "0.2.2" 320 + source = "registry+https://github.com/rust-lang/crates.io-index" 321 + checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" 322 + 323 + [[package]] 324 + name = "ciborium-ll" 325 + version = "0.2.2" 326 + source = "registry+https://github.com/rust-lang/crates.io-index" 327 + checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" 328 + dependencies = [ 329 + "ciborium-io", 330 + "half", 331 + ] 332 + 333 + [[package]] 334 + name = "cid" 335 + version = "0.11.1" 336 + source = "registry+https://github.com/rust-lang/crates.io-index" 337 + checksum = "3147d8272e8fa0ccd29ce51194dd98f79ddfb8191ba9e3409884e751798acf3a" 338 + dependencies = [ 339 + "core2", 340 + "multibase", 341 + "multihash", 342 + "serde", 343 + "serde_bytes", 344 + "unsigned-varint", 345 + ] 346 + 347 + [[package]] 348 + name = "cobs" 349 + version = "0.3.0" 350 + source = "registry+https://github.com/rust-lang/crates.io-index" 351 + checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" 352 + dependencies = [ 353 + "thiserror 2.0.18", 354 + ] 355 + 356 + [[package]] 357 + name = "combine" 358 + version = "4.6.7" 359 + source = "registry+https://github.com/rust-lang/crates.io-index" 360 + checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 361 + dependencies = [ 362 + "bytes", 363 + "memchr", 364 + ] 365 + 366 + [[package]] 367 + name = "compression-codecs" 368 + version = "0.4.37" 369 + source = "registry+https://github.com/rust-lang/crates.io-index" 370 + checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" 371 + dependencies = [ 372 + "compression-core", 373 + "flate2", 374 + "memchr", 375 + ] 376 + 377 + [[package]] 378 + name = "compression-core" 379 + version = "0.4.31" 380 + source = "registry+https://github.com/rust-lang/crates.io-index" 381 + checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" 382 + 383 + [[package]] 384 + name = "const-oid" 385 + version = "0.9.6" 386 + source = "registry+https://github.com/rust-lang/crates.io-index" 387 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 388 + 389 + [[package]] 390 + name = "const-str" 391 + version = "0.4.3" 392 + source = "registry+https://github.com/rust-lang/crates.io-index" 393 + checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" 394 + 395 + [[package]] 396 + name = "cordyceps" 397 + version = "0.3.4" 398 + source = "registry+https://github.com/rust-lang/crates.io-index" 399 + checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" 400 + dependencies = [ 401 + "loom", 402 + "tracing", 403 + ] 404 + 405 + [[package]] 406 + name = "core-foundation" 407 + version = "0.9.4" 408 + source = "registry+https://github.com/rust-lang/crates.io-index" 409 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" 410 + dependencies = [ 411 + "core-foundation-sys", 412 + "libc", 413 + ] 414 + 415 + [[package]] 416 + name = "core-foundation" 417 + version = "0.10.1" 418 + source = "registry+https://github.com/rust-lang/crates.io-index" 419 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" 420 + dependencies = [ 421 + "core-foundation-sys", 422 + "libc", 423 + ] 424 + 425 + [[package]] 426 + name = "core-foundation-sys" 427 + version = "0.8.7" 428 + source = "registry+https://github.com/rust-lang/crates.io-index" 429 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 430 + 431 + [[package]] 432 + name = "core2" 433 + version = "0.4.0" 434 + source = "registry+https://github.com/rust-lang/crates.io-index" 435 + checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" 436 + dependencies = [ 437 + "memchr", 438 + ] 439 + 440 + [[package]] 441 + name = "cpufeatures" 442 + version = "0.2.17" 443 + source = "registry+https://github.com/rust-lang/crates.io-index" 444 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 445 + dependencies = [ 446 + "libc", 447 + ] 448 + 449 + [[package]] 450 + name = "crc32fast" 451 + version = "1.5.0" 452 + source = "registry+https://github.com/rust-lang/crates.io-index" 453 + checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" 454 + dependencies = [ 455 + "cfg-if", 456 + ] 457 + 458 + [[package]] 459 + name = "critical-section" 460 + version = "1.2.0" 461 + source = "registry+https://github.com/rust-lang/crates.io-index" 462 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 463 + 464 + [[package]] 465 + name = "crossbeam-utils" 466 + version = "0.8.21" 467 + source = "registry+https://github.com/rust-lang/crates.io-index" 468 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 469 + 470 + [[package]] 471 + name = "crunchy" 472 + version = "0.2.4" 473 + source = "registry+https://github.com/rust-lang/crates.io-index" 474 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" 475 + 476 + [[package]] 477 + name = "crypto-bigint" 478 + version = "0.5.5" 479 + source = "registry+https://github.com/rust-lang/crates.io-index" 480 + checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 481 + dependencies = [ 482 + "generic-array", 483 + "rand_core 0.6.4", 484 + "subtle", 485 + "zeroize", 486 + ] 487 + 488 + [[package]] 489 + name = "crypto-common" 490 + version = "0.1.6" 491 + source = "registry+https://github.com/rust-lang/crates.io-index" 492 + checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 493 + dependencies = [ 494 + "generic-array", 495 + "typenum", 496 + ] 497 + 498 + [[package]] 499 + name = "curve25519-dalek" 500 + version = "4.1.3" 501 + source = "registry+https://github.com/rust-lang/crates.io-index" 502 + checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" 503 + dependencies = [ 504 + "cfg-if", 505 + "cpufeatures", 506 + "curve25519-dalek-derive", 507 + "digest", 508 + "fiat-crypto", 509 + "rustc_version", 510 + "subtle", 511 + "zeroize", 512 + ] 513 + 514 + [[package]] 515 + name = "curve25519-dalek-derive" 516 + version = "0.1.1" 517 + source = "registry+https://github.com/rust-lang/crates.io-index" 518 + checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" 519 + dependencies = [ 520 + "proc-macro2", 521 + "quote", 522 + "syn", 523 + ] 524 + 525 + [[package]] 526 + name = "darling" 527 + version = "0.23.0" 528 + source = "registry+https://github.com/rust-lang/crates.io-index" 529 + checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" 530 + dependencies = [ 531 + "darling_core", 532 + "darling_macro", 533 + ] 534 + 535 + [[package]] 536 + name = "darling_core" 537 + version = "0.23.0" 538 + source = "registry+https://github.com/rust-lang/crates.io-index" 539 + checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" 540 + dependencies = [ 541 + "ident_case", 542 + "proc-macro2", 543 + "quote", 544 + "strsim", 545 + "syn", 546 + ] 547 + 548 + [[package]] 549 + name = "darling_macro" 550 + version = "0.23.0" 551 + source = "registry+https://github.com/rust-lang/crates.io-index" 552 + checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" 553 + dependencies = [ 554 + "darling_core", 555 + "quote", 556 + "syn", 557 + ] 558 + 559 + [[package]] 560 + name = "dashmap" 561 + version = "6.1.0" 562 + source = "registry+https://github.com/rust-lang/crates.io-index" 563 + checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" 564 + dependencies = [ 565 + "cfg-if", 566 + "crossbeam-utils", 567 + "hashbrown 0.14.5", 568 + "lock_api", 569 + "once_cell", 570 + "parking_lot_core", 571 + ] 572 + 573 + [[package]] 574 + name = "data-encoding" 575 + version = "2.10.0" 576 + source = "registry+https://github.com/rust-lang/crates.io-index" 577 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 578 + 579 + [[package]] 580 + name = "data-encoding-macro" 581 + version = "0.1.19" 582 + source = "registry+https://github.com/rust-lang/crates.io-index" 583 + checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" 584 + dependencies = [ 585 + "data-encoding", 586 + "data-encoding-macro-internal", 587 + ] 588 + 589 + [[package]] 590 + name = "data-encoding-macro-internal" 591 + version = "0.1.17" 592 + source = "registry+https://github.com/rust-lang/crates.io-index" 593 + checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" 594 + dependencies = [ 595 + "data-encoding", 596 + "syn", 597 + ] 598 + 599 + [[package]] 600 + name = "deflate" 601 + version = "1.0.0" 602 + source = "registry+https://github.com/rust-lang/crates.io-index" 603 + checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" 604 + dependencies = [ 605 + "adler32", 606 + "gzip-header", 607 + ] 608 + 609 + [[package]] 610 + name = "der" 611 + version = "0.7.10" 612 + source = "registry+https://github.com/rust-lang/crates.io-index" 613 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 614 + dependencies = [ 615 + "const-oid", 616 + "pem-rfc7468", 617 + "zeroize", 618 + ] 619 + 620 + [[package]] 621 + name = "deranged" 622 + version = "0.5.8" 623 + source = "registry+https://github.com/rust-lang/crates.io-index" 624 + checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" 625 + dependencies = [ 626 + "powerfmt", 627 + ] 628 + 629 + [[package]] 630 + name = "derive_more" 631 + version = "1.0.0" 632 + source = "registry+https://github.com/rust-lang/crates.io-index" 633 + checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 634 + dependencies = [ 635 + "derive_more-impl", 636 + ] 637 + 638 + [[package]] 639 + name = "derive_more-impl" 640 + version = "1.0.0" 641 + source = "registry+https://github.com/rust-lang/crates.io-index" 642 + checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 643 + dependencies = [ 644 + "proc-macro2", 645 + "quote", 646 + "syn", 647 + "unicode-xid", 648 + ] 649 + 650 + [[package]] 651 + name = "diatomic-waker" 652 + version = "0.2.3" 653 + source = "registry+https://github.com/rust-lang/crates.io-index" 654 + checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" 655 + 656 + [[package]] 657 + name = "digest" 658 + version = "0.10.7" 659 + source = "registry+https://github.com/rust-lang/crates.io-index" 660 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 661 + dependencies = [ 662 + "block-buffer", 663 + "const-oid", 664 + "crypto-common", 665 + "subtle", 666 + ] 667 + 668 + [[package]] 669 + name = "displaydoc" 670 + version = "0.2.5" 671 + source = "registry+https://github.com/rust-lang/crates.io-index" 672 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 673 + dependencies = [ 674 + "proc-macro2", 675 + "quote", 676 + "syn", 677 + ] 678 + 679 + [[package]] 680 + name = "ecdsa" 681 + version = "0.16.9" 682 + source = "registry+https://github.com/rust-lang/crates.io-index" 683 + checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" 684 + dependencies = [ 685 + "der", 686 + "digest", 687 + "elliptic-curve", 688 + "rfc6979", 689 + "signature", 690 + "spki", 691 + ] 692 + 693 + [[package]] 694 + name = "ed25519" 695 + version = "2.2.3" 696 + source = "registry+https://github.com/rust-lang/crates.io-index" 697 + checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" 698 + dependencies = [ 699 + "pkcs8", 700 + "signature", 701 + ] 702 + 703 + [[package]] 704 + name = "ed25519-dalek" 705 + version = "2.2.0" 706 + source = "registry+https://github.com/rust-lang/crates.io-index" 707 + checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" 708 + dependencies = [ 709 + "curve25519-dalek", 710 + "ed25519", 711 + "rand_core 0.6.4", 712 + "serde", 713 + "sha2", 714 + "subtle", 715 + "zeroize", 716 + ] 717 + 718 + [[package]] 719 + name = "elliptic-curve" 720 + version = "0.13.8" 721 + source = "registry+https://github.com/rust-lang/crates.io-index" 722 + checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" 723 + dependencies = [ 724 + "base16ct", 725 + "crypto-bigint", 726 + "digest", 727 + "ff", 728 + "generic-array", 729 + "group", 730 + "hkdf", 731 + "pem-rfc7468", 732 + "pkcs8", 733 + "rand_core 0.6.4", 734 + "sec1", 735 + "subtle", 736 + "zeroize", 737 + ] 738 + 739 + [[package]] 740 + name = "embedded-io" 741 + version = "0.4.0" 742 + source = "registry+https://github.com/rust-lang/crates.io-index" 743 + checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" 744 + 745 + [[package]] 746 + name = "embedded-io" 747 + version = "0.6.1" 748 + source = "registry+https://github.com/rust-lang/crates.io-index" 749 + checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" 750 + 751 + [[package]] 752 + name = "encoding_rs" 753 + version = "0.8.35" 754 + source = "registry+https://github.com/rust-lang/crates.io-index" 755 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 756 + dependencies = [ 757 + "cfg-if", 758 + ] 759 + 760 + [[package]] 761 + name = "equivalent" 762 + version = "1.0.2" 763 + source = "registry+https://github.com/rust-lang/crates.io-index" 764 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 765 + 766 + [[package]] 767 + name = "errno" 768 + version = "0.3.14" 769 + source = "registry+https://github.com/rust-lang/crates.io-index" 770 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 771 + dependencies = [ 772 + "libc", 773 + "windows-sys 0.61.2", 774 + ] 775 + 776 + [[package]] 777 + name = "fastrand" 778 + version = "2.3.0" 779 + source = "registry+https://github.com/rust-lang/crates.io-index" 780 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 781 + 782 + [[package]] 783 + name = "ff" 784 + version = "0.13.1" 785 + source = "registry+https://github.com/rust-lang/crates.io-index" 786 + checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 787 + dependencies = [ 788 + "rand_core 0.6.4", 789 + "subtle", 790 + ] 791 + 792 + [[package]] 793 + name = "fiat-crypto" 794 + version = "0.2.9" 795 + source = "registry+https://github.com/rust-lang/crates.io-index" 796 + checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 797 + 798 + [[package]] 799 + name = "filetime" 800 + version = "0.2.27" 801 + source = "registry+https://github.com/rust-lang/crates.io-index" 802 + checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" 803 + dependencies = [ 804 + "cfg-if", 805 + "libc", 806 + "libredox", 807 + ] 808 + 809 + [[package]] 810 + name = "find-msvc-tools" 811 + version = "0.1.9" 812 + source = "registry+https://github.com/rust-lang/crates.io-index" 813 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" 814 + 815 + [[package]] 816 + name = "flate2" 817 + version = "1.1.9" 818 + source = "registry+https://github.com/rust-lang/crates.io-index" 819 + checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" 820 + dependencies = [ 821 + "crc32fast", 822 + "miniz_oxide", 823 + ] 824 + 825 + [[package]] 826 + name = "fluent-uri" 827 + version = "0.4.1" 828 + source = "registry+https://github.com/rust-lang/crates.io-index" 829 + checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" 830 + dependencies = [ 831 + "borrow-or-share", 832 + "ref-cast", 833 + "serde", 834 + ] 835 + 836 + [[package]] 837 + name = "fnv" 838 + version = "1.0.7" 839 + source = "registry+https://github.com/rust-lang/crates.io-index" 840 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 841 + 842 + [[package]] 843 + name = "foldhash" 844 + version = "0.1.5" 845 + source = "registry+https://github.com/rust-lang/crates.io-index" 846 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 847 + 848 + [[package]] 849 + name = "form_urlencoded" 850 + version = "1.2.2" 851 + source = "registry+https://github.com/rust-lang/crates.io-index" 852 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" 853 + dependencies = [ 854 + "percent-encoding", 855 + ] 856 + 857 + [[package]] 858 + name = "futures" 859 + version = "0.3.32" 860 + source = "registry+https://github.com/rust-lang/crates.io-index" 861 + checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" 862 + dependencies = [ 863 + "futures-channel", 864 + "futures-core", 865 + "futures-io", 866 + "futures-sink", 867 + "futures-task", 868 + "futures-util", 869 + ] 870 + 871 + [[package]] 872 + name = "futures-buffered" 873 + version = "0.2.13" 874 + source = "registry+https://github.com/rust-lang/crates.io-index" 875 + checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" 876 + dependencies = [ 877 + "cordyceps", 878 + "diatomic-waker", 879 + "futures-core", 880 + "pin-project-lite", 881 + "spin 0.10.0", 882 + ] 883 + 884 + [[package]] 885 + name = "futures-channel" 886 + version = "0.3.32" 887 + source = "registry+https://github.com/rust-lang/crates.io-index" 888 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 889 + dependencies = [ 890 + "futures-core", 891 + "futures-sink", 892 + ] 893 + 894 + [[package]] 895 + name = "futures-core" 896 + version = "0.3.32" 897 + source = "registry+https://github.com/rust-lang/crates.io-index" 898 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" 899 + 900 + [[package]] 901 + name = "futures-io" 902 + version = "0.3.32" 903 + source = "registry+https://github.com/rust-lang/crates.io-index" 904 + checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" 905 + 906 + [[package]] 907 + name = "futures-lite" 908 + version = "2.6.1" 909 + source = "registry+https://github.com/rust-lang/crates.io-index" 910 + checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" 911 + dependencies = [ 912 + "fastrand", 913 + "futures-core", 914 + "futures-io", 915 + "parking", 916 + "pin-project-lite", 917 + ] 918 + 919 + [[package]] 920 + name = "futures-macro" 921 + version = "0.3.32" 922 + source = "registry+https://github.com/rust-lang/crates.io-index" 923 + checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" 924 + dependencies = [ 925 + "proc-macro2", 926 + "quote", 927 + "syn", 928 + ] 929 + 930 + [[package]] 931 + name = "futures-sink" 932 + version = "0.3.32" 933 + source = "registry+https://github.com/rust-lang/crates.io-index" 934 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" 935 + 936 + [[package]] 937 + name = "futures-task" 938 + version = "0.3.32" 939 + source = "registry+https://github.com/rust-lang/crates.io-index" 940 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" 941 + 942 + [[package]] 943 + name = "futures-util" 944 + version = "0.3.32" 945 + source = "registry+https://github.com/rust-lang/crates.io-index" 946 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 947 + dependencies = [ 948 + "futures-core", 949 + "futures-io", 950 + "futures-macro", 951 + "futures-sink", 952 + "futures-task", 953 + "memchr", 954 + "pin-project-lite", 955 + "slab", 956 + ] 957 + 958 + [[package]] 959 + name = "generator" 960 + version = "0.8.8" 961 + source = "registry+https://github.com/rust-lang/crates.io-index" 962 + checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" 963 + dependencies = [ 964 + "cc", 965 + "cfg-if", 966 + "libc", 967 + "log", 968 + "rustversion", 969 + "windows-link", 970 + "windows-result", 971 + ] 972 + 973 + [[package]] 974 + name = "generic-array" 975 + version = "0.14.9" 976 + source = "registry+https://github.com/rust-lang/crates.io-index" 977 + checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" 978 + dependencies = [ 979 + "typenum", 980 + "version_check", 981 + "zeroize", 982 + ] 983 + 984 + [[package]] 985 + name = "getrandom" 986 + version = "0.2.17" 987 + source = "registry+https://github.com/rust-lang/crates.io-index" 988 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" 989 + dependencies = [ 990 + "cfg-if", 991 + "js-sys", 992 + "libc", 993 + "wasi", 994 + "wasm-bindgen", 995 + ] 996 + 997 + [[package]] 998 + name = "getrandom" 999 + version = "0.3.4" 1000 + source = "registry+https://github.com/rust-lang/crates.io-index" 1001 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" 1002 + dependencies = [ 1003 + "cfg-if", 1004 + "js-sys", 1005 + "libc", 1006 + "r-efi 5.3.0", 1007 + "wasip2", 1008 + "wasm-bindgen", 1009 + ] 1010 + 1011 + [[package]] 1012 + name = "getrandom" 1013 + version = "0.4.2" 1014 + source = "registry+https://github.com/rust-lang/crates.io-index" 1015 + checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" 1016 + dependencies = [ 1017 + "cfg-if", 1018 + "libc", 1019 + "r-efi 6.0.0", 1020 + "wasip2", 1021 + "wasip3", 1022 + ] 1023 + 1024 + [[package]] 1025 + name = "group" 1026 + version = "0.13.0" 1027 + source = "registry+https://github.com/rust-lang/crates.io-index" 1028 + checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 1029 + dependencies = [ 1030 + "ff", 1031 + "rand_core 0.6.4", 1032 + "subtle", 1033 + ] 1034 + 1035 + [[package]] 1036 + name = "gzip-header" 1037 + version = "1.0.0" 1038 + source = "registry+https://github.com/rust-lang/crates.io-index" 1039 + checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" 1040 + dependencies = [ 1041 + "crc32fast", 1042 + ] 1043 + 1044 + [[package]] 1045 + name = "h2" 1046 + version = "0.4.13" 1047 + source = "registry+https://github.com/rust-lang/crates.io-index" 1048 + checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" 1049 + dependencies = [ 1050 + "atomic-waker", 1051 + "bytes", 1052 + "fnv", 1053 + "futures-core", 1054 + "futures-sink", 1055 + "http", 1056 + "indexmap", 1057 + "slab", 1058 + "tokio", 1059 + "tokio-util", 1060 + "tracing", 1061 + ] 1062 + 1063 + [[package]] 1064 + name = "half" 1065 + version = "2.7.1" 1066 + source = "registry+https://github.com/rust-lang/crates.io-index" 1067 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" 1068 + dependencies = [ 1069 + "cfg-if", 1070 + "crunchy", 1071 + "zerocopy", 1072 + ] 1073 + 1074 + [[package]] 1075 + name = "hash32" 1076 + version = "0.2.1" 1077 + source = "registry+https://github.com/rust-lang/crates.io-index" 1078 + checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" 1079 + dependencies = [ 1080 + "byteorder", 1081 + ] 1082 + 1083 + [[package]] 1084 + name = "hashbrown" 1085 + version = "0.14.5" 1086 + source = "registry+https://github.com/rust-lang/crates.io-index" 1087 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 1088 + 1089 + [[package]] 1090 + name = "hashbrown" 1091 + version = "0.15.5" 1092 + source = "registry+https://github.com/rust-lang/crates.io-index" 1093 + checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" 1094 + dependencies = [ 1095 + "allocator-api2", 1096 + "equivalent", 1097 + "foldhash", 1098 + ] 1099 + 1100 + [[package]] 1101 + name = "hashbrown" 1102 + version = "0.16.1" 1103 + source = "registry+https://github.com/rust-lang/crates.io-index" 1104 + checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" 1105 + 1106 + [[package]] 1107 + name = "heapless" 1108 + version = "0.7.17" 1109 + source = "registry+https://github.com/rust-lang/crates.io-index" 1110 + checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" 1111 + dependencies = [ 1112 + "atomic-polyfill", 1113 + "hash32", 1114 + "rustc_version", 1115 + "serde", 1116 + "spin 0.9.8", 1117 + "stable_deref_trait", 1118 + ] 1119 + 1120 + [[package]] 1121 + name = "heck" 1122 + version = "0.4.1" 1123 + source = "registry+https://github.com/rust-lang/crates.io-index" 1124 + checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" 1125 + 1126 + [[package]] 1127 + name = "heck" 1128 + version = "0.5.0" 1129 + source = "registry+https://github.com/rust-lang/crates.io-index" 1130 + checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 1131 + 1132 + [[package]] 1133 + name = "hermit-abi" 1134 + version = "0.5.2" 1135 + source = "registry+https://github.com/rust-lang/crates.io-index" 1136 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 1137 + 1138 + [[package]] 1139 + name = "hex" 1140 + version = "0.4.3" 1141 + source = "registry+https://github.com/rust-lang/crates.io-index" 1142 + checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 1143 + 1144 + [[package]] 1145 + name = "hkdf" 1146 + version = "0.12.4" 1147 + source = "registry+https://github.com/rust-lang/crates.io-index" 1148 + checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" 1149 + dependencies = [ 1150 + "hmac", 1151 + ] 1152 + 1153 + [[package]] 1154 + name = "hmac" 1155 + version = "0.12.1" 1156 + source = "registry+https://github.com/rust-lang/crates.io-index" 1157 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 1158 + dependencies = [ 1159 + "digest", 1160 + ] 1161 + 1162 + [[package]] 1163 + name = "http" 1164 + version = "1.4.0" 1165 + source = "registry+https://github.com/rust-lang/crates.io-index" 1166 + checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" 1167 + dependencies = [ 1168 + "bytes", 1169 + "itoa", 1170 + ] 1171 + 1172 + [[package]] 1173 + name = "http-body" 1174 + version = "1.0.1" 1175 + source = "registry+https://github.com/rust-lang/crates.io-index" 1176 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 1177 + dependencies = [ 1178 + "bytes", 1179 + "http", 1180 + ] 1181 + 1182 + [[package]] 1183 + name = "http-body-util" 1184 + version = "0.1.3" 1185 + source = "registry+https://github.com/rust-lang/crates.io-index" 1186 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" 1187 + dependencies = [ 1188 + "bytes", 1189 + "futures-core", 1190 + "http", 1191 + "http-body", 1192 + "pin-project-lite", 1193 + ] 1194 + 1195 + [[package]] 1196 + name = "httparse" 1197 + version = "1.10.1" 1198 + source = "registry+https://github.com/rust-lang/crates.io-index" 1199 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 1200 + 1201 + [[package]] 1202 + name = "httpdate" 1203 + version = "1.0.3" 1204 + source = "registry+https://github.com/rust-lang/crates.io-index" 1205 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 1206 + 1207 + [[package]] 1208 + name = "hyper" 1209 + version = "1.8.1" 1210 + source = "registry+https://github.com/rust-lang/crates.io-index" 1211 + checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" 1212 + dependencies = [ 1213 + "atomic-waker", 1214 + "bytes", 1215 + "futures-channel", 1216 + "futures-core", 1217 + "h2", 1218 + "http", 1219 + "http-body", 1220 + "httparse", 1221 + "itoa", 1222 + "pin-project-lite", 1223 + "pin-utils", 1224 + "smallvec", 1225 + "tokio", 1226 + "want", 1227 + ] 1228 + 1229 + [[package]] 1230 + name = "hyper-rustls" 1231 + version = "0.27.7" 1232 + source = "registry+https://github.com/rust-lang/crates.io-index" 1233 + checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" 1234 + dependencies = [ 1235 + "http", 1236 + "hyper", 1237 + "hyper-util", 1238 + "rustls", 1239 + "rustls-pki-types", 1240 + "tokio", 1241 + "tokio-rustls", 1242 + "tower-service", 1243 + "webpki-roots", 1244 + ] 1245 + 1246 + [[package]] 1247 + name = "hyper-util" 1248 + version = "0.1.20" 1249 + source = "registry+https://github.com/rust-lang/crates.io-index" 1250 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" 1251 + dependencies = [ 1252 + "base64 0.22.1", 1253 + "bytes", 1254 + "futures-channel", 1255 + "futures-util", 1256 + "http", 1257 + "http-body", 1258 + "hyper", 1259 + "ipnet", 1260 + "libc", 1261 + "percent-encoding", 1262 + "pin-project-lite", 1263 + "socket2", 1264 + "system-configuration", 1265 + "tokio", 1266 + "tower-service", 1267 + "tracing", 1268 + "windows-registry", 1269 + ] 1270 + 1271 + [[package]] 1272 + name = "iana-time-zone" 1273 + version = "0.1.65" 1274 + source = "registry+https://github.com/rust-lang/crates.io-index" 1275 + checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" 1276 + dependencies = [ 1277 + "android_system_properties", 1278 + "core-foundation-sys", 1279 + "iana-time-zone-haiku", 1280 + "js-sys", 1281 + "log", 1282 + "wasm-bindgen", 1283 + "windows-core", 1284 + ] 1285 + 1286 + [[package]] 1287 + name = "iana-time-zone-haiku" 1288 + version = "0.1.2" 1289 + source = "registry+https://github.com/rust-lang/crates.io-index" 1290 + checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 1291 + dependencies = [ 1292 + "cc", 1293 + ] 1294 + 1295 + [[package]] 1296 + name = "icu_collections" 1297 + version = "2.1.1" 1298 + source = "registry+https://github.com/rust-lang/crates.io-index" 1299 + checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" 1300 + dependencies = [ 1301 + "displaydoc", 1302 + "potential_utf", 1303 + "yoke", 1304 + "zerofrom", 1305 + "zerovec", 1306 + ] 1307 + 1308 + [[package]] 1309 + name = "icu_locale_core" 1310 + version = "2.1.1" 1311 + source = "registry+https://github.com/rust-lang/crates.io-index" 1312 + checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" 1313 + dependencies = [ 1314 + "displaydoc", 1315 + "litemap", 1316 + "tinystr", 1317 + "writeable", 1318 + "zerovec", 1319 + ] 1320 + 1321 + [[package]] 1322 + name = "icu_normalizer" 1323 + version = "2.1.1" 1324 + source = "registry+https://github.com/rust-lang/crates.io-index" 1325 + checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" 1326 + dependencies = [ 1327 + "icu_collections", 1328 + "icu_normalizer_data", 1329 + "icu_properties", 1330 + "icu_provider", 1331 + "smallvec", 1332 + "zerovec", 1333 + ] 1334 + 1335 + [[package]] 1336 + name = "icu_normalizer_data" 1337 + version = "2.1.1" 1338 + source = "registry+https://github.com/rust-lang/crates.io-index" 1339 + checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" 1340 + 1341 + [[package]] 1342 + name = "icu_properties" 1343 + version = "2.1.2" 1344 + source = "registry+https://github.com/rust-lang/crates.io-index" 1345 + checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" 1346 + dependencies = [ 1347 + "icu_collections", 1348 + "icu_locale_core", 1349 + "icu_properties_data", 1350 + "icu_provider", 1351 + "zerotrie", 1352 + "zerovec", 1353 + ] 1354 + 1355 + [[package]] 1356 + name = "icu_properties_data" 1357 + version = "2.1.2" 1358 + source = "registry+https://github.com/rust-lang/crates.io-index" 1359 + checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" 1360 + 1361 + [[package]] 1362 + name = "icu_provider" 1363 + version = "2.1.1" 1364 + source = "registry+https://github.com/rust-lang/crates.io-index" 1365 + checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" 1366 + dependencies = [ 1367 + "displaydoc", 1368 + "icu_locale_core", 1369 + "writeable", 1370 + "yoke", 1371 + "zerofrom", 1372 + "zerotrie", 1373 + "zerovec", 1374 + ] 1375 + 1376 + [[package]] 1377 + name = "id-arena" 1378 + version = "2.3.0" 1379 + source = "registry+https://github.com/rust-lang/crates.io-index" 1380 + checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" 1381 + 1382 + [[package]] 1383 + name = "ident_case" 1384 + version = "1.0.1" 1385 + source = "registry+https://github.com/rust-lang/crates.io-index" 1386 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1387 + 1388 + [[package]] 1389 + name = "idna" 1390 + version = "1.1.0" 1391 + source = "registry+https://github.com/rust-lang/crates.io-index" 1392 + checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" 1393 + dependencies = [ 1394 + "idna_adapter", 1395 + "smallvec", 1396 + "utf8_iter", 1397 + ] 1398 + 1399 + [[package]] 1400 + name = "idna_adapter" 1401 + version = "1.2.1" 1402 + source = "registry+https://github.com/rust-lang/crates.io-index" 1403 + checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" 1404 + dependencies = [ 1405 + "icu_normalizer", 1406 + "icu_properties", 1407 + ] 1408 + 1409 + [[package]] 1410 + name = "indexmap" 1411 + version = "2.13.0" 1412 + source = "registry+https://github.com/rust-lang/crates.io-index" 1413 + checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" 1414 + dependencies = [ 1415 + "equivalent", 1416 + "hashbrown 0.16.1", 1417 + "serde", 1418 + "serde_core", 1419 + ] 1420 + 1421 + [[package]] 1422 + name = "inventory" 1423 + version = "0.3.22" 1424 + source = "registry+https://github.com/rust-lang/crates.io-index" 1425 + checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" 1426 + dependencies = [ 1427 + "rustversion", 1428 + ] 1429 + 1430 + [[package]] 1431 + name = "ipld-core" 1432 + version = "0.4.3" 1433 + source = "registry+https://github.com/rust-lang/crates.io-index" 1434 + checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" 1435 + dependencies = [ 1436 + "cid", 1437 + "serde", 1438 + "serde_bytes", 1439 + ] 1440 + 1441 + [[package]] 1442 + name = "ipnet" 1443 + version = "2.12.0" 1444 + source = "registry+https://github.com/rust-lang/crates.io-index" 1445 + checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" 1446 + 1447 + [[package]] 1448 + name = "iri-string" 1449 + version = "0.7.10" 1450 + source = "registry+https://github.com/rust-lang/crates.io-index" 1451 + checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" 1452 + dependencies = [ 1453 + "memchr", 1454 + "serde", 1455 + ] 1456 + 1457 + [[package]] 1458 + name = "itoa" 1459 + version = "1.0.18" 1460 + source = "registry+https://github.com/rust-lang/crates.io-index" 1461 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 1462 + 1463 + [[package]] 1464 + name = "jacquard-common" 1465 + version = "0.11.0" 1466 + source = "registry+https://github.com/rust-lang/crates.io-index" 1467 + checksum = "9631f08f1e65d19e204bc6774d00b4e0b69fb649d7d7ac59ccf97797bd9a196e" 1468 + dependencies = [ 1469 + "base64 0.22.1", 1470 + "bon", 1471 + "bytes", 1472 + "chrono", 1473 + "ciborium", 1474 + "ciborium-io", 1475 + "cid", 1476 + "fluent-uri", 1477 + "futures", 1478 + "getrandom 0.2.17", 1479 + "getrandom 0.3.4", 1480 + "hashbrown 0.15.5", 1481 + "http", 1482 + "ipld-core", 1483 + "k256", 1484 + "maitake-sync", 1485 + "miette", 1486 + "multibase", 1487 + "multihash", 1488 + "n0-future", 1489 + "ouroboros", 1490 + "oxilangtag", 1491 + "p256", 1492 + "phf", 1493 + "postcard", 1494 + "rand 0.9.2", 1495 + "regex", 1496 + "regex-automata", 1497 + "regex-lite", 1498 + "reqwest", 1499 + "rustversion", 1500 + "serde", 1501 + "serde_bytes", 1502 + "serde_html_form", 1503 + "serde_ipld_dagcbor", 1504 + "serde_json", 1505 + "signature", 1506 + "smol_str", 1507 + "spin 0.10.0", 1508 + "thiserror 2.0.18", 1509 + "tokio", 1510 + "tokio-tungstenite-wasm", 1511 + "tokio-util", 1512 + "trait-variant", 1513 + "unicode-segmentation", 1514 + ] 1515 + 1516 + [[package]] 1517 + name = "jacquard-identity" 1518 + version = "0.11.0" 1519 + source = "registry+https://github.com/rust-lang/crates.io-index" 1520 + checksum = "b84a9302ea9dd39c49d748a8eba21faf365570088571ff327ebb00117dbd65b8" 1521 + dependencies = [ 1522 + "bon", 1523 + "bytes", 1524 + "http", 1525 + "jacquard-common", 1526 + "jacquard-lexicon", 1527 + "miette", 1528 + "n0-future", 1529 + "reqwest", 1530 + "serde", 1531 + "serde_html_form", 1532 + "serde_json", 1533 + "thiserror 2.0.18", 1534 + "tokio", 1535 + "trait-variant", 1536 + ] 1537 + 1538 + [[package]] 1539 + name = "jacquard-lexicon" 1540 + version = "0.11.0" 1541 + source = "registry+https://github.com/rust-lang/crates.io-index" 1542 + checksum = "9374c308c12232b7072c5a49527a104700785711cd6a5fd97fbb919f8c46fb03" 1543 + dependencies = [ 1544 + "cid", 1545 + "dashmap", 1546 + "inventory", 1547 + "jacquard-common", 1548 + "miette", 1549 + "multihash", 1550 + "serde", 1551 + "serde_ipld_dagcbor", 1552 + "serde_json", 1553 + "serde_path_to_error", 1554 + "serde_repr", 1555 + "serde_with", 1556 + "sha2", 1557 + "thiserror 2.0.18", 1558 + "unicode-segmentation", 1559 + ] 1560 + 1561 + [[package]] 1562 + name = "jacquard-oauth" 1563 + version = "0.11.0" 1564 + dependencies = [ 1565 + "base64 0.22.1", 1566 + "bytes", 1567 + "chrono", 1568 + "dashmap", 1569 + "ed25519-dalek", 1570 + "elliptic-curve", 1571 + "http", 1572 + "jacquard-common", 1573 + "jacquard-identity", 1574 + "jose-jwa", 1575 + "jose-jwk", 1576 + "k256", 1577 + "miette", 1578 + "n0-future", 1579 + "p256", 1580 + "p384", 1581 + "rand 0.8.5", 1582 + "rouille", 1583 + "serde", 1584 + "serde_html_form", 1585 + "serde_json", 1586 + "sha2", 1587 + "smol_str", 1588 + "thiserror 2.0.18", 1589 + "tokio", 1590 + "tracing", 1591 + "trait-variant", 1592 + "webbrowser", 1593 + ] 1594 + 1595 + [[package]] 1596 + name = "jni" 1597 + version = "0.22.4" 1598 + source = "registry+https://github.com/rust-lang/crates.io-index" 1599 + checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" 1600 + dependencies = [ 1601 + "cfg-if", 1602 + "combine", 1603 + "jni-macros", 1604 + "jni-sys", 1605 + "log", 1606 + "simd_cesu8", 1607 + "thiserror 2.0.18", 1608 + "walkdir", 1609 + "windows-link", 1610 + ] 1611 + 1612 + [[package]] 1613 + name = "jni-macros" 1614 + version = "0.22.4" 1615 + source = "registry+https://github.com/rust-lang/crates.io-index" 1616 + checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" 1617 + dependencies = [ 1618 + "proc-macro2", 1619 + "quote", 1620 + "rustc_version", 1621 + "simd_cesu8", 1622 + "syn", 1623 + ] 1624 + 1625 + [[package]] 1626 + name = "jni-sys" 1627 + version = "0.4.1" 1628 + source = "registry+https://github.com/rust-lang/crates.io-index" 1629 + checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" 1630 + dependencies = [ 1631 + "jni-sys-macros", 1632 + ] 1633 + 1634 + [[package]] 1635 + name = "jni-sys-macros" 1636 + version = "0.4.1" 1637 + source = "registry+https://github.com/rust-lang/crates.io-index" 1638 + checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" 1639 + dependencies = [ 1640 + "quote", 1641 + "syn", 1642 + ] 1643 + 1644 + [[package]] 1645 + name = "jose-b64" 1646 + version = "0.1.2" 1647 + source = "registry+https://github.com/rust-lang/crates.io-index" 1648 + checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" 1649 + dependencies = [ 1650 + "base64ct", 1651 + "serde", 1652 + "subtle", 1653 + "zeroize", 1654 + ] 1655 + 1656 + [[package]] 1657 + name = "jose-jwa" 1658 + version = "0.1.2" 1659 + source = "registry+https://github.com/rust-lang/crates.io-index" 1660 + checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" 1661 + dependencies = [ 1662 + "serde", 1663 + ] 1664 + 1665 + [[package]] 1666 + name = "jose-jwk" 1667 + version = "0.1.2" 1668 + source = "registry+https://github.com/rust-lang/crates.io-index" 1669 + checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" 1670 + dependencies = [ 1671 + "jose-b64", 1672 + "jose-jwa", 1673 + "p256", 1674 + "p384", 1675 + "rsa", 1676 + "serde", 1677 + "zeroize", 1678 + ] 1679 + 1680 + [[package]] 1681 + name = "js-sys" 1682 + version = "0.3.91" 1683 + source = "registry+https://github.com/rust-lang/crates.io-index" 1684 + checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" 1685 + dependencies = [ 1686 + "once_cell", 1687 + "wasm-bindgen", 1688 + ] 1689 + 1690 + [[package]] 1691 + name = "k256" 1692 + version = "0.13.4" 1693 + source = "registry+https://github.com/rust-lang/crates.io-index" 1694 + checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" 1695 + dependencies = [ 1696 + "cfg-if", 1697 + "ecdsa", 1698 + "elliptic-curve", 1699 + "once_cell", 1700 + "sha2", 1701 + "signature", 1702 + ] 1703 + 1704 + [[package]] 1705 + name = "lazy_static" 1706 + version = "1.5.0" 1707 + source = "registry+https://github.com/rust-lang/crates.io-index" 1708 + checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1709 + dependencies = [ 1710 + "spin 0.9.8", 1711 + ] 1712 + 1713 + [[package]] 1714 + name = "leb128fmt" 1715 + version = "0.1.0" 1716 + source = "registry+https://github.com/rust-lang/crates.io-index" 1717 + checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" 1718 + 1719 + [[package]] 1720 + name = "libc" 1721 + version = "0.2.183" 1722 + source = "registry+https://github.com/rust-lang/crates.io-index" 1723 + checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" 1724 + 1725 + [[package]] 1726 + name = "libm" 1727 + version = "0.2.16" 1728 + source = "registry+https://github.com/rust-lang/crates.io-index" 1729 + checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" 1730 + 1731 + [[package]] 1732 + name = "libredox" 1733 + version = "0.1.14" 1734 + source = "registry+https://github.com/rust-lang/crates.io-index" 1735 + checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" 1736 + dependencies = [ 1737 + "bitflags", 1738 + "libc", 1739 + "plain", 1740 + "redox_syscall 0.7.3", 1741 + ] 1742 + 1743 + [[package]] 1744 + name = "linux-raw-sys" 1745 + version = "0.12.1" 1746 + source = "registry+https://github.com/rust-lang/crates.io-index" 1747 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" 1748 + 1749 + [[package]] 1750 + name = "litemap" 1751 + version = "0.8.1" 1752 + source = "registry+https://github.com/rust-lang/crates.io-index" 1753 + checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 1754 + 1755 + [[package]] 1756 + name = "lock_api" 1757 + version = "0.4.14" 1758 + source = "registry+https://github.com/rust-lang/crates.io-index" 1759 + checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" 1760 + dependencies = [ 1761 + "scopeguard", 1762 + ] 1763 + 1764 + [[package]] 1765 + name = "log" 1766 + version = "0.4.29" 1767 + source = "registry+https://github.com/rust-lang/crates.io-index" 1768 + checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1769 + 1770 + [[package]] 1771 + name = "loom" 1772 + version = "0.7.2" 1773 + source = "registry+https://github.com/rust-lang/crates.io-index" 1774 + checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" 1775 + dependencies = [ 1776 + "cfg-if", 1777 + "generator", 1778 + "scoped-tls", 1779 + "tracing", 1780 + "tracing-subscriber", 1781 + ] 1782 + 1783 + [[package]] 1784 + name = "lru-slab" 1785 + version = "0.1.2" 1786 + source = "registry+https://github.com/rust-lang/crates.io-index" 1787 + checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" 1788 + 1789 + [[package]] 1790 + name = "maitake-sync" 1791 + version = "0.1.2" 1792 + source = "registry+https://github.com/rust-lang/crates.io-index" 1793 + checksum = "6816ab14147f80234c675b80ed6dc4f440d8a1cefc158e766067aedb84c0bcd5" 1794 + dependencies = [ 1795 + "cordyceps", 1796 + "loom", 1797 + "mycelium-bitfield", 1798 + "pin-project", 1799 + "portable-atomic", 1800 + ] 1801 + 1802 + [[package]] 1803 + name = "match-lookup" 1804 + version = "0.1.2" 1805 + source = "registry+https://github.com/rust-lang/crates.io-index" 1806 + checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" 1807 + dependencies = [ 1808 + "proc-macro2", 1809 + "quote", 1810 + "syn", 1811 + ] 1812 + 1813 + [[package]] 1814 + name = "matchers" 1815 + version = "0.2.0" 1816 + source = "registry+https://github.com/rust-lang/crates.io-index" 1817 + checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" 1818 + dependencies = [ 1819 + "regex-automata", 1820 + ] 1821 + 1822 + [[package]] 1823 + name = "memchr" 1824 + version = "2.8.0" 1825 + source = "registry+https://github.com/rust-lang/crates.io-index" 1826 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 1827 + 1828 + [[package]] 1829 + name = "miette" 1830 + version = "7.6.0" 1831 + source = "registry+https://github.com/rust-lang/crates.io-index" 1832 + checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" 1833 + dependencies = [ 1834 + "cfg-if", 1835 + "miette-derive", 1836 + "unicode-width", 1837 + ] 1838 + 1839 + [[package]] 1840 + name = "miette-derive" 1841 + version = "7.6.0" 1842 + source = "registry+https://github.com/rust-lang/crates.io-index" 1843 + checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" 1844 + dependencies = [ 1845 + "proc-macro2", 1846 + "quote", 1847 + "syn", 1848 + ] 1849 + 1850 + [[package]] 1851 + name = "mime" 1852 + version = "0.3.17" 1853 + source = "registry+https://github.com/rust-lang/crates.io-index" 1854 + checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1855 + 1856 + [[package]] 1857 + name = "mime_guess" 1858 + version = "2.0.5" 1859 + source = "registry+https://github.com/rust-lang/crates.io-index" 1860 + checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" 1861 + dependencies = [ 1862 + "mime", 1863 + "unicase", 1864 + ] 1865 + 1866 + [[package]] 1867 + name = "miniz_oxide" 1868 + version = "0.8.9" 1869 + source = "registry+https://github.com/rust-lang/crates.io-index" 1870 + checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" 1871 + dependencies = [ 1872 + "adler2", 1873 + "simd-adler32", 1874 + ] 1875 + 1876 + [[package]] 1877 + name = "mio" 1878 + version = "1.1.1" 1879 + source = "registry+https://github.com/rust-lang/crates.io-index" 1880 + checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" 1881 + dependencies = [ 1882 + "libc", 1883 + "wasi", 1884 + "windows-sys 0.61.2", 1885 + ] 1886 + 1887 + [[package]] 1888 + name = "multibase" 1889 + version = "0.9.2" 1890 + source = "registry+https://github.com/rust-lang/crates.io-index" 1891 + checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 1892 + dependencies = [ 1893 + "base-x", 1894 + "base256emoji", 1895 + "data-encoding", 1896 + "data-encoding-macro", 1897 + ] 1898 + 1899 + [[package]] 1900 + name = "multihash" 1901 + version = "0.19.3" 1902 + source = "registry+https://github.com/rust-lang/crates.io-index" 1903 + checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" 1904 + dependencies = [ 1905 + "core2", 1906 + "serde", 1907 + "unsigned-varint", 1908 + ] 1909 + 1910 + [[package]] 1911 + name = "multipart" 1912 + version = "0.18.0" 1913 + source = "registry+https://github.com/rust-lang/crates.io-index" 1914 + checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" 1915 + dependencies = [ 1916 + "buf_redux", 1917 + "httparse", 1918 + "log", 1919 + "mime", 1920 + "mime_guess", 1921 + "quick-error", 1922 + "rand 0.8.5", 1923 + "safemem", 1924 + "tempfile", 1925 + "twoway", 1926 + ] 1927 + 1928 + [[package]] 1929 + name = "mycelium-bitfield" 1930 + version = "0.1.5" 1931 + source = "registry+https://github.com/rust-lang/crates.io-index" 1932 + checksum = "24e0cc5e2c585acbd15c5ce911dff71e1f4d5313f43345873311c4f5efd741cc" 1933 + 1934 + [[package]] 1935 + name = "n0-future" 1936 + version = "0.1.3" 1937 + source = "registry+https://github.com/rust-lang/crates.io-index" 1938 + checksum = "7bb0e5d99e681ab3c938842b96fcb41bf8a7bb4bfdb11ccbd653a7e83e06c794" 1939 + dependencies = [ 1940 + "cfg_aliases", 1941 + "derive_more", 1942 + "futures-buffered", 1943 + "futures-lite", 1944 + "futures-util", 1945 + "js-sys", 1946 + "pin-project", 1947 + "send_wrapper", 1948 + "tokio", 1949 + "tokio-util", 1950 + "wasm-bindgen", 1951 + "wasm-bindgen-futures", 1952 + "web-time", 1953 + ] 1954 + 1955 + [[package]] 1956 + name = "ndk-context" 1957 + version = "0.1.1" 1958 + source = "registry+https://github.com/rust-lang/crates.io-index" 1959 + checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" 1960 + 1961 + [[package]] 1962 + name = "nu-ansi-term" 1963 + version = "0.50.3" 1964 + source = "registry+https://github.com/rust-lang/crates.io-index" 1965 + checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 1966 + dependencies = [ 1967 + "windows-sys 0.61.2", 1968 + ] 1969 + 1970 + [[package]] 1971 + name = "num-bigint-dig" 1972 + version = "0.8.6" 1973 + source = "registry+https://github.com/rust-lang/crates.io-index" 1974 + checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" 1975 + dependencies = [ 1976 + "lazy_static", 1977 + "libm", 1978 + "num-integer", 1979 + "num-iter", 1980 + "num-traits", 1981 + "rand 0.8.5", 1982 + "smallvec", 1983 + "zeroize", 1984 + ] 1985 + 1986 + [[package]] 1987 + name = "num-conv" 1988 + version = "0.2.0" 1989 + source = "registry+https://github.com/rust-lang/crates.io-index" 1990 + checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" 1991 + 1992 + [[package]] 1993 + name = "num-integer" 1994 + version = "0.1.46" 1995 + source = "registry+https://github.com/rust-lang/crates.io-index" 1996 + checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" 1997 + dependencies = [ 1998 + "num-traits", 1999 + ] 2000 + 2001 + [[package]] 2002 + name = "num-iter" 2003 + version = "0.1.45" 2004 + source = "registry+https://github.com/rust-lang/crates.io-index" 2005 + checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" 2006 + dependencies = [ 2007 + "autocfg", 2008 + "num-integer", 2009 + "num-traits", 2010 + ] 2011 + 2012 + [[package]] 2013 + name = "num-traits" 2014 + version = "0.2.19" 2015 + source = "registry+https://github.com/rust-lang/crates.io-index" 2016 + checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 2017 + dependencies = [ 2018 + "autocfg", 2019 + "libm", 2020 + ] 2021 + 2022 + [[package]] 2023 + name = "num_cpus" 2024 + version = "1.17.0" 2025 + source = "registry+https://github.com/rust-lang/crates.io-index" 2026 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 2027 + dependencies = [ 2028 + "hermit-abi", 2029 + "libc", 2030 + ] 2031 + 2032 + [[package]] 2033 + name = "num_threads" 2034 + version = "0.1.7" 2035 + source = "registry+https://github.com/rust-lang/crates.io-index" 2036 + checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" 2037 + dependencies = [ 2038 + "libc", 2039 + ] 2040 + 2041 + [[package]] 2042 + name = "objc2" 2043 + version = "0.6.4" 2044 + source = "registry+https://github.com/rust-lang/crates.io-index" 2045 + checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" 2046 + dependencies = [ 2047 + "objc2-encode", 2048 + ] 2049 + 2050 + [[package]] 2051 + name = "objc2-encode" 2052 + version = "4.1.0" 2053 + source = "registry+https://github.com/rust-lang/crates.io-index" 2054 + checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" 2055 + 2056 + [[package]] 2057 + name = "objc2-foundation" 2058 + version = "0.3.2" 2059 + source = "registry+https://github.com/rust-lang/crates.io-index" 2060 + checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" 2061 + dependencies = [ 2062 + "bitflags", 2063 + "objc2", 2064 + ] 2065 + 2066 + [[package]] 2067 + name = "once_cell" 2068 + version = "1.21.4" 2069 + source = "registry+https://github.com/rust-lang/crates.io-index" 2070 + checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 2071 + 2072 + [[package]] 2073 + name = "openssl-probe" 2074 + version = "0.2.1" 2075 + source = "registry+https://github.com/rust-lang/crates.io-index" 2076 + checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" 2077 + 2078 + [[package]] 2079 + name = "ouroboros" 2080 + version = "0.18.5" 2081 + source = "registry+https://github.com/rust-lang/crates.io-index" 2082 + checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" 2083 + dependencies = [ 2084 + "aliasable", 2085 + "ouroboros_macro", 2086 + "static_assertions", 2087 + ] 2088 + 2089 + [[package]] 2090 + name = "ouroboros_macro" 2091 + version = "0.18.5" 2092 + source = "registry+https://github.com/rust-lang/crates.io-index" 2093 + checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" 2094 + dependencies = [ 2095 + "heck 0.4.1", 2096 + "proc-macro2", 2097 + "proc-macro2-diagnostics", 2098 + "quote", 2099 + "syn", 2100 + ] 2101 + 2102 + [[package]] 2103 + name = "oxilangtag" 2104 + version = "0.1.5" 2105 + source = "registry+https://github.com/rust-lang/crates.io-index" 2106 + checksum = "23f3f87617a86af77fa3691e6350483e7154c2ead9f1261b75130e21ca0f8acb" 2107 + dependencies = [ 2108 + "serde", 2109 + ] 2110 + 2111 + [[package]] 2112 + name = "p256" 2113 + version = "0.13.2" 2114 + source = "registry+https://github.com/rust-lang/crates.io-index" 2115 + checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" 2116 + dependencies = [ 2117 + "ecdsa", 2118 + "elliptic-curve", 2119 + "primeorder", 2120 + "sha2", 2121 + ] 2122 + 2123 + [[package]] 2124 + name = "p384" 2125 + version = "0.13.1" 2126 + source = "registry+https://github.com/rust-lang/crates.io-index" 2127 + checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" 2128 + dependencies = [ 2129 + "ecdsa", 2130 + "elliptic-curve", 2131 + "primeorder", 2132 + "sha2", 2133 + ] 2134 + 2135 + [[package]] 2136 + name = "parking" 2137 + version = "2.2.1" 2138 + source = "registry+https://github.com/rust-lang/crates.io-index" 2139 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 2140 + 2141 + [[package]] 2142 + name = "parking_lot_core" 2143 + version = "0.9.12" 2144 + source = "registry+https://github.com/rust-lang/crates.io-index" 2145 + checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" 2146 + dependencies = [ 2147 + "cfg-if", 2148 + "libc", 2149 + "redox_syscall 0.5.18", 2150 + "smallvec", 2151 + "windows-link", 2152 + ] 2153 + 2154 + [[package]] 2155 + name = "pem-rfc7468" 2156 + version = "0.7.0" 2157 + source = "registry+https://github.com/rust-lang/crates.io-index" 2158 + checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" 2159 + dependencies = [ 2160 + "base64ct", 2161 + ] 2162 + 2163 + [[package]] 2164 + name = "percent-encoding" 2165 + version = "2.3.2" 2166 + source = "registry+https://github.com/rust-lang/crates.io-index" 2167 + checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 2168 + 2169 + [[package]] 2170 + name = "phf" 2171 + version = "0.11.3" 2172 + source = "registry+https://github.com/rust-lang/crates.io-index" 2173 + checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" 2174 + dependencies = [ 2175 + "phf_macros", 2176 + "phf_shared", 2177 + ] 2178 + 2179 + [[package]] 2180 + name = "phf_generator" 2181 + version = "0.11.3" 2182 + source = "registry+https://github.com/rust-lang/crates.io-index" 2183 + checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" 2184 + dependencies = [ 2185 + "phf_shared", 2186 + "rand 0.8.5", 2187 + ] 2188 + 2189 + [[package]] 2190 + name = "phf_macros" 2191 + version = "0.11.3" 2192 + source = "registry+https://github.com/rust-lang/crates.io-index" 2193 + checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" 2194 + dependencies = [ 2195 + "phf_generator", 2196 + "phf_shared", 2197 + "proc-macro2", 2198 + "quote", 2199 + "syn", 2200 + ] 2201 + 2202 + [[package]] 2203 + name = "phf_shared" 2204 + version = "0.11.3" 2205 + source = "registry+https://github.com/rust-lang/crates.io-index" 2206 + checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 2207 + dependencies = [ 2208 + "siphasher", 2209 + ] 2210 + 2211 + [[package]] 2212 + name = "pin-project" 2213 + version = "1.1.11" 2214 + source = "registry+https://github.com/rust-lang/crates.io-index" 2215 + checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" 2216 + dependencies = [ 2217 + "pin-project-internal", 2218 + ] 2219 + 2220 + [[package]] 2221 + name = "pin-project-internal" 2222 + version = "1.1.11" 2223 + source = "registry+https://github.com/rust-lang/crates.io-index" 2224 + checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" 2225 + dependencies = [ 2226 + "proc-macro2", 2227 + "quote", 2228 + "syn", 2229 + ] 2230 + 2231 + [[package]] 2232 + name = "pin-project-lite" 2233 + version = "0.2.17" 2234 + source = "registry+https://github.com/rust-lang/crates.io-index" 2235 + checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" 2236 + 2237 + [[package]] 2238 + name = "pin-utils" 2239 + version = "0.1.0" 2240 + source = "registry+https://github.com/rust-lang/crates.io-index" 2241 + checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 2242 + 2243 + [[package]] 2244 + name = "pkcs1" 2245 + version = "0.7.5" 2246 + source = "registry+https://github.com/rust-lang/crates.io-index" 2247 + checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" 2248 + dependencies = [ 2249 + "der", 2250 + "pkcs8", 2251 + "spki", 2252 + ] 2253 + 2254 + [[package]] 2255 + name = "pkcs8" 2256 + version = "0.10.2" 2257 + source = "registry+https://github.com/rust-lang/crates.io-index" 2258 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 2259 + dependencies = [ 2260 + "der", 2261 + "spki", 2262 + ] 2263 + 2264 + [[package]] 2265 + name = "plain" 2266 + version = "0.2.3" 2267 + source = "registry+https://github.com/rust-lang/crates.io-index" 2268 + checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" 2269 + 2270 + [[package]] 2271 + name = "portable-atomic" 2272 + version = "1.13.1" 2273 + source = "registry+https://github.com/rust-lang/crates.io-index" 2274 + checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" 2275 + 2276 + [[package]] 2277 + name = "postcard" 2278 + version = "1.1.3" 2279 + source = "registry+https://github.com/rust-lang/crates.io-index" 2280 + checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" 2281 + dependencies = [ 2282 + "cobs", 2283 + "embedded-io 0.4.0", 2284 + "embedded-io 0.6.1", 2285 + "heapless", 2286 + "serde", 2287 + ] 2288 + 2289 + [[package]] 2290 + name = "potential_utf" 2291 + version = "0.1.4" 2292 + source = "registry+https://github.com/rust-lang/crates.io-index" 2293 + checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" 2294 + dependencies = [ 2295 + "zerovec", 2296 + ] 2297 + 2298 + [[package]] 2299 + name = "powerfmt" 2300 + version = "0.2.0" 2301 + source = "registry+https://github.com/rust-lang/crates.io-index" 2302 + checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 2303 + 2304 + [[package]] 2305 + name = "ppv-lite86" 2306 + version = "0.2.21" 2307 + source = "registry+https://github.com/rust-lang/crates.io-index" 2308 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 2309 + dependencies = [ 2310 + "zerocopy", 2311 + ] 2312 + 2313 + [[package]] 2314 + name = "prettyplease" 2315 + version = "0.2.37" 2316 + source = "registry+https://github.com/rust-lang/crates.io-index" 2317 + checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" 2318 + dependencies = [ 2319 + "proc-macro2", 2320 + "syn", 2321 + ] 2322 + 2323 + [[package]] 2324 + name = "primeorder" 2325 + version = "0.13.6" 2326 + source = "registry+https://github.com/rust-lang/crates.io-index" 2327 + checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" 2328 + dependencies = [ 2329 + "elliptic-curve", 2330 + ] 2331 + 2332 + [[package]] 2333 + name = "proc-macro2" 2334 + version = "1.0.106" 2335 + source = "registry+https://github.com/rust-lang/crates.io-index" 2336 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 2337 + dependencies = [ 2338 + "unicode-ident", 2339 + ] 2340 + 2341 + [[package]] 2342 + name = "proc-macro2-diagnostics" 2343 + version = "0.10.1" 2344 + source = "registry+https://github.com/rust-lang/crates.io-index" 2345 + checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" 2346 + dependencies = [ 2347 + "proc-macro2", 2348 + "quote", 2349 + "syn", 2350 + "version_check", 2351 + "yansi", 2352 + ] 2353 + 2354 + [[package]] 2355 + name = "quick-error" 2356 + version = "1.2.3" 2357 + source = "registry+https://github.com/rust-lang/crates.io-index" 2358 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 2359 + 2360 + [[package]] 2361 + name = "quinn" 2362 + version = "0.11.9" 2363 + source = "registry+https://github.com/rust-lang/crates.io-index" 2364 + checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" 2365 + dependencies = [ 2366 + "bytes", 2367 + "cfg_aliases", 2368 + "pin-project-lite", 2369 + "quinn-proto", 2370 + "quinn-udp", 2371 + "rustc-hash", 2372 + "rustls", 2373 + "socket2", 2374 + "thiserror 2.0.18", 2375 + "tokio", 2376 + "tracing", 2377 + "web-time", 2378 + ] 2379 + 2380 + [[package]] 2381 + name = "quinn-proto" 2382 + version = "0.11.14" 2383 + source = "registry+https://github.com/rust-lang/crates.io-index" 2384 + checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" 2385 + dependencies = [ 2386 + "bytes", 2387 + "getrandom 0.3.4", 2388 + "lru-slab", 2389 + "rand 0.9.2", 2390 + "ring", 2391 + "rustc-hash", 2392 + "rustls", 2393 + "rustls-pki-types", 2394 + "slab", 2395 + "thiserror 2.0.18", 2396 + "tinyvec", 2397 + "tracing", 2398 + "web-time", 2399 + ] 2400 + 2401 + [[package]] 2402 + name = "quinn-udp" 2403 + version = "0.5.14" 2404 + source = "registry+https://github.com/rust-lang/crates.io-index" 2405 + checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" 2406 + dependencies = [ 2407 + "cfg_aliases", 2408 + "libc", 2409 + "once_cell", 2410 + "socket2", 2411 + "tracing", 2412 + "windows-sys 0.60.2", 2413 + ] 2414 + 2415 + [[package]] 2416 + name = "quote" 2417 + version = "1.0.45" 2418 + source = "registry+https://github.com/rust-lang/crates.io-index" 2419 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 2420 + dependencies = [ 2421 + "proc-macro2", 2422 + ] 2423 + 2424 + [[package]] 2425 + name = "r-efi" 2426 + version = "5.3.0" 2427 + source = "registry+https://github.com/rust-lang/crates.io-index" 2428 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" 2429 + 2430 + [[package]] 2431 + name = "r-efi" 2432 + version = "6.0.0" 2433 + source = "registry+https://github.com/rust-lang/crates.io-index" 2434 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 2435 + 2436 + [[package]] 2437 + name = "rand" 2438 + version = "0.8.5" 2439 + source = "registry+https://github.com/rust-lang/crates.io-index" 2440 + checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 2441 + dependencies = [ 2442 + "libc", 2443 + "rand_chacha 0.3.1", 2444 + "rand_core 0.6.4", 2445 + ] 2446 + 2447 + [[package]] 2448 + name = "rand" 2449 + version = "0.9.2" 2450 + source = "registry+https://github.com/rust-lang/crates.io-index" 2451 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 2452 + dependencies = [ 2453 + "rand_chacha 0.9.0", 2454 + "rand_core 0.9.5", 2455 + ] 2456 + 2457 + [[package]] 2458 + name = "rand_chacha" 2459 + version = "0.3.1" 2460 + source = "registry+https://github.com/rust-lang/crates.io-index" 2461 + checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 2462 + dependencies = [ 2463 + "ppv-lite86", 2464 + "rand_core 0.6.4", 2465 + ] 2466 + 2467 + [[package]] 2468 + name = "rand_chacha" 2469 + version = "0.9.0" 2470 + source = "registry+https://github.com/rust-lang/crates.io-index" 2471 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2472 + dependencies = [ 2473 + "ppv-lite86", 2474 + "rand_core 0.9.5", 2475 + ] 2476 + 2477 + [[package]] 2478 + name = "rand_core" 2479 + version = "0.6.4" 2480 + source = "registry+https://github.com/rust-lang/crates.io-index" 2481 + checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 2482 + dependencies = [ 2483 + "getrandom 0.2.17", 2484 + ] 2485 + 2486 + [[package]] 2487 + name = "rand_core" 2488 + version = "0.9.5" 2489 + source = "registry+https://github.com/rust-lang/crates.io-index" 2490 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 2491 + dependencies = [ 2492 + "getrandom 0.3.4", 2493 + ] 2494 + 2495 + [[package]] 2496 + name = "redox_syscall" 2497 + version = "0.5.18" 2498 + source = "registry+https://github.com/rust-lang/crates.io-index" 2499 + checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 2500 + dependencies = [ 2501 + "bitflags", 2502 + ] 2503 + 2504 + [[package]] 2505 + name = "redox_syscall" 2506 + version = "0.7.3" 2507 + source = "registry+https://github.com/rust-lang/crates.io-index" 2508 + checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" 2509 + dependencies = [ 2510 + "bitflags", 2511 + ] 2512 + 2513 + [[package]] 2514 + name = "ref-cast" 2515 + version = "1.0.25" 2516 + source = "registry+https://github.com/rust-lang/crates.io-index" 2517 + checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" 2518 + dependencies = [ 2519 + "ref-cast-impl", 2520 + ] 2521 + 2522 + [[package]] 2523 + name = "ref-cast-impl" 2524 + version = "1.0.25" 2525 + source = "registry+https://github.com/rust-lang/crates.io-index" 2526 + checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" 2527 + dependencies = [ 2528 + "proc-macro2", 2529 + "quote", 2530 + "syn", 2531 + ] 2532 + 2533 + [[package]] 2534 + name = "regex" 2535 + version = "1.12.3" 2536 + source = "registry+https://github.com/rust-lang/crates.io-index" 2537 + checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" 2538 + dependencies = [ 2539 + "aho-corasick", 2540 + "memchr", 2541 + "regex-automata", 2542 + "regex-syntax", 2543 + ] 2544 + 2545 + [[package]] 2546 + name = "regex-automata" 2547 + version = "0.4.14" 2548 + source = "registry+https://github.com/rust-lang/crates.io-index" 2549 + checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" 2550 + dependencies = [ 2551 + "aho-corasick", 2552 + "memchr", 2553 + "regex-syntax", 2554 + ] 2555 + 2556 + [[package]] 2557 + name = "regex-lite" 2558 + version = "0.1.9" 2559 + source = "registry+https://github.com/rust-lang/crates.io-index" 2560 + checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" 2561 + 2562 + [[package]] 2563 + name = "regex-syntax" 2564 + version = "0.8.10" 2565 + source = "registry+https://github.com/rust-lang/crates.io-index" 2566 + checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" 2567 + 2568 + [[package]] 2569 + name = "reqwest" 2570 + version = "0.12.28" 2571 + source = "registry+https://github.com/rust-lang/crates.io-index" 2572 + checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" 2573 + dependencies = [ 2574 + "base64 0.22.1", 2575 + "bytes", 2576 + "encoding_rs", 2577 + "futures-core", 2578 + "futures-util", 2579 + "h2", 2580 + "http", 2581 + "http-body", 2582 + "http-body-util", 2583 + "hyper", 2584 + "hyper-rustls", 2585 + "hyper-util", 2586 + "js-sys", 2587 + "log", 2588 + "mime", 2589 + "percent-encoding", 2590 + "pin-project-lite", 2591 + "quinn", 2592 + "rustls", 2593 + "rustls-pki-types", 2594 + "serde", 2595 + "serde_json", 2596 + "serde_urlencoded", 2597 + "sync_wrapper", 2598 + "tokio", 2599 + "tokio-rustls", 2600 + "tokio-util", 2601 + "tower", 2602 + "tower-http", 2603 + "tower-service", 2604 + "url", 2605 + "wasm-bindgen", 2606 + "wasm-bindgen-futures", 2607 + "wasm-streams", 2608 + "web-sys", 2609 + "webpki-roots", 2610 + ] 2611 + 2612 + [[package]] 2613 + name = "rfc6979" 2614 + version = "0.4.0" 2615 + source = "registry+https://github.com/rust-lang/crates.io-index" 2616 + checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" 2617 + dependencies = [ 2618 + "hmac", 2619 + "subtle", 2620 + ] 2621 + 2622 + [[package]] 2623 + name = "ring" 2624 + version = "0.17.14" 2625 + source = "registry+https://github.com/rust-lang/crates.io-index" 2626 + checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" 2627 + dependencies = [ 2628 + "cc", 2629 + "cfg-if", 2630 + "getrandom 0.2.17", 2631 + "libc", 2632 + "untrusted", 2633 + "windows-sys 0.52.0", 2634 + ] 2635 + 2636 + [[package]] 2637 + name = "rouille" 2638 + version = "3.6.2" 2639 + source = "registry+https://github.com/rust-lang/crates.io-index" 2640 + checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" 2641 + dependencies = [ 2642 + "base64 0.13.1", 2643 + "brotli", 2644 + "chrono", 2645 + "deflate", 2646 + "filetime", 2647 + "multipart", 2648 + "percent-encoding", 2649 + "rand 0.8.5", 2650 + "serde", 2651 + "serde_derive", 2652 + "serde_json", 2653 + "sha1_smol", 2654 + "threadpool", 2655 + "time", 2656 + "tiny_http", 2657 + "url", 2658 + ] 2659 + 2660 + [[package]] 2661 + name = "rsa" 2662 + version = "0.9.10" 2663 + source = "registry+https://github.com/rust-lang/crates.io-index" 2664 + checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" 2665 + dependencies = [ 2666 + "const-oid", 2667 + "digest", 2668 + "num-bigint-dig", 2669 + "num-integer", 2670 + "num-traits", 2671 + "pkcs1", 2672 + "pkcs8", 2673 + "rand_core 0.6.4", 2674 + "signature", 2675 + "spki", 2676 + "subtle", 2677 + "zeroize", 2678 + ] 2679 + 2680 + [[package]] 2681 + name = "rustc-hash" 2682 + version = "2.1.1" 2683 + source = "registry+https://github.com/rust-lang/crates.io-index" 2684 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2685 + 2686 + [[package]] 2687 + name = "rustc_version" 2688 + version = "0.4.1" 2689 + source = "registry+https://github.com/rust-lang/crates.io-index" 2690 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 2691 + dependencies = [ 2692 + "semver", 2693 + ] 2694 + 2695 + [[package]] 2696 + name = "rustix" 2697 + version = "1.1.4" 2698 + source = "registry+https://github.com/rust-lang/crates.io-index" 2699 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" 2700 + dependencies = [ 2701 + "bitflags", 2702 + "errno", 2703 + "libc", 2704 + "linux-raw-sys", 2705 + "windows-sys 0.61.2", 2706 + ] 2707 + 2708 + [[package]] 2709 + name = "rustls" 2710 + version = "0.23.37" 2711 + source = "registry+https://github.com/rust-lang/crates.io-index" 2712 + checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" 2713 + dependencies = [ 2714 + "once_cell", 2715 + "ring", 2716 + "rustls-pki-types", 2717 + "rustls-webpki", 2718 + "subtle", 2719 + "zeroize", 2720 + ] 2721 + 2722 + [[package]] 2723 + name = "rustls-native-certs" 2724 + version = "0.8.3" 2725 + source = "registry+https://github.com/rust-lang/crates.io-index" 2726 + checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" 2727 + dependencies = [ 2728 + "openssl-probe", 2729 + "rustls-pki-types", 2730 + "schannel", 2731 + "security-framework", 2732 + ] 2733 + 2734 + [[package]] 2735 + name = "rustls-pki-types" 2736 + version = "1.14.0" 2737 + source = "registry+https://github.com/rust-lang/crates.io-index" 2738 + checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" 2739 + dependencies = [ 2740 + "web-time", 2741 + "zeroize", 2742 + ] 2743 + 2744 + [[package]] 2745 + name = "rustls-webpki" 2746 + version = "0.103.9" 2747 + source = "registry+https://github.com/rust-lang/crates.io-index" 2748 + checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" 2749 + dependencies = [ 2750 + "ring", 2751 + "rustls-pki-types", 2752 + "untrusted", 2753 + ] 2754 + 2755 + [[package]] 2756 + name = "rustversion" 2757 + version = "1.0.22" 2758 + source = "registry+https://github.com/rust-lang/crates.io-index" 2759 + checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2760 + 2761 + [[package]] 2762 + name = "ryu" 2763 + version = "1.0.23" 2764 + source = "registry+https://github.com/rust-lang/crates.io-index" 2765 + checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 2766 + 2767 + [[package]] 2768 + name = "safemem" 2769 + version = "0.3.3" 2770 + source = "registry+https://github.com/rust-lang/crates.io-index" 2771 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 2772 + 2773 + [[package]] 2774 + name = "same-file" 2775 + version = "1.0.6" 2776 + source = "registry+https://github.com/rust-lang/crates.io-index" 2777 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 2778 + dependencies = [ 2779 + "winapi-util", 2780 + ] 2781 + 2782 + [[package]] 2783 + name = "schannel" 2784 + version = "0.1.29" 2785 + source = "registry+https://github.com/rust-lang/crates.io-index" 2786 + checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" 2787 + dependencies = [ 2788 + "windows-sys 0.61.2", 2789 + ] 2790 + 2791 + [[package]] 2792 + name = "scoped-tls" 2793 + version = "1.0.1" 2794 + source = "registry+https://github.com/rust-lang/crates.io-index" 2795 + checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 2796 + 2797 + [[package]] 2798 + name = "scopeguard" 2799 + version = "1.2.0" 2800 + source = "registry+https://github.com/rust-lang/crates.io-index" 2801 + checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2802 + 2803 + [[package]] 2804 + name = "sec1" 2805 + version = "0.7.3" 2806 + source = "registry+https://github.com/rust-lang/crates.io-index" 2807 + checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" 2808 + dependencies = [ 2809 + "base16ct", 2810 + "der", 2811 + "generic-array", 2812 + "pkcs8", 2813 + "subtle", 2814 + "zeroize", 2815 + ] 2816 + 2817 + [[package]] 2818 + name = "security-framework" 2819 + version = "3.7.0" 2820 + source = "registry+https://github.com/rust-lang/crates.io-index" 2821 + checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" 2822 + dependencies = [ 2823 + "bitflags", 2824 + "core-foundation 0.10.1", 2825 + "core-foundation-sys", 2826 + "libc", 2827 + "security-framework-sys", 2828 + ] 2829 + 2830 + [[package]] 2831 + name = "security-framework-sys" 2832 + version = "2.17.0" 2833 + source = "registry+https://github.com/rust-lang/crates.io-index" 2834 + checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" 2835 + dependencies = [ 2836 + "core-foundation-sys", 2837 + "libc", 2838 + ] 2839 + 2840 + [[package]] 2841 + name = "semver" 2842 + version = "1.0.27" 2843 + source = "registry+https://github.com/rust-lang/crates.io-index" 2844 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 2845 + 2846 + [[package]] 2847 + name = "send_wrapper" 2848 + version = "0.6.0" 2849 + source = "registry+https://github.com/rust-lang/crates.io-index" 2850 + checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" 2851 + 2852 + [[package]] 2853 + name = "serde" 2854 + version = "1.0.228" 2855 + source = "registry+https://github.com/rust-lang/crates.io-index" 2856 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 2857 + dependencies = [ 2858 + "serde_core", 2859 + "serde_derive", 2860 + ] 2861 + 2862 + [[package]] 2863 + name = "serde_bytes" 2864 + version = "0.11.19" 2865 + source = "registry+https://github.com/rust-lang/crates.io-index" 2866 + checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" 2867 + dependencies = [ 2868 + "serde", 2869 + "serde_core", 2870 + ] 2871 + 2872 + [[package]] 2873 + name = "serde_core" 2874 + version = "1.0.228" 2875 + source = "registry+https://github.com/rust-lang/crates.io-index" 2876 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 2877 + dependencies = [ 2878 + "serde_derive", 2879 + ] 2880 + 2881 + [[package]] 2882 + name = "serde_derive" 2883 + version = "1.0.228" 2884 + source = "registry+https://github.com/rust-lang/crates.io-index" 2885 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 2886 + dependencies = [ 2887 + "proc-macro2", 2888 + "quote", 2889 + "syn", 2890 + ] 2891 + 2892 + [[package]] 2893 + name = "serde_html_form" 2894 + version = "0.3.2" 2895 + source = "registry+https://github.com/rust-lang/crates.io-index" 2896 + checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" 2897 + dependencies = [ 2898 + "form_urlencoded", 2899 + "indexmap", 2900 + "itoa", 2901 + "serde_core", 2902 + ] 2903 + 2904 + [[package]] 2905 + name = "serde_ipld_dagcbor" 2906 + version = "0.6.4" 2907 + source = "registry+https://github.com/rust-lang/crates.io-index" 2908 + checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" 2909 + dependencies = [ 2910 + "cbor4ii", 2911 + "ipld-core", 2912 + "scopeguard", 2913 + "serde", 2914 + ] 2915 + 2916 + [[package]] 2917 + name = "serde_json" 2918 + version = "1.0.149" 2919 + source = "registry+https://github.com/rust-lang/crates.io-index" 2920 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 2921 + dependencies = [ 2922 + "itoa", 2923 + "memchr", 2924 + "serde", 2925 + "serde_core", 2926 + "zmij", 2927 + ] 2928 + 2929 + [[package]] 2930 + name = "serde_path_to_error" 2931 + version = "0.1.20" 2932 + source = "registry+https://github.com/rust-lang/crates.io-index" 2933 + checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" 2934 + dependencies = [ 2935 + "itoa", 2936 + "serde", 2937 + "serde_core", 2938 + ] 2939 + 2940 + [[package]] 2941 + name = "serde_repr" 2942 + version = "0.1.20" 2943 + source = "registry+https://github.com/rust-lang/crates.io-index" 2944 + checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" 2945 + dependencies = [ 2946 + "proc-macro2", 2947 + "quote", 2948 + "syn", 2949 + ] 2950 + 2951 + [[package]] 2952 + name = "serde_urlencoded" 2953 + version = "0.7.1" 2954 + source = "registry+https://github.com/rust-lang/crates.io-index" 2955 + checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 2956 + dependencies = [ 2957 + "form_urlencoded", 2958 + "itoa", 2959 + "ryu", 2960 + "serde", 2961 + ] 2962 + 2963 + [[package]] 2964 + name = "serde_with" 2965 + version = "3.18.0" 2966 + source = "registry+https://github.com/rust-lang/crates.io-index" 2967 + checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" 2968 + dependencies = [ 2969 + "base64 0.22.1", 2970 + "chrono", 2971 + "hex", 2972 + "serde_core", 2973 + "serde_json", 2974 + "serde_with_macros", 2975 + "time", 2976 + ] 2977 + 2978 + [[package]] 2979 + name = "serde_with_macros" 2980 + version = "3.18.0" 2981 + source = "registry+https://github.com/rust-lang/crates.io-index" 2982 + checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" 2983 + dependencies = [ 2984 + "darling", 2985 + "proc-macro2", 2986 + "quote", 2987 + "syn", 2988 + ] 2989 + 2990 + [[package]] 2991 + name = "sha1" 2992 + version = "0.10.6" 2993 + source = "registry+https://github.com/rust-lang/crates.io-index" 2994 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 2995 + dependencies = [ 2996 + "cfg-if", 2997 + "cpufeatures", 2998 + "digest", 2999 + ] 3000 + 3001 + [[package]] 3002 + name = "sha1_smol" 3003 + version = "1.0.1" 3004 + source = "registry+https://github.com/rust-lang/crates.io-index" 3005 + checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" 3006 + 3007 + [[package]] 3008 + name = "sha2" 3009 + version = "0.10.9" 3010 + source = "registry+https://github.com/rust-lang/crates.io-index" 3011 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 3012 + dependencies = [ 3013 + "cfg-if", 3014 + "cpufeatures", 3015 + "digest", 3016 + ] 3017 + 3018 + [[package]] 3019 + name = "sharded-slab" 3020 + version = "0.1.7" 3021 + source = "registry+https://github.com/rust-lang/crates.io-index" 3022 + checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 3023 + dependencies = [ 3024 + "lazy_static", 3025 + ] 3026 + 3027 + [[package]] 3028 + name = "shlex" 3029 + version = "1.3.0" 3030 + source = "registry+https://github.com/rust-lang/crates.io-index" 3031 + checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 3032 + 3033 + [[package]] 3034 + name = "signature" 3035 + version = "2.2.0" 3036 + source = "registry+https://github.com/rust-lang/crates.io-index" 3037 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 3038 + dependencies = [ 3039 + "digest", 3040 + "rand_core 0.6.4", 3041 + ] 3042 + 3043 + [[package]] 3044 + name = "simd-adler32" 3045 + version = "0.3.8" 3046 + source = "registry+https://github.com/rust-lang/crates.io-index" 3047 + checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" 3048 + 3049 + [[package]] 3050 + name = "simd_cesu8" 3051 + version = "1.1.1" 3052 + source = "registry+https://github.com/rust-lang/crates.io-index" 3053 + checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" 3054 + dependencies = [ 3055 + "rustc_version", 3056 + "simdutf8", 3057 + ] 3058 + 3059 + [[package]] 3060 + name = "simdutf8" 3061 + version = "0.1.5" 3062 + source = "registry+https://github.com/rust-lang/crates.io-index" 3063 + checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" 3064 + 3065 + [[package]] 3066 + name = "siphasher" 3067 + version = "1.0.2" 3068 + source = "registry+https://github.com/rust-lang/crates.io-index" 3069 + checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" 3070 + 3071 + [[package]] 3072 + name = "slab" 3073 + version = "0.4.12" 3074 + source = "registry+https://github.com/rust-lang/crates.io-index" 3075 + checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" 3076 + 3077 + [[package]] 3078 + name = "smallvec" 3079 + version = "1.15.1" 3080 + source = "registry+https://github.com/rust-lang/crates.io-index" 3081 + checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 3082 + 3083 + [[package]] 3084 + name = "smol_str" 3085 + version = "0.3.6" 3086 + source = "registry+https://github.com/rust-lang/crates.io-index" 3087 + checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" 3088 + dependencies = [ 3089 + "borsh", 3090 + "serde_core", 3091 + ] 3092 + 3093 + [[package]] 3094 + name = "socket2" 3095 + version = "0.6.3" 3096 + source = "registry+https://github.com/rust-lang/crates.io-index" 3097 + checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" 3098 + dependencies = [ 3099 + "libc", 3100 + "windows-sys 0.61.2", 3101 + ] 3102 + 3103 + [[package]] 3104 + name = "spin" 3105 + version = "0.9.8" 3106 + source = "registry+https://github.com/rust-lang/crates.io-index" 3107 + checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 3108 + dependencies = [ 3109 + "lock_api", 3110 + ] 3111 + 3112 + [[package]] 3113 + name = "spin" 3114 + version = "0.10.0" 3115 + source = "registry+https://github.com/rust-lang/crates.io-index" 3116 + checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" 3117 + 3118 + [[package]] 3119 + name = "spki" 3120 + version = "0.7.3" 3121 + source = "registry+https://github.com/rust-lang/crates.io-index" 3122 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 3123 + dependencies = [ 3124 + "base64ct", 3125 + "der", 3126 + ] 3127 + 3128 + [[package]] 3129 + name = "stable_deref_trait" 3130 + version = "1.2.1" 3131 + source = "registry+https://github.com/rust-lang/crates.io-index" 3132 + checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 3133 + 3134 + [[package]] 3135 + name = "static_assertions" 3136 + version = "1.1.0" 3137 + source = "registry+https://github.com/rust-lang/crates.io-index" 3138 + checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 3139 + 3140 + [[package]] 3141 + name = "strsim" 3142 + version = "0.11.1" 3143 + source = "registry+https://github.com/rust-lang/crates.io-index" 3144 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 3145 + 3146 + [[package]] 3147 + name = "subtle" 3148 + version = "2.6.1" 3149 + source = "registry+https://github.com/rust-lang/crates.io-index" 3150 + checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 3151 + 3152 + [[package]] 3153 + name = "syn" 3154 + version = "2.0.117" 3155 + source = "registry+https://github.com/rust-lang/crates.io-index" 3156 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 3157 + dependencies = [ 3158 + "proc-macro2", 3159 + "quote", 3160 + "unicode-ident", 3161 + ] 3162 + 3163 + [[package]] 3164 + name = "sync_wrapper" 3165 + version = "1.0.2" 3166 + source = "registry+https://github.com/rust-lang/crates.io-index" 3167 + checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 3168 + dependencies = [ 3169 + "futures-core", 3170 + ] 3171 + 3172 + [[package]] 3173 + name = "synstructure" 3174 + version = "0.13.2" 3175 + source = "registry+https://github.com/rust-lang/crates.io-index" 3176 + checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" 3177 + dependencies = [ 3178 + "proc-macro2", 3179 + "quote", 3180 + "syn", 3181 + ] 3182 + 3183 + [[package]] 3184 + name = "system-configuration" 3185 + version = "0.7.0" 3186 + source = "registry+https://github.com/rust-lang/crates.io-index" 3187 + checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" 3188 + dependencies = [ 3189 + "bitflags", 3190 + "core-foundation 0.9.4", 3191 + "system-configuration-sys", 3192 + ] 3193 + 3194 + [[package]] 3195 + name = "system-configuration-sys" 3196 + version = "0.6.0" 3197 + source = "registry+https://github.com/rust-lang/crates.io-index" 3198 + checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" 3199 + dependencies = [ 3200 + "core-foundation-sys", 3201 + "libc", 3202 + ] 3203 + 3204 + [[package]] 3205 + name = "tempfile" 3206 + version = "3.27.0" 3207 + source = "registry+https://github.com/rust-lang/crates.io-index" 3208 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" 3209 + dependencies = [ 3210 + "fastrand", 3211 + "getrandom 0.4.2", 3212 + "once_cell", 3213 + "rustix", 3214 + "windows-sys 0.61.2", 3215 + ] 3216 + 3217 + [[package]] 3218 + name = "thiserror" 3219 + version = "1.0.69" 3220 + source = "registry+https://github.com/rust-lang/crates.io-index" 3221 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 3222 + dependencies = [ 3223 + "thiserror-impl 1.0.69", 3224 + ] 3225 + 3226 + [[package]] 3227 + name = "thiserror" 3228 + version = "2.0.18" 3229 + source = "registry+https://github.com/rust-lang/crates.io-index" 3230 + checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" 3231 + dependencies = [ 3232 + "thiserror-impl 2.0.18", 3233 + ] 3234 + 3235 + [[package]] 3236 + name = "thiserror-impl" 3237 + version = "1.0.69" 3238 + source = "registry+https://github.com/rust-lang/crates.io-index" 3239 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 3240 + dependencies = [ 3241 + "proc-macro2", 3242 + "quote", 3243 + "syn", 3244 + ] 3245 + 3246 + [[package]] 3247 + name = "thiserror-impl" 3248 + version = "2.0.18" 3249 + source = "registry+https://github.com/rust-lang/crates.io-index" 3250 + checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" 3251 + dependencies = [ 3252 + "proc-macro2", 3253 + "quote", 3254 + "syn", 3255 + ] 3256 + 3257 + [[package]] 3258 + name = "thread_local" 3259 + version = "1.1.9" 3260 + source = "registry+https://github.com/rust-lang/crates.io-index" 3261 + checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" 3262 + dependencies = [ 3263 + "cfg-if", 3264 + ] 3265 + 3266 + [[package]] 3267 + name = "threadpool" 3268 + version = "1.8.1" 3269 + source = "registry+https://github.com/rust-lang/crates.io-index" 3270 + checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" 3271 + dependencies = [ 3272 + "num_cpus", 3273 + ] 3274 + 3275 + [[package]] 3276 + name = "time" 3277 + version = "0.3.47" 3278 + source = "registry+https://github.com/rust-lang/crates.io-index" 3279 + checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" 3280 + dependencies = [ 3281 + "deranged", 3282 + "libc", 3283 + "num-conv", 3284 + "num_threads", 3285 + "powerfmt", 3286 + "serde_core", 3287 + "time-core", 3288 + ] 3289 + 3290 + [[package]] 3291 + name = "time-core" 3292 + version = "0.1.8" 3293 + source = "registry+https://github.com/rust-lang/crates.io-index" 3294 + checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" 3295 + 3296 + [[package]] 3297 + name = "tiny_http" 3298 + version = "0.12.0" 3299 + source = "registry+https://github.com/rust-lang/crates.io-index" 3300 + checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" 3301 + dependencies = [ 3302 + "ascii", 3303 + "chunked_transfer", 3304 + "httpdate", 3305 + "log", 3306 + ] 3307 + 3308 + [[package]] 3309 + name = "tinystr" 3310 + version = "0.8.2" 3311 + source = "registry+https://github.com/rust-lang/crates.io-index" 3312 + checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" 3313 + dependencies = [ 3314 + "displaydoc", 3315 + "zerovec", 3316 + ] 3317 + 3318 + [[package]] 3319 + name = "tinyvec" 3320 + version = "1.11.0" 3321 + source = "registry+https://github.com/rust-lang/crates.io-index" 3322 + checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" 3323 + dependencies = [ 3324 + "tinyvec_macros", 3325 + ] 3326 + 3327 + [[package]] 3328 + name = "tinyvec_macros" 3329 + version = "0.1.1" 3330 + source = "registry+https://github.com/rust-lang/crates.io-index" 3331 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 3332 + 3333 + [[package]] 3334 + name = "tokio" 3335 + version = "1.50.0" 3336 + source = "registry+https://github.com/rust-lang/crates.io-index" 3337 + checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" 3338 + dependencies = [ 3339 + "bytes", 3340 + "libc", 3341 + "mio", 3342 + "pin-project-lite", 3343 + "socket2", 3344 + "tokio-macros", 3345 + "windows-sys 0.61.2", 3346 + ] 3347 + 3348 + [[package]] 3349 + name = "tokio-macros" 3350 + version = "2.6.1" 3351 + source = "registry+https://github.com/rust-lang/crates.io-index" 3352 + checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" 3353 + dependencies = [ 3354 + "proc-macro2", 3355 + "quote", 3356 + "syn", 3357 + ] 3358 + 3359 + [[package]] 3360 + name = "tokio-rustls" 3361 + version = "0.26.4" 3362 + source = "registry+https://github.com/rust-lang/crates.io-index" 3363 + checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 3364 + dependencies = [ 3365 + "rustls", 3366 + "tokio", 3367 + ] 3368 + 3369 + [[package]] 3370 + name = "tokio-tungstenite" 3371 + version = "0.24.0" 3372 + source = "registry+https://github.com/rust-lang/crates.io-index" 3373 + checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" 3374 + dependencies = [ 3375 + "futures-util", 3376 + "log", 3377 + "rustls", 3378 + "rustls-native-certs", 3379 + "rustls-pki-types", 3380 + "tokio", 3381 + "tokio-rustls", 3382 + "tungstenite", 3383 + ] 3384 + 3385 + [[package]] 3386 + name = "tokio-tungstenite-wasm" 3387 + version = "0.4.0" 3388 + source = "registry+https://github.com/rust-lang/crates.io-index" 3389 + checksum = "e21a5c399399c3db9f08d8297ac12b500e86bca82e930253fdc62eaf9c0de6ae" 3390 + dependencies = [ 3391 + "futures-channel", 3392 + "futures-util", 3393 + "http", 3394 + "httparse", 3395 + "js-sys", 3396 + "rustls", 3397 + "thiserror 1.0.69", 3398 + "tokio", 3399 + "tokio-tungstenite", 3400 + "wasm-bindgen", 3401 + "web-sys", 3402 + ] 3403 + 3404 + [[package]] 3405 + name = "tokio-util" 3406 + version = "0.7.18" 3407 + source = "registry+https://github.com/rust-lang/crates.io-index" 3408 + checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" 3409 + dependencies = [ 3410 + "bytes", 3411 + "futures-core", 3412 + "futures-sink", 3413 + "futures-util", 3414 + "pin-project-lite", 3415 + "tokio", 3416 + ] 3417 + 3418 + [[package]] 3419 + name = "tower" 3420 + version = "0.5.3" 3421 + source = "registry+https://github.com/rust-lang/crates.io-index" 3422 + checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" 3423 + dependencies = [ 3424 + "futures-core", 3425 + "futures-util", 3426 + "pin-project-lite", 3427 + "sync_wrapper", 3428 + "tokio", 3429 + "tower-layer", 3430 + "tower-service", 3431 + ] 3432 + 3433 + [[package]] 3434 + name = "tower-http" 3435 + version = "0.6.8" 3436 + source = "registry+https://github.com/rust-lang/crates.io-index" 3437 + checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" 3438 + dependencies = [ 3439 + "async-compression", 3440 + "bitflags", 3441 + "bytes", 3442 + "futures-core", 3443 + "futures-util", 3444 + "http", 3445 + "http-body", 3446 + "http-body-util", 3447 + "iri-string", 3448 + "pin-project-lite", 3449 + "tokio", 3450 + "tokio-util", 3451 + "tower", 3452 + "tower-layer", 3453 + "tower-service", 3454 + ] 3455 + 3456 + [[package]] 3457 + name = "tower-layer" 3458 + version = "0.3.3" 3459 + source = "registry+https://github.com/rust-lang/crates.io-index" 3460 + checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 3461 + 3462 + [[package]] 3463 + name = "tower-service" 3464 + version = "0.3.3" 3465 + source = "registry+https://github.com/rust-lang/crates.io-index" 3466 + checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 3467 + 3468 + [[package]] 3469 + name = "tracing" 3470 + version = "0.1.44" 3471 + source = "registry+https://github.com/rust-lang/crates.io-index" 3472 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 3473 + dependencies = [ 3474 + "pin-project-lite", 3475 + "tracing-attributes", 3476 + "tracing-core", 3477 + ] 3478 + 3479 + [[package]] 3480 + name = "tracing-attributes" 3481 + version = "0.1.31" 3482 + source = "registry+https://github.com/rust-lang/crates.io-index" 3483 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" 3484 + dependencies = [ 3485 + "proc-macro2", 3486 + "quote", 3487 + "syn", 3488 + ] 3489 + 3490 + [[package]] 3491 + name = "tracing-core" 3492 + version = "0.1.36" 3493 + source = "registry+https://github.com/rust-lang/crates.io-index" 3494 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 3495 + dependencies = [ 3496 + "once_cell", 3497 + "valuable", 3498 + ] 3499 + 3500 + [[package]] 3501 + name = "tracing-log" 3502 + version = "0.2.0" 3503 + source = "registry+https://github.com/rust-lang/crates.io-index" 3504 + checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 3505 + dependencies = [ 3506 + "log", 3507 + "once_cell", 3508 + "tracing-core", 3509 + ] 3510 + 3511 + [[package]] 3512 + name = "tracing-subscriber" 3513 + version = "0.3.23" 3514 + source = "registry+https://github.com/rust-lang/crates.io-index" 3515 + checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" 3516 + dependencies = [ 3517 + "matchers", 3518 + "nu-ansi-term", 3519 + "once_cell", 3520 + "regex-automata", 3521 + "sharded-slab", 3522 + "smallvec", 3523 + "thread_local", 3524 + "tracing", 3525 + "tracing-core", 3526 + "tracing-log", 3527 + ] 3528 + 3529 + [[package]] 3530 + name = "trait-variant" 3531 + version = "0.1.2" 3532 + source = "registry+https://github.com/rust-lang/crates.io-index" 3533 + checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" 3534 + dependencies = [ 3535 + "proc-macro2", 3536 + "quote", 3537 + "syn", 3538 + ] 3539 + 3540 + [[package]] 3541 + name = "try-lock" 3542 + version = "0.2.5" 3543 + source = "registry+https://github.com/rust-lang/crates.io-index" 3544 + checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 3545 + 3546 + [[package]] 3547 + name = "tungstenite" 3548 + version = "0.24.0" 3549 + source = "registry+https://github.com/rust-lang/crates.io-index" 3550 + checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" 3551 + dependencies = [ 3552 + "byteorder", 3553 + "bytes", 3554 + "data-encoding", 3555 + "http", 3556 + "httparse", 3557 + "log", 3558 + "rand 0.8.5", 3559 + "rustls", 3560 + "rustls-pki-types", 3561 + "sha1", 3562 + "thiserror 1.0.69", 3563 + "utf-8", 3564 + ] 3565 + 3566 + [[package]] 3567 + name = "twoway" 3568 + version = "0.1.8" 3569 + source = "registry+https://github.com/rust-lang/crates.io-index" 3570 + checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" 3571 + dependencies = [ 3572 + "memchr", 3573 + ] 3574 + 3575 + [[package]] 3576 + name = "typenum" 3577 + version = "1.19.0" 3578 + source = "registry+https://github.com/rust-lang/crates.io-index" 3579 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 3580 + 3581 + [[package]] 3582 + name = "unicase" 3583 + version = "2.9.0" 3584 + source = "registry+https://github.com/rust-lang/crates.io-index" 3585 + checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" 3586 + 3587 + [[package]] 3588 + name = "unicode-ident" 3589 + version = "1.0.24" 3590 + source = "registry+https://github.com/rust-lang/crates.io-index" 3591 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 3592 + 3593 + [[package]] 3594 + name = "unicode-segmentation" 3595 + version = "1.12.0" 3596 + source = "registry+https://github.com/rust-lang/crates.io-index" 3597 + checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 3598 + 3599 + [[package]] 3600 + name = "unicode-width" 3601 + version = "0.1.14" 3602 + source = "registry+https://github.com/rust-lang/crates.io-index" 3603 + checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 3604 + 3605 + [[package]] 3606 + name = "unicode-xid" 3607 + version = "0.2.6" 3608 + source = "registry+https://github.com/rust-lang/crates.io-index" 3609 + checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 3610 + 3611 + [[package]] 3612 + name = "unsigned-varint" 3613 + version = "0.8.0" 3614 + source = "registry+https://github.com/rust-lang/crates.io-index" 3615 + checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" 3616 + 3617 + [[package]] 3618 + name = "untrusted" 3619 + version = "0.9.0" 3620 + source = "registry+https://github.com/rust-lang/crates.io-index" 3621 + checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 3622 + 3623 + [[package]] 3624 + name = "url" 3625 + version = "2.5.8" 3626 + source = "registry+https://github.com/rust-lang/crates.io-index" 3627 + checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" 3628 + dependencies = [ 3629 + "form_urlencoded", 3630 + "idna", 3631 + "percent-encoding", 3632 + "serde", 3633 + ] 3634 + 3635 + [[package]] 3636 + name = "utf-8" 3637 + version = "0.7.6" 3638 + source = "registry+https://github.com/rust-lang/crates.io-index" 3639 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 3640 + 3641 + [[package]] 3642 + name = "utf8_iter" 3643 + version = "1.0.4" 3644 + source = "registry+https://github.com/rust-lang/crates.io-index" 3645 + checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 3646 + 3647 + [[package]] 3648 + name = "valuable" 3649 + version = "0.1.1" 3650 + source = "registry+https://github.com/rust-lang/crates.io-index" 3651 + checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 3652 + 3653 + [[package]] 3654 + name = "version_check" 3655 + version = "0.9.5" 3656 + source = "registry+https://github.com/rust-lang/crates.io-index" 3657 + checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3658 + 3659 + [[package]] 3660 + name = "walkdir" 3661 + version = "2.5.0" 3662 + source = "registry+https://github.com/rust-lang/crates.io-index" 3663 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 3664 + dependencies = [ 3665 + "same-file", 3666 + "winapi-util", 3667 + ] 3668 + 3669 + [[package]] 3670 + name = "want" 3671 + version = "0.3.1" 3672 + source = "registry+https://github.com/rust-lang/crates.io-index" 3673 + checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 3674 + dependencies = [ 3675 + "try-lock", 3676 + ] 3677 + 3678 + [[package]] 3679 + name = "wasi" 3680 + version = "0.11.1+wasi-snapshot-preview1" 3681 + source = "registry+https://github.com/rust-lang/crates.io-index" 3682 + checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" 3683 + 3684 + [[package]] 3685 + name = "wasip2" 3686 + version = "1.0.2+wasi-0.2.9" 3687 + source = "registry+https://github.com/rust-lang/crates.io-index" 3688 + checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" 3689 + dependencies = [ 3690 + "wit-bindgen", 3691 + ] 3692 + 3693 + [[package]] 3694 + name = "wasip3" 3695 + version = "0.4.0+wasi-0.3.0-rc-2026-01-06" 3696 + source = "registry+https://github.com/rust-lang/crates.io-index" 3697 + checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" 3698 + dependencies = [ 3699 + "wit-bindgen", 3700 + ] 3701 + 3702 + [[package]] 3703 + name = "wasm-bindgen" 3704 + version = "0.2.114" 3705 + source = "registry+https://github.com/rust-lang/crates.io-index" 3706 + checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" 3707 + dependencies = [ 3708 + "cfg-if", 3709 + "once_cell", 3710 + "rustversion", 3711 + "wasm-bindgen-macro", 3712 + "wasm-bindgen-shared", 3713 + ] 3714 + 3715 + [[package]] 3716 + name = "wasm-bindgen-futures" 3717 + version = "0.4.64" 3718 + source = "registry+https://github.com/rust-lang/crates.io-index" 3719 + checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" 3720 + dependencies = [ 3721 + "cfg-if", 3722 + "futures-util", 3723 + "js-sys", 3724 + "once_cell", 3725 + "wasm-bindgen", 3726 + "web-sys", 3727 + ] 3728 + 3729 + [[package]] 3730 + name = "wasm-bindgen-macro" 3731 + version = "0.2.114" 3732 + source = "registry+https://github.com/rust-lang/crates.io-index" 3733 + checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" 3734 + dependencies = [ 3735 + "quote", 3736 + "wasm-bindgen-macro-support", 3737 + ] 3738 + 3739 + [[package]] 3740 + name = "wasm-bindgen-macro-support" 3741 + version = "0.2.114" 3742 + source = "registry+https://github.com/rust-lang/crates.io-index" 3743 + checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" 3744 + dependencies = [ 3745 + "bumpalo", 3746 + "proc-macro2", 3747 + "quote", 3748 + "syn", 3749 + "wasm-bindgen-shared", 3750 + ] 3751 + 3752 + [[package]] 3753 + name = "wasm-bindgen-shared" 3754 + version = "0.2.114" 3755 + source = "registry+https://github.com/rust-lang/crates.io-index" 3756 + checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" 3757 + dependencies = [ 3758 + "unicode-ident", 3759 + ] 3760 + 3761 + [[package]] 3762 + name = "wasm-encoder" 3763 + version = "0.244.0" 3764 + source = "registry+https://github.com/rust-lang/crates.io-index" 3765 + checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" 3766 + dependencies = [ 3767 + "leb128fmt", 3768 + "wasmparser", 3769 + ] 3770 + 3771 + [[package]] 3772 + name = "wasm-metadata" 3773 + version = "0.244.0" 3774 + source = "registry+https://github.com/rust-lang/crates.io-index" 3775 + checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" 3776 + dependencies = [ 3777 + "anyhow", 3778 + "indexmap", 3779 + "wasm-encoder", 3780 + "wasmparser", 3781 + ] 3782 + 3783 + [[package]] 3784 + name = "wasm-streams" 3785 + version = "0.4.2" 3786 + source = "registry+https://github.com/rust-lang/crates.io-index" 3787 + checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" 3788 + dependencies = [ 3789 + "futures-util", 3790 + "js-sys", 3791 + "wasm-bindgen", 3792 + "wasm-bindgen-futures", 3793 + "web-sys", 3794 + ] 3795 + 3796 + [[package]] 3797 + name = "wasmparser" 3798 + version = "0.244.0" 3799 + source = "registry+https://github.com/rust-lang/crates.io-index" 3800 + checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" 3801 + dependencies = [ 3802 + "bitflags", 3803 + "hashbrown 0.15.5", 3804 + "indexmap", 3805 + "semver", 3806 + ] 3807 + 3808 + [[package]] 3809 + name = "web-sys" 3810 + version = "0.3.91" 3811 + source = "registry+https://github.com/rust-lang/crates.io-index" 3812 + checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" 3813 + dependencies = [ 3814 + "js-sys", 3815 + "wasm-bindgen", 3816 + ] 3817 + 3818 + [[package]] 3819 + name = "web-time" 3820 + version = "1.1.0" 3821 + source = "registry+https://github.com/rust-lang/crates.io-index" 3822 + checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 3823 + dependencies = [ 3824 + "js-sys", 3825 + "wasm-bindgen", 3826 + ] 3827 + 3828 + [[package]] 3829 + name = "webbrowser" 3830 + version = "1.2.0" 3831 + source = "registry+https://github.com/rust-lang/crates.io-index" 3832 + checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" 3833 + dependencies = [ 3834 + "core-foundation 0.10.1", 3835 + "jni", 3836 + "log", 3837 + "ndk-context", 3838 + "objc2", 3839 + "objc2-foundation", 3840 + "url", 3841 + "web-sys", 3842 + ] 3843 + 3844 + [[package]] 3845 + name = "webpki-roots" 3846 + version = "1.0.6" 3847 + source = "registry+https://github.com/rust-lang/crates.io-index" 3848 + checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" 3849 + dependencies = [ 3850 + "rustls-pki-types", 3851 + ] 3852 + 3853 + [[package]] 3854 + name = "winapi-util" 3855 + version = "0.1.11" 3856 + source = "registry+https://github.com/rust-lang/crates.io-index" 3857 + checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 3858 + dependencies = [ 3859 + "windows-sys 0.61.2", 3860 + ] 3861 + 3862 + [[package]] 3863 + name = "windows-core" 3864 + version = "0.62.2" 3865 + source = "registry+https://github.com/rust-lang/crates.io-index" 3866 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 3867 + dependencies = [ 3868 + "windows-implement", 3869 + "windows-interface", 3870 + "windows-link", 3871 + "windows-result", 3872 + "windows-strings", 3873 + ] 3874 + 3875 + [[package]] 3876 + name = "windows-implement" 3877 + version = "0.60.2" 3878 + source = "registry+https://github.com/rust-lang/crates.io-index" 3879 + checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" 3880 + dependencies = [ 3881 + "proc-macro2", 3882 + "quote", 3883 + "syn", 3884 + ] 3885 + 3886 + [[package]] 3887 + name = "windows-interface" 3888 + version = "0.59.3" 3889 + source = "registry+https://github.com/rust-lang/crates.io-index" 3890 + checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" 3891 + dependencies = [ 3892 + "proc-macro2", 3893 + "quote", 3894 + "syn", 3895 + ] 3896 + 3897 + [[package]] 3898 + name = "windows-link" 3899 + version = "0.2.1" 3900 + source = "registry+https://github.com/rust-lang/crates.io-index" 3901 + checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 3902 + 3903 + [[package]] 3904 + name = "windows-registry" 3905 + version = "0.6.1" 3906 + source = "registry+https://github.com/rust-lang/crates.io-index" 3907 + checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" 3908 + dependencies = [ 3909 + "windows-link", 3910 + "windows-result", 3911 + "windows-strings", 3912 + ] 3913 + 3914 + [[package]] 3915 + name = "windows-result" 3916 + version = "0.4.1" 3917 + source = "registry+https://github.com/rust-lang/crates.io-index" 3918 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 3919 + dependencies = [ 3920 + "windows-link", 3921 + ] 3922 + 3923 + [[package]] 3924 + name = "windows-strings" 3925 + version = "0.5.1" 3926 + source = "registry+https://github.com/rust-lang/crates.io-index" 3927 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 3928 + dependencies = [ 3929 + "windows-link", 3930 + ] 3931 + 3932 + [[package]] 3933 + name = "windows-sys" 3934 + version = "0.52.0" 3935 + source = "registry+https://github.com/rust-lang/crates.io-index" 3936 + checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 3937 + dependencies = [ 3938 + "windows-targets 0.52.6", 3939 + ] 3940 + 3941 + [[package]] 3942 + name = "windows-sys" 3943 + version = "0.60.2" 3944 + source = "registry+https://github.com/rust-lang/crates.io-index" 3945 + checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" 3946 + dependencies = [ 3947 + "windows-targets 0.53.5", 3948 + ] 3949 + 3950 + [[package]] 3951 + name = "windows-sys" 3952 + version = "0.61.2" 3953 + source = "registry+https://github.com/rust-lang/crates.io-index" 3954 + checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" 3955 + dependencies = [ 3956 + "windows-link", 3957 + ] 3958 + 3959 + [[package]] 3960 + name = "windows-targets" 3961 + version = "0.52.6" 3962 + source = "registry+https://github.com/rust-lang/crates.io-index" 3963 + checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 3964 + dependencies = [ 3965 + "windows_aarch64_gnullvm 0.52.6", 3966 + "windows_aarch64_msvc 0.52.6", 3967 + "windows_i686_gnu 0.52.6", 3968 + "windows_i686_gnullvm 0.52.6", 3969 + "windows_i686_msvc 0.52.6", 3970 + "windows_x86_64_gnu 0.52.6", 3971 + "windows_x86_64_gnullvm 0.52.6", 3972 + "windows_x86_64_msvc 0.52.6", 3973 + ] 3974 + 3975 + [[package]] 3976 + name = "windows-targets" 3977 + version = "0.53.5" 3978 + source = "registry+https://github.com/rust-lang/crates.io-index" 3979 + checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" 3980 + dependencies = [ 3981 + "windows-link", 3982 + "windows_aarch64_gnullvm 0.53.1", 3983 + "windows_aarch64_msvc 0.53.1", 3984 + "windows_i686_gnu 0.53.1", 3985 + "windows_i686_gnullvm 0.53.1", 3986 + "windows_i686_msvc 0.53.1", 3987 + "windows_x86_64_gnu 0.53.1", 3988 + "windows_x86_64_gnullvm 0.53.1", 3989 + "windows_x86_64_msvc 0.53.1", 3990 + ] 3991 + 3992 + [[package]] 3993 + name = "windows_aarch64_gnullvm" 3994 + version = "0.52.6" 3995 + source = "registry+https://github.com/rust-lang/crates.io-index" 3996 + checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 3997 + 3998 + [[package]] 3999 + name = "windows_aarch64_gnullvm" 4000 + version = "0.53.1" 4001 + source = "registry+https://github.com/rust-lang/crates.io-index" 4002 + checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" 4003 + 4004 + [[package]] 4005 + name = "windows_aarch64_msvc" 4006 + version = "0.52.6" 4007 + source = "registry+https://github.com/rust-lang/crates.io-index" 4008 + checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 4009 + 4010 + [[package]] 4011 + name = "windows_aarch64_msvc" 4012 + version = "0.53.1" 4013 + source = "registry+https://github.com/rust-lang/crates.io-index" 4014 + checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" 4015 + 4016 + [[package]] 4017 + name = "windows_i686_gnu" 4018 + version = "0.52.6" 4019 + source = "registry+https://github.com/rust-lang/crates.io-index" 4020 + checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 4021 + 4022 + [[package]] 4023 + name = "windows_i686_gnu" 4024 + version = "0.53.1" 4025 + source = "registry+https://github.com/rust-lang/crates.io-index" 4026 + checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" 4027 + 4028 + [[package]] 4029 + name = "windows_i686_gnullvm" 4030 + version = "0.52.6" 4031 + source = "registry+https://github.com/rust-lang/crates.io-index" 4032 + checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 4033 + 4034 + [[package]] 4035 + name = "windows_i686_gnullvm" 4036 + version = "0.53.1" 4037 + source = "registry+https://github.com/rust-lang/crates.io-index" 4038 + checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" 4039 + 4040 + [[package]] 4041 + name = "windows_i686_msvc" 4042 + version = "0.52.6" 4043 + source = "registry+https://github.com/rust-lang/crates.io-index" 4044 + checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 4045 + 4046 + [[package]] 4047 + name = "windows_i686_msvc" 4048 + version = "0.53.1" 4049 + source = "registry+https://github.com/rust-lang/crates.io-index" 4050 + checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 4051 + 4052 + [[package]] 4053 + name = "windows_x86_64_gnu" 4054 + version = "0.52.6" 4055 + source = "registry+https://github.com/rust-lang/crates.io-index" 4056 + checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 4057 + 4058 + [[package]] 4059 + name = "windows_x86_64_gnu" 4060 + version = "0.53.1" 4061 + source = "registry+https://github.com/rust-lang/crates.io-index" 4062 + checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 4063 + 4064 + [[package]] 4065 + name = "windows_x86_64_gnullvm" 4066 + version = "0.52.6" 4067 + source = "registry+https://github.com/rust-lang/crates.io-index" 4068 + checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 4069 + 4070 + [[package]] 4071 + name = "windows_x86_64_gnullvm" 4072 + version = "0.53.1" 4073 + source = "registry+https://github.com/rust-lang/crates.io-index" 4074 + checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 4075 + 4076 + [[package]] 4077 + name = "windows_x86_64_msvc" 4078 + version = "0.52.6" 4079 + source = "registry+https://github.com/rust-lang/crates.io-index" 4080 + checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 4081 + 4082 + [[package]] 4083 + name = "windows_x86_64_msvc" 4084 + version = "0.53.1" 4085 + source = "registry+https://github.com/rust-lang/crates.io-index" 4086 + checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" 4087 + 4088 + [[package]] 4089 + name = "wit-bindgen" 4090 + version = "0.51.0" 4091 + source = "registry+https://github.com/rust-lang/crates.io-index" 4092 + checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" 4093 + dependencies = [ 4094 + "wit-bindgen-rust-macro", 4095 + ] 4096 + 4097 + [[package]] 4098 + name = "wit-bindgen-core" 4099 + version = "0.51.0" 4100 + source = "registry+https://github.com/rust-lang/crates.io-index" 4101 + checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" 4102 + dependencies = [ 4103 + "anyhow", 4104 + "heck 0.5.0", 4105 + "wit-parser", 4106 + ] 4107 + 4108 + [[package]] 4109 + name = "wit-bindgen-rust" 4110 + version = "0.51.0" 4111 + source = "registry+https://github.com/rust-lang/crates.io-index" 4112 + checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" 4113 + dependencies = [ 4114 + "anyhow", 4115 + "heck 0.5.0", 4116 + "indexmap", 4117 + "prettyplease", 4118 + "syn", 4119 + "wasm-metadata", 4120 + "wit-bindgen-core", 4121 + "wit-component", 4122 + ] 4123 + 4124 + [[package]] 4125 + name = "wit-bindgen-rust-macro" 4126 + version = "0.51.0" 4127 + source = "registry+https://github.com/rust-lang/crates.io-index" 4128 + checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" 4129 + dependencies = [ 4130 + "anyhow", 4131 + "prettyplease", 4132 + "proc-macro2", 4133 + "quote", 4134 + "syn", 4135 + "wit-bindgen-core", 4136 + "wit-bindgen-rust", 4137 + ] 4138 + 4139 + [[package]] 4140 + name = "wit-component" 4141 + version = "0.244.0" 4142 + source = "registry+https://github.com/rust-lang/crates.io-index" 4143 + checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" 4144 + dependencies = [ 4145 + "anyhow", 4146 + "bitflags", 4147 + "indexmap", 4148 + "log", 4149 + "serde", 4150 + "serde_derive", 4151 + "serde_json", 4152 + "wasm-encoder", 4153 + "wasm-metadata", 4154 + "wasmparser", 4155 + "wit-parser", 4156 + ] 4157 + 4158 + [[package]] 4159 + name = "wit-parser" 4160 + version = "0.244.0" 4161 + source = "registry+https://github.com/rust-lang/crates.io-index" 4162 + checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" 4163 + dependencies = [ 4164 + "anyhow", 4165 + "id-arena", 4166 + "indexmap", 4167 + "log", 4168 + "semver", 4169 + "serde", 4170 + "serde_derive", 4171 + "serde_json", 4172 + "unicode-xid", 4173 + "wasmparser", 4174 + ] 4175 + 4176 + [[package]] 4177 + name = "writeable" 4178 + version = "0.6.2" 4179 + source = "registry+https://github.com/rust-lang/crates.io-index" 4180 + checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 4181 + 4182 + [[package]] 4183 + name = "yansi" 4184 + version = "1.0.1" 4185 + source = "registry+https://github.com/rust-lang/crates.io-index" 4186 + checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" 4187 + 4188 + [[package]] 4189 + name = "yoke" 4190 + version = "0.8.1" 4191 + source = "registry+https://github.com/rust-lang/crates.io-index" 4192 + checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" 4193 + dependencies = [ 4194 + "stable_deref_trait", 4195 + "yoke-derive", 4196 + "zerofrom", 4197 + ] 4198 + 4199 + [[package]] 4200 + name = "yoke-derive" 4201 + version = "0.8.1" 4202 + source = "registry+https://github.com/rust-lang/crates.io-index" 4203 + checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" 4204 + dependencies = [ 4205 + "proc-macro2", 4206 + "quote", 4207 + "syn", 4208 + "synstructure", 4209 + ] 4210 + 4211 + [[package]] 4212 + name = "zerocopy" 4213 + version = "0.8.47" 4214 + source = "registry+https://github.com/rust-lang/crates.io-index" 4215 + checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" 4216 + dependencies = [ 4217 + "zerocopy-derive", 4218 + ] 4219 + 4220 + [[package]] 4221 + name = "zerocopy-derive" 4222 + version = "0.8.47" 4223 + source = "registry+https://github.com/rust-lang/crates.io-index" 4224 + checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" 4225 + dependencies = [ 4226 + "proc-macro2", 4227 + "quote", 4228 + "syn", 4229 + ] 4230 + 4231 + [[package]] 4232 + name = "zerofrom" 4233 + version = "0.1.6" 4234 + source = "registry+https://github.com/rust-lang/crates.io-index" 4235 + checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 4236 + dependencies = [ 4237 + "zerofrom-derive", 4238 + ] 4239 + 4240 + [[package]] 4241 + name = "zerofrom-derive" 4242 + version = "0.1.6" 4243 + source = "registry+https://github.com/rust-lang/crates.io-index" 4244 + checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 4245 + dependencies = [ 4246 + "proc-macro2", 4247 + "quote", 4248 + "syn", 4249 + "synstructure", 4250 + ] 4251 + 4252 + [[package]] 4253 + name = "zeroize" 4254 + version = "1.8.2" 4255 + source = "registry+https://github.com/rust-lang/crates.io-index" 4256 + checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" 4257 + dependencies = [ 4258 + "serde", 4259 + ] 4260 + 4261 + [[package]] 4262 + name = "zerotrie" 4263 + version = "0.2.3" 4264 + source = "registry+https://github.com/rust-lang/crates.io-index" 4265 + checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" 4266 + dependencies = [ 4267 + "displaydoc", 4268 + "yoke", 4269 + "zerofrom", 4270 + ] 4271 + 4272 + [[package]] 4273 + name = "zerovec" 4274 + version = "0.11.5" 4275 + source = "registry+https://github.com/rust-lang/crates.io-index" 4276 + checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" 4277 + dependencies = [ 4278 + "yoke", 4279 + "zerofrom", 4280 + "zerovec-derive", 4281 + ] 4282 + 4283 + [[package]] 4284 + name = "zerovec-derive" 4285 + version = "0.11.2" 4286 + source = "registry+https://github.com/rust-lang/crates.io-index" 4287 + checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" 4288 + dependencies = [ 4289 + "proc-macro2", 4290 + "quote", 4291 + "syn", 4292 + ] 4293 + 4294 + [[package]] 4295 + name = "zmij" 4296 + version = "1.0.21" 4297 + source = "registry+https://github.com/rust-lang/crates.io-index" 4298 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+188
src-tauri/vendor/jacquard-oauth/Cargo.toml
··· 1 + # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO 2 + # 3 + # When uploading crates to the registry Cargo will automatically 4 + # "normalize" Cargo.toml files for maximal compatibility 5 + # with all versions of Cargo and also rewrite `path` dependencies 6 + # to registry (e.g., crates.io) dependencies. 7 + # 8 + # If you are reading this file be aware that the original Cargo.toml 9 + # will likely look very different (and much more reasonable). 10 + # See Cargo.toml.orig for the original contents. 11 + 12 + [package] 13 + edition = "2024" 14 + name = "jacquard-oauth" 15 + version = "0.11.0" 16 + authors = ["Orual <orual@nonbinary.computer>"] 17 + build = false 18 + exclude = [".direnv"] 19 + autolib = false 20 + autobins = false 21 + autoexamples = false 22 + autotests = false 23 + autobenches = false 24 + description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 25 + readme = "README.md" 26 + keywords = [ 27 + "atproto", 28 + "at", 29 + "bluesky", 30 + "api", 31 + "client", 32 + ] 33 + categories = [ 34 + "api-bindings", 35 + "web-programming::http-client", 36 + ] 37 + license = "MPL-2.0" 38 + repository = "https://tangled.org/nonbinary.computer/jacquard" 39 + resolver = "2" 40 + 41 + [package.metadata.docs.rs] 42 + features = [ 43 + "loopback", 44 + "browser-open", 45 + ] 46 + 47 + [features] 48 + browser-open = ["dep:webbrowser"] 49 + default = [] 50 + loopback = ["dep:rouille"] 51 + streaming = [ 52 + "jacquard-common/streaming", 53 + "dep:n0-future", 54 + ] 55 + tracing = ["dep:tracing"] 56 + websocket = ["jacquard-common/websocket"] 57 + 58 + [lib] 59 + name = "jacquard_oauth" 60 + path = "src/lib.rs" 61 + 62 + [dependencies.base64] 63 + version = "0.22" 64 + features = ["alloc"] 65 + default-features = false 66 + 67 + [dependencies.bytes] 68 + version = "1.11" 69 + default-features = false 70 + 71 + [dependencies.chrono] 72 + version = "0.4" 73 + features = ["serde"] 74 + default-features = false 75 + 76 + [dependencies.dashmap] 77 + version = "6.1.0" 78 + 79 + [dependencies.ed25519-dalek] 80 + version = "2" 81 + features = ["rand_core"] 82 + 83 + [dependencies.elliptic-curve] 84 + version = "0.13.8" 85 + 86 + [dependencies.http] 87 + version = "1.4" 88 + default-features = false 89 + 90 + [dependencies.jacquard-common] 91 + version = "0.11" 92 + features = ["reqwest-client"] 93 + 94 + [dependencies.jacquard-identity] 95 + version = "0.11" 96 + 97 + [dependencies.jose-jwa] 98 + version = "0.1" 99 + 100 + [dependencies.jose-jwk] 101 + version = "0.1" 102 + features = [ 103 + "p256", 104 + "p384", 105 + ] 106 + 107 + [dependencies.k256] 108 + version = "0.13" 109 + features = ["ecdsa"] 110 + 111 + [dependencies.miette] 112 + version = "7.6" 113 + 114 + [dependencies.n0-future] 115 + version = "0.1" 116 + optional = true 117 + 118 + [dependencies.p256] 119 + version = "0.13" 120 + features = ["ecdsa"] 121 + 122 + [dependencies.p384] 123 + version = "0.13" 124 + features = ["ecdsa"] 125 + 126 + [dependencies.rand] 127 + version = "0.8.5" 128 + features = ["small_rng"] 129 + 130 + [dependencies.serde] 131 + version = "1.0" 132 + features = [ 133 + "derive", 134 + "alloc", 135 + "derive", 136 + ] 137 + default-features = false 138 + 139 + [dependencies.serde_html_form] 140 + version = "0.3" 141 + default-features = false 142 + 143 + [dependencies.serde_json] 144 + version = "1.0" 145 + features = ["alloc"] 146 + default-features = false 147 + 148 + [dependencies.sha2] 149 + version = "0.10" 150 + 151 + [dependencies.smol_str] 152 + version = "0.3" 153 + features = ["serde"] 154 + 155 + [dependencies.thiserror] 156 + version = "2.0" 157 + default-features = false 158 + 159 + [dependencies.tokio] 160 + version = "1" 161 + features = ["sync"] 162 + default-features = false 163 + 164 + [dependencies.tracing] 165 + version = "0.1" 166 + optional = true 167 + 168 + [dependencies.trait-variant] 169 + version = "0.1.2" 170 + 171 + [dependencies.webbrowser] 172 + version = "1" 173 + optional = true 174 + 175 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies.rouille] 176 + version = "3.6.2" 177 + optional = true 178 + 179 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies.tokio] 180 + version = "1" 181 + features = [ 182 + "rt", 183 + "net", 184 + "time", 185 + ] 186 + default-features = false 187 + 188 + [target.'cfg(target_arch = "wasm32")'.dependencies]
+61
src-tauri/vendor/jacquard-oauth/Cargo.toml.orig
··· 1 + [package] 2 + name = "jacquard-oauth" 3 + version = "0.11.0" 4 + edition.workspace = true 5 + description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 6 + authors.workspace = true 7 + repository.workspace = true 8 + keywords.workspace = true 9 + categories.workspace = true 10 + readme.workspace = true 11 + exclude.workspace = true 12 + license.workspace = true 13 + 14 + 15 + [features] 16 + default = [] 17 + loopback = ["dep:rouille"] 18 + browser-open = ["dep:webbrowser"] 19 + tracing = ["dep:tracing"] 20 + websocket = ["jacquard-common/websocket"] 21 + streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 + 23 + [dependencies] 24 + jacquard-common = { version = "0.11", path = "../jacquard-common", features = ["reqwest-client"] } 25 + jacquard-identity = { version = "0.11", path = "../jacquard-identity" } 26 + serde = { workspace = true, features = ["derive"] } 27 + serde_json = { workspace = true } 28 + smol_str = { workspace = true } 29 + base64.workspace = true 30 + sha2 = { version = "0.10" } 31 + thiserror = { workspace = true } 32 + serde_html_form = { workspace = true } 33 + miette = { workspace = true } 34 + p256 = { workspace = true, features = ["ecdsa"] } 35 + p384 = { version = "0.13", features = ["ecdsa"] } 36 + k256 = { version = "0.13", features = ["ecdsa"] } 37 + ed25519-dalek = { version = "2", features = ["rand_core"] } 38 + jose-jwa = "0.1" 39 + jose-jwk = { workspace = true, features = ["p256", "p384"] } 40 + chrono.workspace = true 41 + elliptic-curve = "0.13.8" 42 + http.workspace = true 43 + bytes.workspace = true 44 + rand = { version = "0.8.5", features = ["small_rng"] } 45 + dashmap = "6.1.0" 46 + tokio = { workspace = true, default-features = false, features = ["sync"] } 47 + trait-variant.workspace = true 48 + n0-future = { workspace = true, optional = true } 49 + webbrowser = { version = "1", optional = true } 50 + tracing = { workspace = true, optional = true } 51 + 52 + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 53 + tokio = { workspace = true, features = ["rt", "net", "time"] } 54 + rouille = { version = "3.6.2", optional = true } 55 + 56 + 57 + [target.'cfg(target_arch = "wasm32")'.dependencies] 58 + #ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"]} 59 + 60 + [package.metadata.docs.rs] 61 + features = ["loopback", "browser-open"]
+169
src-tauri/vendor/jacquard-oauth/README.md
··· 1 + [![Crates.io](https://img.shields.io/crates/v/jacquard.svg)](https://crates.io/crates/jacquard) [![Documentation](https://docs.rs/jacquard/badge.svg)](https://docs.rs/jacquard) 2 + 3 + # Jacquard 4 + 5 + A suite of Rust crates intended to make it much easier to get started with atproto development, without sacrificing flexibility or performance. 6 + 7 + [Jacquard is simpler](https://alpha.weaver.sh/nonbinary.computer/jacquard/jacquard_magic) because it is designed in a way which makes things simple that almost every other atproto library seems to make difficult. 8 + 9 + It is also designed around zero-copy/borrowed deserialization: types like [`Post<'_>`](https://tangled.org/nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/post.rs) can borrow data (via the [`CowStr<'_>`](https://docs.rs/jacquard/latest/jacquard/cowstr/enum.CowStr.html) type and a host of other types built on top of it) directly from the response buffer instead of allocating owned copies. Owned versions are themselves mostly inlined or reference-counted pointers and are therefore still quite efficient. The `IntoStatic` trait (which is derivable) makes it easy to get an owned version and avoid worrying about lifetimes. 10 + 11 + ## Features 12 + 13 + - Validated, spec-compliant, easy to work with, and performant baseline types 14 + - Designed such that you can just work with generated API bindings easily 15 + - Straightforward OAuth 16 + - Server-side convenience features 17 + - Lexicon Data value type for working with unknown atproto data (dag-cbor or json) 18 + - An order of magnitude less boilerplate than some existing crates 19 + - Batteries-included, but easily replaceable batteries. 20 + - Easy to extend with custom lexicons using code generation or handwritten api types 21 + - Stateless options (or options where you handle the state) for rolling your own 22 + - All the building blocks of the convenient abstractions are available 23 + - Use as much or as little from the crates as you need 24 + 25 + 26 + ## Example 27 + 28 + Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline. 29 + 30 + ```rust 31 + // Note: this requires the `loopback` feature enabled (it is currently by default) 32 + use clap::Parser; 33 + use jacquard::CowStr; 34 + use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 35 + use jacquard::client::{Agent, FileAuthStore}; 36 + use jacquard::oauth::client::OAuthClient; 37 + use jacquard::oauth::loopback::LoopbackConfig; 38 + use jacquard::types::xrpc::XrpcClient; 39 + use miette::IntoDiagnostic; 40 + 41 + #[derive(Parser, Debug)] 42 + #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] 43 + struct Args { 44 + /// Handle (e.g., alice.bsky.social), DID, or PDS URL 45 + input: CowStr<'static>, 46 + 47 + /// Path to auth store file (will be created if missing) 48 + #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 49 + store: String, 50 + } 51 + 52 + #[tokio::main] 53 + async fn main() -> miette::Result<()> { 54 + let args = Args::parse(); 55 + 56 + // Build an OAuth client with file-backed auth store and default localhost config 57 + let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 58 + // Authenticate with a PDS, using a loopback server to handle the callback flow 59 + let session = oauth 60 + .login_with_local_server( 61 + args.input.clone(), 62 + Default::default(), 63 + LoopbackConfig::default(), 64 + ) 65 + .await?; 66 + // Wrap in Agent and fetch the timeline 67 + let agent: Agent<_> = Agent::from(session); 68 + let timeline = agent 69 + .send(&GetTimeline::new().limit(5).build()) 70 + .await? 71 + .into_output()?; 72 + for (i, post) in timeline.feed.iter().enumerate() { 73 + println!("\n{}. by {}", i + 1, post.post.author.handle); 74 + println!( 75 + " {}", 76 + serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 77 + ); 78 + } 79 + 80 + Ok(()) 81 + } 82 + 83 + ``` 84 + 85 + If you have `just` installed, you can run the [examples](https://tangled.org/nonbinary.computer/jacquard/tree/main/examples) using `just example {example-name} {ARGS}` or `just examples` to see what's available. 86 + 87 + > [!WARNING] 88 + > The latest version swaps from the `url` crate to the lighter and quicker `fluent-uri`. It also moves the re-exported crate paths around and renames the `Uri<'_>` value type enum to `UriValue<'_>` to avoid confusion. This is likely to have broken some things. Migrating is pretty straightforward but consider yourself forewarned. This crate is *not* 1.0 for a reason. 89 + 90 + ### Changelog 91 + 92 + [CHANGELOG.md](./CHANGELOG.md) 93 + 94 + #### 0.11 Release Highlights: 95 + 96 + - `jacquard-lexgen` and `jacquard-identity` no longer depend on the generated API crate. This is mostly for my own benefit. 97 + 98 + **Code generation pipeline overhaul** (`jacquard-lexicon`, `jacquard-lexgen`) 99 + - Jacquard's codegen output already was nice to *use*. now it's going to be nice to read. 100 + - New code generation tracks the types used, makes an import block for the file, and then organizes the file with stuff you care about at the top and internal stuff, like the builders, at the bottom. 101 + - Import resolution pass now conditionally generates short paths when types are unambiguous within a module, falling back to fully-qualified paths when collisions exist 102 + 103 + #### 0.10 Release Highlights: 104 + 105 + **URL type migration** 106 + - Migrated from `url` crate to `fluent_uri` for validated URL/URI types 107 + - All `Url` types are now `Uri` from `fluent_uri` 108 + - Affects any code that constructs, passes, or pattern-matches on endpoint URLs 109 + 110 + **Re-exported crate paths** 111 + - Re-exported crates (including non-proc-macro dependencies of the generated API crate) are now centralized into a distinct module 112 + - Import paths for re-exported types have changed 113 + 114 + **`no_std` groundwork** 115 + - Initial work toward allowing jacquard to function on platforms without access to the standard library. 116 + - `std` usage is now feature-gated. the library currently *does not compile* without `std` due to some remaining dependencies. 117 + 118 + ### Projects using Jacquard 119 + 120 + - [Tranquil PDS](https://tangled.org/tranquil.farm/tranquil-pds) 121 + - [skywatch-phash-rs](https://tangled.org/skywatch.blue/skywatch-phash-rs) 122 + - [Weaver](https://weaver.sh/) - [tangled repository](https://tangled.org/nonbinary.computer/weaver) 123 + - [wisp.place CLI tool](https://docs.wisp.place/cli/) - formerly 124 + - [PDS MOOver](https://pdsmoover.com/) - [tangled repository](https://tangled.org/baileytownsend.dev/pds-moover) 125 + 126 + ## Component crates 127 + 128 + Jacquard is broken up into several crates for modularity. The correct one to use is generally `jacquard` itself, as it re-exports most of the others. 129 + 130 + | | | | 131 + | --- | --- | --- | 132 + | `jacquard` | Main crate | [![Crates.io](https://img.shields.io/crates/v/jacquard.svg)](https://crates.io/crates/jacquard) [![Documentation](https://docs.rs/jacquard/badge.svg)](https://docs.rs/jacquard) | 133 + |`jacquard-common` | Foundation crate | [![Crates.io](https://img.shields.io/crates/v/jacquard-common.svg)](https://crates.io/crates/jacquard-common) [![Documentation](https://docs.rs/jacquard-common/badge.svg)](https://docs.rs/jacquard-common)| 134 + | `jacquard-axum` | Axum extractor and other helpers | [![Crates.io](https://img.shields.io/crates/v/jacquard-axum.svg)](https://crates.io/crates/jacquard-axum) [![Documentation](https://docs.rs/jacquard-axum/badge.svg)](https://docs.rs/jacquard-axum) | 135 + | `jacquard-api` | Autogenerated API bindings | [![Crates.io](https://img.shields.io/crates/v/jacquard-api.svg)](https://crates.io/crates/jacquard-api) [![Documentation](https://docs.rs/jacquard-api/badge.svg)](https://docs.rs/jacquard-api) | 136 + | `jacquard-oauth` | atproto OAuth implementation | [![Crates.io](https://img.shields.io/crates/v/jacquard-oauth.svg)](https://crates.io/crates/jacquard-oauth) [![Documentation](https://docs.rs/jacquard-oauth/badge.svg)](https://docs.rs/jacquard-oauth) | 137 + | `jacquard-identity` | Identity resolution | [![Crates.io](https://img.shields.io/crates/v/jacquard-identity.svg)](https://crates.io/crates/jacquard-identity) [![Documentation](https://docs.rs/jacquard-identity/badge.svg)](https://docs.rs/jacquard-identity) | 138 + | `jacquard-repo` | Repository primitives (MST, commits, CAR I/O) | [![Crates.io](https://img.shields.io/crates/v/jacquard-repo.svg)](https://crates.io/crates/jacquard-repo) [![Documentation](https://docs.rs/jacquard-repo/badge.svg)](https://docs.rs/jacquard-repo) | 139 + | `jacquard-lexicon` | Lexicon parsing and code generation | [![Crates.io](https://img.shields.io/crates/v/jacquard-lexicon.svg)](https://crates.io/crates/jacquard-lexicon) [![Documentation](https://docs.rs/jacquard-lexicon/badge.svg)](https://docs.rs/jacquard-lexicon) | 140 + | `jacquard-lexgen` | Code generation binaries | [![Crates.io](https://img.shields.io/crates/v/jacquard-lexgen.svg)](https://crates.io/crates/jacquard-lexgen) [![Documentation](https://docs.rs/jacquard-lexgen/badge.svg)](https://docs.rs/jacquard-lexgen) | 141 + | `jacquard-derive` | Macros for lexicon types | [![Crates.io](https://img.shields.io/crates/v/jacquard-derive.svg)](https://crates.io/crates/jacquard-derive) [![Documentation](https://docs.rs/jacquard-derive/badge.svg)](https://docs.rs/jacquard-derive) | 142 + 143 + ### Testimonials 144 + 145 + - ["the most straightforward interface to atproto I've encountered so far."](https://bsky.app/profile/offline.mountainherder.xyz/post/3m3xwewzs3k2v) - @offline.mountainherder.xyz 146 + - "It has saved me a lot of time already! Well worth a few beers and or microcontrollers" - [@baileytownsend.dev](https://bsky.app/profile/baileytownsend.dev) 147 + - ["This is what your library allowed me to do in an hour!!! Thank you!!!"](https://bsky.app/profile/desertthunder.dev/post/3mhhbcula6224) - @desertthunder.dev 148 + 149 + 150 + ## Development 151 + 152 + This repo uses [Flakes](https://nixos.asia/en/flakes) 153 + 154 + ```bash 155 + # Dev shell 156 + nix develop 157 + 158 + # or run via cargo 159 + nix develop -c cargo run 160 + 161 + # build 162 + nix build 163 + ``` 164 + 165 + There's also a [`justfile`](https://just.systems/) for Makefile-esque commands to be run inside of the devShell, and you can generally `cargo ...` or `just ...` whatever just fine if you don't want to use Nix and have the prerequisites installed. 166 + 167 + 168 + 169 + [![License](https://img.shields.io/crates/l/jacquard.svg)](./LICENSE)
+594
src-tauri/vendor/jacquard-oauth/src/atproto.rs
··· 1 + use crate::types::OAuthClientMetadata; 2 + use crate::{keyset::Keyset, scopes::Scope}; 3 + use jacquard_common::cowstr::ToCowStr; 4 + use jacquard_common::deps::fluent_uri::Uri; 5 + use jacquard_common::{CowStr, IntoStatic}; 6 + use serde::{Deserialize, Serialize}; 7 + use smol_str::{SmolStr, ToSmolStr}; 8 + use thiserror::Error; 9 + 10 + /// Errors that can occur when building AT Protocol OAuth client metadata. 11 + #[derive(Error, Debug)] 12 + #[non_exhaustive] 13 + pub enum Error { 14 + /// The `client_id` is not a valid URL. 15 + #[error("`client_id` must be a valid URL")] 16 + InvalidClientId, 17 + /// The `grant_types` list does not include `authorization_code`, which is required by atproto. 18 + #[error("`grant_types` must include `authorization_code`")] 19 + InvalidGrantTypes, 20 + /// The `scope` list does not include `atproto`, which is required for all atproto clients. 21 + #[error("`scope` must not include `atproto`")] 22 + InvalidScope, 23 + /// No redirect URIs were provided; at least one is required. 24 + #[error("`redirect_uris` must not be empty")] 25 + EmptyRedirectUris, 26 + /// The `private_key_jwt` auth method was requested but no JWK keys were provided. 27 + #[error("`private_key_jwt` auth method requires `jwks` keys")] 28 + EmptyJwks, 29 + /// Signing algorithm mismatch: `private_key_jwt` requires `token_endpoint_auth_signing_alg`, 30 + /// and non-`private_key_jwt` methods must not provide it. 31 + #[error( 32 + "`private_key_jwt` auth method requires `token_endpoint_auth_signing_alg`, otherwise must not be provided" 33 + )] 34 + AuthSigningAlg, 35 + /// HTML form serialization of the loopback `client_id` query string failed. 36 + #[error(transparent)] 37 + SerdeHtmlForm(#[from] serde_html_form::ser::Error), 38 + /// A localhost-specific validation error occurred. 39 + #[error(transparent)] 40 + LocalhostClient(#[from] LocalhostClientError), 41 + } 42 + 43 + /// Errors specific to validating a loopback (localhost) OAuth client's redirect URIs. 44 + /// 45 + /// The AT Protocol spec has specific requirements for loopback clients: redirect URIs must 46 + /// use the `http` scheme and must point to actual loopback addresses (not the hostname `localhost`). 47 + #[derive(Error, Debug)] 48 + #[non_exhaustive] 49 + pub enum LocalhostClientError { 50 + /// The redirect URI could not be parsed. 51 + #[error("invalid redirect_uri: {0}")] 52 + Invalid(#[from] jacquard_common::deps::fluent_uri::ParseError), 53 + /// Loopback redirect URIs must use `http:`, not `https:` or any other scheme. 54 + #[error("loopback client_id must use `http:` redirect_uri")] 55 + NotHttpScheme, 56 + /// The hostname `localhost` is not allowed; use a numeric loopback address instead. 57 + #[error("loopback client_id must not use `localhost` as redirect_uri hostname")] 58 + Localhost, 59 + /// The redirect URI host is not a loopback address (127.x.x.x or ::1). 60 + #[error("loopback client_id must not use loopback addresses as redirect_uri")] 61 + NotLoopbackHost, 62 + } 63 + 64 + /// Convenience result type for AT Protocol client metadata operations. 65 + pub type Result<T> = core::result::Result<T, Error>; 66 + 67 + /// The token endpoint authentication method for an OAuth client. 68 + /// 69 + /// AT Protocol clients either authenticate with no client secret (public/loopback clients) 70 + /// or with a private key JWT signed by a key from the client's JWK set. 71 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 72 + #[serde(rename_all = "snake_case")] 73 + pub enum AuthMethod { 74 + /// No client authentication; used for public and loopback clients. 75 + None, 76 + /// Authenticate using a JWT signed with a private key from the client's JWK set. 77 + /// <https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication> 78 + PrivateKeyJwt, 79 + } 80 + 81 + impl From<AuthMethod> for CowStr<'static> { 82 + fn from(value: AuthMethod) -> Self { 83 + match value { 84 + AuthMethod::None => CowStr::new_static("none"), 85 + AuthMethod::PrivateKeyJwt => CowStr::new_static("private_key_jwt"), 86 + } 87 + } 88 + } 89 + 90 + /// OAuth 2.0 grant types supported by AT Protocol clients. 91 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 92 + #[serde(rename_all = "snake_case")] 93 + pub enum GrantType { 94 + /// Standard authorization code grant, required by atproto. 95 + AuthorizationCode, 96 + /// Refresh token grant, used to obtain new access tokens without re-authorization. 97 + RefreshToken, 98 + } 99 + 100 + impl From<GrantType> for CowStr<'static> { 101 + fn from(value: GrantType) -> Self { 102 + match value { 103 + GrantType::AuthorizationCode => CowStr::new_static("authorization_code"), 104 + GrantType::RefreshToken => CowStr::new_static("refresh_token"), 105 + } 106 + } 107 + } 108 + 109 + /// AT Protocol-specific OAuth client metadata, used to describe a client before converting to 110 + /// the generic [`OAuthClientMetadata`] format for server registration. 111 + /// 112 + /// This type provides a validated, atproto-aware view of client registration data, with 113 + /// typed fields for URIs and scopes rather than raw strings. Use [`atproto_client_metadata`] 114 + /// to convert this into the wire format expected by OAuth servers. 115 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 116 + pub struct AtprotoClientMetadata<'m> { 117 + /// The unique identifier for this client, typically the URL of its metadata document. 118 + pub client_id: Uri<String>, 119 + /// The URI of the client's homepage or information page. 120 + pub client_uri: Option<Uri<String>>, 121 + /// The list of allowed redirect URIs for the authorization code flow. 122 + pub redirect_uris: Vec<Uri<String>>, 123 + /// The grant types this client will use. 124 + pub grant_types: Vec<GrantType>, 125 + /// The OAuth scopes this client requests; must include `atproto`. 126 + #[serde(borrow)] 127 + pub scopes: Vec<Scope<'m>>, 128 + /// URI pointing to the client's JWK Set; mutually exclusive with inline `jwks`. 129 + pub jwks_uri: Option<Uri<String>>, 130 + /// Human-readable display name for the client. 131 + pub client_name: Option<SmolStr>, 132 + /// URI of the client's logo image. 133 + pub logo_uri: Option<Uri<String>>, 134 + /// URI of the client's terms of service document. 135 + pub tos_uri: Option<Uri<String>>, 136 + /// URI of the client's privacy policy document. 137 + pub privacy_policy_uri: Option<Uri<String>>, 138 + } 139 + 140 + impl<'m> IntoStatic for AtprotoClientMetadata<'m> { 141 + type Output = AtprotoClientMetadata<'static>; 142 + fn into_static(self) -> AtprotoClientMetadata<'static> { 143 + AtprotoClientMetadata { 144 + client_id: self.client_id, 145 + client_uri: self.client_uri, 146 + redirect_uris: self.redirect_uris, 147 + grant_types: self.grant_types, 148 + scopes: self.scopes.into_static(), 149 + jwks_uri: self.jwks_uri, 150 + client_name: self.client_name, 151 + logo_uri: self.logo_uri, 152 + tos_uri: self.tos_uri, 153 + privacy_policy_uri: None, 154 + } 155 + } 156 + } 157 + 158 + impl<'m> AtprotoClientMetadata<'m> { 159 + /// Attach optional production branding fields to the metadata. 160 + /// 161 + /// Chainable builder method for setting display name, logo, and policy URLs after 162 + /// constructing the base metadata. 163 + pub fn with_prod_info( 164 + mut self, 165 + client_name: &str, 166 + logo_uri: Option<Uri<String>>, 167 + tos_uri: Option<Uri<String>>, 168 + privacy_policy_uri: Option<Uri<String>>, 169 + ) -> Self { 170 + self.client_name = Some(client_name.to_smolstr()); 171 + self.logo_uri = logo_uri; 172 + self.tos_uri = tos_uri; 173 + self.privacy_policy_uri = privacy_policy_uri; 174 + self 175 + } 176 + 177 + /// Create a default loopback client metadata with the `atproto` and `transition:generic` scopes. 178 + /// 179 + /// This is a convenience constructor for local development and CLI tools. The resulting 180 + /// metadata uses `http://localhost` as the `client_id` with both IPv4 and IPv6 loopback 181 + /// redirect URIs. 182 + pub fn default_localhost() -> Self { 183 + Self::new_localhost( 184 + None, 185 + Some(Scope::parse_multiple("atproto transition:generic").unwrap()), 186 + ) 187 + } 188 + 189 + /// Create loopback client metadata with optional custom redirect URIs and scopes. 190 + /// 191 + /// Encodes non-default redirect URIs and scopes into the `client_id` query string as 192 + /// required by the AT Protocol loopback client specification. When `redirect_uris` or 193 + /// `scopes` are `None`, sensible defaults (IPv4 + IPv6 loopback addresses, `atproto` scope) 194 + /// are used. 195 + pub fn new_localhost( 196 + redirect_uris: Option<Vec<Uri<String>>>, 197 + scopes: Option<Vec<Scope<'static>>>, 198 + ) -> AtprotoClientMetadata<'static> { 199 + // determine client_id 200 + #[derive(serde::Serialize)] 201 + struct Parameters<'a> { 202 + #[serde(skip_serializing_if = "Option::is_none")] 203 + redirect_uri: Option<Vec<CowStr<'a>>>, 204 + #[serde(skip_serializing_if = "Option::is_none")] 205 + scope: Option<CowStr<'a>>, 206 + } 207 + let redir_str = redirect_uris.as_ref().map(|uris| { 208 + uris.iter() 209 + .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static()) 210 + .collect() 211 + }); 212 + let query = serde_html_form::to_string(Parameters { 213 + redirect_uri: redir_str, 214 + scope: scopes 215 + .as_ref() 216 + .map(|s| Scope::serialize_multiple(s.as_slice())), 217 + }) 218 + .ok(); 219 + let mut client_id = String::from("http://localhost/"); 220 + if let Some(query) = query 221 + && !query.is_empty() 222 + { 223 + client_id.push_str(&format!("?{query}")); 224 + } 225 + AtprotoClientMetadata { 226 + client_id: Uri::parse(client_id).unwrap(), 227 + client_uri: None, 228 + redirect_uris: redirect_uris.unwrap_or(vec![ 229 + Uri::parse("http://127.0.0.1".to_string()).unwrap(), 230 + Uri::parse("http://[::1]".to_string()).unwrap(), 231 + ]), 232 + grant_types: vec![GrantType::AuthorizationCode, GrantType::RefreshToken], 233 + scopes: scopes.unwrap_or(vec![Scope::Atproto]), 234 + jwks_uri: None, 235 + client_name: None, 236 + logo_uri: None, 237 + tos_uri: None, 238 + privacy_policy_uri: None, 239 + } 240 + } 241 + } 242 + 243 + /// Convert [`AtprotoClientMetadata`] into the [`OAuthClientMetadata`] wire format. 244 + /// 245 + /// Validates all atproto-specific constraints (required scopes, grant types, redirect URIs), 246 + /// selects the appropriate `token_endpoint_auth_method` based on whether a keyset is provided, 247 + /// and serializes scopes and grant types into their string representations. Returns an error 248 + /// if any required field is missing or invalid. 249 + pub fn atproto_client_metadata<'m>( 250 + metadata: AtprotoClientMetadata<'m>, 251 + keyset: &Option<Keyset>, 252 + ) -> Result<OAuthClientMetadata<'static>> { 253 + let is_loopback = metadata.client_id.scheme().as_str() == "http" 254 + && metadata.client_id.authority().map(|a| a.host()) == Some("localhost"); 255 + let application_type = if is_loopback { 256 + Some(CowStr::new_static("native")) 257 + } else { 258 + Some(CowStr::new_static("web")) 259 + }; 260 + if metadata.redirect_uris.is_empty() { 261 + return Err(Error::EmptyRedirectUris); 262 + } 263 + if !metadata.grant_types.contains(&GrantType::AuthorizationCode) { 264 + return Err(Error::InvalidGrantTypes); 265 + } 266 + if !metadata.scopes.contains(&Scope::Atproto) { 267 + return Err(Error::InvalidScope); 268 + } 269 + let (auth_method, jwks_uri, jwks) = if let Some(keyset) = keyset { 270 + let jwks = if metadata.jwks_uri.is_none() { 271 + Some(keyset.public_jwks()) 272 + } else { 273 + None 274 + }; 275 + (AuthMethod::PrivateKeyJwt, metadata.jwks_uri, jwks) 276 + } else { 277 + (AuthMethod::None, None, None) 278 + }; 279 + let client_id = metadata 280 + .client_id 281 + .as_str() 282 + .trim_end_matches("/") 283 + .to_string(); 284 + let client_uri = metadata 285 + .client_uri 286 + .as_ref() 287 + .map(|u| u.as_str().trim_end_matches("/").to_string().into()); 288 + let redirect_uris = metadata 289 + .redirect_uris 290 + .iter() 291 + .map(|u| u.as_str().trim_end_matches("/").to_string().into()) 292 + .collect(); 293 + let jwks_uri = jwks_uri.map(|u| u.as_str().trim_end_matches("/").to_string().into()); 294 + Ok(OAuthClientMetadata { 295 + client_id: client_id.into(), 296 + client_uri, 297 + redirect_uris, 298 + application_type, 299 + token_endpoint_auth_method: Some(auth_method.into()), 300 + grant_types: Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()), 301 + response_types: vec!["code".to_cowstr()], 302 + scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 303 + dpop_bound_access_tokens: Some(true), 304 + jwks_uri, 305 + jwks, 306 + token_endpoint_auth_signing_alg: if keyset.is_some() { 307 + Some(CowStr::new_static("ES256")) 308 + } else { 309 + None 310 + }, 311 + client_name: metadata.client_name, 312 + logo_uri: metadata 313 + .logo_uri 314 + .as_ref() 315 + .map(|u| u.as_str().to_string().into()), 316 + tos_uri: metadata 317 + .tos_uri 318 + .as_ref() 319 + .map(|u| u.as_str().to_string().into()), 320 + privacy_policy_uri: metadata 321 + .privacy_policy_uri 322 + .as_ref() 323 + .map(|u| u.as_str().to_string().into()), 324 + }) 325 + } 326 + 327 + #[cfg(test)] 328 + mod tests { 329 + use crate::scopes::TransitionScope; 330 + 331 + use super::*; 332 + use elliptic_curve::SecretKey; 333 + use jose_jwk::{Jwk, Key, Parameters}; 334 + use p256::pkcs8::DecodePrivateKey; 335 + 336 + const PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- 337 + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgED1AAgC7Fc9kPh5T 338 + 4i4Tn+z+tc47W1zYgzXtyjJtD92hRANCAAT80DqC+Z/JpTO7/pkPBmWqIV1IGh1P 339 + gbGGr0pN+oSing7cZ0169JaRHTNh+0LNQXrFobInX6cj95FzEdRyT4T3 340 + -----END PRIVATE KEY-----"#; 341 + 342 + #[test] 343 + fn test_localhost_client_metadata_default() { 344 + assert_eq!( 345 + atproto_client_metadata(AtprotoClientMetadata::new_localhost(None, None), &None) 346 + .unwrap(), 347 + OAuthClientMetadata { 348 + client_id: CowStr::new_static("http://localhost"), 349 + client_uri: None, 350 + redirect_uris: vec![ 351 + CowStr::new_static("http://127.0.0.1"), 352 + CowStr::new_static("http://[::1]"), 353 + ], 354 + application_type: Some(CowStr::new_static("native")), 355 + scope: Some(CowStr::new_static("atproto")), 356 + grant_types: Some(vec![ 357 + "authorization_code".to_cowstr(), 358 + "refresh_token".to_cowstr() 359 + ]), 360 + response_types: vec!["code".to_cowstr()], 361 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 362 + dpop_bound_access_tokens: Some(true), 363 + jwks_uri: None, 364 + jwks: None, 365 + token_endpoint_auth_signing_alg: None, 366 + tos_uri: None, 367 + privacy_policy_uri: None, 368 + client_name: None, 369 + logo_uri: None, 370 + } 371 + ); 372 + } 373 + 374 + #[test] 375 + fn test_localhost_client_metadata_custom() { 376 + assert_eq!( 377 + atproto_client_metadata( 378 + AtprotoClientMetadata::new_localhost( 379 + Some(vec![ 380 + Uri::parse("http://127.0.0.1/callback".to_string()).unwrap(), 381 + Uri::parse("http://[::1]/callback".to_string()).unwrap(), 382 + ]), 383 + Some(vec![ 384 + Scope::Atproto, 385 + Scope::Transition(TransitionScope::Generic), 386 + Scope::parse("account:email").unwrap() 387 + ]) 388 + ), 389 + &None 390 + ) 391 + .expect("failed to convert metadata"), 392 + OAuthClientMetadata { 393 + client_id: CowStr::new_static( 394 + "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2Fcallback&scope=account%3Aemail+atproto+transition%3Ageneric" 395 + ), 396 + client_uri: None, 397 + redirect_uris: vec![ 398 + CowStr::new_static("http://127.0.0.1/callback"), 399 + CowStr::new_static("http://[::1]/callback"), 400 + ], 401 + scope: Some(CowStr::new_static( 402 + "account:email atproto transition:generic" 403 + )), 404 + application_type: Some(CowStr::new_static("native")), 405 + grant_types: Some(vec![ 406 + "authorization_code".to_cowstr(), 407 + "refresh_token".to_cowstr() 408 + ]), 409 + response_types: vec!["code".to_cowstr()], 410 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 411 + dpop_bound_access_tokens: Some(true), 412 + jwks_uri: None, 413 + jwks: None, 414 + token_endpoint_auth_signing_alg: None, 415 + tos_uri: None, 416 + privacy_policy_uri: None, 417 + client_name: None, 418 + logo_uri: None, 419 + } 420 + ); 421 + } 422 + 423 + #[test] 424 + fn test_localhost_client_metadata_invalid() { 425 + // Invalid inputs are coerced to http://localhost rather than failing 426 + { 427 + let out = atproto_client_metadata( 428 + AtprotoClientMetadata::new_localhost( 429 + Some(vec![Uri::parse("https://127.0.0.1".to_string()).unwrap()]), 430 + None, 431 + ), 432 + &None, 433 + ) 434 + .expect("should coerce to 127.0.0.1"); 435 + assert_eq!( 436 + out, 437 + OAuthClientMetadata { 438 + client_id: CowStr::new_static( 439 + "http://localhost/?redirect_uri=https%3A%2F%2F127.0.0.1" 440 + ), 441 + application_type: Some(CowStr::new_static("native")), 442 + client_uri: None, 443 + redirect_uris: vec![CowStr::new_static("https://127.0.0.1")], 444 + scope: Some(CowStr::new_static("atproto")), 445 + grant_types: Some(vec![ 446 + "authorization_code".to_cowstr(), 447 + "refresh_token".to_cowstr() 448 + ]), 449 + response_types: vec!["code".to_cowstr()], 450 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 451 + dpop_bound_access_tokens: Some(true), 452 + jwks_uri: None, 453 + jwks: None, 454 + token_endpoint_auth_signing_alg: None, 455 + tos_uri: None, 456 + privacy_policy_uri: None, 457 + client_name: None, 458 + logo_uri: None, 459 + } 460 + ); 461 + } 462 + { 463 + let out = atproto_client_metadata( 464 + AtprotoClientMetadata::new_localhost( 465 + Some(vec![ 466 + Uri::parse("http://localhost:8000".to_string()).unwrap(), 467 + ]), 468 + None, 469 + ), 470 + &None, 471 + ) 472 + .expect("should coerce to 127.0.0.1"); 473 + assert_eq!( 474 + out, 475 + OAuthClientMetadata { 476 + client_id: CowStr::new_static( 477 + "http://localhost/?redirect_uri=http%3A%2F%2Flocalhost%3A8000" 478 + ), 479 + client_uri: None, 480 + redirect_uris: vec![CowStr::new_static("http://localhost:8000")], 481 + scope: Some(CowStr::new_static("atproto")), 482 + grant_types: Some(vec![ 483 + "authorization_code".to_cowstr(), 484 + "refresh_token".to_cowstr() 485 + ]), 486 + application_type: Some(CowStr::new_static("native")), 487 + response_types: vec!["code".to_cowstr()], 488 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 489 + dpop_bound_access_tokens: Some(true), 490 + jwks_uri: None, 491 + jwks: None, 492 + token_endpoint_auth_signing_alg: None, 493 + tos_uri: None, 494 + privacy_policy_uri: None, 495 + client_name: None, 496 + logo_uri: None, 497 + } 498 + ); 499 + } 500 + { 501 + let out = atproto_client_metadata( 502 + AtprotoClientMetadata::new_localhost( 503 + Some(vec![Uri::parse("http://192.168.0.0/".to_string()).unwrap()]), 504 + None, 505 + ), 506 + &None, 507 + ) 508 + .expect("should coerce to 127.0.0.1"); 509 + assert_eq!( 510 + out, 511 + OAuthClientMetadata { 512 + client_id: CowStr::new_static( 513 + "http://localhost/?redirect_uri=http%3A%2F%2F192.168.0.0" 514 + ), 515 + client_uri: None, 516 + redirect_uris: vec![CowStr::new_static("http://192.168.0.0")], 517 + scope: Some(CowStr::new_static("atproto")), 518 + grant_types: Some(vec![ 519 + "authorization_code".to_cowstr(), 520 + "refresh_token".to_cowstr() 521 + ]), 522 + application_type: Some(CowStr::new_static("native")), 523 + response_types: vec!["code".to_cowstr()], 524 + token_endpoint_auth_method: Some(AuthMethod::None.into()), 525 + dpop_bound_access_tokens: Some(true), 526 + jwks_uri: None, 527 + jwks: None, 528 + token_endpoint_auth_signing_alg: None, 529 + tos_uri: None, 530 + privacy_policy_uri: None, 531 + client_name: None, 532 + logo_uri: None, 533 + } 534 + ); 535 + } 536 + } 537 + 538 + #[test] 539 + fn test_client_metadata() { 540 + let metadata = AtprotoClientMetadata { 541 + client_id: Uri::parse("https://example.com/client_metadata.json".to_string()).unwrap(), 542 + client_uri: Some(Uri::parse("https://example.com".to_string()).unwrap()), 543 + redirect_uris: vec![Uri::parse("https://example.com/callback".to_string()).unwrap()], 544 + grant_types: vec![GrantType::AuthorizationCode], 545 + scopes: vec![Scope::Atproto], 546 + jwks_uri: None, 547 + client_name: None, 548 + logo_uri: None, 549 + tos_uri: None, 550 + privacy_policy_uri: None, 551 + }; 552 + { 553 + // Non-loopback clients without a keyset should fail (must provide JWKS) 554 + let metadata = metadata.clone(); 555 + let err = atproto_client_metadata(metadata, &None); 556 + assert!(err.is_ok()); 557 + } 558 + { 559 + let metadata = metadata.clone(); 560 + let secret_key = SecretKey::<p256::NistP256>::from_pkcs8_pem(PRIVATE_KEY) 561 + .expect("failed to parse private key"); 562 + let keys = vec![Jwk { 563 + key: Key::from(&secret_key.into()), 564 + prm: Parameters { 565 + kid: Some(String::from("kid00")), 566 + ..Default::default() 567 + }, 568 + }]; 569 + let keyset = Keyset::try_from(keys.clone()).expect("failed to create keyset"); 570 + assert_eq!( 571 + atproto_client_metadata(metadata, &Some(keyset.clone())) 572 + .expect("failed to convert metadata"), 573 + OAuthClientMetadata { 574 + client_id: CowStr::new_static("https://example.com/client_metadata.json"), 575 + client_uri: Some(CowStr::new_static("https://example.com")), 576 + redirect_uris: vec![CowStr::new_static("https://example.com/callback")], 577 + application_type: Some(CowStr::new_static("web")), 578 + scope: Some(CowStr::new_static("atproto")), 579 + grant_types: Some(vec![CowStr::new_static("authorization_code")]), 580 + token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), 581 + dpop_bound_access_tokens: Some(true), 582 + response_types: vec!["code".to_cowstr()], 583 + jwks_uri: None, 584 + jwks: Some(keyset.public_jwks()), 585 + token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")), 586 + client_name: None, 587 + logo_uri: None, 588 + tos_uri: None, 589 + privacy_policy_uri: None, 590 + } 591 + ); 592 + } 593 + } 594 + }
+157
src-tauri/vendor/jacquard-oauth/src/authstore.rs
··· 1 + use std::future::Future; 2 + use std::sync::Arc; 3 + 4 + use dashmap::DashMap; 5 + use jacquard_common::{ 6 + IntoStatic, 7 + session::{SessionStore, SessionStoreError}, 8 + types::did::Did, 9 + }; 10 + use smol_str::{SmolStr, ToSmolStr, format_smolstr}; 11 + 12 + use crate::session::{AuthRequestData, ClientSessionData}; 13 + 14 + /// Persistent storage backend for OAuth client sessions and in-flight authorization requests. 15 + /// 16 + /// Implementors are responsible for durably storing two categories of data: 17 + /// - Active client sessions (access tokens, refresh tokens, nonces) keyed by DID + session ID. 18 + /// - Pending authorization request state, keyed by the OAuth `state` parameter, which must 19 + /// survive the round-trip to the authorization server and be cleaned up after use. 20 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 21 + pub trait ClientAuthStore { 22 + /// Retrieve an active session for the given DID and session identifier, if one exists. 23 + fn get_session( 24 + &self, 25 + did: &Did<'_>, 26 + session_id: &str, 27 + ) -> impl Future<Output = Result<Option<ClientSessionData<'_>>, SessionStoreError>>; 28 + 29 + /// Insert or update a session, replacing any existing entry for the same DID and session ID. 30 + fn upsert_session( 31 + &self, 32 + session: ClientSessionData<'_>, 33 + ) -> impl Future<Output = Result<(), SessionStoreError>>; 34 + 35 + /// Delete the session for the given DID and session identifier. 36 + fn delete_session( 37 + &self, 38 + did: &Did<'_>, 39 + session_id: &str, 40 + ) -> impl Future<Output = Result<(), SessionStoreError>>; 41 + 42 + /// Retrieve the authorization request data associated with the given OAuth `state` value. 43 + fn get_auth_req_info( 44 + &self, 45 + state: &str, 46 + ) -> impl Future<Output = Result<Option<AuthRequestData<'_>>, SessionStoreError>>; 47 + 48 + /// Persist authorization request data so it can be retrieved after the OAuth redirect. 49 + fn save_auth_req_info( 50 + &self, 51 + auth_req_info: &AuthRequestData<'_>, 52 + ) -> impl Future<Output = Result<(), SessionStoreError>>; 53 + 54 + /// Remove authorization request data after the callback has been handled. 55 + fn delete_auth_req_info( 56 + &self, 57 + state: &str, 58 + ) -> impl Future<Output = Result<(), SessionStoreError>>; 59 + } 60 + 61 + /// An in-memory implementation of [`ClientAuthStore`], suitable for testing and single-process 62 + /// deployments where session persistence across restarts is not required. 63 + pub struct MemoryAuthStore { 64 + sessions: DashMap<SmolStr, ClientSessionData<'static>>, 65 + auth_reqs: DashMap<SmolStr, AuthRequestData<'static>>, 66 + } 67 + 68 + impl MemoryAuthStore { 69 + /// Create a new, empty in-memory auth store. 70 + pub fn new() -> Self { 71 + Self { 72 + sessions: DashMap::new(), 73 + auth_reqs: DashMap::new(), 74 + } 75 + } 76 + } 77 + 78 + impl ClientAuthStore for MemoryAuthStore { 79 + async fn get_session( 80 + &self, 81 + did: &Did<'_>, 82 + session_id: &str, 83 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 84 + let key = format_smolstr!("{}_{}", did, session_id); 85 + Ok(self.sessions.get(&key).map(|v| v.clone())) 86 + } 87 + 88 + async fn upsert_session( 89 + &self, 90 + session: ClientSessionData<'_>, 91 + ) -> Result<(), SessionStoreError> { 92 + let key = format_smolstr!("{}_{}", session.account_did, session.session_id); 93 + self.sessions.insert(key, session.into_static()); 94 + Ok(()) 95 + } 96 + 97 + async fn delete_session( 98 + &self, 99 + did: &Did<'_>, 100 + session_id: &str, 101 + ) -> Result<(), SessionStoreError> { 102 + let key = format_smolstr!("{}_{}", did, session_id); 103 + self.sessions.remove(&key); 104 + Ok(()) 105 + } 106 + 107 + async fn get_auth_req_info( 108 + &self, 109 + state: &str, 110 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 111 + Ok(self.auth_reqs.get(state).map(|v| v.clone())) 112 + } 113 + 114 + async fn save_auth_req_info( 115 + &self, 116 + auth_req_info: &AuthRequestData<'_>, 117 + ) -> Result<(), SessionStoreError> { 118 + self.auth_reqs.insert( 119 + auth_req_info.state.clone().to_smolstr(), 120 + auth_req_info.clone().into_static(), 121 + ); 122 + Ok(()) 123 + } 124 + 125 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 126 + self.auth_reqs.remove(state); 127 + Ok(()) 128 + } 129 + } 130 + 131 + impl<T: ClientAuthStore + Send + Sync> 132 + SessionStore<(Did<'static>, SmolStr), ClientSessionData<'static>> for Arc<T> 133 + { 134 + /// Get the current session if present. 135 + async fn get(&self, key: &(Did<'static>, SmolStr)) -> Option<ClientSessionData<'static>> { 136 + let (did, session_id) = key; 137 + self.as_ref() 138 + .get_session(did, session_id) 139 + .await 140 + .ok() 141 + .flatten() 142 + .into_static() 143 + } 144 + /// Persist the given session. 145 + async fn set( 146 + &self, 147 + _key: (Did<'static>, SmolStr), 148 + session: ClientSessionData<'static>, 149 + ) -> Result<(), SessionStoreError> { 150 + self.as_ref().upsert_session(session).await 151 + } 152 + /// Delete the given session. 153 + async fn del(&self, key: &(Did<'static>, SmolStr)) -> Result<(), SessionStoreError> { 154 + let (did, session_id) = key; 155 + self.as_ref().delete_session(did, session_id).await 156 + } 157 + }
+1085
src-tauri/vendor/jacquard-oauth/src/client.rs
··· 1 + use crate::{ 2 + atproto::atproto_client_metadata, 3 + authstore::ClientAuthStore, 4 + dpop::DpopExt, 5 + error::{CallbackError, Result}, 6 + request::{OAuthMetadata, exchange_code, par}, 7 + resolver::OAuthResolver, 8 + scopes::Scope, 9 + session::{ClientData, ClientSessionData, DpopClientData, SessionRegistry}, 10 + types::{AuthorizeOptions, CallbackParams}, 11 + }; 12 + use jacquard_common::{ 13 + AuthorizationToken, CowStr, IntoStatic, 14 + cowstr::ToCowStr, 15 + deps::fluent_uri::Uri, 16 + error::{AuthError, ClientError, XrpcResult}, 17 + http_client::HttpClient, 18 + types::{did::Did, string::Handle}, 19 + xrpc::{ 20 + CallOptions, Response, XrpcClient, XrpcError, XrpcExt, XrpcRequest, XrpcResp, XrpcResponse, 21 + build_http_request, process_response, 22 + }, 23 + }; 24 + 25 + #[cfg(feature = "websocket")] 26 + use jacquard_common::websocket::{WebSocketClient, WebSocketConnection}; 27 + #[cfg(feature = "websocket")] 28 + use jacquard_common::xrpc::XrpcSubscription; 29 + use jacquard_identity::{ 30 + JacquardResolver, 31 + resolver::{DidDocResponse, IdentityError, IdentityResolver, ResolverOptions}, 32 + }; 33 + use jose_jwk::JwkSet; 34 + use std::{future::Future, sync::Arc}; 35 + use tokio::sync::RwLock; 36 + 37 + /// The top-level OAuth client responsible for driving the authorization flow. 38 + pub struct OAuthClient<T, S> 39 + where 40 + T: OAuthResolver, 41 + S: ClientAuthStore, 42 + { 43 + /// Shared session registry that mediates access to the backing auth store. 44 + pub registry: Arc<SessionRegistry<T, S>>, 45 + /// Default call options applied to every outgoing XRPC request. 46 + pub options: RwLock<CallOptions<'static>>, 47 + /// Override for the XRPC base URI; falls back to the public Bluesky AppView when `None`. 48 + pub endpoint: RwLock<Option<Uri<String>>>, 49 + /// Underlying HTTP/identity/OAuth resolver used for all network operations. 50 + pub client: Arc<T>, 51 + } 52 + 53 + impl<S: ClientAuthStore> OAuthClient<JacquardResolver, S> { 54 + /// Create an `OAuthClient` using the default [`JacquardResolver`] for identity and metadata resolution. 55 + pub fn new(store: S, client_data: ClientData<'static>) -> Self { 56 + let client = JacquardResolver::default(); 57 + Self::new_from_resolver(store, client, client_data) 58 + } 59 + 60 + /// Create an OAuth client with the provided store and default localhost client metadata. 61 + /// 62 + /// This is a convenience constructor for quickly setting up an OAuth client 63 + /// with default localhost redirect URIs and "atproto transition:generic" scopes. 64 + /// 65 + /// # Example 66 + /// 67 + /// ```no_run 68 + /// # use jacquard_oauth::client::OAuthClient; 69 + /// # use jacquard_oauth::authstore::MemoryAuthStore; 70 + /// # #[tokio::main] 71 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 72 + /// let store = MemoryAuthStore::new(); 73 + /// let oauth = OAuthClient::with_default_config(store); 74 + /// # Ok(()) 75 + /// # } 76 + /// ``` 77 + pub fn with_default_config(store: S) -> Self { 78 + let client_data = ClientData { 79 + keyset: None, 80 + config: crate::atproto::AtprotoClientMetadata::default_localhost(), 81 + }; 82 + Self::new(store, client_data) 83 + } 84 + } 85 + 86 + impl OAuthClient<JacquardResolver, crate::authstore::MemoryAuthStore> { 87 + /// Create an OAuth client with an in-memory auth store and default localhost client metadata. 88 + /// 89 + /// This is a convenience constructor for simple testing and development. 90 + /// The session will not persist across restarts. 91 + /// 92 + /// # Example 93 + /// 94 + /// ```no_run 95 + /// # use jacquard_oauth::client::OAuthClient; 96 + /// # #[tokio::main] 97 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 98 + /// let oauth = OAuthClient::with_memory_store(); 99 + /// # Ok(()) 100 + /// # } 101 + /// ``` 102 + pub fn with_memory_store() -> Self { 103 + Self::with_default_config(crate::authstore::MemoryAuthStore::new()) 104 + } 105 + } 106 + 107 + impl<T, S> OAuthClient<T, S> 108 + where 109 + T: OAuthResolver, 110 + S: ClientAuthStore, 111 + { 112 + /// Create an OAuth client from an explicit resolver instance, taking ownership of both. 113 + pub fn new_from_resolver(store: S, client: T, client_data: ClientData<'static>) -> Self { 114 + // #[cfg(feature = "tracing")] 115 + // tracing::info!( 116 + // redirect_uris = ?client_data.config.redirect_uris, 117 + // scopes = ?client_data.config.scopes, 118 + // has_keyset = client_data.keyset.is_some(), 119 + // "oauth client created:" 120 + // ); 121 + 122 + let client = Arc::new(client); 123 + let registry = Arc::new(SessionRegistry::new(store, client.clone(), client_data)); 124 + Self { 125 + registry, 126 + client, 127 + options: RwLock::new(CallOptions::default()), 128 + endpoint: RwLock::new(None), 129 + } 130 + } 131 + 132 + /// Create an OAuth client from already-`Arc`-wrapped store and resolver. 133 + pub fn new_with_shared( 134 + store: Arc<S>, 135 + client: Arc<T>, 136 + client_data: ClientData<'static>, 137 + ) -> Self { 138 + let registry = Arc::new(SessionRegistry::new_shared( 139 + store, 140 + client.clone(), 141 + client_data, 142 + )); 143 + Self { 144 + registry, 145 + client, 146 + options: RwLock::new(CallOptions::default()), 147 + endpoint: RwLock::new(None), 148 + } 149 + } 150 + } 151 + 152 + impl<T, S> OAuthClient<T, S> 153 + where 154 + S: ClientAuthStore + Send + Sync + 'static, 155 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 156 + { 157 + /// Return the public JWK set for this client's keyset, or an empty set if no keyset is configured. 158 + pub fn jwks(&self) -> JwkSet { 159 + self.registry 160 + .client_data 161 + .keyset 162 + .as_ref() 163 + .map(|keyset| keyset.public_jwks()) 164 + .unwrap_or_default() 165 + } 166 + /// Begin an OAuth authorization flow and return the URL to which the user should be redirected. 167 + /// 168 + /// This resolves OAuth metadata for the given `input` (a handle, DID, or PDS/entryway URL), 169 + /// performs a Pushed Authorization Request (PAR) to the authorization server, persists the 170 + /// resulting state for later callback verification, and returns a fully-constructed 171 + /// authorization endpoint URL. 172 + /// 173 + /// The caller is responsible for redirecting the user's browser to the returned URL. 174 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip(self, input), fields(input = input.as_ref())))] 175 + pub async fn start_auth( 176 + &self, 177 + input: impl AsRef<str>, 178 + options: AuthorizeOptions<'_>, 179 + ) -> Result<String> { 180 + let client_metadata = atproto_client_metadata( 181 + self.registry.client_data.config.clone(), 182 + &self.registry.client_data.keyset, 183 + )?; 184 + let (server_metadata, identity) = self.client.resolve_oauth(input.as_ref()).await?; 185 + let login_hint = if identity.is_some() { 186 + Some(input.as_ref().into()) 187 + } else { 188 + None 189 + }; 190 + let metadata = OAuthMetadata { 191 + server_metadata, 192 + client_metadata, 193 + keyset: self.registry.client_data.keyset.clone(), 194 + }; 195 + 196 + let auth_req_info = par( 197 + self.client.as_ref(), 198 + login_hint, 199 + options.prompt, 200 + &metadata, 201 + options.state, 202 + ) 203 + .await?; 204 + 205 + // Persist state for callback handling 206 + self.registry 207 + .store 208 + .save_auth_req_info(&auth_req_info) 209 + .await?; 210 + 211 + #[derive(serde::Serialize)] 212 + struct Parameters<'s> { 213 + client_id: CowStr<'s>, 214 + request_uri: CowStr<'s>, 215 + } 216 + Ok(metadata.server_metadata.authorization_endpoint.to_string() 217 + + "?" 218 + + &serde_html_form::to_string(Parameters { 219 + client_id: metadata.client_metadata.client_id, 220 + request_uri: auth_req_info.request_uri, 221 + }) 222 + .unwrap()) 223 + } 224 + 225 + /// Complete the OAuth authorization flow after the authorization server redirects back to the client. 226 + /// 227 + /// Validates the `state` and optional `iss` parameters, exchanges the authorization code for 228 + /// tokens via the token endpoint, verifies the `sub` claim against the expected issuer, and 229 + /// persists the resulting session. On success returns an [`OAuthSession`] ready for API calls. 230 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "info", skip_all, fields(state = params.state.as_ref().map(|s| s.as_ref()))))] 231 + pub async fn callback(&self, params: CallbackParams<'_>) -> Result<OAuthSession<T, S>> { 232 + let Some(state_key) = params.state else { 233 + return Err(CallbackError::MissingState.into()); 234 + }; 235 + 236 + let Some(auth_req_info) = self.registry.store.get_auth_req_info(&state_key).await? else { 237 + return Err(CallbackError::MissingState.into()); 238 + }; 239 + 240 + self.registry.store.delete_auth_req_info(&state_key).await?; 241 + 242 + let metadata = self 243 + .client 244 + .get_authorization_server_metadata(&auth_req_info.authserver_url.to_cowstr()) 245 + .await?; 246 + 247 + if let Some(iss) = params.iss { 248 + if iss != metadata.issuer { 249 + return Err(CallbackError::IssuerMismatch { 250 + expected: metadata.issuer.to_string(), 251 + got: iss.to_string(), 252 + } 253 + .into()); 254 + } 255 + } else if metadata.authorization_response_iss_parameter_supported == Some(true) { 256 + return Err(CallbackError::MissingIssuer.into()); 257 + } 258 + let metadata = OAuthMetadata { 259 + server_metadata: metadata, 260 + client_metadata: atproto_client_metadata( 261 + self.registry.client_data.config.clone(), 262 + &self.registry.client_data.keyset, 263 + )?, 264 + keyset: self.registry.client_data.keyset.clone(), 265 + }; 266 + let authserver_nonce = auth_req_info.dpop_data.dpop_authserver_nonce.clone(); 267 + 268 + match exchange_code( 269 + self.client.as_ref(), 270 + &mut auth_req_info.dpop_data.clone(), 271 + &params.code, 272 + &auth_req_info.pkce_verifier, 273 + &metadata, 274 + ) 275 + .await 276 + { 277 + Ok(token_set) => { 278 + let scopes = if let Some(scope) = &token_set.scope { 279 + Scope::parse_multiple_reduced(&scope) 280 + .expect("Failed to parse scopes") 281 + .into_static() 282 + } else { 283 + vec![] 284 + }; 285 + let client_data = ClientSessionData { 286 + account_did: token_set.sub.clone(), 287 + session_id: auth_req_info.state, 288 + host_url: Uri::parse(token_set.aud.as_ref())?.to_owned(), 289 + authserver_url: auth_req_info.authserver_url.to_cowstr(), 290 + authserver_token_endpoint: auth_req_info.authserver_token_endpoint, 291 + authserver_revocation_endpoint: auth_req_info.authserver_revocation_endpoint, 292 + scopes, 293 + dpop_data: DpopClientData { 294 + dpop_key: auth_req_info.dpop_data.dpop_key.clone(), 295 + dpop_authserver_nonce: authserver_nonce.unwrap_or(CowStr::default()), 296 + dpop_host_nonce: auth_req_info 297 + .dpop_data 298 + .dpop_authserver_nonce 299 + .unwrap_or(CowStr::default()), 300 + }, 301 + token_set, 302 + }; 303 + 304 + self.create_session(client_data).await 305 + } 306 + Err(e) => Err(e.into()), 307 + } 308 + } 309 + 310 + async fn create_session(&self, data: ClientSessionData<'_>) -> Result<OAuthSession<T, S>> { 311 + self.registry.set(data.clone()).await?; 312 + Ok(OAuthSession::new( 313 + self.registry.clone(), 314 + self.client.clone(), 315 + data.into_static(), 316 + )) 317 + } 318 + 319 + /// Restore a previously created session from the backing store, refreshing tokens if needed. 320 + pub async fn restore(&self, did: &Did<'_>, session_id: &str) -> Result<OAuthSession<T, S>> { 321 + self.create_session(self.registry.get(did, session_id, true).await?) 322 + .await 323 + } 324 + 325 + /// Revoke a session by deleting it from the backing store. 326 + /// 327 + /// Note: this removes the session from local storage but does **not** call the authorization 328 + /// server's revocation endpoint. To also invalidate the token server-side, prefer 329 + /// [`OAuthSession::logout`], which calls `revoke` on the token before deleting the session. 330 + pub async fn revoke(&self, did: &Did<'_>, session_id: &str) -> Result<()> { 331 + Ok(self.registry.del(did, session_id).await?) 332 + } 333 + } 334 + 335 + impl<T, S> HttpClient for OAuthClient<T, S> 336 + where 337 + S: ClientAuthStore + Send + Sync + 'static, 338 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 339 + { 340 + type Error = T::Error; 341 + 342 + async fn send_http( 343 + &self, 344 + request: http::Request<Vec<u8>>, 345 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 346 + self.client.send_http(request).await 347 + } 348 + } 349 + 350 + impl<T, S> IdentityResolver for OAuthClient<T, S> 351 + where 352 + S: ClientAuthStore + Send + Sync + 'static, 353 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 354 + { 355 + fn options(&self) -> &ResolverOptions { 356 + self.client.options() 357 + } 358 + 359 + async fn resolve_handle( 360 + &self, 361 + handle: &Handle<'_>, 362 + ) -> jacquard_identity::resolver::Result<Did<'static>> { 363 + self.client.resolve_handle(handle).await 364 + } 365 + 366 + async fn resolve_did_doc( 367 + &self, 368 + did: &Did<'_>, 369 + ) -> jacquard_identity::resolver::Result<DidDocResponse> { 370 + self.client.resolve_did_doc(did).await 371 + } 372 + } 373 + 374 + impl<T, S> XrpcClient for OAuthClient<T, S> 375 + where 376 + S: ClientAuthStore + Send + Sync + 'static, 377 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 378 + { 379 + async fn base_uri(&self) -> Uri<String> { 380 + self.endpoint.read().await.clone().unwrap_or_else(|| { 381 + Uri::parse("https://public.api.bsky.app") 382 + .expect("hardcoded URI is valid") 383 + .to_owned() 384 + }) 385 + } 386 + 387 + async fn opts(&self) -> CallOptions<'_> { 388 + self.options.read().await.clone() 389 + } 390 + 391 + async fn set_opts(&self, opts: CallOptions<'_>) { 392 + let mut guard = self.options.write().await; 393 + *guard = opts.into_static(); 394 + } 395 + 396 + async fn set_base_uri(&self, uri: Uri<String>) { 397 + let normalized = jacquard_common::xrpc::normalize_base_uri(uri); 398 + let mut guard = self.endpoint.write().await; 399 + *guard = Some(normalized); 400 + } 401 + 402 + async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> 403 + where 404 + R: XrpcRequest + Send + Sync, 405 + <R as XrpcRequest>::Response: Send + Sync, 406 + { 407 + let opts = self.options.read().await.clone(); 408 + self.send_with_opts(request, opts).await 409 + } 410 + 411 + async fn send_with_opts<R>( 412 + &self, 413 + request: R, 414 + opts: CallOptions<'_>, 415 + ) -> XrpcResult<XrpcResponse<R>> 416 + where 417 + R: XrpcRequest + Send + Sync, 418 + <R as XrpcRequest>::Response: Send + Sync, 419 + { 420 + let base_uri = self.base_uri().await; 421 + self.client 422 + .xrpc(base_uri) 423 + .with_options(opts.clone()) 424 + .send(&request) 425 + .await 426 + } 427 + } 428 + 429 + /// An active OAuth session for a specific account, used to make authenticated API requests. 430 + /// 431 + /// `OAuthSession` holds the DPoP-bound token set for one account and handles transparent 432 + /// token refresh on `401 invalid_token` responses. The optional `W` type parameter allows 433 + /// attaching a WebSocket client (defaults to `()` when WebSocket support is not needed). 434 + /// 435 + /// Obtain an `OAuthSession` from [`OAuthClient::callback`] or [`OAuthClient::restore`]. 436 + pub struct OAuthSession<T, S, W = ()> 437 + where 438 + T: OAuthResolver, 439 + S: ClientAuthStore, 440 + { 441 + /// Shared registry used to persist and retrieve session data across refresh operations. 442 + pub registry: Arc<SessionRegistry<T, S>>, 443 + /// Underlying HTTP/identity/OAuth resolver shared with the parent `OAuthClient`. 444 + pub client: Arc<T>, 445 + /// Optional WebSocket client; `()` when WebSocket support is not required. 446 + pub ws_client: W, 447 + /// Mutable session data including DPoP key, nonces, and token set. 448 + pub data: RwLock<ClientSessionData<'static>>, 449 + /// Default call options applied to every outgoing XRPC request from this session. 450 + pub options: RwLock<CallOptions<'static>>, 451 + } 452 + 453 + impl<T, S> OAuthSession<T, S, ()> 454 + where 455 + T: OAuthResolver, 456 + S: ClientAuthStore, 457 + { 458 + /// Create a new session without a WebSocket client. 459 + /// 460 + /// This is the standard constructor used by [`OAuthClient::callback`] and 461 + /// [`OAuthClient::restore`]. For WebSocket support use [`OAuthSession::new_with_ws`]. 462 + pub fn new( 463 + registry: Arc<SessionRegistry<T, S>>, 464 + client: Arc<T>, 465 + data: ClientSessionData<'static>, 466 + ) -> Self { 467 + Self { 468 + registry, 469 + client, 470 + ws_client: (), 471 + data: RwLock::new(data), 472 + options: RwLock::new(CallOptions::default()), 473 + } 474 + } 475 + } 476 + 477 + impl<T, S, W> OAuthSession<T, S, W> 478 + where 479 + T: OAuthResolver, 480 + S: ClientAuthStore, 481 + { 482 + /// Create a new session with an attached WebSocket client. 483 + /// 484 + /// Use this variant when the session needs to support WebSocket subscriptions in addition 485 + /// to standard XRPC calls. The `ws_client` is exposed via [`OAuthSession::ws_client`] and 486 + /// is used by the `WebSocketClient` impl when the `websocket` feature is enabled. 487 + pub fn new_with_ws( 488 + registry: Arc<SessionRegistry<T, S>>, 489 + client: Arc<T>, 490 + ws_client: W, 491 + data: ClientSessionData<'static>, 492 + ) -> Self { 493 + Self { 494 + registry, 495 + client, 496 + ws_client, 497 + data: RwLock::new(data), 498 + options: RwLock::new(CallOptions::default()), 499 + } 500 + } 501 + 502 + /// Consume this session and return a new one with the given call options pre-applied. 503 + /// 504 + /// Useful for setting request-level defaults (e.g., `atproto-proxy` or custom headers) once 505 + /// at construction time rather than passing them to every individual XRPC call. 506 + pub fn with_options(self, options: CallOptions<'_>) -> Self { 507 + Self { 508 + registry: self.registry, 509 + client: self.client, 510 + ws_client: self.ws_client, 511 + data: self.data, 512 + options: RwLock::new(options.into_static()), 513 + } 514 + } 515 + 516 + /// Get a reference to the WebSocket client. 517 + pub fn ws_client(&self) -> &W { 518 + &self.ws_client 519 + } 520 + 521 + /// Replace the default call options for this session without consuming it. 522 + pub async fn set_options(&self, options: CallOptions<'_>) { 523 + *self.options.write().await = options.into_static(); 524 + } 525 + 526 + /// Return the DID and session ID for this session. 527 + /// 528 + /// The session ID is the random `state` token generated during the PAR flow and can 529 + /// be used together with the DID to restore the session via [`OAuthClient::restore`]. 530 + pub async fn session_info(&self) -> (Did<'_>, CowStr<'_>) { 531 + let data = self.data.read().await; 532 + (data.account_did.clone(), data.session_id.clone()) 533 + } 534 + 535 + /// Return the resource server (PDS) base URI for this session. 536 + pub async fn endpoint(&self) -> Uri<String> { 537 + self.data.read().await.host_url.clone() 538 + } 539 + 540 + /// Return the current DPoP-bound access token for this session. 541 + /// 542 + /// The token may be stale if it has expired; use [`OAuthSession::refresh`] or 543 + /// rely on the automatic refresh performed by `send_with_opts` to obtain a fresh one. 544 + pub async fn access_token(&self) -> AuthorizationToken<'_> { 545 + AuthorizationToken::Dpop(self.data.read().await.token_set.access_token.clone()) 546 + } 547 + 548 + /// Return the current refresh token for this session, if one is present. 549 + /// 550 + /// Not all authorization servers issue refresh tokens. When `None` is returned, 551 + /// the session cannot be silently renewed and the user must re-authenticate. 552 + pub async fn refresh_token(&self) -> Option<AuthorizationToken<'_>> { 553 + self.data 554 + .read() 555 + .await 556 + .token_set 557 + .refresh_token 558 + .as_ref() 559 + .map(|t| AuthorizationToken::Dpop(t.clone())) 560 + } 561 + 562 + /// Derive an unauthenticated [`OAuthClient`] that shares the same registry and resolver. 563 + /// 564 + /// Useful when you need to initiate a new authorization flow from within an existing 565 + /// session context (e.g., to add a second account) without constructing a fresh client. 566 + pub fn to_client(&self) -> OAuthClient<T, S> { 567 + OAuthClient::from_session(self) 568 + } 569 + } 570 + impl<T, S, W> OAuthSession<T, S, W> 571 + where 572 + S: ClientAuthStore + Send + Sync + 'static, 573 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 574 + { 575 + /// Revoke the access token at the authorization server and delete the session from the store. 576 + /// 577 + /// Revocation is best-effort: if the server does not advertise a revocation endpoint, or if 578 + /// the revocation call fails, the session is still deleted locally. This prevents a dangling 579 + /// session record from blocking future logins for the same account. 580 + pub async fn logout(&self) -> Result<()> { 581 + use crate::request::{OAuthMetadata, revoke}; 582 + let mut data = self.data.write().await; 583 + let meta = 584 + OAuthMetadata::new(self.client.as_ref(), &self.registry.client_data, &data).await?; 585 + if meta.server_metadata.revocation_endpoint.is_some() { 586 + let token = data.token_set.access_token.clone(); 587 + revoke(self.client.as_ref(), &mut data.dpop_data, &token, &meta) 588 + .await 589 + .ok(); 590 + } 591 + // Remove from store 592 + self.registry 593 + .del(&data.account_did, &data.session_id) 594 + .await?; 595 + Ok(()) 596 + } 597 + } 598 + 599 + impl<T, S> OAuthClient<T, S> 600 + where 601 + T: OAuthResolver, 602 + S: ClientAuthStore, 603 + { 604 + /// Construct an `OAuthClient` that shares the registry and resolver of an existing session. 605 + /// 606 + /// Equivalent to [`OAuthSession::to_client`]; provided on `OAuthClient` for symmetry so 607 + /// callers can obtain an unauthenticated client without holding a session reference. 608 + pub fn from_session<W>(session: &OAuthSession<T, S, W>) -> Self { 609 + Self { 610 + registry: session.registry.clone(), 611 + client: session.client.clone(), 612 + options: RwLock::new(CallOptions::default()), 613 + endpoint: RwLock::new(None), 614 + } 615 + } 616 + } 617 + impl<T, S, W> OAuthSession<T, S, W> 618 + where 619 + S: ClientAuthStore + Send + Sync + 'static, 620 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 621 + { 622 + /// Explicitly refresh the access token using the stored refresh token. 623 + /// 624 + /// On success the new token set is written back into both the in-memory session data and 625 + /// the backing store. The returned `AuthorizationToken` is the new access token, which 626 + /// callers can immediately use to retry a failed request. 627 + /// 628 + /// The actual token exchange is serialized per `(DID, session_id)` pair via a `Mutex` inside 629 + /// the registry, so concurrent refresh attempts will not result in duplicate token exchanges. 630 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))] 631 + pub async fn refresh(&self) -> Result<AuthorizationToken<'_>> { 632 + // Read identifiers without holding the lock across await 633 + let (did, sid) = { 634 + let data = self.data.read().await; 635 + (data.account_did.clone(), data.session_id.clone()) 636 + }; 637 + let refreshed = self.registry.as_ref().get(&did, &sid, true).await?; 638 + let token = AuthorizationToken::Dpop(refreshed.token_set.access_token.clone()); 639 + // Write back updated session 640 + *self.data.write().await = refreshed.clone().into_static(); 641 + // Store in the registry 642 + self.registry.set(refreshed).await?; 643 + Ok(token) 644 + } 645 + } 646 + 647 + impl<T, S, W> HttpClient for OAuthSession<T, S, W> 648 + where 649 + S: ClientAuthStore + Send + Sync + 'static, 650 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 651 + W: Send + Sync, 652 + { 653 + type Error = T::Error; 654 + 655 + async fn send_http( 656 + &self, 657 + request: http::Request<Vec<u8>>, 658 + ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 659 + self.client.send_http(request).await 660 + } 661 + } 662 + 663 + impl<T, S, W> XrpcClient for OAuthSession<T, S, W> 664 + where 665 + S: ClientAuthStore + Send + Sync + 'static, 666 + T: OAuthResolver + DpopExt + XrpcExt + Send + Sync + 'static, 667 + W: Send + Sync, 668 + { 669 + async fn base_uri(&self) -> Uri<String> { 670 + self.data.read().await.host_url.clone() 671 + } 672 + 673 + async fn opts(&self) -> CallOptions<'_> { 674 + self.options.read().await.clone() 675 + } 676 + 677 + async fn set_opts(&self, opts: CallOptions<'_>) { 678 + let mut guard = self.options.write().await; 679 + *guard = opts.into_static(); 680 + } 681 + 682 + async fn set_base_uri(&self, uri: Uri<String>) { 683 + let normalized = jacquard_common::xrpc::normalize_base_uri(uri); 684 + let mut guard = self.data.write().await; 685 + guard.host_url = normalized; 686 + } 687 + 688 + async fn send<R>(&self, request: R) -> XrpcResult<XrpcResponse<R>> 689 + where 690 + R: XrpcRequest + Send + Sync, 691 + <R as XrpcRequest>::Response: Send + Sync, 692 + { 693 + let opts = self.options.read().await.clone(); 694 + self.send_with_opts(request, opts).await 695 + } 696 + 697 + async fn send_with_opts<R>( 698 + &self, 699 + request: R, 700 + mut opts: CallOptions<'_>, 701 + ) -> XrpcResult<XrpcResponse<R>> 702 + where 703 + R: XrpcRequest + Send + Sync, 704 + <R as XrpcRequest>::Response: Send + Sync, 705 + { 706 + let base_uri = self.base_uri().await; 707 + let original_token = self.access_token().await; 708 + opts.auth = Some(original_token.clone()); 709 + // Clone dpop_data and release read lock before the await point 710 + let mut dpop = self.data.read().await.dpop_data.clone(); 711 + let http_response = self 712 + .client 713 + .dpop_call(&mut dpop) 714 + .send(build_http_request(&base_uri, &request, &opts)?) 715 + .await 716 + .map_err(|e| ClientError::from(e).for_nsid(R::NSID))?; 717 + let resp = process_response(http_response); 718 + 719 + // Write back updated nonce to session data (dpop_call may have updated it) 720 + { 721 + let mut guard = self.data.write().await; 722 + guard.dpop_data.dpop_host_nonce = dpop.dpop_host_nonce.clone(); 723 + } 724 + 725 + if is_invalid_token_response(&resp) { 726 + // Optimistic refresh: check if another request already refreshed the token 727 + let current_token = self.access_token().await; 728 + if current_token != original_token { 729 + // Token was already refreshed by another concurrent request, use it 730 + opts.auth = Some(current_token); 731 + } else { 732 + // We need to refresh - this will be serialized by the registry's Mutex 733 + opts.auth = Some( 734 + self.refresh() 735 + .await 736 + .map_err(|e| ClientError::transport(e))?, 737 + ); 738 + } 739 + // Re-read dpop_data after refresh (refresh may have updated it) 740 + let mut dpop = self.data.read().await.dpop_data.clone(); 741 + let http_response = self 742 + .client 743 + .dpop_call(&mut dpop) 744 + .send(build_http_request(&base_uri, &request, &opts)?) 745 + .await 746 + .map_err(|e| { 747 + ClientError::from(e) 748 + .for_nsid(R::NSID) 749 + .append_context("after token refresh") 750 + })?; 751 + let resp = process_response(http_response); 752 + 753 + // Write back updated nonce after retry 754 + { 755 + let mut guard = self.data.write().await; 756 + guard.dpop_data.dpop_host_nonce = dpop.dpop_host_nonce.clone(); 757 + } 758 + 759 + resp 760 + } else { 761 + resp 762 + } 763 + } 764 + } 765 + 766 + #[cfg(feature = "streaming")] 767 + impl<T, S, W> jacquard_common::http_client::HttpClientExt for OAuthSession<T, S, W> 768 + where 769 + S: ClientAuthStore + Send + Sync + 'static, 770 + T: OAuthResolver 771 + + DpopExt 772 + + XrpcExt 773 + + jacquard_common::http_client::HttpClientExt 774 + + Send 775 + + Sync 776 + + 'static, 777 + W: Send + Sync, 778 + { 779 + async fn send_http_streaming( 780 + &self, 781 + request: http::Request<Vec<u8>>, 782 + ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 783 + { 784 + self.client.send_http_streaming(request).await 785 + } 786 + 787 + #[cfg(not(target_arch = "wasm32"))] 788 + async fn send_http_bidirectional<Str>( 789 + &self, 790 + parts: http::request::Parts, 791 + body: Str, 792 + ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 793 + where 794 + Str: n0_future::Stream< 795 + Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>, 796 + > + Send 797 + + 'static, 798 + { 799 + self.client.send_http_bidirectional(parts, body).await 800 + } 801 + 802 + #[cfg(target_arch = "wasm32")] 803 + async fn send_http_bidirectional<Str>( 804 + &self, 805 + parts: http::request::Parts, 806 + body: Str, 807 + ) -> core::result::Result<http::Response<jacquard_common::stream::ByteStream>, Self::Error> 808 + where 809 + Str: n0_future::Stream< 810 + Item = core::result::Result<bytes::Bytes, jacquard_common::StreamError>, 811 + > + 'static, 812 + { 813 + self.client.send_http_bidirectional(parts, body).await 814 + } 815 + } 816 + 817 + #[cfg(feature = "streaming")] 818 + impl<T, S, W> jacquard_common::xrpc::XrpcStreamingClient for OAuthSession<T, S, W> 819 + where 820 + S: ClientAuthStore + Send + Sync + 'static, 821 + T: OAuthResolver 822 + + DpopExt 823 + + XrpcExt 824 + + jacquard_common::http_client::HttpClientExt 825 + + Send 826 + + Sync 827 + + 'static, 828 + W: Send + Sync, 829 + { 830 + async fn download<R>( 831 + &self, 832 + request: R, 833 + ) -> core::result::Result<jacquard_common::xrpc::StreamingResponse, jacquard_common::StreamError> 834 + where 835 + R: XrpcRequest + Send + Sync, 836 + <R as XrpcRequest>::Response: Send + Sync, 837 + { 838 + use jacquard_common::StreamError; 839 + 840 + let base_uri = <Self as XrpcClient>::base_uri(self).await; 841 + let mut opts = self.options.read().await.clone(); 842 + opts.auth = Some(self.access_token().await); 843 + let http_request = build_http_request(&base_uri, &request, &opts) 844 + .map_err(|e| StreamError::protocol(e.to_string()))?; 845 + let guard = self.data.read().await; 846 + let mut dpop = guard.dpop_data.clone(); 847 + let result = self 848 + .client 849 + .dpop_call(&mut dpop) 850 + .send_streaming(http_request) 851 + .await; 852 + drop(guard); 853 + 854 + match result { 855 + Ok(response) => Ok(response), 856 + Err(_e) => { 857 + // Check if it's an auth error and retry 858 + opts.auth = Some( 859 + self.refresh() 860 + .await 861 + .map_err(|e| StreamError::transport(e))?, 862 + ); 863 + let http_request = build_http_request(&base_uri, &request, &opts) 864 + .map_err(|e| StreamError::protocol(e.to_string()))?; 865 + let guard = self.data.read().await; 866 + let mut dpop = guard.dpop_data.clone(); 867 + self.client 868 + .dpop_call(&mut dpop) 869 + .send_streaming(http_request) 870 + .await 871 + .map_err(StreamError::transport) 872 + } 873 + } 874 + } 875 + 876 + async fn stream<Str>( 877 + &self, 878 + stream: jacquard_common::xrpc::streaming::XrpcProcedureSend<Str::Frame<'static>>, 879 + ) -> core::result::Result< 880 + jacquard_common::xrpc::streaming::XrpcResponseStream< 881 + <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>, 882 + >, 883 + jacquard_common::StreamError, 884 + > 885 + where 886 + Str: jacquard_common::xrpc::streaming::XrpcProcedureStream + 'static, 887 + <<Str as jacquard_common::xrpc::streaming::XrpcProcedureStream>::Response as jacquard_common::xrpc::streaming::XrpcStreamResp>::Frame<'static>: jacquard_common::xrpc::streaming::XrpcStreamResp, 888 + { 889 + use jacquard_common::StreamError; 890 + use n0_future::TryStreamExt; 891 + 892 + let base_uri = self.base_uri().await; 893 + let mut opts = self.options.read().await.clone(); 894 + opts.auth = Some(self.access_token().await); 895 + 896 + let mut path = String::from(base_uri.as_str().trim_end_matches('/')); 897 + path.push_str("/xrpc/"); 898 + path.push_str(<Str::Request as jacquard_common::xrpc::XrpcRequest>::NSID); 899 + 900 + let mut builder = http::Request::post(path); 901 + 902 + if let Some(token) = &opts.auth { 903 + use jacquard_common::AuthorizationToken; 904 + let hv = match token { 905 + AuthorizationToken::Bearer(t) => { 906 + http::HeaderValue::from_str(&format!("Bearer {}", t.as_ref())) 907 + } 908 + AuthorizationToken::Dpop(t) => { 909 + http::HeaderValue::from_str(&format!("DPoP {}", t.as_ref())) 910 + } 911 + } 912 + .map_err(|e| StreamError::protocol(format!("Invalid authorization token: {}", e)))?; 913 + builder = builder.header(http::header::AUTHORIZATION, hv); 914 + } 915 + 916 + if let Some(proxy) = &opts.atproto_proxy { 917 + builder = builder.header("atproto-proxy", proxy.as_ref()); 918 + } 919 + if let Some(labelers) = &opts.atproto_accept_labelers { 920 + if !labelers.is_empty() { 921 + let joined = labelers 922 + .iter() 923 + .map(|s| s.as_ref()) 924 + .collect::<Vec<_>>() 925 + .join(", "); 926 + builder = builder.header("atproto-accept-labelers", joined); 927 + } 928 + } 929 + for (name, value) in &opts.extra_headers { 930 + builder = builder.header(name, value); 931 + } 932 + 933 + let (parts, _) = builder 934 + .body(()) 935 + .map_err(|e| StreamError::protocol(e.to_string()))? 936 + .into_parts(); 937 + 938 + let body_stream = 939 + jacquard_common::stream::ByteStream::new(Box::pin(stream.0.map_ok(|f| f.buffer))); 940 + 941 + let guard = self.data.read().await; 942 + let mut dpop = guard.dpop_data.clone(); 943 + let result = self 944 + .client 945 + .dpop_call(&mut dpop) 946 + .send_bidirectional(parts, body_stream) 947 + .await; 948 + drop(guard); 949 + 950 + match result { 951 + Ok(response) => { 952 + let (resp_parts, resp_body) = response.into_parts(); 953 + Ok( 954 + jacquard_common::xrpc::streaming::XrpcResponseStream::from_typed_parts( 955 + resp_parts, resp_body, 956 + ), 957 + ) 958 + } 959 + Err(e) => { 960 + // OAuth token refresh and retry is handled by dpop wrapper 961 + // If we get here, it's a real error 962 + Err(StreamError::transport(e)) 963 + } 964 + } 965 + } 966 + } 967 + 968 + fn is_invalid_token_response<R: XrpcResp>(response: &XrpcResult<Response<R>>) -> bool { 969 + use jacquard_common::error::ClientErrorKind; 970 + 971 + match response { 972 + Err(e) => match e.kind() { 973 + ClientErrorKind::Auth(AuthError::InvalidToken) => true, 974 + ClientErrorKind::Auth(AuthError::Other(value)) => value 975 + .to_str() 976 + .is_ok_and(|s| s.starts_with("DPoP ") && s.contains("error=\"invalid_token\"")), 977 + _ => false, 978 + }, 979 + Ok(resp) => match resp.parse() { 980 + Err(XrpcError::Auth(AuthError::InvalidToken)) => true, 981 + _ => false, 982 + }, 983 + } 984 + } 985 + 986 + impl<T, S, W> IdentityResolver for OAuthSession<T, S, W> 987 + where 988 + S: ClientAuthStore + Send + Sync + 'static, 989 + T: OAuthResolver + IdentityResolver + XrpcExt + Send + Sync + 'static, 990 + W: Send + Sync, 991 + { 992 + fn options(&self) -> &ResolverOptions { 993 + self.client.options() 994 + } 995 + 996 + fn resolve_handle( 997 + &self, 998 + handle: &Handle<'_>, 999 + ) -> impl Future<Output = std::result::Result<Did<'static>, IdentityError>> { 1000 + async { self.client.resolve_handle(handle).await } 1001 + } 1002 + 1003 + fn resolve_did_doc( 1004 + &self, 1005 + did: &Did<'_>, 1006 + ) -> impl Future<Output = std::result::Result<DidDocResponse, IdentityError>> { 1007 + async { self.client.resolve_did_doc(did).await } 1008 + } 1009 + } 1010 + 1011 + #[cfg(feature = "websocket")] 1012 + impl<T, S, W> WebSocketClient for OAuthSession<T, S, W> 1013 + where 1014 + S: ClientAuthStore + Send + Sync + 'static, 1015 + T: OAuthResolver + Send + Sync + 'static, 1016 + W: WebSocketClient + Send + Sync, 1017 + { 1018 + type Error = W::Error; 1019 + 1020 + async fn connect( 1021 + &self, 1022 + uri: Uri<&str>, 1023 + ) -> std::result::Result<WebSocketConnection, Self::Error> { 1024 + self.ws_client.connect(uri).await 1025 + } 1026 + 1027 + async fn connect_with_headers( 1028 + &self, 1029 + uri: Uri<&str>, 1030 + headers: Vec<(CowStr<'_>, CowStr<'_>)>, 1031 + ) -> std::result::Result<WebSocketConnection, Self::Error> { 1032 + self.ws_client.connect_with_headers(uri, headers).await 1033 + } 1034 + } 1035 + 1036 + #[cfg(feature = "websocket")] 1037 + impl<T, S, W> jacquard_common::xrpc::SubscriptionClient for OAuthSession<T, S, W> 1038 + where 1039 + S: ClientAuthStore + Send + Sync + 'static, 1040 + T: OAuthResolver + Send + Sync + 'static, 1041 + W: WebSocketClient + Send + Sync, 1042 + { 1043 + async fn base_uri(&self) -> Uri<String> { 1044 + self.data.read().await.host_url.clone() 1045 + } 1046 + 1047 + async fn subscription_opts(&self) -> jacquard_common::xrpc::SubscriptionOptions<'_> { 1048 + let mut opts = jacquard_common::xrpc::SubscriptionOptions::default(); 1049 + let token = self.access_token().await; 1050 + let auth_value = match token { 1051 + AuthorizationToken::Bearer(t) => format!("Bearer {}", t.as_ref()), 1052 + AuthorizationToken::Dpop(t) => format!("DPoP {}", t.as_ref()), 1053 + }; 1054 + opts.headers 1055 + .push((CowStr::from("Authorization"), CowStr::from(auth_value))); 1056 + opts 1057 + } 1058 + 1059 + async fn subscribe<Sub>( 1060 + &self, 1061 + params: &Sub, 1062 + ) -> std::result::Result<jacquard_common::xrpc::SubscriptionStream<Sub::Stream>, Self::Error> 1063 + where 1064 + Sub: XrpcSubscription + Send + Sync, 1065 + { 1066 + let opts = self.subscription_opts().await; 1067 + self.subscribe_with_opts(params, opts).await 1068 + } 1069 + 1070 + async fn subscribe_with_opts<Sub>( 1071 + &self, 1072 + params: &Sub, 1073 + opts: jacquard_common::xrpc::SubscriptionOptions<'_>, 1074 + ) -> std::result::Result<jacquard_common::xrpc::SubscriptionStream<Sub::Stream>, Self::Error> 1075 + where 1076 + Sub: XrpcSubscription + Send + Sync, 1077 + { 1078 + use jacquard_common::xrpc::SubscriptionExt; 1079 + let base = self.base_uri().await; 1080 + self.subscription(base) 1081 + .with_options(opts) 1082 + .subscribe(params) 1083 + .await 1084 + } 1085 + }
+810
src-tauri/vendor/jacquard-oauth/src/dpop.rs
··· 1 + use std::error::Error as StdError; 2 + use std::fmt; 3 + use std::future::Future; 4 + 5 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 6 + use chrono::Utc; 7 + use http::{Request, Response, header::InvalidHeaderValue}; 8 + use jacquard_common::{CowStr, IntoStatic, cowstr::ToCowStr, http_client::HttpClient}; 9 + use jacquard_identity::JacquardResolver; 10 + use jose_jwa::{Algorithm, Signing}; 11 + use jose_jwk::{Jwk, Key, crypto}; 12 + use p256::ecdsa::SigningKey; 13 + use rand::{RngCore, SeedableRng}; 14 + use sha2::Digest; 15 + use smol_str::SmolStr; 16 + 17 + use crate::{ 18 + jose::{ 19 + jws::RegisteredHeader, 20 + jwt::{Claims, PublicClaims, RegisteredClaims}, 21 + signing, 22 + }, 23 + session::DpopDataSource, 24 + }; 25 + 26 + /// The `typ` header value required in all DPoP proof JWTs, per RFC 9449. 27 + pub const JWT_HEADER_TYP_DPOP: &str = "dpop+jwt"; 28 + 29 + #[derive(serde::Deserialize)] 30 + struct ErrorResponse { 31 + error: String, 32 + } 33 + 34 + /// Boxed error type for error sources. 35 + pub type BoxError = Box<dyn StdError + Send + Sync + 'static>; 36 + 37 + /// Target server type for DPoP requests. 38 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 39 + pub enum DpopTarget { 40 + /// OAuth authorization server (token endpoint, PAR, etc.) 41 + AuthServer, 42 + /// Resource server (PDS, AppView, etc.) 43 + ResourceServer, 44 + } 45 + 46 + impl fmt::Display for DpopTarget { 47 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 48 + match self { 49 + DpopTarget::AuthServer => write!(f, "auth server"), 50 + DpopTarget::ResourceServer => write!(f, "resource server"), 51 + } 52 + } 53 + } 54 + 55 + /// Error categories for DPoP operations. 56 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 57 + #[non_exhaustive] 58 + pub enum DpopErrorKind { 59 + /// DPoP proof construction failed. 60 + ProofBuild, 61 + /// Initial HTTP request failed. 62 + Transport, 63 + /// Retry after nonce update also failed. 64 + NonceRetry, 65 + /// Header value parsing failed. 66 + InvalidHeader, 67 + /// JWK crypto operation failed. 68 + Crypto, 69 + /// Key type not supported for DPoP. 70 + UnsupportedKey, 71 + /// JSON serialization failed. 72 + Serialization, 73 + } 74 + 75 + impl fmt::Display for DpopErrorKind { 76 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 77 + match self { 78 + DpopErrorKind::ProofBuild => write!(f, "DPoP proof construction failed"), 79 + DpopErrorKind::Transport => write!(f, "HTTP request failed"), 80 + DpopErrorKind::NonceRetry => write!(f, "request failed after nonce retry"), 81 + DpopErrorKind::InvalidHeader => write!(f, "invalid header value"), 82 + DpopErrorKind::Crypto => write!(f, "JWK crypto operation failed"), 83 + DpopErrorKind::UnsupportedKey => write!(f, "unsupported key type"), 84 + DpopErrorKind::Serialization => write!(f, "JSON serialization failed"), 85 + } 86 + } 87 + } 88 + 89 + /// DPoP operation error with rich context. 90 + #[derive(Debug, miette::Diagnostic)] 91 + pub struct DpopError { 92 + kind: DpopErrorKind, 93 + target: Option<DpopTarget>, 94 + url: Option<SmolStr>, 95 + source: Option<BoxError>, 96 + context: Option<SmolStr>, 97 + #[help] 98 + help: Option<&'static str>, 99 + } 100 + 101 + impl fmt::Display for DpopError { 102 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 103 + write!(f, "{}", self.kind)?; 104 + 105 + if let Some(target) = &self.target { 106 + write!(f, " (to {})", target)?; 107 + } 108 + 109 + if let Some(url) = &self.url { 110 + write!(f, " [{}]", url)?; 111 + } 112 + 113 + if let Some(ctx) = &self.context { 114 + write!(f, ": {}", ctx)?; 115 + } 116 + 117 + Ok(()) 118 + } 119 + } 120 + 121 + impl StdError for DpopError { 122 + fn source(&self) -> Option<&(dyn StdError + 'static)> { 123 + self.source 124 + .as_ref() 125 + .map(|e| e.as_ref() as &(dyn StdError + 'static)) 126 + } 127 + } 128 + 129 + impl DpopError { 130 + /// Create a new error with the given kind. 131 + fn new(kind: DpopErrorKind) -> Self { 132 + Self { 133 + kind, 134 + target: None, 135 + url: None, 136 + source: None, 137 + context: None, 138 + help: None, 139 + } 140 + } 141 + 142 + /// Get the error kind. 143 + pub fn kind(&self) -> DpopErrorKind { 144 + self.kind 145 + } 146 + 147 + /// Get the target server type, if known. 148 + pub fn target(&self) -> Option<DpopTarget> { 149 + self.target 150 + } 151 + 152 + /// Get the URL, if known. 153 + pub fn url(&self) -> Option<&str> { 154 + self.url.as_deref() 155 + } 156 + 157 + /// Get the context string, if any. 158 + pub fn context(&self) -> Option<&str> { 159 + self.context.as_deref() 160 + } 161 + 162 + // Builder methods 163 + 164 + fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { 165 + self.source = Some(Box::new(source)); 166 + self 167 + } 168 + 169 + fn with_target(mut self, target: DpopTarget) -> Self { 170 + self.target = Some(target); 171 + self 172 + } 173 + 174 + fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 175 + self.url = Some(url.into()); 176 + self 177 + } 178 + 179 + fn with_help(mut self, help: &'static str) -> Self { 180 + self.help = Some(help); 181 + self 182 + } 183 + 184 + /// Add context information to the error. 185 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 186 + self.context = Some(context.into()); 187 + self 188 + } 189 + 190 + /// Append additional context to the error. 191 + pub fn append_context(mut self, additional: impl AsRef<str>) -> Self { 192 + self.context = Some(match self.context.take() { 193 + Some(existing) => smol_str::format_smolstr!("{}: {}", existing, additional.as_ref()), 194 + None => SmolStr::new(additional.as_ref()), 195 + }); 196 + self 197 + } 198 + 199 + /// Add NSID context (for use by higher-level code). 200 + pub fn for_nsid(self, nsid: &str) -> Self { 201 + self.append_context(smol_str::format_smolstr!("[{}]", nsid)) 202 + } 203 + 204 + // Constructors for specific error kinds 205 + 206 + /// Create a proof build error. 207 + pub fn proof_build(source: impl StdError + Send + Sync + 'static) -> Self { 208 + Self::new(DpopErrorKind::ProofBuild) 209 + .with_source(source) 210 + .with_help("check that the DPoP key is valid and the JWT claims are correct") 211 + } 212 + 213 + /// Create a transport error for initial request. 214 + pub fn transport( 215 + target: DpopTarget, 216 + url: impl Into<SmolStr>, 217 + source: impl StdError + Send + Sync + 'static, 218 + ) -> Self { 219 + Self::new(DpopErrorKind::Transport) 220 + .with_target(target) 221 + .with_url(url) 222 + .with_source(source) 223 + } 224 + 225 + /// Create a nonce retry error. 226 + pub fn nonce_retry( 227 + target: DpopTarget, 228 + url: impl Into<SmolStr>, 229 + source: impl StdError + Send + Sync + 'static, 230 + ) -> Self { 231 + Self::new(DpopErrorKind::NonceRetry) 232 + .with_target(target) 233 + .with_url(url) 234 + .with_source(source) 235 + .with_help( 236 + "the server rejected both the initial request and the retry with updated nonce", 237 + ) 238 + } 239 + 240 + /// Create an invalid header error. 241 + pub fn invalid_header(source: InvalidHeaderValue) -> Self { 242 + Self::new(DpopErrorKind::InvalidHeader) 243 + .with_source(source) 244 + .with_help("the DPoP proof could not be set as a header value") 245 + } 246 + 247 + /// Create a crypto error. 248 + pub fn crypto(source: crypto::Error) -> Self { 249 + Self::new(DpopErrorKind::Crypto) 250 + .with_context(format!("{:?}", source)) 251 + .with_help( 252 + "ensure the key is a valid secret key in JWK format with a supported algorithm", 253 + ) 254 + } 255 + 256 + /// Create an unsupported key error. 257 + pub fn unsupported_key() -> Self { 258 + Self::new(DpopErrorKind::UnsupportedKey) 259 + .with_help("DPoP requires an EC P-256 key; other key types are not currently supported") 260 + } 261 + 262 + /// Create a serialization error. 263 + pub fn serialization(source: serde_json::Error) -> Self { 264 + Self::new(DpopErrorKind::Serialization) 265 + .with_source(source) 266 + .with_help("failed to serialize JWT claims or header") 267 + } 268 + } 269 + 270 + impl From<InvalidHeaderValue> for DpopError { 271 + fn from(e: InvalidHeaderValue) -> Self { 272 + Self::invalid_header(e) 273 + } 274 + } 275 + 276 + impl From<serde_json::Error> for DpopError { 277 + fn from(e: serde_json::Error) -> Self { 278 + Self::serialization(e) 279 + } 280 + } 281 + 282 + impl From<DpopError> for jacquard_common::error::ClientError { 283 + fn from(e: DpopError) -> Self { 284 + use jacquard_common::error::{AuthError, ClientError}; 285 + 286 + // Extract context from DpopError before converting 287 + let kind = e.kind; 288 + let url = e.url.clone(); 289 + let context = e.context.clone(); 290 + let target = e.target; 291 + 292 + // Build combined context string 293 + let combined_context = match (target, context) { 294 + (Some(t), Some(c)) => Some(smol_str::format_smolstr!("to {}: {}", t, c)), 295 + (Some(t), None) => Some(smol_str::format_smolstr!("to {}", t)), 296 + (None, Some(c)) => Some(c), 297 + (None, None) => None, 298 + }; 299 + 300 + // Map DpopErrorKind to appropriate ClientError 301 + let mut client_err = match kind { 302 + DpopErrorKind::ProofBuild | DpopErrorKind::Crypto | DpopErrorKind::UnsupportedKey => { 303 + ClientError::auth(AuthError::DpopProofFailed) 304 + } 305 + DpopErrorKind::NonceRetry => ClientError::auth(AuthError::DpopNonceFailed), 306 + DpopErrorKind::Transport => ClientError::new( 307 + jacquard_common::error::ClientErrorKind::Transport, 308 + Some(Box::new(e)), 309 + ), 310 + DpopErrorKind::InvalidHeader | DpopErrorKind::Serialization => { 311 + let msg = smol_str::format_smolstr!("DPoP: {:?}", kind); 312 + ClientError::encode(msg) 313 + } 314 + }; 315 + 316 + // Add URL if present (skip for Transport since e was consumed) 317 + if !matches!(kind, DpopErrorKind::Transport) { 318 + if let Some(u) = url { 319 + client_err = client_err.with_url(u); 320 + } 321 + } 322 + 323 + // Add combined context if present (skip for Transport since e was consumed) 324 + if !matches!(kind, DpopErrorKind::Transport) { 325 + if let Some(ctx) = combined_context { 326 + client_err = client_err.with_context(ctx); 327 + } 328 + } 329 + 330 + client_err 331 + } 332 + } 333 + 334 + type Result<T> = core::result::Result<T, DpopError>; 335 + 336 + /// An HTTP client capable of making DPoP-protected requests to both auth servers and resource servers. 337 + /// 338 + /// Implementors must be able to attach a DPoP proof header, handle nonce challenges, and 339 + /// retry transparently on `use_dpop_nonce` errors. 340 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 341 + pub trait DpopClient: HttpClient { 342 + /// Send a DPoP-protected request to an authorization server (token endpoint, PAR, etc.). 343 + fn dpop_server( 344 + &self, 345 + request: Request<Vec<u8>>, 346 + ) -> impl Future<Output = Result<Response<Vec<u8>>>>; 347 + /// Send a DPoP-protected request to a resource server (PDS, AppView, etc.). 348 + fn dpop_client( 349 + &self, 350 + request: Request<Vec<u8>>, 351 + ) -> impl Future<Output = Result<Response<Vec<u8>>>>; 352 + /// Send a DPoP-protected request, inferring the target type from the request context. 353 + fn wrap_request( 354 + &self, 355 + request: Request<Vec<u8>>, 356 + ) -> impl Future<Output = Result<Response<Vec<u8>>>>; 357 + } 358 + 359 + /// Extension trait for any [`HttpClient`] that adds builder methods for constructing 360 + /// DPoP-protected request calls without requiring a full [`DpopClient`] implementation. 361 + pub trait DpopExt: HttpClient { 362 + /// Begin building a DPoP-protected request targeting an authorization server. 363 + fn dpop_server_call<'r, D>(&'r self, data_source: &'r mut D) -> DpopCall<'r, Self, D> 364 + where 365 + Self: Sized, 366 + D: DpopDataSource, 367 + { 368 + DpopCall::server(self, data_source) 369 + } 370 + 371 + /// Begin building a DPoP-protected request targeting a resource server. 372 + fn dpop_call<'r, N>(&'r self, data_source: &'r mut N) -> DpopCall<'r, Self, N> 373 + where 374 + Self: Sized, 375 + N: DpopDataSource, 376 + { 377 + DpopCall::client(self, data_source) 378 + } 379 + } 380 + 381 + /// A builder for a single DPoP-protected HTTP request, holding references to the underlying 382 + /// client and the session data source that supplies nonces and the DPoP signing key. 383 + pub struct DpopCall<'r, C: HttpClient, D: DpopDataSource> { 384 + /// The HTTP client that will send the request. 385 + pub client: &'r C, 386 + /// Whether the request targets an authorization server rather than a resource server. 387 + /// 388 + /// This controls which nonce slot is read from and written to, and how `use_dpop_nonce` 389 + /// errors are detected in the response. 390 + pub is_to_auth_server: bool, 391 + /// The session data source providing the DPoP key and current nonces. 392 + pub data_source: &'r mut D, 393 + } 394 + 395 + impl<'r, C: HttpClient, N: DpopDataSource> DpopCall<'r, C, N> { 396 + /// Create a call builder targeting an authorization server. 397 + pub fn server(client: &'r C, data_source: &'r mut N) -> Self { 398 + Self { 399 + client, 400 + is_to_auth_server: true, 401 + data_source, 402 + } 403 + } 404 + 405 + /// Create a call builder targeting a resource server. 406 + pub fn client(client: &'r C, data_source: &'r mut N) -> Self { 407 + Self { 408 + client, 409 + is_to_auth_server: false, 410 + data_source, 411 + } 412 + } 413 + 414 + /// Send the request with a DPoP proof, retrying once if the server provides a new nonce. 415 + pub async fn send(self, request: Request<Vec<u8>>) -> Result<Response<Vec<u8>>> { 416 + wrap_request_with_dpop( 417 + self.client, 418 + self.data_source, 419 + self.is_to_auth_server, 420 + request, 421 + ) 422 + .await 423 + } 424 + 425 + /// Sends the request with DPoP proof and returns a streaming response. 426 + #[cfg(feature = "streaming")] 427 + pub async fn send_streaming( 428 + self, 429 + request: Request<Vec<u8>>, 430 + ) -> Result<jacquard_common::xrpc::StreamingResponse> 431 + where 432 + C: jacquard_common::http_client::HttpClientExt, 433 + { 434 + wrap_request_with_dpop_streaming( 435 + self.client, 436 + self.data_source, 437 + self.is_to_auth_server, 438 + request, 439 + ) 440 + .await 441 + } 442 + 443 + /// Sends the request with DPoP proof using bidirectional streaming. 444 + #[cfg(feature = "streaming")] 445 + pub async fn send_bidirectional( 446 + self, 447 + parts: http::request::Parts, 448 + body: jacquard_common::stream::ByteStream, 449 + ) -> Result<jacquard_common::xrpc::StreamingResponse> 450 + where 451 + C: jacquard_common::http_client::HttpClientExt, 452 + { 453 + wrap_request_with_dpop_bidirectional( 454 + self.client, 455 + self.data_source, 456 + self.is_to_auth_server, 457 + parts, 458 + body, 459 + ) 460 + .await 461 + } 462 + } 463 + 464 + /// Extract authorization hash from request headers 465 + fn extract_ath(headers: &http::HeaderMap) -> Option<CowStr<'static>> { 466 + headers 467 + .get("authorization") 468 + .filter(|v| v.to_str().is_ok_and(|s| s.starts_with("DPoP "))) 469 + .map(|auth| { 470 + URL_SAFE_NO_PAD 471 + .encode(sha2::Sha256::digest(&auth.as_bytes()[5..])) 472 + .into() 473 + }) 474 + } 475 + 476 + /// Get nonce from data source based on target 477 + fn get_nonce<N: DpopDataSource>(data_source: &N, is_to_auth_server: bool) -> Option<CowStr<'_>> { 478 + if is_to_auth_server { 479 + data_source.authserver_nonce() 480 + } else { 481 + data_source.host_nonce() 482 + } 483 + } 484 + 485 + /// Store nonce in data source based on target 486 + fn store_nonce<N: DpopDataSource>( 487 + data_source: &mut N, 488 + is_to_auth_server: bool, 489 + nonce: CowStr<'static>, 490 + ) { 491 + if is_to_auth_server { 492 + data_source.set_authserver_nonce(nonce); 493 + } else { 494 + data_source.set_host_nonce(nonce); 495 + } 496 + } 497 + 498 + /// Attach a DPoP proof to `request`, send it, and transparently retry once if the server 499 + /// responds with a `use_dpop_nonce` error and a fresh nonce. 500 + /// 501 + /// The nonce is read from and written back to `data_source` based on `is_to_auth_server`, 502 + /// keeping the two nonce slots (auth server vs. resource server) independent. 503 + pub async fn wrap_request_with_dpop<T, N>( 504 + client: &T, 505 + data_source: &mut N, 506 + is_to_auth_server: bool, 507 + mut request: Request<Vec<u8>>, 508 + ) -> Result<Response<Vec<u8>>> 509 + where 510 + T: HttpClient, 511 + N: DpopDataSource, 512 + { 513 + let target = if is_to_auth_server { 514 + DpopTarget::AuthServer 515 + } else { 516 + DpopTarget::ResourceServer 517 + }; 518 + let uri = request.uri().clone(); 519 + let method = request.method().to_cowstr().into_static(); 520 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 521 + let uri = uri.to_cowstr(); 522 + let ath = extract_ath(request.headers()); 523 + 524 + let init_nonce = get_nonce(data_source, is_to_auth_server); 525 + let init_proof = build_dpop_proof( 526 + data_source.key(), 527 + method.clone(), 528 + uri.clone(), 529 + init_nonce.clone(), 530 + ath.clone(), 531 + )?; 532 + request.headers_mut().insert("DPoP", init_proof.parse()?); 533 + let response = client 534 + .send_http(request.clone()) 535 + .await 536 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 537 + 538 + let next_nonce = response 539 + .headers() 540 + .get("dpop-nonce") 541 + .and_then(|v| v.to_str().ok()) 542 + .map(|c| CowStr::copy_from_str(c)); 543 + match &next_nonce { 544 + Some(s) if next_nonce != init_nonce => { 545 + store_nonce(data_source, is_to_auth_server, s.clone()); 546 + } 547 + _ => { 548 + return Ok(response); 549 + } 550 + } 551 + 552 + if !is_use_dpop_nonce_error(is_to_auth_server, &response) { 553 + return Ok(response); 554 + } 555 + let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?; 556 + request.headers_mut().insert("DPoP", next_proof.parse()?); 557 + let response = client 558 + .send_http(request) 559 + .await 560 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 561 + Ok(response) 562 + } 563 + 564 + /// Wraps an HTTP request with a DPoP proof and returns a streaming response. 565 + /// 566 + /// Like [`wrap_request_with_dpop`], but returns a [`StreamingResponse`](jacquard_common::xrpc::StreamingResponse) 567 + /// instead of buffering the body. Nonce retry is limited to status/header inspection 568 + /// since the body stream cannot be rewound. 569 + #[cfg(feature = "streaming")] 570 + pub async fn wrap_request_with_dpop_streaming<T, N>( 571 + client: &T, 572 + data_source: &mut N, 573 + is_to_auth_server: bool, 574 + mut request: Request<Vec<u8>>, 575 + ) -> Result<jacquard_common::xrpc::StreamingResponse> 576 + where 577 + T: jacquard_common::http_client::HttpClientExt, 578 + N: DpopDataSource, 579 + { 580 + use jacquard_common::xrpc::StreamingResponse; 581 + 582 + let target = if is_to_auth_server { 583 + DpopTarget::AuthServer 584 + } else { 585 + DpopTarget::ResourceServer 586 + }; 587 + let uri = request.uri().clone(); 588 + let method = request.method().to_cowstr().into_static(); 589 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 590 + let uri = uri.to_cowstr(); 591 + let ath = extract_ath(request.headers()); 592 + 593 + let init_nonce = get_nonce(data_source, is_to_auth_server); 594 + let init_proof = build_dpop_proof( 595 + data_source.key(), 596 + method.clone(), 597 + uri.clone(), 598 + init_nonce.clone(), 599 + ath.clone(), 600 + )?; 601 + request.headers_mut().insert("DPoP", init_proof.parse()?); 602 + let http_response = client 603 + .send_http_streaming(request.clone()) 604 + .await 605 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 606 + 607 + let (parts, body) = http_response.into_parts(); 608 + let next_nonce = parts 609 + .headers 610 + .get("DPoP-Nonce") 611 + .and_then(|v| v.to_str().ok()) 612 + .map(|c| CowStr::from(c.to_string())); 613 + match &next_nonce { 614 + Some(s) if next_nonce != init_nonce => { 615 + store_nonce(data_source, is_to_auth_server, s.clone()); 616 + } 617 + _ => { 618 + return Ok(StreamingResponse::new(parts, body)); 619 + } 620 + } 621 + 622 + // For streaming responses, we can't easily check the body for use_dpop_nonce error 623 + // We check status code + headers only 624 + if !is_use_dpop_nonce_error_streaming(is_to_auth_server, parts.status, &parts.headers) { 625 + return Ok(StreamingResponse::new(parts, body)); 626 + } 627 + 628 + let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?; 629 + request.headers_mut().insert("DPoP", next_proof.parse()?); 630 + let http_response = client 631 + .send_http_streaming(request) 632 + .await 633 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 634 + let (parts, body) = http_response.into_parts(); 635 + Ok(StreamingResponse::new(parts, body)) 636 + } 637 + 638 + /// Wraps an HTTP request with a DPoP proof using bidirectional streaming. 639 + /// 640 + /// Similar to [`wrap_request_with_dpop_streaming`] but accepts a [`ByteStream`](jacquard_common::stream::ByteStream) 641 + /// request body for upload streaming scenarios. 642 + #[cfg(feature = "streaming")] 643 + pub async fn wrap_request_with_dpop_bidirectional<T, N>( 644 + client: &T, 645 + data_source: &mut N, 646 + is_to_auth_server: bool, 647 + mut parts: http::request::Parts, 648 + body: jacquard_common::stream::ByteStream, 649 + ) -> Result<jacquard_common::xrpc::StreamingResponse> 650 + where 651 + T: jacquard_common::http_client::HttpClientExt, 652 + N: DpopDataSource, 653 + { 654 + use jacquard_common::xrpc::StreamingResponse; 655 + 656 + let target = if is_to_auth_server { 657 + DpopTarget::AuthServer 658 + } else { 659 + DpopTarget::ResourceServer 660 + }; 661 + let uri = parts.uri.clone(); 662 + let method = parts.method.to_cowstr().into_static(); 663 + let url_str: SmolStr = uri.to_cowstr().as_ref().into(); 664 + let uri = uri.to_cowstr(); 665 + let ath = extract_ath(&parts.headers); 666 + 667 + let init_nonce = get_nonce(data_source, is_to_auth_server); 668 + let init_proof = build_dpop_proof( 669 + data_source.key(), 670 + method.clone(), 671 + uri.clone(), 672 + init_nonce.clone(), 673 + ath.clone(), 674 + )?; 675 + parts.headers.insert("DPoP", init_proof.parse()?); 676 + 677 + // Clone the stream for potential retry 678 + let (body1, body2) = body.tee(); 679 + 680 + let http_response = client 681 + .send_http_bidirectional(parts.clone(), body1.into_inner()) 682 + .await 683 + .map_err(|e| DpopError::transport(target, url_str.clone(), e))?; 684 + 685 + let (resp_parts, resp_body) = http_response.into_parts(); 686 + let next_nonce = resp_parts 687 + .headers 688 + .get("DPoP-Nonce") 689 + .and_then(|v| v.to_str().ok()) 690 + .map(|c| CowStr::from(c.to_string())); 691 + match &next_nonce { 692 + Some(s) if next_nonce != init_nonce => { 693 + store_nonce(data_source, is_to_auth_server, s.clone()); 694 + } 695 + _ => { 696 + return Ok(StreamingResponse::new(resp_parts, resp_body)); 697 + } 698 + } 699 + 700 + // For streaming responses, we can't easily check the body for use_dpop_nonce error 701 + // We check status code + headers only 702 + if !is_use_dpop_nonce_error_streaming(is_to_auth_server, resp_parts.status, &resp_parts.headers) 703 + { 704 + return Ok(StreamingResponse::new(resp_parts, resp_body)); 705 + } 706 + 707 + let next_proof = build_dpop_proof(data_source.key(), method, uri, next_nonce, ath)?; 708 + parts.headers.insert("DPoP", next_proof.parse()?); 709 + let http_response = client 710 + .send_http_bidirectional(parts, body2.into_inner()) 711 + .await 712 + .map_err(|e| DpopError::nonce_retry(target, url_str, e))?; 713 + let (parts, body) = http_response.into_parts(); 714 + Ok(StreamingResponse::new(parts, body)) 715 + } 716 + 717 + #[cfg(feature = "streaming")] 718 + fn is_use_dpop_nonce_error_streaming( 719 + is_to_auth_server: bool, 720 + status: http::StatusCode, 721 + headers: &http::HeaderMap, 722 + ) -> bool { 723 + if is_to_auth_server && status == 400 { 724 + // Can't check body for streaming, so we rely on DPoP-Nonce header presence 725 + return false; 726 + } 727 + if !is_to_auth_server && status == 401 { 728 + if let Some(www_auth) = headers 729 + .get("www-authenticate") 730 + .and_then(|v| v.to_str().ok()) 731 + { 732 + return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#); 733 + } 734 + } 735 + false 736 + } 737 + 738 + #[inline] 739 + fn is_use_dpop_nonce_error(is_to_auth_server: bool, response: &Response<Vec<u8>>) -> bool { 740 + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid 741 + if is_to_auth_server { 742 + if response.status() == 400 { 743 + if let Ok(res) = serde_json::from_slice::<ErrorResponse>(response.body()) { 744 + return res.error == "use_dpop_nonce"; 745 + }; 746 + } 747 + } 748 + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 749 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no 750 + else if response.status() == 401 { 751 + if let Some(www_auth) = response 752 + .headers() 753 + .get("www-authenticate") 754 + .and_then(|v| v.to_str().ok()) 755 + { 756 + return www_auth.starts_with("DPoP") && www_auth.contains(r#"error="use_dpop_nonce""#); 757 + } 758 + } 759 + false 760 + } 761 + 762 + #[inline] 763 + pub(crate) fn generate_jti() -> CowStr<'static> { 764 + let mut rng = rand::rngs::SmallRng::from_entropy(); 765 + let mut bytes = [0u8; 12]; 766 + rng.fill_bytes(&mut bytes); 767 + URL_SAFE_NO_PAD.encode(bytes).into() 768 + } 769 + 770 + /// Build a compact JWS (ES256) for DPoP with embedded public JWK. 771 + #[inline] 772 + pub fn build_dpop_proof<'s>( 773 + key: &Key, 774 + method: CowStr<'s>, 775 + url: CowStr<'s>, 776 + nonce: Option<CowStr<'s>>, 777 + ath: Option<CowStr<'s>>, 778 + ) -> Result<CowStr<'s>> { 779 + let secret = match crypto::Key::try_from(key).map_err(DpopError::crypto)? { 780 + crypto::Key::P256(crypto::Kind::Secret(sk)) => sk, 781 + _ => return Err(DpopError::unsupported_key()), 782 + }; 783 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 784 + header.typ = Some(JWT_HEADER_TYP_DPOP.into()); 785 + header.jwk = Some(Jwk { 786 + key: Key::from(&crypto::Key::from(secret.public_key())), 787 + prm: Default::default(), 788 + }); 789 + 790 + let claims = Claims { 791 + registered: RegisteredClaims { 792 + jti: Some(generate_jti()), 793 + iat: Some(Utc::now().timestamp()), 794 + ..Default::default() 795 + }, 796 + public: PublicClaims { 797 + htm: Some(method), 798 + htu: Some(url), 799 + ath: ath, 800 + nonce: nonce, 801 + }, 802 + }; 803 + Ok(signing::create_signed_jwt_es256( 804 + SigningKey::from(secret.clone()), 805 + header.into(), 806 + claims, 807 + )?) 808 + } 809 + 810 + impl DpopExt for JacquardResolver {}
+100
src-tauri/vendor/jacquard-oauth/src/error.rs
··· 1 + use jacquard_common::session::SessionStoreError; 2 + use miette::Diagnostic; 3 + 4 + use crate::request::RequestError; 5 + use crate::resolver::ResolverError; 6 + 7 + /// High-level errors emitted by OAuth helpers. 8 + #[derive(Debug, thiserror::Error, Diagnostic)] 9 + #[non_exhaustive] 10 + pub enum OAuthError { 11 + /// An error occurred during identity or metadata resolution. 12 + #[error(transparent)] 13 + #[diagnostic(code(jacquard_oauth::resolver))] 14 + Resolver(#[from] ResolverError), 15 + 16 + /// An error occurred while making an OAuth HTTP request. 17 + #[error(transparent)] 18 + #[diagnostic(code(jacquard_oauth::request))] 19 + Request(#[from] RequestError), 20 + 21 + /// An error occurred reading or writing session state. 22 + #[error(transparent)] 23 + #[diagnostic(code(jacquard_oauth::storage))] 24 + Storage(#[from] SessionStoreError), 25 + 26 + /// An error occurred during DPoP proof generation or validation. 27 + #[error(transparent)] 28 + #[diagnostic(code(jacquard_oauth::dpop))] 29 + Dpop(#[from] crate::dpop::DpopError), 30 + 31 + /// An error occurred with the client's key set. 32 + #[error(transparent)] 33 + #[diagnostic(code(jacquard_oauth::keyset))] 34 + Keyset(#[from] crate::keyset::Error), 35 + 36 + /// An ATProto-specific OAuth error (e.g. scope validation, client ID). 37 + #[error(transparent)] 38 + #[diagnostic(code(jacquard_oauth::atproto))] 39 + Atproto(#[from] crate::atproto::Error), 40 + 41 + /// An error occurred managing or refreshing an OAuth session. 42 + #[error(transparent)] 43 + #[diagnostic(code(jacquard_oauth::session))] 44 + Session(#[from] crate::session::Error), 45 + 46 + /// A JSON serialization or deserialization error. 47 + #[error(transparent)] 48 + #[diagnostic(code(jacquard_oauth::serde_json))] 49 + SerdeJson(#[from] serde_json::Error), 50 + 51 + /// A URI parse error. 52 + #[error(transparent)] 53 + #[diagnostic(code(jacquard_oauth::url))] 54 + Url(#[from] jacquard_common::deps::fluent_uri::ParseError), 55 + 56 + /// A form (URL-encoded) serialization error. 57 + #[error(transparent)] 58 + #[diagnostic(code(jacquard_oauth::form))] 59 + Form(#[from] serde_html_form::ser::Error), 60 + 61 + /// An error validating an authorization callback. 62 + #[error(transparent)] 63 + #[diagnostic(code(jacquard_oauth::callback))] 64 + Callback(#[from] CallbackError), 65 + } 66 + 67 + /// Typed callback validation errors (redirect handling). 68 + #[derive(Debug, thiserror::Error, Diagnostic)] 69 + #[non_exhaustive] 70 + pub enum CallbackError { 71 + /// The `state` parameter was absent from the authorization callback. 72 + /// 73 + /// State is required to prevent CSRF attacks per RFC 6749 §10.12. 74 + #[error("missing state parameter in callback")] 75 + #[diagnostic(code(jacquard_oauth::callback::missing_state))] 76 + MissingState, 77 + /// The `iss` (issuer) parameter was absent from the authorization callback. 78 + /// 79 + /// RFC 9207 requires `iss` to be present so that clients can reject 80 + /// mix-up attacks from malicious authorization servers. 81 + #[error("missing `iss` parameter")] 82 + #[diagnostic(code(jacquard_oauth::callback::missing_iss))] 83 + MissingIssuer, 84 + /// The issuer in the callback did not match the expected authorization server. 85 + #[error("issuer mismatch: expected {expected}, got {got}")] 86 + #[diagnostic(code(jacquard_oauth::callback::issuer_mismatch))] 87 + IssuerMismatch { 88 + /// The issuer that was expected. 89 + expected: String, 90 + /// The issuer that was actually present in the callback. 91 + got: String, 92 + }, 93 + /// The authorization request timed out before a callback was received. 94 + #[error("timeout")] 95 + #[diagnostic(code(jacquard_oauth::callback::timeout))] 96 + Timeout, 97 + } 98 + 99 + /// Convenience alias for `Result<T, OAuthError>`. 100 + pub type Result<T> = core::result::Result<T, OAuthError>;
+20
src-tauri/vendor/jacquard-oauth/src/jose.rs
··· 1 + /// JWS (JSON Web Signature) header types. 2 + pub mod jws; 3 + /// JWT (JSON Web Token) claims types. 4 + pub mod jwt; 5 + /// Signed JWT creation for supported algorithms (ES256, ES384, ES256K, EdDSA). 6 + pub mod signing; 7 + 8 + use serde::{Deserialize, Serialize}; 9 + 10 + /// A JOSE header, covering the supported JWS formats. 11 + /// 12 + /// Serialized as an untagged enum so the wire format matches the relevant JOSE spec directly. 13 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 14 + #[serde(untagged)] 15 + pub enum Header<'a> { 16 + /// A JWS compact-serialization header. 17 + #[serde(borrow)] 18 + Jws(jws::Header<'a>), 19 + } 20 +
+97
src-tauri/vendor/jacquard-oauth/src/jose/jws.rs
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use jose_jwa::Algorithm; 3 + use jose_jwk::Jwk; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + /// A JWS compact-serialization header, wrapping the registered header fields. 7 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 8 + pub struct Header<'a> { 9 + /// The registered header parameters defined by the JWS specification. 10 + #[serde(flatten)] 11 + #[serde(borrow)] 12 + pub registered: RegisteredHeader<'a>, 13 + } 14 + 15 + impl<'a> From<Header<'a>> for super::super::jose::Header<'a> { 16 + fn from(header: Header<'a>) -> Self { 17 + super::super::jose::Header::Jws(header) 18 + } 19 + } 20 + 21 + /// Registered JWS header parameters as defined in RFC 7515 §4.1. 22 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 23 + 24 + pub struct RegisteredHeader<'a> { 25 + /// The cryptographic algorithm used to sign the JWS (e.g., `ES256`). 26 + pub alg: Algorithm, 27 + /// JWK Set URL: a URI pointing to a resource containing the public key(s) used to sign the JWS. 28 + #[serde(borrow)] 29 + #[serde(skip_serializing_if = "Option::is_none")] 30 + pub jku: Option<CowStr<'a>>, 31 + /// JSON Web Key: the public key used to verify the JWS, embedded directly in the header. 32 + #[serde(skip_serializing_if = "Option::is_none")] 33 + pub jwk: Option<Jwk>, 34 + /// Key ID: a hint indicating which key was used to sign the JWS. 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub kid: Option<CowStr<'a>>, 37 + /// X.509 URL: a URI pointing to a resource for the X.509 certificate used to sign the JWS. 38 + #[serde(skip_serializing_if = "Option::is_none")] 39 + pub x5u: Option<CowStr<'a>>, 40 + /// X.509 certificate chain: the certificate (and chain) corresponding to the key used to sign the JWS. 41 + #[serde(skip_serializing_if = "Option::is_none")] 42 + pub x5c: Option<CowStr<'a>>, 43 + /// X.509 certificate SHA-1 thumbprint: base64url-encoded SHA-1 digest of the DER-encoded certificate. 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub x5t: Option<CowStr<'a>>, 46 + /// X.509 certificate SHA-256 thumbprint: base64url-encoded SHA-256 digest of the DER-encoded certificate. 47 + #[serde(skip_serializing_if = "Option::is_none")] 48 + #[serde(rename = "x5t#S256")] 49 + pub x5ts256: Option<CowStr<'a>>, 50 + /// Type: declares the media type of the complete JWS, used by applications to disambiguate among JOSe objects. 51 + #[serde(skip_serializing_if = "Option::is_none")] 52 + pub typ: Option<CowStr<'a>>, 53 + /// Content type: declares the media type of the secured content (the payload). 54 + #[serde(skip_serializing_if = "Option::is_none")] 55 + pub cty: Option<CowStr<'a>>, 56 + } 57 + 58 + impl From<Algorithm> for RegisteredHeader<'_> { 59 + fn from(alg: Algorithm) -> Self { 60 + Self { 61 + alg, 62 + jku: None, 63 + jwk: None, 64 + kid: None, 65 + x5u: None, 66 + x5c: None, 67 + x5t: None, 68 + x5ts256: None, 69 + typ: None, 70 + cty: None, 71 + } 72 + } 73 + } 74 + 75 + impl<'a> From<RegisteredHeader<'a>> for super::super::jose::Header<'a> { 76 + fn from(registered: RegisteredHeader<'a>) -> Self { 77 + super::super::jose::Header::Jws(Header { registered }) 78 + } 79 + } 80 + 81 + impl IntoStatic for RegisteredHeader<'_> { 82 + type Output = RegisteredHeader<'static>; 83 + fn into_static(self) -> Self::Output { 84 + RegisteredHeader { 85 + alg: self.alg, 86 + jku: self.jku.map(IntoStatic::into_static), 87 + jwk: self.jwk, 88 + kid: self.kid.map(IntoStatic::into_static), 89 + x5u: self.x5u.map(IntoStatic::into_static), 90 + x5c: self.x5c.map(IntoStatic::into_static), 91 + x5t: self.x5t.map(IntoStatic::into_static), 92 + x5ts256: self.x5ts256.map(IntoStatic::into_static), 93 + typ: self.typ.map(IntoStatic::into_static), 94 + cty: self.cty.map(IntoStatic::into_static), 95 + } 96 + } 97 + }
+123
src-tauri/vendor/jacquard-oauth/src/jose/jwt.rs
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + /// Full JWT claims payload, combining registered and public (DPoP-specific) claims. 5 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] 6 + pub struct Claims<'a> { 7 + /// Standard registered JWT claims (iss, sub, aud, exp, etc.). 8 + #[serde(flatten)] 9 + pub registered: RegisteredClaims<'a>, 10 + /// Public claims used in DPoP proofs (htm, htu, ath, nonce). 11 + #[serde(flatten)] 12 + #[serde(borrow)] 13 + pub public: PublicClaims<'a>, 14 + } 15 + 16 + /// Standard registered JWT claims as defined in RFC 7519 §4.1. 17 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] 18 + 19 + pub struct RegisteredClaims<'a> { 20 + /// Issuer: identifies the principal that issued the JWT. 21 + #[serde(borrow)] 22 + #[serde(skip_serializing_if = "Option::is_none")] 23 + pub iss: Option<CowStr<'a>>, 24 + /// Subject: identifies the principal that is the subject of the JWT. 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub sub: Option<CowStr<'a>>, 27 + /// Audience: recipients that the JWT is intended for. 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub aud: Option<RegisteredClaimsAud<'a>>, 30 + /// Expiration time (Unix timestamp): the JWT must not be accepted on or after this time. 31 + #[serde(skip_serializing_if = "Option::is_none")] 32 + pub exp: Option<i64>, 33 + /// Not before (Unix timestamp): the JWT must not be accepted before this time. 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + pub nbf: Option<i64>, 36 + /// Issued at (Unix timestamp): identifies when the JWT was created. 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub iat: Option<i64>, 39 + /// JWT ID: unique identifier for the token, used to prevent replay attacks. 40 + #[serde(skip_serializing_if = "Option::is_none")] 41 + pub jti: Option<CowStr<'a>>, 42 + } 43 + 44 + /// Public claims used in DPoP proof JWTs (RFC 9449). 45 + /// 46 + /// These claims bind the DPoP proof to a specific HTTP request, preventing 47 + /// the proof from being replayed against a different endpoint or method. 48 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] 49 + 50 + pub struct PublicClaims<'a> { 51 + /// HTTP method of the request the DPoP proof is bound to (e.g., `"POST"`). 52 + #[serde(borrow)] 53 + #[serde(skip_serializing_if = "Option::is_none")] 54 + pub htm: Option<CowStr<'a>>, 55 + /// HTTP target URI of the request the DPoP proof is bound to. 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub htu: Option<CowStr<'a>>, 58 + /// Access token hash: base64url-encoded SHA-256 of the access token, binding the proof to a specific token. 59 + #[serde(skip_serializing_if = "Option::is_none")] 60 + pub ath: Option<CowStr<'a>>, 61 + /// Server-provided nonce, included to prevent replay attacks when required by the authorization server. 62 + #[serde(skip_serializing_if = "Option::is_none")] 63 + pub nonce: Option<CowStr<'a>>, 64 + } 65 + 66 + impl<'a> From<RegisteredClaims<'a>> for Claims<'a> { 67 + fn from(registered: RegisteredClaims<'a>) -> Self { 68 + Self { 69 + registered, 70 + public: PublicClaims::default(), 71 + } 72 + } 73 + } 74 + 75 + /// The `aud` (audience) claim, which may be a single string or a list of strings per RFC 7519. 76 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 77 + #[serde(untagged)] 78 + pub enum RegisteredClaimsAud<'a> { 79 + /// A single audience identifier. 80 + #[serde(borrow)] 81 + Single(CowStr<'a>), 82 + /// Multiple audience identifiers. 83 + Multiple(Vec<CowStr<'a>>), 84 + } 85 + 86 + impl IntoStatic for RegisteredClaims<'_> { 87 + type Output = RegisteredClaims<'static>; 88 + fn into_static(self) -> Self::Output { 89 + RegisteredClaims { 90 + iss: self.iss.map(IntoStatic::into_static), 91 + sub: self.sub.map(IntoStatic::into_static), 92 + aud: self.aud.map(IntoStatic::into_static), 93 + exp: self.exp, 94 + nbf: self.nbf, 95 + iat: self.iat, 96 + jti: self.jti.map(IntoStatic::into_static), 97 + } 98 + } 99 + } 100 + 101 + impl IntoStatic for PublicClaims<'_> { 102 + type Output = PublicClaims<'static>; 103 + fn into_static(self) -> Self::Output { 104 + PublicClaims { 105 + htm: self.htm.map(IntoStatic::into_static), 106 + htu: self.htu.map(IntoStatic::into_static), 107 + ath: self.ath.map(IntoStatic::into_static), 108 + nonce: self.nonce.map(IntoStatic::into_static), 109 + } 110 + } 111 + } 112 + 113 + impl IntoStatic for RegisteredClaimsAud<'_> { 114 + type Output = RegisteredClaimsAud<'static>; 115 + fn into_static(self) -> Self::Output { 116 + match self { 117 + RegisteredClaimsAud::Single(s) => RegisteredClaimsAud::Single(s.into_static()), 118 + RegisteredClaimsAud::Multiple(v) => { 119 + RegisteredClaimsAud::Multiple(v.into_iter().map(IntoStatic::into_static).collect()) 120 + } 121 + } 122 + } 123 + }
+65
src-tauri/vendor/jacquard-oauth/src/jose/signing.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use jacquard_common::CowStr; 4 + 5 + use super::{Header, jwt::Claims}; 6 + 7 + /// Builds the base64url-encoded `header.payload` signing input. 8 + fn signing_input(header: &Header, claims: &Claims) -> serde_json::Result<(String, String)> { 9 + let h = URL_SAFE_NO_PAD.encode(serde_json::to_string(header)?); 10 + let p = URL_SAFE_NO_PAD.encode(serde_json::to_string(claims)?); 11 + Ok((h, p)) 12 + } 13 + 14 + /// Assembles a compact JWS from pre-encoded parts and raw signature bytes. 15 + fn assemble(header: &str, payload: &str, sig: &[u8]) -> CowStr<'static> { 16 + format!("{header}.{payload}.{}", URL_SAFE_NO_PAD.encode(sig)).into() 17 + } 18 + 19 + /// Creates a compact-serialized signed JWT using ES256 (P-256 ECDSA with SHA-256). 20 + pub fn create_signed_jwt_es256( 21 + key: p256::ecdsa::SigningKey, 22 + header: Header, 23 + claims: Claims, 24 + ) -> serde_json::Result<CowStr<'static>> { 25 + use p256::ecdsa::signature::Signer; 26 + let (h, p) = signing_input(&header, &claims)?; 27 + let sig: p256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); 28 + Ok(assemble(&h, &p, &sig.to_bytes())) 29 + } 30 + 31 + /// Creates a compact-serialized signed JWT using ES384 (P-384 ECDSA with SHA-384). 32 + pub fn create_signed_jwt_es384( 33 + key: p384::ecdsa::SigningKey, 34 + header: Header, 35 + claims: Claims, 36 + ) -> serde_json::Result<CowStr<'static>> { 37 + use p384::ecdsa::signature::Signer; 38 + let (h, p) = signing_input(&header, &claims)?; 39 + let sig: p384::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); 40 + Ok(assemble(&h, &p, &sig.to_bytes())) 41 + } 42 + 43 + /// Creates a compact-serialized signed JWT using ES256K (secp256k1 ECDSA with SHA-256). 44 + pub fn create_signed_jwt_es256k( 45 + key: k256::ecdsa::SigningKey, 46 + header: Header, 47 + claims: Claims, 48 + ) -> serde_json::Result<CowStr<'static>> { 49 + use k256::ecdsa::signature::Signer; 50 + let (h, p) = signing_input(&header, &claims)?; 51 + let sig: k256::ecdsa::Signature = key.sign(format!("{h}.{p}").as_bytes()); 52 + Ok(assemble(&h, &p, &sig.to_bytes())) 53 + } 54 + 55 + /// Creates a compact-serialized signed JWT using EdDSA (Ed25519). 56 + pub fn create_signed_jwt_eddsa( 57 + key: ed25519_dalek::SigningKey, 58 + header: Header, 59 + claims: Claims, 60 + ) -> serde_json::Result<CowStr<'static>> { 61 + use ed25519_dalek::Signer; 62 + let (h, p) = signing_input(&header, &claims)?; 63 + let sig = key.sign(format!("{h}.{p}").as_bytes()); 64 + Ok(assemble(&h, &p, &sig.to_bytes())) 65 + }
+257
src-tauri/vendor/jacquard-oauth/src/keyset.rs
··· 1 + use crate::jose::jws::RegisteredHeader; 2 + use crate::jose::jwt::Claims; 3 + use crate::jose::signing; 4 + use jacquard_common::CowStr; 5 + use jose_jwa::{Algorithm, Signing}; 6 + use jose_jwk::{Class, EcCurves, OkpCurves, crypto}; 7 + use jose_jwk::{Jwk, JwkSet, Key}; 8 + use std::collections::HashSet; 9 + use thiserror::Error; 10 + 11 + /// Errors that can occur when constructing or using a [`Keyset`]. 12 + #[derive(Error, Debug)] 13 + #[non_exhaustive] 14 + pub enum Error { 15 + /// Two keys in the set share the same `kid`, which would make key selection ambiguous. 16 + #[error("duplicate kid: {0}")] 17 + DuplicateKid(String), 18 + /// A keyset with no keys cannot sign anything. 19 + #[error("keys must not be empty")] 20 + EmptyKeys, 21 + /// Each key must carry a `kid` so it can be referenced in JWS headers. 22 + #[error("key at index {0} must have a `kid`")] 23 + EmptyKid(usize), 24 + /// No key in the set matches any of the requested signing algorithms. 25 + #[error("no signing key found for algorithms: {0:?}")] 26 + NotFound(Vec<Signing>), 27 + /// Only secret (private) keys may be used for signing; a public key was provided. 28 + #[error("key for signing must be a secret key")] 29 + PublicKey, 30 + /// The key type or curve is not supported for signing. 31 + #[error("unsupported key type for signing")] 32 + UnsupportedKey, 33 + /// The private key (`d` parameter) is missing from the JWK. 34 + #[error("missing private key material")] 35 + MissingPrivateKey, 36 + /// An error from the underlying JWK cryptographic operation. 37 + #[error("crypto error: {0:?}")] 38 + JwkCrypto(crypto::Error), 39 + /// The raw key bytes have an invalid length or format. 40 + #[error("invalid key material: {0}")] 41 + InvalidKey(String), 42 + /// JSON serialization of a JWT header or claims payload failed. 43 + #[error(transparent)] 44 + SerdeJson(#[from] serde_json::Error), 45 + } 46 + 47 + /// Convenience result type for keyset operations. 48 + pub type Result<T> = core::result::Result<T, Error>; 49 + 50 + /// Signing algorithm preference order for AT Protocol OAuth. 51 + /// 52 + /// EdDSA and ES256K are preferred for their security properties, followed by 53 + /// the NIST curves. This order matches common AT Protocol server expectations. 54 + const PREFERRED_SIGNING_ALGORITHMS: [Signing; 4] = [ 55 + Signing::EdDsa, 56 + Signing::Es256K, 57 + Signing::Es256, 58 + Signing::Es384, 59 + ]; 60 + 61 + /// A validated collection of JWK secret keys used for signing DPoP proofs and client assertions. 62 + /// 63 + /// Key selection follows [`PREFERRED_SIGNING_ALGORITHMS`] when multiple keys match. 64 + /// Supported algorithms: EdDSA (Ed25519), ES256K (secp256k1), ES256 (P-256), ES384 (P-384). 65 + #[derive(Clone, Debug, Default, PartialEq, Eq)] 66 + pub struct Keyset(Vec<Jwk>); 67 + 68 + impl Keyset { 69 + /// Returns a [`JwkSet`] containing the public halves of all keys in this keyset. 70 + pub fn public_jwks(&self) -> JwkSet { 71 + let mut keys = Vec::with_capacity(self.0.len()); 72 + for mut key in self.0.clone() { 73 + match key.key { 74 + Key::Ec(ref mut ec) => { 75 + ec.d = None; 76 + } 77 + Key::Okp(ref mut okp) => { 78 + okp.d = None; 79 + } 80 + _ => {} 81 + } 82 + keys.push(key); 83 + } 84 + JwkSet { keys } 85 + } 86 + 87 + /// Signs a JWT with the best available key that matches one of the requested algorithms. 88 + /// 89 + /// Returns [`Error::NotFound`] if no key in the keyset supports any of the given algorithms. 90 + pub fn create_jwt(&self, algs: &[Signing], claims: Claims) -> Result<CowStr<'static>> { 91 + let Some(jwk) = self.find_key(algs, Class::Signing) else { 92 + return Err(Error::NotFound(algs.to_vec())); 93 + }; 94 + self.create_jwt_with_key(jwk, claims) 95 + } 96 + 97 + fn find_key(&self, algs: &[Signing], cls: Class) -> Option<&Jwk> { 98 + let candidates = self 99 + .0 100 + .iter() 101 + .filter_map(|key| { 102 + if key.prm.cls.is_some_and(|c| c != cls) { 103 + return None; 104 + } 105 + let alg = alg_for_key(&key.key)?; 106 + Some((alg, key)).filter(|(alg, _)| algs.contains(alg)) 107 + }) 108 + .collect::<Vec<_>>(); 109 + for pref_alg in PREFERRED_SIGNING_ALGORITHMS { 110 + for (alg, key) in &candidates { 111 + if *alg == pref_alg { 112 + return Some(key); 113 + } 114 + } 115 + } 116 + None 117 + } 118 + 119 + fn create_jwt_with_key(&self, key: &Jwk, claims: Claims) -> Result<CowStr<'static>> { 120 + let kid = key.prm.kid.clone().unwrap(); 121 + match &key.key { 122 + Key::Ec(ec) => { 123 + let d = ec.d.as_ref().ok_or(Error::MissingPrivateKey)?; 124 + let d_bytes: &[u8] = d.as_ref(); 125 + match ec.crv { 126 + EcCurves::P256 => { 127 + let signing_key = p256::ecdsa::SigningKey::from_bytes(d_bytes.into()) 128 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 129 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es256)); 130 + header.kid = Some(kid.into()); 131 + Ok(signing::create_signed_jwt_es256( 132 + signing_key, 133 + header.into(), 134 + claims, 135 + )?) 136 + } 137 + EcCurves::P384 => { 138 + let signing_key = p384::ecdsa::SigningKey::from_bytes(d_bytes.into()) 139 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 140 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::Es384)); 141 + header.kid = Some(kid.into()); 142 + Ok(signing::create_signed_jwt_es384( 143 + signing_key, 144 + header.into(), 145 + claims, 146 + )?) 147 + } 148 + EcCurves::P256K => { 149 + let signing_key = k256::ecdsa::SigningKey::from_bytes(d_bytes.into()) 150 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 151 + let mut header = 152 + RegisteredHeader::from(Algorithm::Signing(Signing::Es256K)); 153 + header.kid = Some(kid.into()); 154 + Ok(signing::create_signed_jwt_es256k( 155 + signing_key, 156 + header.into(), 157 + claims, 158 + )?) 159 + } 160 + _ => Err(Error::UnsupportedKey), 161 + } 162 + } 163 + Key::Okp(okp) => match okp.crv { 164 + OkpCurves::Ed25519 => { 165 + let d = okp.d.as_ref().ok_or(Error::MissingPrivateKey)?; 166 + let d_bytes: &[u8] = d.as_ref(); 167 + let signing_key = ed25519_dalek::SigningKey::try_from(d_bytes) 168 + .map_err(|e| Error::InvalidKey(e.to_string()))?; 169 + let mut header = RegisteredHeader::from(Algorithm::Signing(Signing::EdDsa)); 170 + header.kid = Some(kid.into()); 171 + Ok(signing::create_signed_jwt_eddsa( 172 + signing_key, 173 + header.into(), 174 + claims, 175 + )?) 176 + } 177 + _ => Err(Error::UnsupportedKey), 178 + }, 179 + _ => Err(Error::UnsupportedKey), 180 + } 181 + } 182 + } 183 + 184 + /// Returns the signing algorithm for the given JWK key type, if supported. 185 + fn alg_for_key(key: &Key) -> Option<Signing> { 186 + match key { 187 + Key::Ec(ec) => match ec.crv { 188 + EcCurves::P256 => Some(Signing::Es256), 189 + EcCurves::P384 => Some(Signing::Es384), 190 + EcCurves::P256K => Some(Signing::Es256K), 191 + _ => None, 192 + }, 193 + Key::Okp(okp) => match okp.crv { 194 + OkpCurves::Ed25519 => Some(Signing::EdDsa), 195 + _ => None, 196 + }, 197 + _ => None, 198 + } 199 + } 200 + 201 + /// Parses a string-based algorithm name into a [`Signing`] variant, if it maps to 202 + /// an algorithm this crate supports. 203 + pub fn parse_signing_alg(s: &str) -> Option<Signing> { 204 + match s { 205 + "ES256" => Some(Signing::Es256), 206 + "ES384" => Some(Signing::Es384), 207 + "ES256K" => Some(Signing::Es256K), 208 + "EdDSA" => Some(Signing::EdDsa), 209 + _ => None, 210 + } 211 + } 212 + 213 + impl TryFrom<Vec<Jwk>> for Keyset { 214 + type Error = Error; 215 + 216 + fn try_from(keys: Vec<Jwk>) -> Result<Self> { 217 + if keys.is_empty() { 218 + return Err(Error::EmptyKeys); 219 + } 220 + let mut v = Vec::with_capacity(keys.len()); 221 + let mut hs = HashSet::with_capacity(keys.len()); 222 + for (i, key) in keys.into_iter().enumerate() { 223 + if let Some(kid) = key.prm.kid.clone() { 224 + if hs.contains(&kid) { 225 + return Err(Error::DuplicateKid(kid)); 226 + } 227 + hs.insert(kid); 228 + 229 + // Validate that the key has private material and is a supported type. 230 + match &key.key { 231 + Key::Ec(ec) => { 232 + if ec.d.is_none() { 233 + return Err(Error::PublicKey); 234 + } 235 + if alg_for_key(&key.key).is_none() { 236 + return Err(Error::UnsupportedKey); 237 + } 238 + } 239 + Key::Okp(okp) => { 240 + if okp.d.is_none() { 241 + return Err(Error::PublicKey); 242 + } 243 + if alg_for_key(&key.key).is_none() { 244 + return Err(Error::UnsupportedKey); 245 + } 246 + } 247 + _ => return Err(Error::UnsupportedKey), 248 + } 249 + 250 + v.push(key); 251 + } else { 252 + return Err(Error::EmptyKid(i)); 253 + } 254 + } 255 + Ok(Self(v)) 256 + } 257 + }
+82
src-tauri/vendor/jacquard-oauth/src/lib.rs
··· 1 + //! # Jacquard OAuth 2.1 implementation for the AT Protocol 2 + //! 3 + //! Implements the AT Protocol OAuth profile, including DPoP (Demonstrating 4 + //! Proof-of-Possession), PKCE, PAR (Pushed Authorization Requests), and token management. 5 + //! 6 + //! 7 + //! ## Authentication flow 8 + //! 9 + //! ```no_run 10 + //! # #[cfg(feature = "loopback")] 11 + //! # async fn example() -> Result<(), Box<dyn std::error::Error>> { 12 + //! use jacquard_oauth::client::OAuthClient; 13 + //! use jacquard_oauth::session::ClientData; 14 + //! use jacquard_oauth::atproto::AtprotoClientMetadata; 15 + //! use jacquard_oauth::loopback::LoopbackConfig; 16 + //! use jacquard_oauth::authstore::MemoryAuthStore; 17 + //! 18 + //! let store = MemoryAuthStore::new(); 19 + //! 20 + //! // Create client with metadata 21 + //! let client_data = ClientData { 22 + //! keyset: None, // Will generate ES256 keypair if needed 23 + //! config: AtprotoClientMetadata::default_localhost(), 24 + //! }; 25 + //! let oauth = OAuthClient::new(store, client_data); 26 + //! 27 + //! // Start auth flow (with loopback feature) 28 + //! let session = oauth.login_with_local_server( 29 + //! "alice.bsky.social", 30 + //! Default::default(), 31 + //! LoopbackConfig::default(), 32 + //! ).await?; 33 + //! 34 + //! // Session handles token refresh automatically 35 + //! # Ok(()) 36 + //! # } 37 + //! ``` 38 + //! 39 + //! ## AT Protocol specifics 40 + //! 41 + //! The AT Protocol OAuth profile adds: 42 + //! - Required DPoP for all token requests 43 + //! - PAR (Pushed Authorization Requests) for better security 44 + //! - Specific scope format (`atproto`, `transition:generic`, etc.) 45 + //! - Server metadata discovery at `/.well-known/oauth-authorization-server` 46 + //! 47 + //! See [`atproto`] module for AT Protocol-specific metadata helpers. 48 + 49 + #![warn(missing_docs)] 50 + /// AT Protocol-specific OAuth client metadata helpers and builder types. 51 + pub mod atproto; 52 + /// Storage trait and in-memory implementation for OAuth client auth state. 53 + pub mod authstore; 54 + /// High-level OAuth client for driving the full authorization code flow. 55 + pub mod client; 56 + /// DPoP (Demonstrating Proof-of-Possession) key generation and request signing. 57 + pub mod dpop; 58 + /// Top-level OAuth error types for the authorization flow. 59 + pub mod error; 60 + /// JOSE primitives: JWS headers, JWT claims, and signing utilities. 61 + pub mod jose; 62 + /// JWK keyset management for signing keys used in DPoP and client auth. 63 + pub mod keyset; 64 + /// Low-level OAuth request helpers: PAR, token exchange, and refresh. 65 + pub mod request; 66 + /// OAuth server metadata resolution: authorization server and protected resource discovery. 67 + pub mod resolver; 68 + /// 69 + pub mod scopes; 70 + /// OAuth session types, token storage, and DPoP session state. 71 + pub mod session; 72 + /// OAuth protocol types: client metadata, token sets, and server metadata. 73 + pub mod types; 74 + /// Miscellaneous cryptographic utilities: key generation, PKCE, and hashing helpers. 75 + pub mod utils; 76 + 77 + /// Fallback signing algorithm used when no preferred algorithm is negotiated with the server. 78 + pub const FALLBACK_ALG: &str = "ES256"; 79 + 80 + /// Loopback server helpers for the local redirect-based OAuth flow. 81 + #[cfg(feature = "loopback")] 82 + pub mod loopback;
+273
src-tauri/vendor/jacquard-oauth/src/loopback.rs
··· 1 + //! 2 + //! Helpers for the local loopback server method of atproto OAuth. 3 + //! 4 + //! `OAuthClient::login_with_local_server()` is the nice helper. Here is where 5 + //! it and its components live. Below is what it does, so you can have more 6 + //! granular control without having to make your own loopback server. 7 + //! 8 + //! ```ignore 9 + //! let input = "your_handle_here"; 10 + //! let cfg = LoopbackConfig::default(); 11 + //! let opts = AuthorizeOptions::default(); 12 + //! let port = match cfg.port { 13 + //! LoopbackPort::Fixed(p) => p, 14 + //! LoopbackPort::Ephemeral => 0, 15 + //! }; 16 + //! // TODO: fix this to it also accepts ipv6 and properly finds a free port 17 + //! let bind_addr: SocketAddr = format!("0.0.0.0:{}", port) 18 + //! .parse() 19 + //! .expect("invalid loopback host/port"); 20 + //! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 21 + //! 22 + //! let (local_addr, handle) = one_shot_server(bind_addr); 23 + //! println!("Listening on {}", local_addr); 24 + //! 25 + //! let client_data = oauth.build_localhost_client_data(&cfg, &opts, local_addr); 26 + //! // Build client using store and resolver 27 + //! let flow_client = OAuthClient::new_with_shared( 28 + //! self.registry.store.clone(), 29 + //! self.client.clone(), 30 + //! client_data, 31 + //! ); 32 + //! 33 + //! // Start auth and get authorization URL 34 + //! let auth_url = flow_client.start_auth(input.as_ref(), opts).await?; 35 + //! // Print URL for copy/paste 36 + //! println!("To authenticate with your PDS, visit:\n{}\n", auth_url); 37 + //! // Optionally open browser 38 + //! if cfg.open_browser { 39 + //! let _ = try_open_in_browser(&auth_url); 40 + //! } 41 + //! 42 + //! handle_localhost_callback(handle, &flow_client, &cfg).await 43 + //! ``` 44 + //! 45 + //! 46 + #![cfg(feature = "loopback")] 47 + use crate::{ 48 + atproto::AtprotoClientMetadata, 49 + authstore::ClientAuthStore, 50 + client::OAuthClient, 51 + dpop::DpopExt, 52 + error::{CallbackError, OAuthError}, 53 + resolver::OAuthResolver, 54 + types::{AuthorizeOptions, CallbackParams}, 55 + }; 56 + use jacquard_common::deps::fluent_uri::Uri; 57 + use jacquard_common::{IntoStatic, cowstr::ToCowStr}; 58 + use rouille::Server; 59 + use std::net::SocketAddr; 60 + use tokio::sync::mpsc; 61 + 62 + /// Port selection strategy for the loopback OAuth callback server. 63 + #[derive(Clone, Debug)] 64 + pub enum LoopbackPort { 65 + /// Bind to a specific port number. 66 + Fixed(u16), 67 + /// Let the OS assign an available port. 68 + Ephemeral, 69 + } 70 + 71 + /// Configuration for the loopback OAuth callback server. 72 + #[derive(Clone, Debug)] 73 + pub struct LoopbackConfig { 74 + /// The host address to bind to (e.g., `"127.0.0.1"`). 75 + pub host: String, 76 + /// Port selection strategy. 77 + pub port: LoopbackPort, 78 + /// Whether to attempt opening the authorization URL in the user's browser. 79 + pub open_browser: bool, 80 + /// How long to wait for the callback before timing out, in milliseconds. 81 + pub timeout_ms: u64, 82 + } 83 + 84 + impl Default for LoopbackConfig { 85 + fn default() -> Self { 86 + Self { 87 + host: "127.0.0.1".into(), 88 + port: LoopbackPort::Fixed(4000), 89 + open_browser: true, 90 + timeout_ms: 5 * 60 * 1000, 91 + } 92 + } 93 + } 94 + 95 + /// Attempts to open the given URL in the user's default browser. 96 + /// 97 + /// Returns `true` if the browser was opened successfully, `false` otherwise. 98 + #[cfg(feature = "browser-open")] 99 + pub fn try_open_in_browser(url: &str) -> bool { 100 + webbrowser::open(url).is_ok() 101 + } 102 + /// Stub for when the `browser-open` feature is disabled. Always returns `false`. 103 + #[cfg(not(feature = "browser-open"))] 104 + pub fn try_open_in_browser(_url: &str) -> bool { 105 + false 106 + } 107 + 108 + fn create_callback_router( 109 + request: &rouille::Request, 110 + tx: mpsc::Sender<CallbackParams>, 111 + ) -> rouille::Response { 112 + rouille::router!(request, 113 + (GET) (/oauth/callback) => { 114 + let state = request.get_param("state").unwrap(); 115 + let code = request.get_param("code").unwrap(); 116 + let iss = request.get_param("iss").unwrap(); 117 + let callback_params = CallbackParams { 118 + state: Some(state.to_cowstr().into_static()), 119 + code: code.to_cowstr().into_static(), 120 + iss: Some(iss.to_cowstr().into_static()), 121 + }; 122 + tx.try_send(callback_params).unwrap(); 123 + rouille::Response::text("Logged in!") 124 + }, 125 + _ => rouille::Response::empty_404() 126 + ) 127 + } 128 + 129 + /// Handle to a running loopback callback server, used to await the OAuth redirect. 130 + pub struct CallbackHandle { 131 + #[allow(dead_code)] 132 + server_handle: std::thread::JoinHandle<()>, 133 + server_stop: std::sync::mpsc::Sender<()>, 134 + callback_rx: mpsc::Receiver<CallbackParams<'static>>, 135 + } 136 + 137 + /// One-shot OAuth callback server. 138 + /// 139 + /// Starts an ephemeral in-process web server that listens for the OAuth 140 + /// callback redirect. Returns the server address and a [`CallbackHandle`] 141 + /// that can be used to wait for the callback and stop the server. 142 + /// 143 + /// Use in combination with [`handle_localhost_callback`] to handle the 144 + /// callback for the localhost loopback server. 145 + pub fn one_shot_server(addr: SocketAddr) -> (SocketAddr, CallbackHandle) { 146 + let (tx, callback_rx) = mpsc::channel(5); 147 + let server = Server::new(addr, move |request| { 148 + create_callback_router(request, tx.clone()) 149 + }) 150 + .expect("Could not start server"); 151 + let (server_handle, server_stop) = server.stoppable(); 152 + let handle = CallbackHandle { 153 + server_handle, 154 + server_stop, 155 + callback_rx, 156 + }; 157 + (addr, handle) 158 + } 159 + 160 + /// Handles the OAuth callback for the localhost loopback server. 161 + /// 162 + /// Returns a session if the callback succeeds within the configured timeout 163 + /// and shuts down the server. 164 + pub async fn handle_localhost_callback<T, S>( 165 + handle: CallbackHandle, 166 + flow_client: &super::client::OAuthClient<T, S>, 167 + cfg: &LoopbackConfig, 168 + ) -> crate::error::Result<super::client::OAuthSession<T, S>> 169 + where 170 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 171 + S: ClientAuthStore + Send + Sync + 'static, 172 + { 173 + // Await callback or timeout 174 + let mut callback_rx = handle.callback_rx; 175 + let cb = tokio::time::timeout( 176 + std::time::Duration::from_millis(cfg.timeout_ms), 177 + callback_rx.recv(), 178 + ) 179 + .await; 180 + // trigger shutdown 181 + let _ = handle.server_stop.send(()); 182 + if let Ok(Some(cb)) = cb { 183 + // Handle callback and create a session 184 + Ok(flow_client.callback(cb).await?) 185 + } else { 186 + Err(OAuthError::Callback(CallbackError::Timeout)) 187 + } 188 + } 189 + 190 + impl<T, S> OAuthClient<T, S> 191 + where 192 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 193 + S: ClientAuthStore + Send + Sync + 'static, 194 + { 195 + /// Drive the full OAuth flow using a local loopback server. 196 + /// 197 + /// This uses localhost OAuth and an ephemeral in-process web server to 198 + /// handle the OAuth callback redirect. It has a bunch of nice friendly 199 + /// defaults to help you get started and will basically drive the *entire* 200 + /// callback flow itself. 201 + /// 202 + /// Best used for development and for small CLI applications that don't 203 + /// require long session lengths. For long-running unattended sessions, 204 + /// app passwords (via CredentialSession in the jacquard crate) remain 205 + /// the best option. For more complex OAuth, or if you want more control 206 + /// over the process, use the other methods on OAuthClient. 207 + /// 208 + /// 'input' parameter is what you type in the login box (usually, your handle) 209 + /// for it to look up your PDS and redirect to its authentication interface. 210 + /// 211 + /// If the `browser-open` feature is enabled, this will open a web browser 212 + /// for you to authenticate with your PDS. It will also print the 213 + /// callback url to the console for you to copy. 214 + pub async fn login_with_local_server( 215 + &self, 216 + input: impl AsRef<str>, 217 + opts: AuthorizeOptions<'_>, 218 + cfg: LoopbackConfig, 219 + ) -> crate::error::Result<super::client::OAuthSession<T, S>> { 220 + let port = match cfg.port { 221 + LoopbackPort::Fixed(p) => p, 222 + LoopbackPort::Ephemeral => 0, 223 + }; 224 + // TODO: fix this to it also accepts ipv6 and properly finds a free port 225 + let bind_addr: SocketAddr = format!("0.0.0.0:{}", port) 226 + .parse() 227 + .expect("invalid loopback host/port"); 228 + let (local_addr, handle) = one_shot_server(bind_addr); 229 + println!("Listening on {}", local_addr); 230 + 231 + let client_data = self.build_localhost_client_data(&cfg, &opts, local_addr); 232 + // Build client using store and resolver 233 + let flow_client = OAuthClient::new_with_shared( 234 + self.registry.store.clone(), 235 + self.client.clone(), 236 + client_data, 237 + ); 238 + 239 + // Start auth and get authorization URL 240 + let auth_url = flow_client.start_auth(input.as_ref(), opts).await?; 241 + // Print URL for copy/paste 242 + println!("To authenticate with your PDS, visit:\n{}\n", auth_url); 243 + // Optionally open browser 244 + if cfg.open_browser { 245 + let _ = try_open_in_browser(&auth_url); 246 + } 247 + 248 + handle_localhost_callback(handle, &flow_client, &cfg).await 249 + } 250 + 251 + /// Builds a [`crate::session::ClientData`] for use with the local loopback server method of OAuth. 252 + pub fn build_localhost_client_data( 253 + &self, 254 + cfg: &LoopbackConfig, 255 + opts: &AuthorizeOptions<'_>, 256 + local_addr: SocketAddr, 257 + ) -> crate::session::ClientData<'static> { 258 + let redirect_uri = format!("http://{}:{}/oauth/callback", cfg.host, local_addr.port(),); 259 + let redirect = Uri::parse(redirect_uri).unwrap(); 260 + 261 + let scopes = if opts.scopes.is_empty() { 262 + Some(self.registry.client_data.config.scopes.clone()) 263 + } else { 264 + Some(opts.scopes.clone().into_static()) 265 + }; 266 + 267 + crate::session::ClientData { 268 + keyset: self.registry.client_data.keyset.clone(), 269 + config: AtprotoClientMetadata::new_localhost(Some(vec![redirect]), scopes), 270 + } 271 + .into_static() 272 + } 273 + }
+1116
src-tauri/vendor/jacquard-oauth/src/request.rs
··· 1 + use chrono::{TimeDelta, Utc}; 2 + use http::{Method, Request, StatusCode}; 3 + use jacquard_common::{ 4 + CowStr, IntoStatic, 5 + cowstr::ToCowStr, 6 + http_client::HttpClient, 7 + session::SessionStoreError, 8 + types::{ 9 + did::Did, 10 + string::{AtStrError, Datetime}, 11 + }, 12 + }; 13 + use jacquard_identity::resolver::IdentityError; 14 + use serde::Serialize; 15 + use serde_json::Value; 16 + use smol_str::ToSmolStr; 17 + 18 + use jose_jwa::Signing; 19 + 20 + use crate::{ 21 + FALLBACK_ALG, 22 + atproto::atproto_client_metadata, 23 + dpop::DpopExt, 24 + jose::jwt::{RegisteredClaims, RegisteredClaimsAud}, 25 + keyset::Keyset, 26 + resolver::OAuthResolver, 27 + scopes::Scope, 28 + session::{ 29 + AuthRequestData, ClientData, ClientSessionData, DpopClientData, DpopDataSource, DpopReqData, 30 + }, 31 + types::{ 32 + AuthorizationCodeChallengeMethod, AuthorizationResponseType, AuthorizeOptionPrompt, 33 + OAuthAuthorizationServerMetadata, OAuthClientMetadata, OAuthParResponse, 34 + OAuthTokenResponse, ParParameters, RefreshRequestParameters, RevocationRequestParameters, 35 + TokenGrantType, TokenRequestParameters, TokenSet, 36 + }, 37 + utils::{compare_algos, generate_dpop_key, generate_nonce, generate_pkce}, 38 + }; 39 + 40 + // https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 41 + const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str = 42 + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 43 + 44 + use smol_str::SmolStr; 45 + 46 + /// Convenience alias for a heap-allocated, thread-safe, `'static` error value. 47 + pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 48 + 49 + /// OAuth request error for token operations and auth flows 50 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 51 + #[error("{kind}")] 52 + pub struct RequestError { 53 + #[diagnostic_source] 54 + kind: RequestErrorKind, 55 + #[source] 56 + source: Option<BoxError>, 57 + #[help] 58 + help: Option<SmolStr>, 59 + context: Option<SmolStr>, 60 + url: Option<SmolStr>, 61 + details: Option<SmolStr>, 62 + location: Option<SmolStr>, 63 + } 64 + 65 + /// Error categories for OAuth request operations 66 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 67 + #[non_exhaustive] 68 + pub enum RequestErrorKind { 69 + /// No endpoint available 70 + #[error("no {0} endpoint available")] 71 + #[diagnostic( 72 + code(jacquard_oauth::request::no_endpoint), 73 + help("server does not advertise this endpoint") 74 + )] 75 + NoEndpoint(SmolStr), 76 + 77 + /// Token response verification failed 78 + #[error("token response verification failed")] 79 + #[diagnostic(code(jacquard_oauth::request::token_verification))] 80 + TokenVerification, 81 + 82 + /// Unsupported authentication method 83 + #[error("unsupported authentication method")] 84 + #[diagnostic( 85 + code(jacquard_oauth::request::unsupported_auth_method), 86 + help( 87 + "server must support `private_key_jwt` or `none`; configure client metadata accordingly" 88 + ) 89 + )] 90 + UnsupportedAuthMethod, 91 + 92 + /// No refresh token available 93 + #[error("no refresh token available")] 94 + #[diagnostic(code(jacquard_oauth::request::no_refresh_token))] 95 + NoRefreshToken, 96 + 97 + /// Invalid DID 98 + #[error("failed to parse DID")] 99 + #[diagnostic(code(jacquard_oauth::request::invalid_did))] 100 + InvalidDid, 101 + 102 + /// DPoP client error 103 + #[error("dpop error")] 104 + #[diagnostic(code(jacquard_oauth::request::dpop))] 105 + Dpop, 106 + 107 + /// Session storage error 108 + #[error("storage error")] 109 + #[diagnostic(code(jacquard_oauth::request::storage))] 110 + Storage, 111 + 112 + /// Resolver error 113 + #[error("resolver error")] 114 + #[diagnostic(code(jacquard_oauth::request::resolver))] 115 + Resolver, 116 + 117 + /// HTTP build error 118 + #[error("http build error")] 119 + #[diagnostic(code(jacquard_oauth::request::http_build))] 120 + HttpBuild, 121 + 122 + /// HTTP status error 123 + #[error("http status: {0}")] 124 + #[diagnostic( 125 + code(jacquard_oauth::request::http_status), 126 + help("see server response for details") 127 + )] 128 + HttpStatus(StatusCode), 129 + 130 + /// HTTP status with error body 131 + #[error("http status: {status}, body: {body:?}")] 132 + #[diagnostic( 133 + code(jacquard_oauth::request::http_status_body), 134 + help("server returned error JSON; inspect fields like `error`, `error_description`") 135 + )] 136 + HttpStatusWithBody { 137 + /// HTTP status code returned by the server. 138 + status: StatusCode, 139 + /// Parsed JSON body containing OAuth error fields such as `error` and `error_description`. 140 + body: Value, 141 + }, 142 + 143 + /// Identity resolution error 144 + #[error("identity error")] 145 + #[diagnostic(code(jacquard_oauth::request::identity))] 146 + Identity, 147 + 148 + /// Keyset error 149 + #[error("keyset error")] 150 + #[diagnostic(code(jacquard_oauth::request::keyset))] 151 + Keyset, 152 + 153 + /// Form serialization error 154 + #[error("form serialization error")] 155 + #[diagnostic(code(jacquard_oauth::request::serde_form))] 156 + SerdeHtmlForm, 157 + 158 + /// JSON error 159 + #[error("json error")] 160 + #[diagnostic(code(jacquard_oauth::request::serde_json))] 161 + SerdeJson, 162 + 163 + /// Atproto metadata error 164 + #[error("atproto error")] 165 + #[diagnostic(code(jacquard_oauth::request::atproto))] 166 + Atproto, 167 + } 168 + 169 + impl RequestError { 170 + /// Create a new error with the given kind and optional source 171 + pub fn new(kind: RequestErrorKind, source: Option<BoxError>) -> Self { 172 + Self { 173 + kind, 174 + source, 175 + help: None, 176 + context: None, 177 + url: None, 178 + details: None, 179 + location: None, 180 + } 181 + } 182 + 183 + /// Get the error kind 184 + pub fn kind(&self) -> &RequestErrorKind { 185 + &self.kind 186 + } 187 + 188 + /// Get the source error if present 189 + pub fn source_err(&self) -> Option<&BoxError> { 190 + self.source.as_ref() 191 + } 192 + 193 + /// Get the context string if present 194 + pub fn context(&self) -> Option<&str> { 195 + self.context.as_ref().map(|s| s.as_str()) 196 + } 197 + 198 + /// Get the URL if present 199 + pub fn url(&self) -> Option<&str> { 200 + self.url.as_ref().map(|s| s.as_str()) 201 + } 202 + 203 + /// Get the details if present 204 + pub fn details(&self) -> Option<&str> { 205 + self.details.as_ref().map(|s| s.as_str()) 206 + } 207 + 208 + /// Get the location if present 209 + pub fn location(&self) -> Option<&str> { 210 + self.location.as_ref().map(|s| s.as_str()) 211 + } 212 + 213 + /// Add help text to this error 214 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 215 + self.help = Some(help.into()); 216 + self 217 + } 218 + 219 + /// Add context to this error 220 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 221 + self.context = Some(context.into()); 222 + self 223 + } 224 + 225 + /// Add URL to this error 226 + pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 227 + self.url = Some(url.into()); 228 + self 229 + } 230 + 231 + /// Add details to this error 232 + pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 233 + self.details = Some(details.into()); 234 + self 235 + } 236 + 237 + /// Add location to this error 238 + pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 239 + self.location = Some(location.into()); 240 + self 241 + } 242 + 243 + // Constructors for each kind 244 + 245 + /// Create a no endpoint error 246 + pub fn no_endpoint(endpoint: impl Into<SmolStr>) -> Self { 247 + Self::new(RequestErrorKind::NoEndpoint(endpoint.into()), None) 248 + } 249 + 250 + /// Create a token verification error 251 + pub fn token_verification() -> Self { 252 + Self::new(RequestErrorKind::TokenVerification, None) 253 + } 254 + 255 + /// Create an unsupported authentication method error 256 + pub fn unsupported_auth_method() -> Self { 257 + Self::new(RequestErrorKind::UnsupportedAuthMethod, None) 258 + } 259 + 260 + /// Create a no refresh token error 261 + pub fn no_refresh_token() -> Self { 262 + Self::new(RequestErrorKind::NoRefreshToken, None) 263 + } 264 + 265 + /// Create an invalid DID error 266 + pub fn invalid_did(source: impl std::error::Error + Send + Sync + 'static) -> Self { 267 + Self::new(RequestErrorKind::InvalidDid, Some(Box::new(source))) 268 + } 269 + 270 + /// Create a DPoP error 271 + pub fn dpop(source: impl std::error::Error + Send + Sync + 'static) -> Self { 272 + Self::new(RequestErrorKind::Dpop, Some(Box::new(source))) 273 + } 274 + 275 + /// Create a storage error 276 + pub fn storage(source: impl std::error::Error + Send + Sync + 'static) -> Self { 277 + Self::new(RequestErrorKind::Storage, Some(Box::new(source))) 278 + } 279 + 280 + /// Create a resolver error 281 + pub fn resolver(source: impl std::error::Error + Send + Sync + 'static) -> Self { 282 + Self::new(RequestErrorKind::Resolver, Some(Box::new(source))) 283 + } 284 + 285 + /// Create an HTTP build error 286 + pub fn http_build(source: impl std::error::Error + Send + Sync + 'static) -> Self { 287 + Self::new(RequestErrorKind::HttpBuild, Some(Box::new(source))) 288 + } 289 + 290 + /// Create an HTTP status error 291 + pub fn http_status(status: StatusCode) -> Self { 292 + Self::new(RequestErrorKind::HttpStatus(status), None) 293 + } 294 + 295 + /// Create an HTTP status with body error 296 + pub fn http_status_with_body(status: StatusCode, body: Value) -> Self { 297 + Self::new(RequestErrorKind::HttpStatusWithBody { status, body }, None) 298 + } 299 + 300 + /// Create an identity error 301 + pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { 302 + Self::new(RequestErrorKind::Identity, Some(Box::new(source))) 303 + } 304 + 305 + /// Create a keyset error 306 + pub fn keyset(source: impl std::error::Error + Send + Sync + 'static) -> Self { 307 + Self::new(RequestErrorKind::Keyset, Some(Box::new(source))) 308 + } 309 + 310 + /// Create an atproto metadata error 311 + pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self { 312 + Self::new(RequestErrorKind::Atproto, Some(Box::new(source))) 313 + } 314 + 315 + /// Returns true if this error indicates permanent auth failure 316 + /// (token revoked, refresh_token expired, etc.) 317 + /// 318 + /// When this returns true, the session should be cleared from storage 319 + /// rather than retried. 320 + pub fn is_permanent(&self) -> bool { 321 + match &self.kind { 322 + RequestErrorKind::NoRefreshToken => true, 323 + RequestErrorKind::HttpStatusWithBody { body, .. } => body 324 + .get("error") 325 + .and_then(|e| e.as_str()) 326 + .is_some_and(|e| matches!(e, "invalid_grant" | "access_denied")), 327 + _ => false, 328 + } 329 + } 330 + } 331 + 332 + // From impls for common error types 333 + 334 + impl From<AtStrError> for RequestError { 335 + fn from(e: AtStrError) -> Self { 336 + let msg = smol_str::format_smolstr!("{:?}", e); 337 + Self::new(RequestErrorKind::InvalidDid, Some(Box::new(e))) 338 + .with_context(msg) 339 + .with_help("ensure DID is correctly formatted (e.g., did:plc:abc123)") 340 + } 341 + } 342 + 343 + impl From<crate::dpop::DpopError> for RequestError { 344 + fn from(e: crate::dpop::DpopError) -> Self { 345 + let msg = smol_str::format_smolstr!("{:?}", e); 346 + Self::new(RequestErrorKind::Dpop, Some(Box::new(e))) 347 + .with_context(msg) 348 + .with_help("check DPoP key configuration and nonce handling") 349 + } 350 + } 351 + 352 + impl From<SessionStoreError> for RequestError { 353 + fn from(e: SessionStoreError) -> Self { 354 + let msg = smol_str::format_smolstr!("{:?}", e); 355 + Self::new(RequestErrorKind::Storage, Some(Box::new(e))) 356 + .with_context(msg) 357 + .with_help("verify session store is accessible and writable") 358 + } 359 + } 360 + 361 + impl From<crate::resolver::ResolverError> for RequestError { 362 + fn from(e: crate::resolver::ResolverError) -> Self { 363 + let msg = smol_str::format_smolstr!("{:?}", e); 364 + Self::new(RequestErrorKind::Resolver, Some(Box::new(e))) 365 + .with_context(msg) 366 + .with_help("check identity resolution and OAuth metadata endpoints") 367 + } 368 + } 369 + 370 + impl From<http::Error> for RequestError { 371 + fn from(e: http::Error) -> Self { 372 + let msg = smol_str::format_smolstr!("{:?}", e); 373 + Self::new(RequestErrorKind::HttpBuild, Some(Box::new(e))) 374 + .with_context(msg) 375 + .with_help("verify request URIs and headers are valid") 376 + } 377 + } 378 + 379 + impl From<IdentityError> for RequestError { 380 + fn from(e: IdentityError) -> Self { 381 + let msg = smol_str::format_smolstr!("{:?}", e); 382 + Self::new(RequestErrorKind::Identity, Some(Box::new(e))) 383 + .with_context(msg) 384 + .with_help("check handle/DID is valid and identity resolver is configured") 385 + } 386 + } 387 + 388 + impl From<crate::keyset::Error> for RequestError { 389 + fn from(e: crate::keyset::Error) -> Self { 390 + let msg = smol_str::format_smolstr!("{:?}", e); 391 + Self::new(RequestErrorKind::Keyset, Some(Box::new(e))) 392 + .with_context(msg) 393 + .with_help("verify keyset configuration and signing algorithm support") 394 + } 395 + } 396 + 397 + impl From<serde_html_form::ser::Error> for RequestError { 398 + fn from(e: serde_html_form::ser::Error) -> Self { 399 + let msg = smol_str::format_smolstr!("{:?}", e); 400 + Self::new(RequestErrorKind::SerdeHtmlForm, Some(Box::new(e))) 401 + .with_context(msg) 402 + .with_help("check OAuth request parameters are serializable") 403 + } 404 + } 405 + 406 + impl From<serde_json::Error> for RequestError { 407 + fn from(e: serde_json::Error) -> Self { 408 + let msg = smol_str::format_smolstr!("{:?}", e); 409 + Self::new(RequestErrorKind::SerdeJson, Some(Box::new(e))) 410 + .with_context(msg) 411 + .with_help("verify OAuth response body is valid JSON") 412 + } 413 + } 414 + 415 + impl From<crate::atproto::Error> for RequestError { 416 + fn from(e: crate::atproto::Error) -> Self { 417 + let msg = smol_str::format_smolstr!("{:?}", e); 418 + Self::new(RequestErrorKind::Atproto, Some(Box::new(e))) 419 + .with_context(msg) 420 + .with_help("ensure client metadata matches atproto requirements") 421 + } 422 + } 423 + 424 + /// Convenience `Result` type for OAuth request operations, defaulting to [`RequestError`]. 425 + pub type Result<T> = core::result::Result<T, RequestError>; 426 + 427 + /// Represents the different OAuth token-endpoint request types sent by this crate. 428 + #[allow(dead_code)] 429 + pub enum OAuthRequest<'a> { 430 + /// Standard authorization-code token exchange. 431 + Token(TokenRequestParameters<'a>), 432 + /// Refresh-token grant to obtain a fresh access token. 433 + Refresh(RefreshRequestParameters<'a>), 434 + /// Token revocation request (RFC 7009). 435 + Revocation(RevocationRequestParameters<'a>), 436 + /// Token introspection request (RFC 7662). 437 + Introspection, 438 + /// Pushed authorization request (RFC 9126) for pre-registering auth parameters. 439 + PushedAuthorizationRequest(ParParameters<'a>), 440 + } 441 + 442 + impl OAuthRequest<'_> { 443 + /// Return a human-readable name for this request variant, used in error messages. 444 + pub fn name(&self) -> CowStr<'static> { 445 + CowStr::new_static(match self { 446 + Self::Token(_) => "token", 447 + Self::Refresh(_) => "refresh", 448 + Self::Revocation(_) => "revocation", 449 + Self::Introspection => "introspection", 450 + Self::PushedAuthorizationRequest(_) => "pushed_authorization_request", 451 + }) 452 + } 453 + /// Returns the HTTP status code that a successful response to this request should carry. 454 + pub fn expected_status(&self) -> StatusCode { 455 + match self { 456 + Self::Token(_) | Self::Refresh(_) => StatusCode::OK, 457 + Self::PushedAuthorizationRequest(_) => StatusCode::CREATED, 458 + // Unlike https://datatracker.ietf.org/doc/html/rfc7009#section-2.2, oauth-provider seems to return `204`. 459 + Self::Revocation(_) => StatusCode::NO_CONTENT, 460 + _ => unimplemented!(), 461 + } 462 + } 463 + } 464 + 465 + /// The serialized body of an OAuth token-endpoint request. 466 + #[derive(Debug, Serialize)] 467 + pub struct RequestPayload<'a, T> 468 + where 469 + T: Serialize, 470 + { 471 + /// The OAuth `client_id` advertised in the client metadata document. 472 + client_id: CowStr<'a>, 473 + /// The assertion type URI; set to `urn:ietf:params:oauth:client-assertion-type:jwt-bearer` 474 + /// when using `private_key_jwt` client authentication. 475 + #[serde(skip_serializing_if = "Option::is_none")] 476 + client_assertion_type: Option<CowStr<'a>>, 477 + /// A JWT signed with the client's private key, proving client identity to the server. 478 + #[serde(skip_serializing_if = "Option::is_none")] 479 + client_assertion: Option<CowStr<'a>>, 480 + /// The grant-specific parameters (token request, refresh, PAR, etc.) flattened into the body. 481 + #[serde(flatten)] 482 + parameters: T, 483 + } 484 + 485 + /// Bundled OAuth metadata needed to perform token-endpoint operations. 486 + /// 487 + /// Aggregates the server's authorization server metadata, the client's own registered metadata, 488 + /// and the optional signing keyset into a single value that is passed to helper functions such 489 + /// as [`par`], [`exchange_code`], [`refresh`], and [`revoke`]. 490 + #[derive(Debug, Clone)] 491 + pub struct OAuthMetadata { 492 + /// Metadata fetched from the authorization server's `/.well-known/oauth-authorization-server` document. 493 + pub server_metadata: OAuthAuthorizationServerMetadata<'static>, 494 + /// This client's registered metadata, derived from [`crate::atproto::AtprotoClientMetadata`]. 495 + pub client_metadata: OAuthClientMetadata<'static>, 496 + /// Optional signing keyset; required for `private_key_jwt` client authentication. 497 + pub keyset: Option<Keyset>, 498 + } 499 + 500 + impl OAuthMetadata { 501 + /// Fetch server metadata and assemble an `OAuthMetadata` from an active session context. 502 + /// 503 + /// Contacts the authorization server recorded in `session_data` to retrieve its current 504 + /// metadata, then combines it with the client configuration. This is the preferred way to 505 + /// build an `OAuthMetadata` during token refresh or revocation. 506 + pub async fn new<'r, T: HttpClient + OAuthResolver + Send + Sync>( 507 + client: &T, 508 + ClientData { keyset, config }: &ClientData<'r>, 509 + session_data: &ClientSessionData<'r>, 510 + ) -> Result<Self> { 511 + Ok(OAuthMetadata { 512 + server_metadata: client 513 + .get_authorization_server_metadata(&session_data.authserver_url) 514 + .await?, 515 + client_metadata: atproto_client_metadata(config.clone(), &keyset) 516 + .unwrap() 517 + .into_static(), 518 + keyset: keyset.clone(), 519 + }) 520 + } 521 + } 522 + 523 + /// Perform a Pushed Authorization Request (PAR) and return the resulting state for the auth flow. 524 + /// 525 + /// Generates a PKCE code challenge, a fresh DPoP key, and a random `state` token, then POSTs 526 + /// them to the authorization server's PAR endpoint. The returned [`AuthRequestData`] must be 527 + /// persisted (e.g., in the auth store) so it can be retrieved and verified during 528 + /// [`crate::client::OAuthClient::callback`]. 529 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all, fields(login_hint = login_hint.as_ref().map(|h| h.as_ref()))))] 530 + pub async fn par<'r, T: OAuthResolver + DpopExt + Send + Sync + 'static>( 531 + client: &T, 532 + login_hint: Option<CowStr<'r>>, 533 + prompt: Option<AuthorizeOptionPrompt>, 534 + metadata: &OAuthMetadata, 535 + state: Option<CowStr<'r>>, 536 + ) -> crate::request::Result<AuthRequestData<'r>> { 537 + let state = if let Some(state) = state { 538 + state 539 + } else { 540 + generate_nonce() 541 + }; 542 + let (code_challenge, verifier) = generate_pkce(); 543 + 544 + let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { 545 + return Err(RequestError::token_verification()); 546 + }; 547 + let mut dpop_data = DpopReqData { 548 + dpop_key, 549 + dpop_authserver_nonce: None, 550 + }; 551 + let parameters = ParParameters { 552 + response_type: AuthorizationResponseType::Code, 553 + redirect_uri: metadata.client_metadata.redirect_uris[0].to_cowstr(), 554 + state: state.clone(), 555 + scope: metadata.client_metadata.scope.clone(), 556 + response_mode: None, 557 + code_challenge, 558 + code_challenge_method: AuthorizationCodeChallengeMethod::S256, 559 + login_hint: login_hint, 560 + prompt: prompt.map(CowStr::from), 561 + }; 562 + 563 + if metadata 564 + .server_metadata 565 + .pushed_authorization_request_endpoint 566 + .is_some() 567 + { 568 + let par_response = oauth_request::<OAuthParResponse, T, DpopReqData>( 569 + &client, 570 + &mut dpop_data, 571 + OAuthRequest::PushedAuthorizationRequest(parameters), 572 + metadata, 573 + ) 574 + .await?; 575 + 576 + let scopes = if let Some(scope) = &metadata.client_metadata.scope { 577 + Scope::parse_multiple_reduced(&scope) 578 + .expect("Failed to parse scopes") 579 + .into_static() 580 + } else { 581 + vec![] 582 + }; 583 + let auth_req_data = AuthRequestData { 584 + state, 585 + authserver_url: metadata.server_metadata.issuer.clone(), 586 + account_did: None, 587 + scopes, 588 + request_uri: par_response.request_uri.to_cowstr().into_static(), 589 + authserver_token_endpoint: metadata.server_metadata.token_endpoint.clone(), 590 + authserver_revocation_endpoint: metadata.server_metadata.revocation_endpoint.clone(), 591 + pkce_verifier: verifier, 592 + dpop_data, 593 + }; 594 + 595 + Ok(auth_req_data) 596 + } else if metadata 597 + .server_metadata 598 + .require_pushed_authorization_requests 599 + == Some(true) 600 + { 601 + Err(RequestError::no_endpoint("pushed_authorization_request")) 602 + } else { 603 + todo!("use of PAR is mandatory") 604 + } 605 + } 606 + 607 + /// Exchange a refresh token for a fresh token set and update the session data in place. 608 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all, fields(did = %session_data.account_did)))] 609 + pub async fn refresh<'r, T>( 610 + client: &T, 611 + mut session_data: ClientSessionData<'r>, 612 + metadata: &OAuthMetadata, 613 + ) -> Result<ClientSessionData<'r>> 614 + where 615 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 616 + { 617 + let Some(refresh_token) = session_data.token_set.refresh_token.as_ref() else { 618 + return Err(RequestError::no_refresh_token()); 619 + }; 620 + 621 + // /!\ IMPORTANT /!\ 622 + // 623 + // The "sub" MUST be a DID, whose issuer authority is indeed the server we 624 + // are trying to obtain credentials from. Note that we are doing this 625 + // *before* we actually try to refresh the token: 626 + // 1) To avoid unnecessary refresh 627 + // 2) So that the refresh is the last async operation, ensuring as few 628 + // async operations happen before the result gets a chance to be stored. 629 + let aud = client 630 + .verify_issuer(&metadata.server_metadata, &session_data.token_set.sub) 631 + .await?; 632 + let iss = metadata.server_metadata.issuer.clone(); 633 + 634 + let response = oauth_request::<OAuthTokenResponse, T, DpopClientData>( 635 + client, 636 + &mut session_data.dpop_data, 637 + OAuthRequest::Refresh(RefreshRequestParameters { 638 + grant_type: TokenGrantType::RefreshToken, 639 + refresh_token: refresh_token.clone(), 640 + scope: None, 641 + }), 642 + metadata, 643 + ) 644 + .await?; 645 + 646 + let expires_at = response.expires_in.and_then(|expires_in| { 647 + let now = Datetime::now(); 648 + now.as_ref() 649 + .checked_add_signed(TimeDelta::seconds(expires_in)) 650 + .map(Datetime::new) 651 + }); 652 + 653 + session_data.update_with_tokens(TokenSet { 654 + iss, 655 + sub: session_data.token_set.sub.clone(), 656 + aud: CowStr::Owned(aud.to_smolstr()), 657 + scope: response.scope.map(CowStr::Owned), 658 + access_token: CowStr::Owned(response.access_token), 659 + refresh_token: response.refresh_token.map(CowStr::Owned), 660 + token_type: response.token_type, 661 + expires_at, 662 + }); 663 + 664 + Ok(session_data) 665 + } 666 + 667 + /// Exchange an authorization code for a token set and return a fully-verified [`TokenSet`]. 668 + /// 669 + /// Per the AT Protocol OAuth spec, the `sub` claim in the token response **must** be verified 670 + /// against the expected authorization server issuer before the token can be trusted. This 671 + /// function performs that verification as part of the exchange, so callers receive a token 672 + /// set that is safe to persist. 673 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))] 674 + pub async fn exchange_code<'r, T, D>( 675 + client: &T, 676 + data_source: &'r mut D, 677 + code: &str, 678 + verifier: &str, 679 + metadata: &OAuthMetadata, 680 + ) -> Result<TokenSet<'r>> 681 + where 682 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 683 + D: DpopDataSource, 684 + { 685 + let token_response = oauth_request::<OAuthTokenResponse, T, D>( 686 + client, 687 + data_source, 688 + OAuthRequest::Token(TokenRequestParameters { 689 + grant_type: TokenGrantType::AuthorizationCode, 690 + code: code.into(), 691 + redirect_uri: CowStr::Owned( 692 + metadata.client_metadata.redirect_uris[0] 693 + .clone() 694 + .to_smolstr(), 695 + ), 696 + code_verifier: verifier.into(), 697 + }), 698 + metadata, 699 + ) 700 + .await?; 701 + let Some(sub) = token_response.sub else { 702 + return Err(RequestError::token_verification()); 703 + }; 704 + let sub = Did::new_owned(sub)?; 705 + let iss = metadata.server_metadata.issuer.clone(); 706 + // /!\ IMPORTANT /!\ 707 + // 708 + // The token_response MUST always be valid before the "sub" it contains 709 + // can be trusted (see Atproto's OAuth spec for details). 710 + let aud = client 711 + .verify_issuer(&metadata.server_metadata, &sub) 712 + .await?; 713 + 714 + let expires_at = token_response.expires_in.and_then(|expires_in| { 715 + Datetime::now() 716 + .as_ref() 717 + .checked_add_signed(TimeDelta::seconds(expires_in)) 718 + .map(Datetime::new) 719 + }); 720 + Ok(TokenSet { 721 + iss, 722 + sub, 723 + aud: CowStr::Owned(aud.to_smolstr()), 724 + scope: token_response.scope.map(CowStr::Owned), 725 + access_token: CowStr::Owned(token_response.access_token), 726 + refresh_token: token_response.refresh_token.map(CowStr::Owned), 727 + token_type: token_response.token_type, 728 + expires_at, 729 + }) 730 + } 731 + 732 + /// Send a token revocation request (RFC 7009) to the authorization server. 733 + /// 734 + /// This function is called by [`crate::client::OAuthSession::logout`] when a revocation endpoint is advertised 735 + /// by the server. The caller is responsible for deleting the session from local storage regardless 736 + /// of whether revocation succeeds. 737 + #[cfg_attr(feature = "tracing", tracing::instrument(level = "debug", skip_all))] 738 + pub async fn revoke<'r, T, D>( 739 + client: &T, 740 + data_source: &'r mut D, 741 + token: &str, 742 + metadata: &OAuthMetadata, 743 + ) -> Result<()> 744 + where 745 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 746 + D: DpopDataSource, 747 + { 748 + oauth_request::<(), T, D>( 749 + client, 750 + data_source, 751 + OAuthRequest::Revocation(RevocationRequestParameters { 752 + token: token.into(), 753 + }), 754 + metadata, 755 + ) 756 + .await?; 757 + Ok(()) 758 + } 759 + 760 + /// Low-level function for sending an OAuth token-endpoint request and deserializing the response. 761 + /// 762 + /// Selects the correct server endpoint for `request`, builds the form-encoded body with 763 + /// client authentication, performs the DPoP-wrapped HTTP POST, and deserializes the response 764 + /// body into `O`. The type parameter `O` is inferred from the call site; use `()` for requests 765 + /// where the response body is empty (e.g., revocation). 766 + pub async fn oauth_request<'de: 'r, 'r, O, T, D>( 767 + client: &T, 768 + data_source: &'r mut D, 769 + request: OAuthRequest<'r>, 770 + metadata: &OAuthMetadata, 771 + ) -> Result<O> 772 + where 773 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 774 + O: serde::de::DeserializeOwned, 775 + D: DpopDataSource, 776 + { 777 + let Some(url) = endpoint_for_req(&metadata.server_metadata, &request) else { 778 + return Err(RequestError::no_endpoint(request.name())); 779 + }; 780 + let client_assertions = build_auth( 781 + metadata.keyset.as_ref(), 782 + &metadata.server_metadata, 783 + &metadata.client_metadata, 784 + )?; 785 + let body = match &request { 786 + OAuthRequest::Token(params) => build_oauth_req_body(client_assertions, params)?, 787 + OAuthRequest::Refresh(params) => build_oauth_req_body(client_assertions, params)?, 788 + OAuthRequest::Revocation(params) => build_oauth_req_body(client_assertions, params)?, 789 + OAuthRequest::PushedAuthorizationRequest(params) => { 790 + build_oauth_req_body(client_assertions, params)? 791 + } 792 + _ => unimplemented!(), 793 + }; 794 + let req = Request::builder() 795 + .uri(url.to_string()) 796 + .method(Method::POST) 797 + .header("Content-Type", "application/x-www-form-urlencoded") 798 + .body(body.into_bytes())?; 799 + let res = client.dpop_server_call(data_source).send(req).await?; 800 + if res.status() == request.expected_status() { 801 + let body = res.body(); 802 + if body.is_empty() { 803 + // since an empty body cannot be deserialized, use “null” temporarily to allow deserialization to `()`. 804 + Ok(serde_json::from_slice(b"null")?) 805 + } else { 806 + let output: O = serde_json::from_slice(body)?; 807 + Ok(output) 808 + } 809 + } else if res.status().is_client_error() { 810 + Err(RequestError::http_status_with_body( 811 + res.status(), 812 + serde_json::from_slice(res.body())?, 813 + )) 814 + } else { 815 + Err(RequestError::http_status(res.status())) 816 + } 817 + } 818 + 819 + #[inline] 820 + fn endpoint_for_req<'a, 'r>( 821 + server_metadata: &'r OAuthAuthorizationServerMetadata<'a>, 822 + request: &'r OAuthRequest, 823 + ) -> Option<&'r CowStr<'a>> { 824 + match request { 825 + OAuthRequest::Token(_) | OAuthRequest::Refresh(_) => Some(&server_metadata.token_endpoint), 826 + OAuthRequest::Revocation(_) => server_metadata.revocation_endpoint.as_ref(), 827 + OAuthRequest::Introspection => server_metadata.introspection_endpoint.as_ref(), 828 + OAuthRequest::PushedAuthorizationRequest(_) => server_metadata 829 + .pushed_authorization_request_endpoint 830 + .as_ref(), 831 + } 832 + } 833 + 834 + #[inline] 835 + fn build_oauth_req_body<'a, S>(client_assertions: ClientAuth<'a>, parameters: S) -> Result<String> 836 + where 837 + S: Serialize, 838 + { 839 + Ok(serde_html_form::to_string(RequestPayload { 840 + client_id: client_assertions.client_id, 841 + client_assertion_type: client_assertions.assertion_type, 842 + client_assertion: client_assertions.assertion, 843 + parameters, 844 + })?) 845 + } 846 + 847 + /// Client identity fields appended to every token-endpoint request body. 848 + /// 849 + /// Encapsulates the result of choosing a client authentication method (`none` vs. 850 + /// `private_key_jwt`). The `build_auth` helper selects the appropriate variant based 851 + /// on server capabilities and client configuration. 852 + #[derive(Debug, Clone, Default)] 853 + pub struct ClientAuth<'a> { 854 + /// The OAuth `client_id` for this client. 855 + client_id: CowStr<'a>, 856 + /// Either absent (for `none` auth) or `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. 857 + assertion_type: Option<CowStr<'a>>, 858 + /// A signed JWT proving client identity; present only for `private_key_jwt` auth. 859 + assertion: Option<CowStr<'a>>, 860 + } 861 + 862 + impl<'s> ClientAuth<'s> { 863 + /// Construct a `ClientAuth` with only a `client_id` and no assertion (the `none` method). 864 + pub fn new_id(client_id: CowStr<'s>) -> Self { 865 + Self { 866 + client_id, 867 + assertion_type: None, 868 + assertion: None, 869 + } 870 + } 871 + } 872 + 873 + fn build_auth<'a>( 874 + keyset: Option<&Keyset>, 875 + server_metadata: &OAuthAuthorizationServerMetadata<'a>, 876 + client_metadata: &OAuthClientMetadata<'a>, 877 + ) -> Result<ClientAuth<'a>> { 878 + let method_supported = server_metadata 879 + .token_endpoint_auth_methods_supported 880 + .as_ref(); 881 + 882 + let client_id = client_metadata.client_id.to_cowstr().into_static(); 883 + if let Some(method) = client_metadata.token_endpoint_auth_method.as_ref() { 884 + match (*method).as_ref() { 885 + "private_key_jwt" 886 + if method_supported 887 + .as_ref() 888 + .is_some_and(|v| v.contains(&CowStr::new_static("private_key_jwt"))) => 889 + { 890 + if let Some(keyset) = &keyset { 891 + let mut alg_strs = server_metadata 892 + .token_endpoint_auth_signing_alg_values_supported 893 + .clone() 894 + .unwrap_or(vec![FALLBACK_ALG.into()]); 895 + alg_strs.sort_by(compare_algos); 896 + let algs: Vec<Signing> = alg_strs 897 + .iter() 898 + .filter_map(|s| crate::keyset::parse_signing_alg(s)) 899 + .collect(); 900 + let iat = Utc::now().timestamp(); 901 + return Ok(ClientAuth { 902 + client_id: client_id.clone(), 903 + assertion_type: Some(CowStr::new_static(CLIENT_ASSERTION_TYPE_JWT_BEARER)), 904 + assertion: Some( 905 + keyset.create_jwt( 906 + &algs, 907 + // https://datatracker.ietf.org/doc/html/rfc7523#section-3 908 + RegisteredClaims { 909 + iss: Some(client_id.clone()), 910 + sub: Some(client_id), 911 + aud: Some(RegisteredClaimsAud::Single( 912 + server_metadata.issuer.clone(), 913 + )), 914 + exp: Some(iat + 60), 915 + // "iat" is required and **MUST** be less than one minute 916 + // https://datatracker.ietf.org/doc/html/rfc9101 917 + iat: Some(iat), 918 + // atproto oauth-provider requires "jti" to be present 919 + jti: Some(generate_nonce()), 920 + ..Default::default() 921 + } 922 + .into(), 923 + )?, 924 + ), 925 + }); 926 + } 927 + } 928 + "none" 929 + if method_supported 930 + .as_ref() 931 + .is_some_and(|v| v.contains(&CowStr::new_static("none"))) => 932 + { 933 + return Ok(ClientAuth::new_id(client_id)); 934 + } 935 + _ => {} 936 + } 937 + } 938 + 939 + Err(RequestError::unsupported_auth_method()) 940 + } 941 + 942 + #[cfg(test)] 943 + mod tests { 944 + use super::*; 945 + use crate::types::{OAuthAuthorizationServerMetadata, OAuthClientMetadata}; 946 + use bytes::Bytes; 947 + use http::{Response as HttpResponse, StatusCode}; 948 + use jacquard_common::{deps::fluent_uri::Uri, http_client::HttpClient, types::string::Did}; 949 + use jacquard_identity::resolver::IdentityResolver; 950 + use std::sync::Arc; 951 + use tokio::sync::Mutex; 952 + 953 + #[derive(Clone, Default)] 954 + struct MockClient { 955 + resp: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>, 956 + } 957 + 958 + impl HttpClient for MockClient { 959 + type Error = std::convert::Infallible; 960 + fn send_http( 961 + &self, 962 + _request: http::Request<Vec<u8>>, 963 + ) -> impl core::future::Future< 964 + Output = core::result::Result<http::Response<Vec<u8>>, Self::Error>, 965 + > + Send { 966 + let resp = self.resp.clone(); 967 + async move { Ok(resp.lock().await.take().unwrap()) } 968 + } 969 + } 970 + 971 + // IdentityResolver methods won't be called in these tests; provide stubs. 972 + impl IdentityResolver for MockClient { 973 + fn options(&self) -> &jacquard_identity::resolver::ResolverOptions { 974 + use std::sync::LazyLock; 975 + static OPTS: LazyLock<jacquard_identity::resolver::ResolverOptions> = 976 + LazyLock::new(|| jacquard_identity::resolver::ResolverOptions::default()); 977 + &OPTS 978 + } 979 + async fn resolve_handle( 980 + &self, 981 + _handle: &jacquard_common::types::string::Handle<'_>, 982 + ) -> std::result::Result<Did<'static>, jacquard_identity::resolver::IdentityError> { 983 + Ok(Did::new_static("did:plc:alice").unwrap()) 984 + } 985 + async fn resolve_did_doc( 986 + &self, 987 + _did: &Did<'_>, 988 + ) -> std::result::Result< 989 + jacquard_identity::resolver::DidDocResponse, 990 + jacquard_identity::resolver::IdentityError, 991 + > { 992 + let doc = serde_json::json!({ 993 + "id": "did:plc:alice", 994 + "service": [{ 995 + "id": "#pds", 996 + "type": "AtprotoPersonalDataServer", 997 + "serviceEndpoint": "https://pds" 998 + }] 999 + }); 1000 + let buf = Bytes::from(serde_json::to_vec(&doc).unwrap()); 1001 + Ok(jacquard_identity::resolver::DidDocResponse { 1002 + buffer: buf, 1003 + status: StatusCode::OK, 1004 + requested: None, 1005 + }) 1006 + } 1007 + } 1008 + 1009 + // Allow using DPoP helpers on MockClient 1010 + impl crate::dpop::DpopExt for MockClient {} 1011 + impl crate::resolver::OAuthResolver for MockClient {} 1012 + 1013 + fn base_metadata() -> OAuthMetadata { 1014 + let mut server = OAuthAuthorizationServerMetadata::default(); 1015 + server.issuer = CowStr::from("https://issuer"); 1016 + server.authorization_endpoint = CowStr::from("https://issuer/authorize"); 1017 + server.token_endpoint = CowStr::from("https://issuer/token"); 1018 + server.token_endpoint_auth_methods_supported = Some(vec![CowStr::from("none")]); 1019 + OAuthMetadata { 1020 + server_metadata: server, 1021 + client_metadata: OAuthClientMetadata { 1022 + client_id: CowStr::new_static("https://client"), 1023 + client_uri: None, 1024 + redirect_uris: vec![CowStr::new_static("https://client/cb")], 1025 + scope: Some(CowStr::from("atproto")), 1026 + grant_types: None, 1027 + response_types: vec![CowStr::new_static("code")], 1028 + application_type: Some(CowStr::new_static("web")), 1029 + token_endpoint_auth_method: Some(CowStr::from("none")), 1030 + dpop_bound_access_tokens: None, 1031 + jwks_uri: None, 1032 + jwks: None, 1033 + token_endpoint_auth_signing_alg: None, 1034 + client_name: None, 1035 + privacy_policy_uri: None, 1036 + tos_uri: None, 1037 + logo_uri: None, 1038 + }, 1039 + keyset: None, 1040 + } 1041 + } 1042 + 1043 + #[tokio::test] 1044 + async fn par_missing_endpoint() { 1045 + let mut meta = base_metadata(); 1046 + meta.server_metadata.require_pushed_authorization_requests = Some(true); 1047 + meta.server_metadata.pushed_authorization_request_endpoint = None; 1048 + // require_pushed_authorization_requests is true and no endpoint 1049 + let err = super::par(&MockClient::default(), None, None, &meta, None) 1050 + .await 1051 + .unwrap_err(); 1052 + assert!( 1053 + matches!(err.kind(), RequestErrorKind::NoEndpoint(name) if name == "pushed_authorization_request") 1054 + ); 1055 + } 1056 + 1057 + #[tokio::test] 1058 + async fn refresh_no_refresh_token() { 1059 + let client = MockClient::default(); 1060 + let meta = base_metadata(); 1061 + let session = ClientSessionData { 1062 + account_did: Did::new_static("did:plc:alice").unwrap(), 1063 + session_id: CowStr::from("state"), 1064 + host_url: Uri::parse("https://pds").expect("valid").to_owned(), 1065 + authserver_url: CowStr::new_static("https://issuer"), 1066 + authserver_token_endpoint: CowStr::from("https://issuer/token"), 1067 + authserver_revocation_endpoint: None, 1068 + scopes: vec![], 1069 + dpop_data: DpopClientData { 1070 + dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(), 1071 + dpop_authserver_nonce: CowStr::from(""), 1072 + dpop_host_nonce: CowStr::from(""), 1073 + }, 1074 + token_set: crate::types::TokenSet { 1075 + iss: CowStr::from("https://issuer"), 1076 + sub: Did::new_static("did:plc:alice").unwrap(), 1077 + aud: CowStr::from("https://pds"), 1078 + scope: None, 1079 + refresh_token: None, 1080 + access_token: CowStr::from("abc"), 1081 + token_type: crate::types::OAuthTokenType::DPoP, 1082 + expires_at: None, 1083 + }, 1084 + }; 1085 + let err = super::refresh(&client, session, &meta).await.unwrap_err(); 1086 + assert!(matches!(err.kind(), RequestErrorKind::NoRefreshToken)); 1087 + } 1088 + 1089 + #[tokio::test] 1090 + async fn exchange_code_missing_sub() { 1091 + let client = MockClient::default(); 1092 + // set mock HTTP response body: token response without `sub` 1093 + *client.resp.lock().await = Some( 1094 + HttpResponse::builder() 1095 + .status(StatusCode::OK) 1096 + .body( 1097 + serde_json::to_vec(&serde_json::json!({ 1098 + "access_token":"tok", 1099 + "token_type":"DPoP", 1100 + "expires_in": 3600 1101 + })) 1102 + .unwrap(), 1103 + ) 1104 + .unwrap(), 1105 + ); 1106 + let meta = base_metadata(); 1107 + let mut dpop = DpopReqData { 1108 + dpop_key: crate::utils::generate_key(&[CowStr::from("ES256")]).unwrap(), 1109 + dpop_authserver_nonce: None, 1110 + }; 1111 + let err = super::exchange_code(&client, &mut dpop, "abc", "verifier", &meta) 1112 + .await 1113 + .unwrap_err(); 1114 + assert!(matches!(err.kind(), RequestErrorKind::TokenVerification)); 1115 + } 1116 + }
+954
src-tauri/vendor/jacquard-oauth/src/resolver.rs
··· 1 + #[cfg(not(target_arch = "wasm32"))] 2 + use std::future::Future; 3 + 4 + use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 5 + use http::{Request, StatusCode}; 6 + use jacquard_common::CowStr; 7 + use jacquard_common::IntoStatic; 8 + #[allow(unused_imports)] 9 + use jacquard_common::cowstr::ToCowStr; 10 + use jacquard_common::deps::fluent_uri::Uri; 11 + use jacquard_common::types::did_doc::DidDocument; 12 + use jacquard_common::types::ident::AtIdentifier; 13 + use jacquard_common::{http_client::HttpClient, types::did::Did}; 14 + use jacquard_identity::resolver::{IdentityError, IdentityResolver}; 15 + use smol_str::SmolStr; 16 + 17 + /// Convenience alias for a heap-allocated, thread-safe, `'static` error value. 18 + pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>; 19 + 20 + /// OAuth resolver error for identity and metadata resolution 21 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 22 + #[error("{kind}")] 23 + pub struct ResolverError { 24 + #[diagnostic_source] 25 + kind: ResolverErrorKind, 26 + #[source] 27 + source: Option<BoxError>, 28 + #[help] 29 + help: Option<SmolStr>, 30 + context: Option<SmolStr>, 31 + url: Option<SmolStr>, 32 + details: Option<SmolStr>, 33 + location: Option<SmolStr>, 34 + } 35 + 36 + /// Error categories for OAuth resolver operations 37 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 38 + #[non_exhaustive] 39 + pub enum ResolverErrorKind { 40 + /// Resource not found 41 + #[error("resource not found")] 42 + #[diagnostic( 43 + code(jacquard_oauth::resolver::not_found), 44 + help("check the base URL or identifier") 45 + )] 46 + NotFound, 47 + 48 + /// Invalid AT identifier 49 + #[error("invalid at identifier: {0}")] 50 + #[diagnostic( 51 + code(jacquard_oauth::resolver::at_identifier), 52 + help("ensure a valid handle or DID was provided") 53 + )] 54 + AtIdentifier(SmolStr), 55 + 56 + /// Invalid DID 57 + #[error("invalid did: {0}")] 58 + #[diagnostic( 59 + code(jacquard_oauth::resolver::did), 60 + help("ensure DID is correctly formed (did:plc or did:web)") 61 + )] 62 + Did(SmolStr), 63 + 64 + /// Invalid DID document 65 + #[error("invalid did document: {0}")] 66 + #[diagnostic( 67 + code(jacquard_oauth::resolver::did_document), 68 + help("verify the DID document structure and service entries") 69 + )] 70 + DidDocument(SmolStr), 71 + 72 + /// Protected resource metadata is invalid 73 + #[error("protected resource metadata is invalid: {0}")] 74 + #[diagnostic( 75 + code(jacquard_oauth::resolver::protected_resource_metadata), 76 + help("PDS must advertise an authorization server in its protected resource metadata") 77 + )] 78 + ProtectedResourceMetadata(SmolStr), 79 + 80 + /// Authorization server metadata is invalid 81 + #[error("authorization server metadata is invalid: {0}")] 82 + #[diagnostic( 83 + code(jacquard_oauth::resolver::authorization_server_metadata), 84 + help("issuer must match and include the PDS resource") 85 + )] 86 + AuthorizationServerMetadata(SmolStr), 87 + 88 + /// Identity resolution error 89 + #[error("error resolving identity")] 90 + #[diagnostic(code(jacquard_oauth::resolver::identity))] 91 + Identity, 92 + 93 + /// Unsupported DID method 94 + #[error("unsupported did method: {0:?}")] 95 + #[diagnostic( 96 + code(jacquard_oauth::resolver::unsupported_did_method), 97 + help("supported DID methods: did:web, did:plc") 98 + )] 99 + UnsupportedDidMethod(Did<'static>), 100 + 101 + /// HTTP transport error 102 + #[error("transport error")] 103 + #[diagnostic(code(jacquard_oauth::resolver::transport))] 104 + Transport, 105 + 106 + /// HTTP status error 107 + #[error("http status: {0}")] 108 + #[diagnostic( 109 + code(jacquard_oauth::resolver::http_status), 110 + help("check well-known paths and server configuration") 111 + )] 112 + HttpStatus(StatusCode), 113 + 114 + /// JSON serialization error 115 + #[error("json error")] 116 + #[diagnostic(code(jacquard_oauth::resolver::serde_json))] 117 + SerdeJson, 118 + 119 + /// Form serialization error 120 + #[error("form serialization error")] 121 + #[diagnostic(code(jacquard_oauth::resolver::serde_form))] 122 + SerdeHtmlForm, 123 + 124 + /// URL parsing error 125 + #[error("url parsing error")] 126 + #[diagnostic(code(jacquard_oauth::resolver::url))] 127 + Uri, 128 + } 129 + 130 + impl ResolverError { 131 + /// Create a new error with the given kind and optional source 132 + pub fn new(kind: ResolverErrorKind, source: Option<BoxError>) -> Self { 133 + Self { 134 + kind, 135 + source, 136 + help: None, 137 + context: None, 138 + url: None, 139 + details: None, 140 + location: None, 141 + } 142 + } 143 + 144 + /// Get the error kind 145 + pub fn kind(&self) -> &ResolverErrorKind { 146 + &self.kind 147 + } 148 + 149 + /// Get the source error if present 150 + pub fn source_err(&self) -> Option<&BoxError> { 151 + self.source.as_ref() 152 + } 153 + 154 + /// Get the context string if present 155 + pub fn context(&self) -> Option<&str> { 156 + self.context.as_ref().map(|s| s.as_str()) 157 + } 158 + 159 + /// Get the URL if present 160 + pub fn url(&self) -> Option<&str> { 161 + self.url.as_ref().map(|s| s.as_str()) 162 + } 163 + 164 + /// Get the details if present 165 + pub fn details(&self) -> Option<&str> { 166 + self.details.as_ref().map(|s| s.as_str()) 167 + } 168 + 169 + /// Get the location if present 170 + pub fn location(&self) -> Option<&str> { 171 + self.location.as_ref().map(|s| s.as_str()) 172 + } 173 + 174 + /// Add help text to this error 175 + pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self { 176 + self.help = Some(help.into()); 177 + self 178 + } 179 + 180 + /// Add context to this error 181 + pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self { 182 + self.context = Some(context.into()); 183 + self 184 + } 185 + 186 + /// Add URL to this error 187 + pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self { 188 + self.url = Some(url.into()); 189 + self 190 + } 191 + 192 + /// Add details to this error 193 + pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self { 194 + self.details = Some(details.into()); 195 + self 196 + } 197 + 198 + /// Add location to this error 199 + pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self { 200 + self.location = Some(location.into()); 201 + self 202 + } 203 + 204 + // Constructors for each kind 205 + 206 + /// Create a not found error 207 + pub fn not_found() -> Self { 208 + Self::new(ResolverErrorKind::NotFound, None) 209 + } 210 + 211 + /// Create an invalid AT identifier error 212 + pub fn at_identifier(msg: impl Into<SmolStr>) -> Self { 213 + Self::new(ResolverErrorKind::AtIdentifier(msg.into()), None) 214 + } 215 + 216 + /// Create an invalid DID error 217 + pub fn did(msg: impl Into<SmolStr>) -> Self { 218 + Self::new(ResolverErrorKind::Did(msg.into()), None) 219 + } 220 + 221 + /// Create an invalid DID document error 222 + pub fn did_document(msg: impl Into<SmolStr>) -> Self { 223 + Self::new(ResolverErrorKind::DidDocument(msg.into()), None) 224 + } 225 + 226 + /// Create a protected resource metadata error 227 + pub fn protected_resource_metadata(msg: impl Into<SmolStr>) -> Self { 228 + Self::new( 229 + ResolverErrorKind::ProtectedResourceMetadata(msg.into()), 230 + None, 231 + ) 232 + } 233 + 234 + /// Create an authorization server metadata error 235 + pub fn authorization_server_metadata(msg: impl Into<SmolStr>) -> Self { 236 + Self::new( 237 + ResolverErrorKind::AuthorizationServerMetadata(msg.into()), 238 + None, 239 + ) 240 + } 241 + 242 + /// Create an identity resolution error 243 + pub fn identity(source: impl std::error::Error + Send + Sync + 'static) -> Self { 244 + Self::new(ResolverErrorKind::Identity, Some(Box::new(source))) 245 + } 246 + 247 + /// Create an unsupported DID method error 248 + pub fn unsupported_did_method(did: Did<'static>) -> Self { 249 + Self::new(ResolverErrorKind::UnsupportedDidMethod(did), None) 250 + } 251 + 252 + /// Create a transport error 253 + pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 254 + Self::new(ResolverErrorKind::Transport, Some(Box::new(source))) 255 + } 256 + 257 + /// Create an HTTP status error 258 + pub fn http_status(status: StatusCode) -> Self { 259 + Self::new(ResolverErrorKind::HttpStatus(status), None) 260 + } 261 + } 262 + 263 + /// Result type for resolver operations 264 + pub type Result<T> = std::result::Result<T, ResolverError>; 265 + 266 + // From impls for common error types 267 + 268 + impl From<IdentityError> for ResolverError { 269 + fn from(e: IdentityError) -> Self { 270 + let msg = smol_str::format_smolstr!("{:?}", e); 271 + Self::new(ResolverErrorKind::Identity, Some(Box::new(e))) 272 + .with_context(msg) 273 + .with_help("verify handle/DID is valid and resolver configuration") 274 + } 275 + } 276 + 277 + impl From<jacquard_common::error::ClientError> for ResolverError { 278 + fn from(e: jacquard_common::error::ClientError) -> Self { 279 + let msg = smol_str::format_smolstr!("{:?}", e); 280 + Self::new(ResolverErrorKind::Transport, Some(Box::new(e))) 281 + .with_context(msg) 282 + .with_help("check network connectivity and well-known endpoint availability") 283 + } 284 + } 285 + 286 + impl From<serde_json::Error> for ResolverError { 287 + fn from(e: serde_json::Error) -> Self { 288 + let msg = smol_str::format_smolstr!("{:?}", e); 289 + Self::new(ResolverErrorKind::SerdeJson, Some(Box::new(e))) 290 + .with_context(msg) 291 + .with_help("verify OAuth metadata response format is valid JSON") 292 + } 293 + } 294 + 295 + impl From<serde_html_form::ser::Error> for ResolverError { 296 + fn from(e: serde_html_form::ser::Error) -> Self { 297 + let msg = smol_str::format_smolstr!("{:?}", e); 298 + Self::new(ResolverErrorKind::SerdeHtmlForm, Some(Box::new(e))) 299 + .with_context(msg) 300 + .with_help("check form parameters are serializable") 301 + } 302 + } 303 + 304 + impl From<jacquard_common::deps::fluent_uri::ParseError> for ResolverError { 305 + fn from(e: jacquard_common::deps::fluent_uri::ParseError) -> Self { 306 + let msg = smol_str::format_smolstr!("{:?}", e); 307 + Self::new(ResolverErrorKind::Uri, Some(Box::new(e))) 308 + .with_context(msg) 309 + .with_help("ensure URIs are well-formed (e.g., https://example.com)") 310 + } 311 + } 312 + 313 + // // Deprecated - for compatibility with old TransportError usage 314 + // #[allow(deprecated)] 315 + // impl From<jacquard_common::error::TransportError> for ResolverError { 316 + // fn from(e: jacquard_common::error::TransportError) -> Self { 317 + // Self::transport(e) 318 + // } 319 + // } 320 + 321 + #[cfg(not(target_arch = "wasm32"))] 322 + async fn verify_issuer_impl<T: OAuthResolver + Sync + ?Sized>( 323 + resolver: &T, 324 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 325 + sub: &Did<'_>, 326 + ) -> Result<Uri<String>> { 327 + let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 328 + if metadata.issuer != server_metadata.issuer { 329 + return Err(ResolverError::authorization_server_metadata( 330 + "issuer mismatch", 331 + )); 332 + } 333 + Ok(identity 334 + .pds_endpoint() 335 + .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) 336 + } 337 + 338 + #[cfg(target_arch = "wasm32")] 339 + async fn verify_issuer_impl<T: OAuthResolver + ?Sized>( 340 + resolver: &T, 341 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 342 + sub: &Did<'_>, 343 + ) -> Result<Uri<String>> { 344 + let (metadata, identity) = resolver.resolve_from_identity(sub.as_str()).await?; 345 + if metadata.issuer != server_metadata.issuer { 346 + return Err(ResolverError::authorization_server_metadata( 347 + "issuer mismatch", 348 + )); 349 + } 350 + Ok(identity 351 + .pds_endpoint() 352 + .ok_or_else(|| ResolverError::did_document(smol_str::format_smolstr!("{:?}", identity)))?) 353 + } 354 + 355 + #[cfg(not(target_arch = "wasm32"))] 356 + async fn resolve_oauth_impl<T: OAuthResolver + Sync + ?Sized>( 357 + resolver: &T, 358 + input: &str, 359 + ) -> Result<( 360 + OAuthAuthorizationServerMetadata<'static>, 361 + Option<DidDocument<'static>>, 362 + )> { 363 + // Allow using an entryway, or PDS url, directly as login input (e.g. 364 + // when the user forgot their handle, or when the handle does not 365 + // resolve to a DID) 366 + Ok(if input.starts_with("https://") { 367 + let uri = Uri::parse(input) 368 + .map_err(|e| { 369 + let err = ResolverError::new(ResolverErrorKind::Uri, Some(Box::new(e))); 370 + err.with_context("failed to parse service URL") 371 + })? 372 + .to_owned(); 373 + ( 374 + resolver.resolve_from_service(&uri.as_str().into()).await?, 375 + None, 376 + ) 377 + } else { 378 + let (metadata, identity) = resolver.resolve_from_identity(input).await?; 379 + (metadata, Some(identity)) 380 + }) 381 + } 382 + 383 + #[cfg(target_arch = "wasm32")] 384 + async fn resolve_oauth_impl<T: OAuthResolver + ?Sized>( 385 + resolver: &T, 386 + input: &str, 387 + ) -> Result<( 388 + OAuthAuthorizationServerMetadata<'static>, 389 + Option<DidDocument<'static>>, 390 + )> { 391 + // Allow using an entryway, or PDS url, directly as login input (e.g. 392 + // when the user forgot their handle, or when the handle does not 393 + // resolve to a DID) 394 + Ok(if input.starts_with("https://") { 395 + let uri = Uri::parse(input) 396 + .map_err(|e| { 397 + let err = ResolverError::new(ResolverErrorKind::Uri, Some(Box::new(e))); 398 + err.with_context("failed to parse service URL") 399 + })? 400 + .to_owned(); 401 + ( 402 + resolver.resolve_from_service(&uri.as_str().into()).await?, 403 + None, 404 + ) 405 + } else { 406 + let (metadata, identity) = resolver.resolve_from_identity(input).await?; 407 + (metadata, Some(identity)) 408 + }) 409 + } 410 + 411 + #[cfg(not(target_arch = "wasm32"))] 412 + async fn resolve_from_service_impl<T: OAuthResolver + Sync + ?Sized>( 413 + resolver: &T, 414 + input: &CowStr<'_>, 415 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 416 + // Assume first that input is a PDS URL (as required by ATPROTO) 417 + if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 418 + return Ok(metadata); 419 + } 420 + // Fallback to trying to fetch as an issuer (Entryway) 421 + resolver.get_authorization_server_metadata(input).await 422 + } 423 + 424 + #[cfg(target_arch = "wasm32")] 425 + async fn resolve_from_service_impl<T: OAuthResolver + ?Sized>( 426 + resolver: &T, 427 + input: &CowStr<'_>, 428 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 429 + // Assume first that input is a PDS URL (as required by ATPROTO) 430 + if let Ok(metadata) = resolver.get_resource_server_metadata(input).await { 431 + return Ok(metadata); 432 + } 433 + // Fallback to trying to fetch as an issuer (Entryway) 434 + resolver.get_authorization_server_metadata(input).await 435 + } 436 + 437 + #[cfg(not(target_arch = "wasm32"))] 438 + async fn resolve_from_identity_impl<T: OAuthResolver + Sync + ?Sized>( 439 + resolver: &T, 440 + input: &str, 441 + ) -> Result<( 442 + OAuthAuthorizationServerMetadata<'static>, 443 + DidDocument<'static>, 444 + )> { 445 + let actor = AtIdentifier::new(input) 446 + .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 447 + let identity = resolver.resolve_ident_owned(&actor).await?; 448 + if let Some(pds) = &identity.pds_endpoint() { 449 + use jacquard_common::cowstr::ToCowStr; 450 + 451 + let metadata = resolver 452 + .get_resource_server_metadata(&pds.to_cowstr()) 453 + .await?; 454 + Ok((metadata, identity)) 455 + } else { 456 + Err(ResolverError::did_document("Did doc lacking pds")) 457 + } 458 + } 459 + 460 + #[cfg(target_arch = "wasm32")] 461 + async fn resolve_from_identity_impl<T: OAuthResolver + ?Sized>( 462 + resolver: &T, 463 + input: &str, 464 + ) -> Result<( 465 + OAuthAuthorizationServerMetadata<'static>, 466 + DidDocument<'static>, 467 + )> { 468 + let actor = AtIdentifier::new(input) 469 + .map_err(|e| ResolverError::at_identifier(smol_str::format_smolstr!("{:?}", e)))?; 470 + let identity = resolver.resolve_ident_owned(&actor).await?; 471 + if let Some(pds) = &identity.pds_endpoint() { 472 + let metadata = resolver 473 + .get_resource_server_metadata(&pds.to_cowstr()) 474 + .await?; 475 + Ok((metadata, identity)) 476 + } else { 477 + Err(ResolverError::did_document("Did doc lacking pds")) 478 + } 479 + } 480 + 481 + #[cfg(not(target_arch = "wasm32"))] 482 + async fn get_authorization_server_metadata_impl<T: HttpClient + Sync + ?Sized>( 483 + client: &T, 484 + issuer: &CowStr<'_>, 485 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 486 + let mut md = resolve_authorization_server(client, issuer).await?; 487 + md.issuer = issuer.clone().into_static(); 488 + Ok(md) 489 + } 490 + 491 + #[cfg(target_arch = "wasm32")] 492 + async fn get_authorization_server_metadata_impl<T: HttpClient + ?Sized>( 493 + client: &T, 494 + issuer: &CowStr<'_>, 495 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 496 + let mut md = resolve_authorization_server(client, issuer).await?; 497 + md.issuer = issuer.clone().into_static(); 498 + Ok(md) 499 + } 500 + 501 + #[cfg(not(target_arch = "wasm32"))] 502 + async fn get_resource_server_metadata_impl<T: OAuthResolver + Sync + ?Sized>( 503 + resolver: &T, 504 + pds: &CowStr<'_>, 505 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 506 + let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 507 + // ATPROTO requires one, and only one, authorization server entry 508 + // > That document MUST contain a single item in the authorization_servers array. 509 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 510 + let issuer = match &rs_metadata.authorization_servers { 511 + Some(servers) if !servers.is_empty() => { 512 + if servers.len() > 1 { 513 + return Err(ResolverError::protected_resource_metadata( 514 + smol_str::format_smolstr!( 515 + "unable to determine authorization server for PDS: {pds}" 516 + ), 517 + )); 518 + } 519 + &servers[0] 520 + } 521 + _ => { 522 + return Err(ResolverError::protected_resource_metadata( 523 + smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), 524 + )); 525 + } 526 + }; 527 + let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; 528 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 529 + if let Some(protected_resources) = &as_metadata.protected_resources { 530 + let resource_url = rs_metadata 531 + .resource 532 + .strip_suffix('/') 533 + .unwrap_or(rs_metadata.resource.as_str()); 534 + if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 535 + return Err(ResolverError::authorization_server_metadata( 536 + smol_str::format_smolstr!( 537 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 538 + rs_metadata.resource, 539 + protected_resources 540 + ), 541 + )); 542 + } 543 + } 544 + 545 + // TODO: atproot specific validation? 546 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 547 + // 548 + // eg. 549 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 550 + // if as_metadata.client_id_metadata_document_supported != Some(true) { 551 + // return Err(Error::AuthorizationServerMetadata(format!( 552 + // "authorization server does not support client_id_metadata_document: {issuer}" 553 + // ))); 554 + // } 555 + 556 + Ok(as_metadata) 557 + } 558 + 559 + #[cfg(target_arch = "wasm32")] 560 + async fn get_resource_server_metadata_impl<T: OAuthResolver + ?Sized>( 561 + resolver: &T, 562 + pds: &CowStr<'_>, 563 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 564 + let rs_metadata = resolve_protected_resource_info(resolver, pds).await?; 565 + // ATPROTO requires one, and only one, authorization server entry 566 + // > That document MUST contain a single item in the authorization_servers array. 567 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 568 + let issuer = match &rs_metadata.authorization_servers { 569 + Some(servers) if !servers.is_empty() => { 570 + if servers.len() > 1 { 571 + return Err(ResolverError::protected_resource_metadata( 572 + smol_str::format_smolstr!( 573 + "unable to determine authorization server for PDS: {pds}" 574 + ), 575 + )); 576 + } 577 + &servers[0] 578 + } 579 + _ => { 580 + return Err(ResolverError::protected_resource_metadata( 581 + smol_str::format_smolstr!("no authorization server found for PDS: {pds}"), 582 + )); 583 + } 584 + }; 585 + let as_metadata = resolver.get_authorization_server_metadata(issuer).await?; 586 + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 587 + if let Some(protected_resources) = &as_metadata.protected_resources { 588 + let resource_url = rs_metadata 589 + .resource 590 + .strip_suffix('/') 591 + .unwrap_or(rs_metadata.resource.as_str()); 592 + if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 593 + return Err(ResolverError::authorization_server_metadata( 594 + smol_str::format_smolstr!( 595 + "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 596 + rs_metadata.resource, 597 + protected_resources 598 + ), 599 + )); 600 + } 601 + } 602 + 603 + // TODO: atproot specific validation? 604 + // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 605 + // 606 + // eg. 607 + // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 608 + // if as_metadata.client_id_metadata_document_supported != Some(true) { 609 + // return Err(Error::AuthorizationServerMetadata(format!( 610 + // "authorization server does not support client_id_metadata_document: {issuer}" 611 + // ))); 612 + // } 613 + 614 + Ok(as_metadata) 615 + } 616 + 617 + /// Resolver trait for the AT Protocol OAuth flow. 618 + /// 619 + /// `OAuthResolver` extends [`IdentityResolver`] and [`HttpClient`] with the methods needed to 620 + /// drive the full OAuth flow: resolving an AT identifier (handle or DID) to the authorization 621 + /// server that protects its PDS, fetching server metadata, and verifying that a token's `sub` 622 + /// claim is authorized by the expected issuer. 623 + /// 624 + /// A default implementation based on [`jacquard_identity::JacquardResolver`] is provided. 625 + /// Custom implementations are possible for testing or for environments that require 626 + /// non-standard identity resolution (e.g., federated or offline setups). 627 + #[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))] 628 + pub trait OAuthResolver: IdentityResolver + HttpClient { 629 + /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`. 630 + #[cfg(not(target_arch = "wasm32"))] 631 + fn verify_issuer( 632 + &self, 633 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 634 + sub: &Did<'_>, 635 + ) -> impl Future<Output = Result<Uri<String>>> + Send 636 + where 637 + Self: Sync, 638 + { 639 + verify_issuer_impl(self, server_metadata, sub) 640 + } 641 + 642 + /// Verify that the authorization server in `server_metadata` is the correct issuer for `sub`. 643 + #[cfg(target_arch = "wasm32")] 644 + fn verify_issuer( 645 + &self, 646 + server_metadata: &OAuthAuthorizationServerMetadata<'_>, 647 + sub: &Did<'_>, 648 + ) -> impl Future<Output = Result<Uri<String>>> { 649 + verify_issuer_impl(self, server_metadata, sub) 650 + } 651 + 652 + /// Resolve `input` (a handle, DID, PDS URL, or entryway URL) to OAuth metadata. 653 + /// 654 + /// When `input` starts with `https://`, it is treated as a service URL and resolved 655 + /// directly via [`OAuthResolver::resolve_from_service`]. Otherwise it is treated as an 656 + /// AT identifier and resolved via [`OAuthResolver::resolve_from_identity`]. Returns the 657 + /// authorization server metadata and, when `input` was an identity, the resolved DID document. 658 + #[cfg(not(target_arch = "wasm32"))] 659 + fn resolve_oauth( 660 + &self, 661 + input: &str, 662 + ) -> impl Future< 663 + Output = Result<( 664 + OAuthAuthorizationServerMetadata<'static>, 665 + Option<DidDocument<'static>>, 666 + )>, 667 + > + Send 668 + where 669 + Self: Sync, 670 + { 671 + resolve_oauth_impl(self, input) 672 + } 673 + 674 + /// Resolve `input` (a handle, DID, PDS URL, or entryway URL) to OAuth metadata. 675 + /// 676 + /// When `input` starts with `https://`, it is treated as a service URL and resolved 677 + /// directly via [`OAuthResolver::resolve_from_service`]. Otherwise it is treated as an 678 + /// AT identifier and resolved via [`OAuthResolver::resolve_from_identity`]. Returns the 679 + /// authorization server metadata and, when `input` was an identity, the resolved DID document. 680 + #[cfg(target_arch = "wasm32")] 681 + fn resolve_oauth( 682 + &self, 683 + input: &str, 684 + ) -> impl Future< 685 + Output = Result<( 686 + OAuthAuthorizationServerMetadata<'static>, 687 + Option<DidDocument<'static>>, 688 + )>, 689 + > { 690 + resolve_oauth_impl(self, input) 691 + } 692 + 693 + /// Resolve a service URL (PDS or entryway) to its authorization server metadata. 694 + /// 695 + /// First attempts to fetch the PDS's protected resource metadata; if that fails, falls back 696 + /// to treating the URL as an entryway and fetching authorization server metadata directly. 697 + #[cfg(not(target_arch = "wasm32"))] 698 + fn resolve_from_service( 699 + &self, 700 + input: &CowStr<'_>, 701 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 702 + where 703 + Self: Sync, 704 + { 705 + resolve_from_service_impl(self, input) 706 + } 707 + 708 + /// Resolve a service URL to its authorization server metadata. 709 + /// 710 + /// First attempts to fetch the PDS's protected resource metadata; if that fails, falls back 711 + /// to treating the URL as an entryway and fetching authorization server metadata directly. 712 + #[cfg(target_arch = "wasm32")] 713 + fn resolve_from_service( 714 + &self, 715 + input: &CowStr<'_>, 716 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 717 + resolve_from_service_impl(self, input) 718 + } 719 + 720 + /// Resolve an AT identifier (handle or DID) to its authorization server metadata and DID document. 721 + #[cfg(not(target_arch = "wasm32"))] 722 + fn resolve_from_identity( 723 + &self, 724 + input: &str, 725 + ) -> impl Future< 726 + Output = Result<( 727 + OAuthAuthorizationServerMetadata<'static>, 728 + DidDocument<'static>, 729 + )>, 730 + > + Send 731 + where 732 + Self: Sync, 733 + { 734 + resolve_from_identity_impl(self, input) 735 + } 736 + 737 + /// Resolve an AT identifier to its authorization server metadata and DID document. 738 + #[cfg(target_arch = "wasm32")] 739 + fn resolve_from_identity( 740 + &self, 741 + input: &str, 742 + ) -> impl Future< 743 + Output = Result<( 744 + OAuthAuthorizationServerMetadata<'static>, 745 + DidDocument<'static>, 746 + )>, 747 + > { 748 + resolve_from_identity_impl(self, input) 749 + } 750 + 751 + /// Fetch and validate the authorization server metadata for the given issuer URL. 752 + /// 753 + /// Retrieves the `/.well-known/oauth-authorization-server` document and confirms that 754 + /// the `issuer` field in the response matches the requested URL, as required by RFC 8414 §3.3. 755 + #[cfg(not(target_arch = "wasm32"))] 756 + fn get_authorization_server_metadata( 757 + &self, 758 + issuer: &CowStr<'_>, 759 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 760 + where 761 + Self: Sync, 762 + { 763 + get_authorization_server_metadata_impl(self, issuer) 764 + } 765 + 766 + /// Fetch and validate the authorization server metadata for the given issuer URL. 767 + /// 768 + /// Retrieves the `/.well-known/oauth-authorization-server` document and confirms that 769 + /// the `issuer` field in the response matches the requested URL, as required by RFC 8414 §3.3. 770 + #[cfg(target_arch = "wasm32")] 771 + fn get_authorization_server_metadata( 772 + &self, 773 + issuer: &CowStr<'_>, 774 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 775 + get_authorization_server_metadata_impl(self, issuer) 776 + } 777 + 778 + /// Resolve a PDS base URL to its authorization server metadata. 779 + #[cfg(not(target_arch = "wasm32"))] 780 + fn get_resource_server_metadata( 781 + &self, 782 + pds: &CowStr<'_>, 783 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> + Send 784 + where 785 + Self: Sync, 786 + { 787 + get_resource_server_metadata_impl(self, pds) 788 + } 789 + 790 + /// Resolve a PDS base URL to its authorization server metadata. 791 + #[cfg(target_arch = "wasm32")] 792 + fn get_resource_server_metadata( 793 + &self, 794 + pds: &CowStr<'_>, 795 + ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>>> { 796 + get_resource_server_metadata_impl(self, pds) 797 + } 798 + } 799 + 800 + /// Fetch and validate the `/.well-known/oauth-authorization-server` document for `server`. 801 + /// 802 + /// Per RFC 8414 §3.3 the `issuer` field in the response must equal the `server` URL exactly; 803 + /// this prevents a compromised server from claiming to be a different issuer. 804 + pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 805 + client: &T, 806 + server: &CowStr<'_>, 807 + ) -> Result<OAuthAuthorizationServerMetadata<'static>> { 808 + let url = format!( 809 + "{}/.well-known/oauth-authorization-server", 810 + server.trim_end_matches("/") 811 + ); 812 + 813 + let req = Request::builder() 814 + .uri(url) 815 + .body(Vec::new()) 816 + .map_err(|e| ResolverError::transport(e))?; 817 + let res = client 818 + .send_http(req) 819 + .await 820 + .map_err(|e| ResolverError::transport(e))?; 821 + if res.status() == StatusCode::OK { 822 + let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body())?; 823 + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 824 + if metadata.issuer == server.as_str() { 825 + Ok(metadata.into_static()) 826 + } else { 827 + Err(ResolverError::authorization_server_metadata( 828 + smol_str::format_smolstr!("invalid issuer: {}", metadata.issuer), 829 + )) 830 + } 831 + } else { 832 + Err(ResolverError::http_status(res.status())) 833 + } 834 + } 835 + 836 + /// Fetch the `/.well-known/oauth-protected-resource` document for `server`. 837 + /// 838 + /// The `resource` field in the response must equal the requested `server` URL, ensuring 839 + /// that the metadata belongs to the PDS we queried and not a different resource. 840 + pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 841 + client: &T, 842 + server: &CowStr<'_>, 843 + ) -> Result<OAuthProtectedResourceMetadata<'static>> { 844 + let url = format!( 845 + "{}/.well-known/oauth-protected-resource", 846 + server.trim_end_matches("/") 847 + ); 848 + 849 + let req = Request::builder() 850 + .uri(url) 851 + .body(Vec::new()) 852 + .map_err(|e| ResolverError::transport(e))?; 853 + let res = client 854 + .send_http(req) 855 + .await 856 + .map_err(|e| ResolverError::transport(e))?; 857 + if res.status() == StatusCode::OK { 858 + let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body())?; 859 + // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 860 + if metadata.resource == server.as_str() { 861 + Ok(metadata.into_static()) 862 + } else { 863 + Err(ResolverError::authorization_server_metadata( 864 + smol_str::format_smolstr!("invalid resource: {}", metadata.resource), 865 + )) 866 + } 867 + } else { 868 + Err(ResolverError::http_status(res.status())) 869 + } 870 + } 871 + 872 + impl OAuthResolver for jacquard_identity::JacquardResolver {} 873 + 874 + #[cfg(test)] 875 + mod tests { 876 + use core::future::Future; 877 + use std::{convert::Infallible, sync::Arc}; 878 + 879 + use super::*; 880 + use http::{Request as HttpRequest, Response as HttpResponse, StatusCode}; 881 + use jacquard_common::http_client::HttpClient; 882 + use tokio::sync::Mutex; 883 + 884 + #[derive(Default, Clone)] 885 + struct MockHttp { 886 + next: Arc<Mutex<Option<HttpResponse<Vec<u8>>>>>, 887 + } 888 + 889 + impl HttpClient for MockHttp { 890 + type Error = Infallible; 891 + fn send_http( 892 + &self, 893 + _request: HttpRequest<Vec<u8>>, 894 + ) -> impl Future<Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>> + Send 895 + { 896 + let next = self.next.clone(); 897 + async move { Ok(next.lock().await.take().unwrap()) } 898 + } 899 + } 900 + 901 + #[tokio::test] 902 + async fn authorization_server_http_status() { 903 + let client = MockHttp::default(); 904 + *client.next.lock().await = Some( 905 + HttpResponse::builder() 906 + .status(StatusCode::NOT_FOUND) 907 + .body(Vec::new()) 908 + .unwrap(), 909 + ); 910 + let issuer = CowStr::new_static("https://issuer"); 911 + let err = super::resolve_authorization_server(&client, &issuer) 912 + .await 913 + .unwrap_err(); 914 + assert!(matches!( 915 + err.kind(), 916 + ResolverErrorKind::HttpStatus(StatusCode::NOT_FOUND) 917 + )); 918 + } 919 + 920 + #[tokio::test] 921 + async fn authorization_server_bad_json() { 922 + let client = MockHttp::default(); 923 + *client.next.lock().await = Some( 924 + HttpResponse::builder() 925 + .status(StatusCode::OK) 926 + .body(b"{not json}".to_vec()) 927 + .unwrap(), 928 + ); 929 + let issuer = CowStr::new_static("https://issuer"); 930 + let err = super::resolve_authorization_server(&client, &issuer) 931 + .await 932 + .unwrap_err(); 933 + assert!(matches!(err.kind(), ResolverErrorKind::SerdeJson)); 934 + } 935 + 936 + #[test] 937 + fn issuer_plain_string_equality() { 938 + // AC5.1: Matching issuer strings pass comparison 939 + let issuer1 = CowStr::new_static("https://issuer.example.com"); 940 + let issuer2 = CowStr::new_static("https://issuer.example.com"); 941 + assert_eq!(issuer1, issuer2); 942 + 943 + // AC5.2: Semantically equivalent but string-different issuers fail comparison 944 + // fluent-uri preserves exact input, so these should NOT be equal 945 + let issuer_no_slash = CowStr::new_static("https://issuer.example.com"); 946 + let issuer_with_slash = CowStr::new_static("https://issuer.example.com/"); 947 + assert_ne!(issuer_no_slash, issuer_with_slash); 948 + 949 + // AC5.2: Different query/path parameters should also not be equal 950 + let issuer_base = CowStr::new_static("https://issuer.example.com"); 951 + let issuer_with_path = CowStr::new_static("https://issuer.example.com/path"); 952 + assert_ne!(issuer_base, issuer_with_path); 953 + } 954 + }
+2020
src-tauri/vendor/jacquard-oauth/src/scopes.rs
··· 1 + //! AT Protocol OAuth scopes 2 + //! 3 + //! Derived from <https://tangled.org/smokesignal.events/atproto-identity-rs/raw/main/crates/atproto-oauth/src/scopes.rs> 4 + //! 5 + //! This module provides comprehensive support for AT Protocol OAuth scopes, 6 + //! including parsing, serialization, normalization, and permission checking. 7 + //! 8 + //! Scopes in AT Protocol follow a prefix-based format with optional query parameters: 9 + //! - `account`: Access to account information (email, repo, status) 10 + //! - `identity`: Access to identity information (handle) 11 + //! - `blob`: Access to blob operations with mime type constraints 12 + //! - `repo`: Repository operations with collection and action constraints 13 + //! - `rpc`: RPC method access with lexicon and audience constraints 14 + //! - `atproto`: Required scope to indicate that other AT Protocol scopes will be used 15 + //! - `transition`: Migration operations (generic or email) 16 + //! 17 + //! Standard OpenID Connect scopes (no suffixes or query parameters): 18 + //! - `openid`: Required for OpenID Connect authentication 19 + //! - `profile`: Access to user profile information 20 + //! - `email`: Access to user email address 21 + 22 + use std::collections::{BTreeMap, BTreeSet}; 23 + use std::fmt; 24 + use std::str::FromStr; 25 + 26 + use jacquard_common::types::did::Did; 27 + use jacquard_common::types::nsid::Nsid; 28 + use jacquard_common::types::string::AtStrError; 29 + use jacquard_common::{CowStr, IntoStatic}; 30 + use serde::de::Visitor; 31 + use serde::{Deserialize, Serialize}; 32 + use smol_str::{SmolStr, ToSmolStr}; 33 + 34 + /// Represents an AT Protocol OAuth scope 35 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 36 + pub enum Scope<'s> { 37 + /// Account scope for accessing account information 38 + Account(AccountScope), 39 + /// Identity scope for accessing identity information 40 + Identity(IdentityScope), 41 + /// Blob scope for blob operations with mime type constraints 42 + Blob(BlobScope<'s>), 43 + /// Repository scope for collection operations 44 + Repo(RepoScope<'s>), 45 + /// RPC scope for method access 46 + Rpc(RpcScope<'s>), 47 + /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used 48 + Atproto, 49 + /// Transition scope for migration operations 50 + Transition(TransitionScope), 51 + /// OpenID Connect scope - required for OpenID Connect authentication 52 + OpenId, 53 + /// Profile scope - access to user profile information 54 + Profile, 55 + /// Email scope - access to user email address 56 + Email, 57 + } 58 + 59 + impl Serialize for Scope<'_> { 60 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 61 + where 62 + S: serde::Serializer, 63 + { 64 + serializer.serialize_str(&self.to_string_normalized()) 65 + } 66 + } 67 + 68 + impl<'de> Deserialize<'de> for Scope<'_> { 69 + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 70 + where 71 + D: serde::Deserializer<'de>, 72 + { 73 + struct ScopeVisitor; 74 + 75 + impl Visitor<'_> for ScopeVisitor { 76 + type Value = Scope<'static>; 77 + 78 + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 79 + write!(formatter, "a scope string") 80 + } 81 + fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> 82 + where 83 + E: serde::de::Error, 84 + { 85 + Scope::parse(v) 86 + .map(|s| s.into_static()) 87 + .map_err(|e| serde::de::Error::custom(format!("{:?}", e))) 88 + } 89 + } 90 + deserializer.deserialize_str(ScopeVisitor) 91 + } 92 + } 93 + 94 + impl IntoStatic for Scope<'_> { 95 + type Output = Scope<'static>; 96 + 97 + fn into_static(self) -> Self::Output { 98 + match self { 99 + Scope::Account(scope) => Scope::Account(scope), 100 + Scope::Identity(scope) => Scope::Identity(scope), 101 + Scope::Blob(scope) => Scope::Blob(scope.into_static()), 102 + Scope::Repo(scope) => Scope::Repo(scope.into_static()), 103 + Scope::Rpc(scope) => Scope::Rpc(scope.into_static()), 104 + Scope::Atproto => Scope::Atproto, 105 + Scope::Transition(scope) => Scope::Transition(scope), 106 + Scope::OpenId => Scope::OpenId, 107 + Scope::Profile => Scope::Profile, 108 + Scope::Email => Scope::Email, 109 + } 110 + } 111 + } 112 + 113 + /// Account scope attributes 114 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 115 + pub struct AccountScope { 116 + /// The account resource type 117 + pub resource: AccountResource, 118 + /// The action permission level 119 + pub action: AccountAction, 120 + } 121 + 122 + /// Account resource types 123 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 124 + pub enum AccountResource { 125 + /// Email access 126 + Email, 127 + /// Repository access 128 + Repo, 129 + /// Status access 130 + Status, 131 + } 132 + 133 + /// Account action permissions 134 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 135 + pub enum AccountAction { 136 + /// Read-only access 137 + Read, 138 + /// Management access (includes read) 139 + Manage, 140 + } 141 + 142 + /// Identity scope attributes 143 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 144 + pub enum IdentityScope { 145 + /// Handle access 146 + Handle, 147 + /// All identity access (wildcard) 148 + All, 149 + } 150 + 151 + /// Transition scope types 152 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 153 + pub enum TransitionScope { 154 + /// Generic transition operations 155 + Generic, 156 + /// Bluesky chat / DM transition operations 157 + ChatBsky, 158 + /// Email transition operations 159 + Email, 160 + } 161 + 162 + /// Blob scope with mime type constraints 163 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 164 + pub struct BlobScope<'s> { 165 + /// Accepted mime types 166 + pub accept: BTreeSet<MimePattern<'s>>, 167 + } 168 + 169 + impl IntoStatic for BlobScope<'_> { 170 + type Output = BlobScope<'static>; 171 + 172 + fn into_static(self) -> Self::Output { 173 + BlobScope { 174 + accept: self.accept.into_iter().map(|p| p.into_static()).collect(), 175 + } 176 + } 177 + } 178 + 179 + /// MIME type pattern for blob scope 180 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 181 + pub enum MimePattern<'s> { 182 + /// Match all types 183 + All, 184 + /// Match all subtypes of a type (e.g., "image/*") 185 + TypeWildcard(CowStr<'s>), 186 + /// Exact mime type match 187 + Exact(CowStr<'s>), 188 + } 189 + 190 + impl IntoStatic for MimePattern<'_> { 191 + type Output = MimePattern<'static>; 192 + 193 + fn into_static(self) -> Self::Output { 194 + match self { 195 + MimePattern::All => MimePattern::All, 196 + MimePattern::TypeWildcard(s) => MimePattern::TypeWildcard(s.into_static()), 197 + MimePattern::Exact(s) => MimePattern::Exact(s.into_static()), 198 + } 199 + } 200 + } 201 + 202 + /// Repository scope with collection and action constraints 203 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 204 + pub struct RepoScope<'s> { 205 + /// Collection NSID or wildcard 206 + pub collection: RepoCollection<'s>, 207 + /// Allowed actions 208 + pub actions: BTreeSet<RepoAction>, 209 + } 210 + 211 + impl IntoStatic for RepoScope<'_> { 212 + type Output = RepoScope<'static>; 213 + 214 + fn into_static(self) -> Self::Output { 215 + RepoScope { 216 + collection: self.collection.into_static(), 217 + actions: self.actions, 218 + } 219 + } 220 + } 221 + 222 + /// Repository collection identifier 223 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 224 + pub enum RepoCollection<'s> { 225 + /// All collections (wildcard) 226 + All, 227 + /// Specific collection NSID 228 + Nsid(Nsid<'s>), 229 + } 230 + 231 + impl IntoStatic for RepoCollection<'_> { 232 + type Output = RepoCollection<'static>; 233 + 234 + fn into_static(self) -> Self::Output { 235 + match self { 236 + RepoCollection::All => RepoCollection::All, 237 + RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()), 238 + } 239 + } 240 + } 241 + 242 + /// Repository actions 243 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 244 + pub enum RepoAction { 245 + /// Create records 246 + Create, 247 + /// Update records 248 + Update, 249 + /// Delete records 250 + Delete, 251 + } 252 + 253 + /// RPC scope with lexicon method and audience constraints 254 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 255 + pub struct RpcScope<'s> { 256 + /// Lexicon methods (NSIDs or wildcard) 257 + pub lxm: BTreeSet<RpcLexicon<'s>>, 258 + /// Audiences (DIDs or wildcard) 259 + pub aud: BTreeSet<RpcAudience<'s>>, 260 + } 261 + 262 + impl IntoStatic for RpcScope<'_> { 263 + type Output = RpcScope<'static>; 264 + 265 + fn into_static(self) -> Self::Output { 266 + RpcScope { 267 + lxm: self.lxm.into_iter().map(|s| s.into_static()).collect(), 268 + aud: self.aud.into_iter().map(|s| s.into_static()).collect(), 269 + } 270 + } 271 + } 272 + 273 + /// RPC lexicon identifier 274 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 275 + pub enum RpcLexicon<'s> { 276 + /// All lexicons (wildcard) 277 + All, 278 + /// Specific lexicon NSID 279 + Nsid(Nsid<'s>), 280 + } 281 + 282 + impl IntoStatic for RpcLexicon<'_> { 283 + type Output = RpcLexicon<'static>; 284 + 285 + fn into_static(self) -> Self::Output { 286 + match self { 287 + RpcLexicon::All => RpcLexicon::All, 288 + RpcLexicon::Nsid(nsid) => RpcLexicon::Nsid(nsid.into_static()), 289 + } 290 + } 291 + } 292 + 293 + /// RPC audience identifier 294 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 295 + pub enum RpcAudience<'s> { 296 + /// All audiences (wildcard) 297 + All, 298 + /// Specific DID 299 + Did(Did<'s>), 300 + } 301 + 302 + impl IntoStatic for RpcAudience<'_> { 303 + type Output = RpcAudience<'static>; 304 + 305 + fn into_static(self) -> Self::Output { 306 + match self { 307 + RpcAudience::All => RpcAudience::All, 308 + RpcAudience::Did(did) => RpcAudience::Did(did.into_static()), 309 + } 310 + } 311 + } 312 + 313 + impl<'s> Scope<'s> { 314 + /// Parse multiple space-separated scopes from a string 315 + /// 316 + /// # Examples 317 + /// ``` 318 + /// # use jacquard_oauth::scopes::Scope; 319 + /// let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 320 + /// assert_eq!(scopes.len(), 2); 321 + /// ``` 322 + pub fn parse_multiple(s: &'s str) -> Result<Vec<Self>, ParseError> { 323 + if s.trim().is_empty() { 324 + return Ok(Vec::new()); 325 + } 326 + 327 + let mut scopes = Vec::new(); 328 + for scope_str in s.split_whitespace() { 329 + scopes.push(Self::parse(scope_str)?); 330 + } 331 + 332 + Ok(scopes) 333 + } 334 + 335 + /// Parse multiple space-separated scopes and return the minimal set needed 336 + /// 337 + /// This method removes duplicate scopes and scopes that are already granted 338 + /// by other scopes in the list, returning only the minimal set of scopes needed. 339 + /// 340 + /// # Examples 341 + /// ``` 342 + /// # use jacquard_oauth::scopes::Scope; 343 + /// // repo:* grants repo:foo.bar, so only repo:* is kept 344 + /// let scopes = Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 345 + /// assert_eq!(scopes.len(), 2); // atproto and repo:* 346 + /// ``` 347 + pub fn parse_multiple_reduced(s: &'s str) -> Result<Vec<Self>, ParseError> { 348 + let all_scopes = Self::parse_multiple(s)?; 349 + 350 + if all_scopes.is_empty() { 351 + return Ok(Vec::new()); 352 + } 353 + 354 + let mut result: Vec<Self> = Vec::new(); 355 + 356 + for scope in all_scopes { 357 + // Check if this scope is already granted by something in the result 358 + let mut is_granted = false; 359 + for existing in &result { 360 + if existing.grants(&scope) && existing != &scope { 361 + is_granted = true; 362 + break; 363 + } 364 + } 365 + 366 + if is_granted { 367 + continue; // Skip this scope, it's already covered 368 + } 369 + 370 + // Check if this scope grants any existing scopes in the result 371 + let mut indices_to_remove = Vec::new(); 372 + for (i, existing) in result.iter().enumerate() { 373 + if scope.grants(existing) && &scope != existing { 374 + indices_to_remove.push(i); 375 + } 376 + } 377 + 378 + // Remove scopes that are granted by the new scope (in reverse order to maintain indices) 379 + for i in indices_to_remove.into_iter().rev() { 380 + result.remove(i); 381 + } 382 + 383 + // Add the new scope if it's not a duplicate 384 + if !result.contains(&scope) { 385 + result.push(scope); 386 + } 387 + } 388 + 389 + Ok(result) 390 + } 391 + 392 + /// Serialize a list of scopes into a space-separated OAuth scopes string 393 + /// 394 + /// The scopes are sorted alphabetically by their string representation to ensure 395 + /// consistent output regardless of input order. 396 + /// 397 + /// # Examples 398 + /// ``` 399 + /// # use jacquard_oauth::scopes::Scope; 400 + /// let scopes = vec![ 401 + /// Scope::parse("repo:*").unwrap(), 402 + /// Scope::parse("atproto").unwrap(), 403 + /// Scope::parse("account:email").unwrap(), 404 + /// ]; 405 + /// let result = Scope::serialize_multiple(&scopes); 406 + /// assert_eq!(result, "account:email atproto repo:*"); 407 + /// ``` 408 + pub fn serialize_multiple(scopes: &[Self]) -> CowStr<'static> { 409 + if scopes.is_empty() { 410 + return CowStr::default(); 411 + } 412 + 413 + let mut serialized: Vec<String> = scopes 414 + .iter() 415 + .map(|scope| scope.to_string_normalized()) 416 + .collect(); 417 + 418 + serialized.sort(); 419 + serialized.join(" ").into() 420 + } 421 + 422 + /// Remove a scope from a list of scopes 423 + /// 424 + /// Returns a new vector with all instances of the specified scope removed. 425 + /// If the scope doesn't exist in the list, returns a copy of the original list. 426 + /// 427 + /// # Examples 428 + /// ``` 429 + /// # use jacquard_oauth::scopes::Scope; 430 + /// let scopes = vec![ 431 + /// Scope::parse("repo:*").unwrap(), 432 + /// Scope::parse("atproto").unwrap(), 433 + /// Scope::parse("account:email").unwrap(), 434 + /// ]; 435 + /// let to_remove = Scope::parse("atproto").unwrap(); 436 + /// let result = Scope::remove_scope(&scopes, &to_remove); 437 + /// assert_eq!(result.len(), 2); 438 + /// assert!(!result.contains(&to_remove)); 439 + /// ``` 440 + pub fn remove_scope(scopes: &[Self], scope_to_remove: &Self) -> Vec<Self> { 441 + scopes 442 + .iter() 443 + .filter(|s| *s != scope_to_remove) 444 + .cloned() 445 + .collect() 446 + } 447 + 448 + /// Parse a scope from a string 449 + pub fn parse(s: &'s str) -> Result<Self, ParseError> { 450 + // Determine the prefix first by checking for known prefixes 451 + let prefixes = [ 452 + "account", 453 + "identity", 454 + "blob", 455 + "repo", 456 + "rpc", 457 + "atproto", 458 + "transition", 459 + "openid", 460 + "profile", 461 + "email", 462 + ]; 463 + let mut found_prefix = None; 464 + let mut suffix = None; 465 + 466 + for prefix in &prefixes { 467 + if let Some(remainder) = s.strip_prefix(prefix) 468 + && (remainder.is_empty() 469 + || remainder.starts_with(':') 470 + || remainder.starts_with('?')) 471 + { 472 + found_prefix = Some(*prefix); 473 + if let Some(stripped) = remainder.strip_prefix(':') { 474 + suffix = Some(stripped); 475 + } else if remainder.starts_with('?') { 476 + suffix = Some(remainder); 477 + } else { 478 + suffix = None; 479 + } 480 + break; 481 + } 482 + } 483 + 484 + let prefix = found_prefix.ok_or_else(|| { 485 + // If no known prefix found, extract what looks like a prefix for error reporting 486 + let end = s.find(':').or_else(|| s.find('?')).unwrap_or(s.len()); 487 + ParseError::UnknownPrefix(s[..end].to_string()) 488 + })?; 489 + 490 + match prefix { 491 + "account" => Self::parse_account(suffix), 492 + "identity" => Self::parse_identity(suffix), 493 + "blob" => Self::parse_blob(suffix), 494 + "repo" => Self::parse_repo(suffix), 495 + "rpc" => Self::parse_rpc(suffix), 496 + "atproto" => Self::parse_atproto(suffix), 497 + "transition" => Self::parse_transition(suffix), 498 + "openid" => Self::parse_openid(suffix), 499 + "profile" => Self::parse_profile(suffix), 500 + "email" => Self::parse_email(suffix), 501 + _ => Err(ParseError::UnknownPrefix(prefix.to_string())), 502 + } 503 + } 504 + 505 + fn parse_account(suffix: Option<&'s str>) -> Result<Self, ParseError> { 506 + let (resource_str, params) = match suffix { 507 + Some(s) => { 508 + if let Some(pos) = s.find('?') { 509 + (&s[..pos], Some(&s[pos + 1..])) 510 + } else { 511 + (s, None) 512 + } 513 + } 514 + None => return Err(ParseError::MissingResource), 515 + }; 516 + 517 + let resource = match resource_str { 518 + "email" => AccountResource::Email, 519 + "repo" => AccountResource::Repo, 520 + "status" => AccountResource::Status, 521 + _ => return Err(ParseError::InvalidResource(resource_str.to_string())), 522 + }; 523 + 524 + let action = if let Some(params) = params { 525 + let parsed_params = parse_query_string(params); 526 + match parsed_params 527 + .get("action") 528 + .and_then(|v| v.first()) 529 + .map(|s| s.as_ref()) 530 + { 531 + Some("read") => AccountAction::Read, 532 + Some("manage") => AccountAction::Manage, 533 + Some(other) => return Err(ParseError::InvalidAction(other.to_string())), 534 + None => AccountAction::Read, 535 + } 536 + } else { 537 + AccountAction::Read 538 + }; 539 + 540 + Ok(Scope::Account(AccountScope { resource, action })) 541 + } 542 + 543 + fn parse_identity(suffix: Option<&'s str>) -> Result<Self, ParseError> { 544 + let scope = match suffix { 545 + Some("handle") => IdentityScope::Handle, 546 + Some("*") => IdentityScope::All, 547 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 548 + None => return Err(ParseError::MissingResource), 549 + }; 550 + 551 + Ok(Scope::Identity(scope)) 552 + } 553 + 554 + fn parse_blob(suffix: Option<&'s str>) -> Result<Self, ParseError> { 555 + let mut accept = BTreeSet::new(); 556 + 557 + match suffix { 558 + Some(s) if s.starts_with('?') => { 559 + let params = parse_query_string(&s[1..]); 560 + if let Some(values) = params.get("accept") { 561 + for value in values { 562 + accept.insert(MimePattern::from_str(value)?); 563 + } 564 + } 565 + } 566 + Some(s) => { 567 + accept.insert(MimePattern::from_str(s)?); 568 + } 569 + None => { 570 + accept.insert(MimePattern::All); 571 + } 572 + } 573 + 574 + if accept.is_empty() { 575 + accept.insert(MimePattern::All); 576 + } 577 + 578 + Ok(Scope::Blob(BlobScope { accept })) 579 + } 580 + 581 + fn parse_repo(suffix: Option<&'s str>) -> Result<Self, ParseError> { 582 + let (collection_str, params) = match suffix { 583 + Some(s) => { 584 + if let Some(pos) = s.find('?') { 585 + (Some(&s[..pos]), Some(&s[pos + 1..])) 586 + } else { 587 + (Some(s), None) 588 + } 589 + } 590 + None => (None, None), 591 + }; 592 + 593 + let collection = match collection_str { 594 + Some("*") | None => RepoCollection::All, 595 + Some(nsid) => RepoCollection::Nsid(Nsid::new(nsid)?), 596 + }; 597 + 598 + let mut actions = BTreeSet::new(); 599 + if let Some(params) = params { 600 + let parsed_params = parse_query_string(params); 601 + if let Some(values) = parsed_params.get("action") { 602 + for value in values { 603 + match value.as_ref() { 604 + "create" => { 605 + actions.insert(RepoAction::Create); 606 + } 607 + "update" => { 608 + actions.insert(RepoAction::Update); 609 + } 610 + "delete" => { 611 + actions.insert(RepoAction::Delete); 612 + } 613 + "*" => { 614 + actions.insert(RepoAction::Create); 615 + actions.insert(RepoAction::Update); 616 + actions.insert(RepoAction::Delete); 617 + } 618 + other => return Err(ParseError::InvalidAction(other.to_string())), 619 + } 620 + } 621 + } 622 + } 623 + 624 + if actions.is_empty() { 625 + actions.insert(RepoAction::Create); 626 + actions.insert(RepoAction::Update); 627 + actions.insert(RepoAction::Delete); 628 + } 629 + 630 + Ok(Scope::Repo(RepoScope { 631 + collection, 632 + actions, 633 + })) 634 + } 635 + 636 + fn parse_rpc(suffix: Option<&'s str>) -> Result<Self, ParseError> { 637 + let mut lxm = BTreeSet::new(); 638 + let mut aud = BTreeSet::new(); 639 + 640 + match suffix { 641 + Some("*") => { 642 + lxm.insert(RpcLexicon::All); 643 + aud.insert(RpcAudience::All); 644 + } 645 + Some(s) if s.starts_with('?') => { 646 + let params = parse_query_string(&s[1..]); 647 + 648 + if let Some(values) = params.get("lxm") { 649 + for value in values { 650 + if value.as_ref() == "*" { 651 + lxm.insert(RpcLexicon::All); 652 + } else { 653 + lxm.insert(RpcLexicon::Nsid(Nsid::new(value)?.into_static())); 654 + } 655 + } 656 + } 657 + 658 + if let Some(values) = params.get("aud") { 659 + for value in values { 660 + if value.as_ref() == "*" { 661 + aud.insert(RpcAudience::All); 662 + } else { 663 + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); 664 + } 665 + } 666 + } 667 + } 668 + Some(s) => { 669 + // Check if there's a query string in the suffix 670 + if let Some(pos) = s.find('?') { 671 + let nsid = &s[..pos]; 672 + let params = parse_query_string(&s[pos + 1..]); 673 + 674 + lxm.insert(RpcLexicon::Nsid(Nsid::new(nsid)?.into_static())); 675 + 676 + if let Some(values) = params.get("aud") { 677 + for value in values { 678 + if value.as_ref() == "*" { 679 + aud.insert(RpcAudience::All); 680 + } else { 681 + aud.insert(RpcAudience::Did(Did::new(value)?.into_static())); 682 + } 683 + } 684 + } 685 + } else { 686 + lxm.insert(RpcLexicon::Nsid(Nsid::new(s)?.into_static())); 687 + } 688 + } 689 + None => {} 690 + } 691 + 692 + if lxm.is_empty() { 693 + lxm.insert(RpcLexicon::All); 694 + } 695 + if aud.is_empty() { 696 + aud.insert(RpcAudience::All); 697 + } 698 + 699 + Ok(Scope::Rpc(RpcScope { lxm, aud })) 700 + } 701 + 702 + fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> { 703 + if suffix.is_some() { 704 + return Err(ParseError::InvalidResource( 705 + "atproto scope does not accept suffixes".to_string(), 706 + )); 707 + } 708 + Ok(Scope::Atproto) 709 + } 710 + 711 + fn parse_transition(suffix: Option<&str>) -> Result<Self, ParseError> { 712 + let scope = match suffix { 713 + Some("generic") => TransitionScope::Generic, 714 + Some("chat.bsky") => TransitionScope::ChatBsky, 715 + Some("email") => TransitionScope::Email, 716 + Some(other) => return Err(ParseError::InvalidResource(other.to_string())), 717 + None => return Err(ParseError::MissingResource), 718 + }; 719 + 720 + Ok(Scope::Transition(scope)) 721 + } 722 + 723 + fn parse_openid(suffix: Option<&str>) -> Result<Self, ParseError> { 724 + if suffix.is_some() { 725 + return Err(ParseError::InvalidResource( 726 + "openid scope does not accept suffixes".to_string(), 727 + )); 728 + } 729 + Ok(Scope::OpenId) 730 + } 731 + 732 + fn parse_profile(suffix: Option<&str>) -> Result<Self, ParseError> { 733 + if suffix.is_some() { 734 + return Err(ParseError::InvalidResource( 735 + "profile scope does not accept suffixes".to_string(), 736 + )); 737 + } 738 + Ok(Scope::Profile) 739 + } 740 + 741 + fn parse_email(suffix: Option<&str>) -> Result<Self, ParseError> { 742 + if suffix.is_some() { 743 + return Err(ParseError::InvalidResource( 744 + "email scope does not accept suffixes".to_string(), 745 + )); 746 + } 747 + Ok(Scope::Email) 748 + } 749 + 750 + /// Convert the scope to its normalized string representation 751 + pub fn to_string_normalized(&self) -> String { 752 + match self { 753 + Scope::Account(scope) => { 754 + let resource = match scope.resource { 755 + AccountResource::Email => "email", 756 + AccountResource::Repo => "repo", 757 + AccountResource::Status => "status", 758 + }; 759 + 760 + match scope.action { 761 + AccountAction::Read => format!("account:{}", resource), 762 + AccountAction::Manage => format!("account:{}?action=manage", resource), 763 + } 764 + } 765 + Scope::Identity(scope) => match scope { 766 + IdentityScope::Handle => "identity:handle".to_string(), 767 + IdentityScope::All => "identity:*".to_string(), 768 + }, 769 + Scope::Blob(scope) => { 770 + if scope.accept.len() == 1 { 771 + if let Some(pattern) = scope.accept.iter().next() { 772 + match pattern { 773 + MimePattern::All => "blob:*/*".to_string(), 774 + MimePattern::TypeWildcard(t) => format!("blob:{}/*", t), 775 + MimePattern::Exact(mime) => format!("blob:{}", mime), 776 + } 777 + } else { 778 + "blob:*/*".to_string() 779 + } 780 + } else { 781 + let mut params = Vec::new(); 782 + for pattern in &scope.accept { 783 + match pattern { 784 + MimePattern::All => params.push("accept=*/*".to_string()), 785 + MimePattern::TypeWildcard(t) => params.push(format!("accept={}/*", t)), 786 + MimePattern::Exact(mime) => params.push(format!("accept={}", mime)), 787 + } 788 + } 789 + params.sort(); 790 + format!("blob?{}", params.join("&")) 791 + } 792 + } 793 + Scope::Repo(scope) => { 794 + let collection = match &scope.collection { 795 + RepoCollection::All => "*", 796 + RepoCollection::Nsid(nsid) => nsid, 797 + }; 798 + 799 + if scope.actions.len() == 3 { 800 + format!("repo:{}", collection) 801 + } else { 802 + let mut params = Vec::new(); 803 + for action in &scope.actions { 804 + match action { 805 + RepoAction::Create => params.push("action=create"), 806 + RepoAction::Update => params.push("action=update"), 807 + RepoAction::Delete => params.push("action=delete"), 808 + } 809 + } 810 + format!("repo:{}?{}", collection, params.join("&")) 811 + } 812 + } 813 + Scope::Rpc(scope) => { 814 + if scope.lxm.len() == 1 815 + && scope.lxm.contains(&RpcLexicon::All) 816 + && scope.aud.len() == 1 817 + && scope.aud.contains(&RpcAudience::All) 818 + { 819 + "rpc:*".to_string() 820 + } else if scope.lxm.len() == 1 821 + && scope.aud.len() == 1 822 + && scope.aud.contains(&RpcAudience::All) 823 + { 824 + if let Some(lxm) = scope.lxm.iter().next() { 825 + match lxm { 826 + RpcLexicon::All => "rpc:*".to_string(), 827 + RpcLexicon::Nsid(nsid) => format!("rpc:{}", nsid), 828 + } 829 + } else { 830 + "rpc:*".to_string() 831 + } 832 + } else { 833 + let mut params = Vec::new(); 834 + 835 + for lxm in &scope.lxm { 836 + match lxm { 837 + RpcLexicon::All => params.push("lxm=*".to_string()), 838 + RpcLexicon::Nsid(nsid) => params.push(format!("lxm={}", nsid)), 839 + } 840 + } 841 + 842 + for aud in &scope.aud { 843 + match aud { 844 + RpcAudience::All => params.push("aud=*".to_string()), 845 + RpcAudience::Did(did) => params.push(format!("aud={}", did)), 846 + } 847 + } 848 + 849 + params.sort(); 850 + 851 + if params.is_empty() { 852 + "rpc:*".to_string() 853 + } else { 854 + format!("rpc?{}", params.join("&")) 855 + } 856 + } 857 + } 858 + Scope::Atproto => "atproto".to_string(), 859 + Scope::Transition(scope) => match scope { 860 + TransitionScope::Generic => "transition:generic".to_string(), 861 + TransitionScope::ChatBsky => "transition:chat.bsky".to_string(), 862 + TransitionScope::Email => "transition:email".to_string(), 863 + }, 864 + Scope::OpenId => "openid".to_string(), 865 + Scope::Profile => "profile".to_string(), 866 + Scope::Email => "email".to_string(), 867 + } 868 + } 869 + 870 + /// Check if this scope grants the permissions of another scope 871 + pub fn grants(&self, other: &Scope) -> bool { 872 + match (self, other) { 873 + // Atproto only grants itself (it's a required scope, not a permission grant) 874 + (Scope::Atproto, Scope::Atproto) => true, 875 + (Scope::Atproto, _) => false, 876 + // Nothing else grants atproto 877 + (_, Scope::Atproto) => false, 878 + // Transition scopes only grant themselves 879 + (Scope::Transition(a), Scope::Transition(b)) => a == b, 880 + // Other scopes don't grant transition scopes 881 + (_, Scope::Transition(_)) => false, 882 + (Scope::Transition(_), _) => false, 883 + // OpenID Connect scopes only grant themselves 884 + (Scope::OpenId, Scope::OpenId) => true, 885 + (Scope::OpenId, _) => false, 886 + (_, Scope::OpenId) => false, 887 + (Scope::Profile, Scope::Profile) => true, 888 + (Scope::Profile, _) => false, 889 + (_, Scope::Profile) => false, 890 + (Scope::Email, Scope::Email) => true, 891 + (Scope::Email, _) => false, 892 + (_, Scope::Email) => false, 893 + (Scope::Account(a), Scope::Account(b)) => { 894 + a.resource == b.resource 895 + && matches!( 896 + (a.action, b.action), 897 + (AccountAction::Manage, _) | (AccountAction::Read, AccountAction::Read) 898 + ) 899 + } 900 + (Scope::Identity(a), Scope::Identity(b)) => matches!( 901 + (a, b), 902 + (IdentityScope::All, _) | (IdentityScope::Handle, IdentityScope::Handle) 903 + ), 904 + (Scope::Blob(a), Scope::Blob(b)) => { 905 + for b_pattern in &b.accept { 906 + let mut granted = false; 907 + for a_pattern in &a.accept { 908 + if a_pattern.grants(b_pattern) { 909 + granted = true; 910 + break; 911 + } 912 + } 913 + if !granted { 914 + return false; 915 + } 916 + } 917 + true 918 + } 919 + (Scope::Repo(a), Scope::Repo(b)) => { 920 + let collection_match = match (&a.collection, &b.collection) { 921 + (RepoCollection::All, _) => true, 922 + (RepoCollection::Nsid(a_nsid), RepoCollection::Nsid(b_nsid)) => { 923 + a_nsid == b_nsid 924 + } 925 + _ => false, 926 + }; 927 + 928 + if !collection_match { 929 + return false; 930 + } 931 + 932 + b.actions.is_subset(&a.actions) || a.actions.len() == 3 933 + } 934 + (Scope::Rpc(a), Scope::Rpc(b)) => { 935 + let lxm_match = if a.lxm.contains(&RpcLexicon::All) { 936 + true 937 + } else { 938 + b.lxm.iter().all(|b_lxm| match b_lxm { 939 + RpcLexicon::All => false, 940 + RpcLexicon::Nsid(_) => a.lxm.contains(b_lxm), 941 + }) 942 + }; 943 + 944 + let aud_match = if a.aud.contains(&RpcAudience::All) { 945 + true 946 + } else { 947 + b.aud.iter().all(|b_aud| match b_aud { 948 + RpcAudience::All => false, 949 + RpcAudience::Did(_) => a.aud.contains(b_aud), 950 + }) 951 + }; 952 + 953 + lxm_match && aud_match 954 + } 955 + _ => false, 956 + } 957 + } 958 + } 959 + 960 + impl MimePattern<'_> { 961 + fn grants(&self, other: &MimePattern) -> bool { 962 + match (self, other) { 963 + (MimePattern::All, _) => true, 964 + (MimePattern::TypeWildcard(a_type), MimePattern::TypeWildcard(b_type)) => { 965 + a_type == b_type 966 + } 967 + (MimePattern::TypeWildcard(a_type), MimePattern::Exact(b_mime)) => { 968 + b_mime.starts_with(&format!("{}/", a_type)) 969 + } 970 + (MimePattern::Exact(a), MimePattern::Exact(b)) => a == b, 971 + _ => false, 972 + } 973 + } 974 + } 975 + 976 + impl FromStr for MimePattern<'_> { 977 + type Err = ParseError; 978 + 979 + fn from_str(s: &str) -> Result<Self, Self::Err> { 980 + if s == "*/*" { 981 + Ok(MimePattern::All) 982 + } else if let Some(stripped) = s.strip_suffix("/*") { 983 + Ok(MimePattern::TypeWildcard(CowStr::Owned( 984 + stripped.to_smolstr(), 985 + ))) 986 + } else if s.contains('/') { 987 + Ok(MimePattern::Exact(CowStr::Owned(s.to_smolstr()))) 988 + } else { 989 + Err(ParseError::InvalidMimeType(s.to_string())) 990 + } 991 + } 992 + } 993 + 994 + impl FromStr for Scope<'_> { 995 + type Err = ParseError; 996 + 997 + fn from_str(s: &str) -> Result<Scope<'static>, Self::Err> { 998 + match Scope::parse(s) { 999 + Ok(parsed) => Ok(parsed.into_static()), 1000 + Err(e) => Err(e), 1001 + } 1002 + } 1003 + } 1004 + 1005 + impl fmt::Display for Scope<'_> { 1006 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 1007 + write!(f, "{}", self.to_string_normalized()) 1008 + } 1009 + } 1010 + 1011 + /// Parse a query string into a map of keys to lists of values 1012 + fn parse_query_string(query: &str) -> BTreeMap<SmolStr, Vec<CowStr<'static>>> { 1013 + let mut params = BTreeMap::new(); 1014 + 1015 + for pair in query.split('&') { 1016 + if let Some(pos) = pair.find('=') { 1017 + let key = &pair[..pos]; 1018 + let value = &pair[pos + 1..]; 1019 + params 1020 + .entry(key.to_smolstr()) 1021 + .or_insert_with(Vec::new) 1022 + .push(CowStr::Owned(value.to_smolstr())); 1023 + } 1024 + } 1025 + 1026 + params 1027 + } 1028 + 1029 + /// Error type for scope parsing 1030 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, miette::Diagnostic)] 1031 + #[non_exhaustive] 1032 + pub enum ParseError { 1033 + /// Unknown scope prefix 1034 + UnknownPrefix(String), 1035 + /// Missing required resource 1036 + MissingResource, 1037 + /// Invalid resource type 1038 + InvalidResource(String), 1039 + /// Invalid action type 1040 + InvalidAction(String), 1041 + /// Invalid MIME type 1042 + InvalidMimeType(String), 1043 + /// An AT Protocol string type (DID, NSID, etc.) failed validation during scope parsing. 1044 + ParseError(#[from] AtStrError), 1045 + } 1046 + 1047 + impl fmt::Display for ParseError { 1048 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 1049 + match self { 1050 + ParseError::UnknownPrefix(prefix) => write!(f, "Unknown scope prefix: {}", prefix), 1051 + ParseError::MissingResource => write!(f, "Missing required resource"), 1052 + ParseError::InvalidResource(resource) => write!(f, "Invalid resource: {}", resource), 1053 + ParseError::InvalidAction(action) => write!(f, "Invalid action: {}", action), 1054 + ParseError::InvalidMimeType(mime) => write!(f, "Invalid MIME type: {}", mime), 1055 + ParseError::ParseError(err) => write!(f, "Parse error: {}", err), 1056 + } 1057 + } 1058 + } 1059 + 1060 + #[cfg(test)] 1061 + mod tests { 1062 + use super::*; 1063 + 1064 + #[test] 1065 + fn test_account_scope_parsing() { 1066 + let scope = Scope::parse("account:email").unwrap(); 1067 + assert_eq!( 1068 + scope, 1069 + Scope::Account(AccountScope { 1070 + resource: AccountResource::Email, 1071 + action: AccountAction::Read, 1072 + }) 1073 + ); 1074 + 1075 + let scope = Scope::parse("account:repo?action=manage").unwrap(); 1076 + assert_eq!( 1077 + scope, 1078 + Scope::Account(AccountScope { 1079 + resource: AccountResource::Repo, 1080 + action: AccountAction::Manage, 1081 + }) 1082 + ); 1083 + 1084 + let scope = Scope::parse("account:status?action=read").unwrap(); 1085 + assert_eq!( 1086 + scope, 1087 + Scope::Account(AccountScope { 1088 + resource: AccountResource::Status, 1089 + action: AccountAction::Read, 1090 + }) 1091 + ); 1092 + } 1093 + 1094 + #[test] 1095 + fn test_identity_scope_parsing() { 1096 + let scope = Scope::parse("identity:handle").unwrap(); 1097 + assert_eq!(scope, Scope::Identity(IdentityScope::Handle)); 1098 + 1099 + let scope = Scope::parse("identity:*").unwrap(); 1100 + assert_eq!(scope, Scope::Identity(IdentityScope::All)); 1101 + } 1102 + 1103 + #[test] 1104 + fn test_blob_scope_parsing() { 1105 + let scope = Scope::parse("blob:*/*").unwrap(); 1106 + let mut accept = BTreeSet::new(); 1107 + accept.insert(MimePattern::All); 1108 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1109 + 1110 + let scope = Scope::parse("blob:image/png").unwrap(); 1111 + let mut accept = BTreeSet::new(); 1112 + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); 1113 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1114 + 1115 + let scope = Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(); 1116 + let mut accept = BTreeSet::new(); 1117 + accept.insert(MimePattern::Exact(CowStr::new_static("image/png"))); 1118 + accept.insert(MimePattern::Exact(CowStr::new_static("image/jpeg"))); 1119 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1120 + 1121 + let scope = Scope::parse("blob:image/*").unwrap(); 1122 + let mut accept = BTreeSet::new(); 1123 + accept.insert(MimePattern::TypeWildcard(CowStr::new_static("image"))); 1124 + assert_eq!(scope, Scope::Blob(BlobScope { accept })); 1125 + } 1126 + 1127 + #[test] 1128 + fn test_repo_scope_parsing() { 1129 + let scope = Scope::parse("repo:*?action=create").unwrap(); 1130 + let mut actions = BTreeSet::new(); 1131 + actions.insert(RepoAction::Create); 1132 + assert_eq!( 1133 + scope, 1134 + Scope::Repo(RepoScope { 1135 + collection: RepoCollection::All, 1136 + actions, 1137 + }) 1138 + ); 1139 + 1140 + let scope = Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(); 1141 + let mut actions = BTreeSet::new(); 1142 + actions.insert(RepoAction::Create); 1143 + actions.insert(RepoAction::Update); 1144 + assert_eq!( 1145 + scope, 1146 + Scope::Repo(RepoScope { 1147 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1148 + actions, 1149 + }) 1150 + ); 1151 + 1152 + let scope = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1153 + let mut actions = BTreeSet::new(); 1154 + actions.insert(RepoAction::Create); 1155 + actions.insert(RepoAction::Update); 1156 + actions.insert(RepoAction::Delete); 1157 + assert_eq!( 1158 + scope, 1159 + Scope::Repo(RepoScope { 1160 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1161 + actions, 1162 + }) 1163 + ); 1164 + } 1165 + 1166 + #[test] 1167 + fn test_rpc_scope_parsing() { 1168 + let scope = Scope::parse("rpc:*").unwrap(); 1169 + let mut lxm = BTreeSet::new(); 1170 + let mut aud = BTreeSet::new(); 1171 + lxm.insert(RpcLexicon::All); 1172 + aud.insert(RpcAudience::All); 1173 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1174 + 1175 + let scope = Scope::parse("rpc:com.example.service").unwrap(); 1176 + let mut lxm = BTreeSet::new(); 1177 + let mut aud = BTreeSet::new(); 1178 + lxm.insert(RpcLexicon::Nsid( 1179 + Nsid::new_static("com.example.service").unwrap(), 1180 + )); 1181 + aud.insert(RpcAudience::All); 1182 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1183 + 1184 + let scope = 1185 + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(); 1186 + let mut lxm = BTreeSet::new(); 1187 + let mut aud = BTreeSet::new(); 1188 + lxm.insert(RpcLexicon::Nsid( 1189 + Nsid::new_static("com.example.service").unwrap(), 1190 + )); 1191 + aud.insert(RpcAudience::Did( 1192 + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 1193 + )); 1194 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1195 + 1196 + let scope = 1197 + Scope::parse("rpc?lxm=com.example.method1&lxm=com.example.method2&aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g") 1198 + .unwrap(); 1199 + let mut lxm = BTreeSet::new(); 1200 + let mut aud = BTreeSet::new(); 1201 + lxm.insert(RpcLexicon::Nsid( 1202 + Nsid::new_static("com.example.method1").unwrap(), 1203 + )); 1204 + lxm.insert(RpcLexicon::Nsid( 1205 + Nsid::new_static("com.example.method2").unwrap(), 1206 + )); 1207 + aud.insert(RpcAudience::Did( 1208 + Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap(), 1209 + )); 1210 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1211 + } 1212 + 1213 + #[test] 1214 + fn test_scope_normalization() { 1215 + let tests = vec![ 1216 + ("account:email", "account:email"), 1217 + ("account:email?action=read", "account:email"), 1218 + ("account:email?action=manage", "account:email?action=manage"), 1219 + ("blob:image/png", "blob:image/png"), 1220 + ( 1221 + "blob?accept=image/jpeg&accept=image/png", 1222 + "blob?accept=image/jpeg&accept=image/png", 1223 + ), 1224 + ("repo:app.bsky.feed.post", "repo:app.bsky.feed.post"), 1225 + ( 1226 + "repo:app.bsky.feed.post?action=create", 1227 + "repo:app.bsky.feed.post?action=create", 1228 + ), 1229 + ("rpc:*", "rpc:*"), 1230 + ]; 1231 + 1232 + for (input, expected) in tests { 1233 + let scope = Scope::parse(input).unwrap(); 1234 + assert_eq!(scope.to_string_normalized(), expected); 1235 + } 1236 + } 1237 + 1238 + #[test] 1239 + fn test_account_scope_grants() { 1240 + let manage = Scope::parse("account:email?action=manage").unwrap(); 1241 + let read = Scope::parse("account:email?action=read").unwrap(); 1242 + let other_read = Scope::parse("account:repo?action=read").unwrap(); 1243 + 1244 + assert!(manage.grants(&read)); 1245 + assert!(manage.grants(&manage)); 1246 + assert!(!read.grants(&manage)); 1247 + assert!(read.grants(&read)); 1248 + assert!(!read.grants(&other_read)); 1249 + } 1250 + 1251 + #[test] 1252 + fn test_identity_scope_grants() { 1253 + let all = Scope::parse("identity:*").unwrap(); 1254 + let handle = Scope::parse("identity:handle").unwrap(); 1255 + 1256 + assert!(all.grants(&handle)); 1257 + assert!(all.grants(&all)); 1258 + assert!(!handle.grants(&all)); 1259 + assert!(handle.grants(&handle)); 1260 + } 1261 + 1262 + #[test] 1263 + fn test_blob_scope_grants() { 1264 + let all = Scope::parse("blob:*/*").unwrap(); 1265 + let image_all = Scope::parse("blob:image/*").unwrap(); 1266 + let image_png = Scope::parse("blob:image/png").unwrap(); 1267 + let text_plain = Scope::parse("blob:text/plain").unwrap(); 1268 + 1269 + assert!(all.grants(&image_all)); 1270 + assert!(all.grants(&image_png)); 1271 + assert!(all.grants(&text_plain)); 1272 + assert!(image_all.grants(&image_png)); 1273 + assert!(!image_all.grants(&text_plain)); 1274 + assert!(!image_png.grants(&image_all)); 1275 + } 1276 + 1277 + #[test] 1278 + fn test_repo_scope_grants() { 1279 + let all_all = Scope::parse("repo:*").unwrap(); 1280 + let all_create = Scope::parse("repo:*?action=create").unwrap(); 1281 + let specific_all = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1282 + let specific_create = Scope::parse("repo:app.bsky.feed.post?action=create").unwrap(); 1283 + let other_create = Scope::parse("repo:pub.leaflet.publication?action=create").unwrap(); 1284 + 1285 + assert!(all_all.grants(&all_create)); 1286 + assert!(all_all.grants(&specific_all)); 1287 + assert!(all_all.grants(&specific_create)); 1288 + assert!(all_create.grants(&all_create)); 1289 + assert!(!all_create.grants(&specific_all)); 1290 + assert!(specific_all.grants(&specific_create)); 1291 + assert!(!specific_create.grants(&specific_all)); 1292 + assert!(!specific_create.grants(&other_create)); 1293 + } 1294 + 1295 + #[test] 1296 + fn test_rpc_scope_grants() { 1297 + let all = Scope::parse("rpc:*").unwrap(); 1298 + let specific_lxm = Scope::parse("rpc:com.example.service").unwrap(); 1299 + let specific_both = Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(); 1300 + 1301 + assert!(all.grants(&specific_lxm)); 1302 + assert!(all.grants(&specific_both)); 1303 + assert!(specific_lxm.grants(&specific_both)); 1304 + assert!(!specific_both.grants(&specific_lxm)); 1305 + assert!(!specific_both.grants(&all)); 1306 + } 1307 + 1308 + #[test] 1309 + fn test_cross_scope_grants() { 1310 + let account = Scope::parse("account:email").unwrap(); 1311 + let identity = Scope::parse("identity:handle").unwrap(); 1312 + 1313 + assert!(!account.grants(&identity)); 1314 + assert!(!identity.grants(&account)); 1315 + } 1316 + 1317 + #[test] 1318 + fn test_parse_errors() { 1319 + assert!(matches!( 1320 + Scope::parse("unknown:test"), 1321 + Err(ParseError::UnknownPrefix(_)) 1322 + )); 1323 + 1324 + assert!(matches!( 1325 + Scope::parse("account"), 1326 + Err(ParseError::MissingResource) 1327 + )); 1328 + 1329 + assert!(matches!( 1330 + Scope::parse("account:invalid"), 1331 + Err(ParseError::InvalidResource(_)) 1332 + )); 1333 + 1334 + assert!(matches!( 1335 + Scope::parse("account:email?action=invalid"), 1336 + Err(ParseError::InvalidAction(_)) 1337 + )); 1338 + } 1339 + 1340 + #[test] 1341 + fn test_query_parameter_sorting() { 1342 + let scope = 1343 + Scope::parse("blob?accept=image/png&accept=application/pdf&accept=image/jpeg").unwrap(); 1344 + let normalized = scope.to_string_normalized(); 1345 + assert!(normalized.contains("accept=application/pdf")); 1346 + assert!(normalized.contains("accept=image/jpeg")); 1347 + assert!(normalized.contains("accept=image/png")); 1348 + let pdf_pos = normalized.find("accept=application/pdf").unwrap(); 1349 + let jpeg_pos = normalized.find("accept=image/jpeg").unwrap(); 1350 + let png_pos = normalized.find("accept=image/png").unwrap(); 1351 + assert!(pdf_pos < jpeg_pos); 1352 + assert!(jpeg_pos < png_pos); 1353 + } 1354 + 1355 + #[test] 1356 + fn test_repo_action_wildcard() { 1357 + let scope = Scope::parse("repo:app.bsky.feed.post?action=*").unwrap(); 1358 + let mut actions = BTreeSet::new(); 1359 + actions.insert(RepoAction::Create); 1360 + actions.insert(RepoAction::Update); 1361 + actions.insert(RepoAction::Delete); 1362 + assert_eq!( 1363 + scope, 1364 + Scope::Repo(RepoScope { 1365 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1366 + actions, 1367 + }) 1368 + ); 1369 + } 1370 + 1371 + #[test] 1372 + fn test_multiple_blob_accepts() { 1373 + let scope = Scope::parse("blob?accept=image/*&accept=text/plain").unwrap(); 1374 + assert!(scope.grants(&Scope::parse("blob:image/png").unwrap())); 1375 + assert!(scope.grants(&Scope::parse("blob:text/plain").unwrap())); 1376 + assert!(!scope.grants(&Scope::parse("blob:application/json").unwrap())); 1377 + } 1378 + 1379 + #[test] 1380 + fn test_rpc_default_wildcards() { 1381 + let scope = Scope::parse("rpc").unwrap(); 1382 + let mut lxm = BTreeSet::new(); 1383 + let mut aud = BTreeSet::new(); 1384 + lxm.insert(RpcLexicon::All); 1385 + aud.insert(RpcAudience::All); 1386 + assert_eq!(scope, Scope::Rpc(RpcScope { lxm, aud })); 1387 + } 1388 + 1389 + #[test] 1390 + fn test_atproto_scope_parsing() { 1391 + let scope = Scope::parse("atproto").unwrap(); 1392 + assert_eq!(scope, Scope::Atproto); 1393 + 1394 + // Atproto should not accept suffixes 1395 + assert!(Scope::parse("atproto:something").is_err()); 1396 + assert!(Scope::parse("atproto?param=value").is_err()); 1397 + } 1398 + 1399 + #[test] 1400 + fn test_transition_scope_parsing() { 1401 + let scope = Scope::parse("transition:generic").unwrap(); 1402 + assert_eq!(scope, Scope::Transition(TransitionScope::Generic)); 1403 + 1404 + let scope = Scope::parse("transition:chat.bsky").unwrap(); 1405 + assert_eq!(scope, Scope::Transition(TransitionScope::ChatBsky)); 1406 + 1407 + let scope = Scope::parse("transition:email").unwrap(); 1408 + assert_eq!(scope, Scope::Transition(TransitionScope::Email)); 1409 + 1410 + // Test invalid transition types 1411 + assert!(matches!( 1412 + Scope::parse("transition:invalid"), 1413 + Err(ParseError::InvalidResource(_)) 1414 + )); 1415 + 1416 + // Test missing suffix 1417 + assert!(matches!( 1418 + Scope::parse("transition"), 1419 + Err(ParseError::MissingResource) 1420 + )); 1421 + 1422 + // Test transition doesn't accept query parameters 1423 + assert!(matches!( 1424 + Scope::parse("transition:generic?param=value"), 1425 + Err(ParseError::InvalidResource(_)) 1426 + )); 1427 + } 1428 + 1429 + #[test] 1430 + fn test_atproto_scope_normalization() { 1431 + let scope = Scope::parse("atproto").unwrap(); 1432 + assert_eq!(scope.to_string_normalized(), "atproto"); 1433 + } 1434 + 1435 + #[test] 1436 + fn test_transition_scope_normalization() { 1437 + let tests = vec![ 1438 + ("transition:generic", "transition:generic"), 1439 + ("transition:email", "transition:email"), 1440 + ]; 1441 + 1442 + for (input, expected) in tests { 1443 + let scope = Scope::parse(input).unwrap(); 1444 + assert_eq!(scope.to_string_normalized(), expected); 1445 + } 1446 + } 1447 + 1448 + #[test] 1449 + fn test_atproto_scope_grants() { 1450 + let atproto = Scope::parse("atproto").unwrap(); 1451 + let account = Scope::parse("account:email").unwrap(); 1452 + let identity = Scope::parse("identity:handle").unwrap(); 1453 + let blob = Scope::parse("blob:image/png").unwrap(); 1454 + let repo = Scope::parse("repo:app.bsky.feed.post").unwrap(); 1455 + let rpc = Scope::parse("rpc:com.example.service").unwrap(); 1456 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1457 + let transition_email = Scope::parse("transition:email").unwrap(); 1458 + 1459 + // Atproto only grants itself (it's a required scope, not a permission grant) 1460 + assert!(atproto.grants(&atproto)); 1461 + assert!(!atproto.grants(&account)); 1462 + assert!(!atproto.grants(&identity)); 1463 + assert!(!atproto.grants(&blob)); 1464 + assert!(!atproto.grants(&repo)); 1465 + assert!(!atproto.grants(&rpc)); 1466 + assert!(!atproto.grants(&transition_generic)); 1467 + assert!(!atproto.grants(&transition_email)); 1468 + 1469 + // Nothing else grants atproto 1470 + assert!(!account.grants(&atproto)); 1471 + assert!(!identity.grants(&atproto)); 1472 + assert!(!blob.grants(&atproto)); 1473 + assert!(!repo.grants(&atproto)); 1474 + assert!(!rpc.grants(&atproto)); 1475 + assert!(!transition_generic.grants(&atproto)); 1476 + assert!(!transition_email.grants(&atproto)); 1477 + } 1478 + 1479 + #[test] 1480 + fn test_transition_scope_grants() { 1481 + let transition_generic = Scope::parse("transition:generic").unwrap(); 1482 + let transition_email = Scope::parse("transition:email").unwrap(); 1483 + let account = Scope::parse("account:email").unwrap(); 1484 + 1485 + // Transition scopes only grant themselves 1486 + assert!(transition_generic.grants(&transition_generic)); 1487 + assert!(transition_email.grants(&transition_email)); 1488 + assert!(!transition_generic.grants(&transition_email)); 1489 + assert!(!transition_email.grants(&transition_generic)); 1490 + 1491 + // Transition scopes don't grant other scope types 1492 + assert!(!transition_generic.grants(&account)); 1493 + assert!(!transition_email.grants(&account)); 1494 + 1495 + // Other scopes don't grant transition scopes 1496 + assert!(!account.grants(&transition_generic)); 1497 + assert!(!account.grants(&transition_email)); 1498 + } 1499 + 1500 + #[test] 1501 + fn test_parse_multiple() { 1502 + // Test parsing multiple scopes 1503 + let scopes = Scope::parse_multiple("atproto repo:*").unwrap(); 1504 + assert_eq!(scopes.len(), 2); 1505 + assert_eq!(scopes[0], Scope::Atproto); 1506 + assert_eq!( 1507 + scopes[1], 1508 + Scope::Repo(RepoScope { 1509 + collection: RepoCollection::All, 1510 + actions: { 1511 + let mut actions = BTreeSet::new(); 1512 + actions.insert(RepoAction::Create); 1513 + actions.insert(RepoAction::Update); 1514 + actions.insert(RepoAction::Delete); 1515 + actions 1516 + } 1517 + }) 1518 + ); 1519 + 1520 + // Test with more scopes 1521 + let scopes = Scope::parse_multiple("account:email identity:handle blob:image/png").unwrap(); 1522 + assert_eq!(scopes.len(), 3); 1523 + assert!(matches!(scopes[0], Scope::Account(_))); 1524 + assert!(matches!(scopes[1], Scope::Identity(_))); 1525 + assert!(matches!(scopes[2], Scope::Blob(_))); 1526 + 1527 + // Test with complex scopes 1528 + let scopes = Scope::parse_multiple( 1529 + "account:email?action=manage repo:app.bsky.feed.post?action=create transition:email", 1530 + ) 1531 + .unwrap(); 1532 + assert_eq!(scopes.len(), 3); 1533 + 1534 + // Test empty string 1535 + let scopes = Scope::parse_multiple("").unwrap(); 1536 + assert_eq!(scopes.len(), 0); 1537 + 1538 + // Test whitespace only 1539 + let scopes = Scope::parse_multiple(" ").unwrap(); 1540 + assert_eq!(scopes.len(), 0); 1541 + 1542 + // Test with extra whitespace 1543 + let scopes = Scope::parse_multiple(" atproto repo:* ").unwrap(); 1544 + assert_eq!(scopes.len(), 2); 1545 + 1546 + // Test single scope 1547 + let scopes = Scope::parse_multiple("atproto").unwrap(); 1548 + assert_eq!(scopes.len(), 1); 1549 + assert_eq!(scopes[0], Scope::Atproto); 1550 + 1551 + // Test error propagation 1552 + assert!(Scope::parse_multiple("atproto invalid:scope").is_err()); 1553 + assert!(Scope::parse_multiple("account:invalid repo:*").is_err()); 1554 + } 1555 + 1556 + #[test] 1557 + fn test_parse_multiple_reduced() { 1558 + // Test repo scope reduction - wildcard grants specific 1559 + let scopes = 1560 + Scope::parse_multiple_reduced("atproto repo:app.bsky.feed.post repo:*").unwrap(); 1561 + assert_eq!(scopes.len(), 2); 1562 + assert!(scopes.contains(&Scope::Atproto)); 1563 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1564 + collection: RepoCollection::All, 1565 + actions: { 1566 + let mut actions = BTreeSet::new(); 1567 + actions.insert(RepoAction::Create); 1568 + actions.insert(RepoAction::Update); 1569 + actions.insert(RepoAction::Delete); 1570 + actions 1571 + } 1572 + }))); 1573 + 1574 + // Test reverse order - should get same result 1575 + let scopes = 1576 + Scope::parse_multiple_reduced("atproto repo:* repo:app.bsky.feed.post").unwrap(); 1577 + assert_eq!(scopes.len(), 2); 1578 + assert!(scopes.contains(&Scope::Atproto)); 1579 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1580 + collection: RepoCollection::All, 1581 + actions: { 1582 + let mut actions = BTreeSet::new(); 1583 + actions.insert(RepoAction::Create); 1584 + actions.insert(RepoAction::Update); 1585 + actions.insert(RepoAction::Delete); 1586 + actions 1587 + } 1588 + }))); 1589 + 1590 + // Test account scope reduction - manage grants read 1591 + let scopes = 1592 + Scope::parse_multiple_reduced("account:email account:email?action=manage").unwrap(); 1593 + assert_eq!(scopes.len(), 1); 1594 + assert_eq!( 1595 + scopes[0], 1596 + Scope::Account(AccountScope { 1597 + resource: AccountResource::Email, 1598 + action: AccountAction::Manage, 1599 + }) 1600 + ); 1601 + 1602 + // Test identity scope reduction - wildcard grants specific 1603 + let scopes = Scope::parse_multiple_reduced("identity:handle identity:*").unwrap(); 1604 + assert_eq!(scopes.len(), 1); 1605 + assert_eq!(scopes[0], Scope::Identity(IdentityScope::All)); 1606 + 1607 + // Test blob scope reduction - wildcard grants specific 1608 + let scopes = Scope::parse_multiple_reduced("blob:image/png blob:image/* blob:*/*").unwrap(); 1609 + assert_eq!(scopes.len(), 1); 1610 + let mut accept = BTreeSet::new(); 1611 + accept.insert(MimePattern::All); 1612 + assert_eq!(scopes[0], Scope::Blob(BlobScope { accept })); 1613 + 1614 + // Test no reduction needed - different scope types 1615 + let scopes = 1616 + Scope::parse_multiple_reduced("account:email identity:handle blob:image/png").unwrap(); 1617 + assert_eq!(scopes.len(), 3); 1618 + 1619 + // Test repo action reduction 1620 + let scopes = Scope::parse_multiple_reduced( 1621 + "repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post", 1622 + ) 1623 + .unwrap(); 1624 + assert_eq!(scopes.len(), 1); 1625 + assert_eq!( 1626 + scopes[0], 1627 + Scope::Repo(RepoScope { 1628 + collection: RepoCollection::Nsid(Nsid::new_static("app.bsky.feed.post").unwrap()), 1629 + actions: { 1630 + let mut actions = BTreeSet::new(); 1631 + actions.insert(RepoAction::Create); 1632 + actions.insert(RepoAction::Update); 1633 + actions.insert(RepoAction::Delete); 1634 + actions 1635 + } 1636 + }) 1637 + ); 1638 + 1639 + // Test RPC scope reduction 1640 + let scopes = Scope::parse_multiple_reduced( 1641 + "rpc:com.example.service?aud=did:example:123 rpc:com.example.service rpc:*", 1642 + ) 1643 + .unwrap(); 1644 + assert_eq!(scopes.len(), 1); 1645 + assert_eq!( 1646 + scopes[0], 1647 + Scope::Rpc(RpcScope { 1648 + lxm: { 1649 + let mut lxm = BTreeSet::new(); 1650 + lxm.insert(RpcLexicon::All); 1651 + lxm 1652 + }, 1653 + aud: { 1654 + let mut aud = BTreeSet::new(); 1655 + aud.insert(RpcAudience::All); 1656 + aud 1657 + } 1658 + }) 1659 + ); 1660 + 1661 + // Test duplicate removal 1662 + let scopes = Scope::parse_multiple_reduced("atproto atproto atproto").unwrap(); 1663 + assert_eq!(scopes.len(), 1); 1664 + assert_eq!(scopes[0], Scope::Atproto); 1665 + 1666 + // Test transition scopes - only grant themselves 1667 + let scopes = Scope::parse_multiple_reduced("transition:generic transition:email").unwrap(); 1668 + assert_eq!(scopes.len(), 2); 1669 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Generic))); 1670 + assert!(scopes.contains(&Scope::Transition(TransitionScope::Email))); 1671 + 1672 + // Test empty input 1673 + let scopes = Scope::parse_multiple_reduced("").unwrap(); 1674 + assert_eq!(scopes.len(), 0); 1675 + 1676 + // Test complex scenario with multiple reductions 1677 + let scopes = Scope::parse_multiple_reduced( 1678 + "account:email?action=manage account:email account:repo account:repo?action=read identity:* identity:handle" 1679 + ).unwrap(); 1680 + assert_eq!(scopes.len(), 3); 1681 + // Should have: account:email?action=manage, account:repo, identity:* 1682 + assert!(scopes.contains(&Scope::Account(AccountScope { 1683 + resource: AccountResource::Email, 1684 + action: AccountAction::Manage, 1685 + }))); 1686 + assert!(scopes.contains(&Scope::Account(AccountScope { 1687 + resource: AccountResource::Repo, 1688 + action: AccountAction::Read, 1689 + }))); 1690 + assert!(scopes.contains(&Scope::Identity(IdentityScope::All))); 1691 + 1692 + // Test that atproto doesn't grant other scopes (per recent change) 1693 + let scopes = Scope::parse_multiple_reduced("atproto account:email repo:*").unwrap(); 1694 + assert_eq!(scopes.len(), 3); 1695 + assert!(scopes.contains(&Scope::Atproto)); 1696 + assert!(scopes.contains(&Scope::Account(AccountScope { 1697 + resource: AccountResource::Email, 1698 + action: AccountAction::Read, 1699 + }))); 1700 + assert!(scopes.contains(&Scope::Repo(RepoScope { 1701 + collection: RepoCollection::All, 1702 + actions: { 1703 + let mut actions = BTreeSet::new(); 1704 + actions.insert(RepoAction::Create); 1705 + actions.insert(RepoAction::Update); 1706 + actions.insert(RepoAction::Delete); 1707 + actions 1708 + } 1709 + }))); 1710 + } 1711 + 1712 + #[test] 1713 + fn test_openid_connect_scope_parsing() { 1714 + // Test OpenID scope 1715 + let scope = Scope::parse("openid").unwrap(); 1716 + assert_eq!(scope, Scope::OpenId); 1717 + 1718 + // Test Profile scope 1719 + let scope = Scope::parse("profile").unwrap(); 1720 + assert_eq!(scope, Scope::Profile); 1721 + 1722 + // Test Email scope 1723 + let scope = Scope::parse("email").unwrap(); 1724 + assert_eq!(scope, Scope::Email); 1725 + 1726 + // Test that they don't accept suffixes 1727 + assert!(Scope::parse("openid:something").is_err()); 1728 + assert!(Scope::parse("profile:something").is_err()); 1729 + assert!(Scope::parse("email:something").is_err()); 1730 + 1731 + // Test that they don't accept query parameters 1732 + assert!(Scope::parse("openid?param=value").is_err()); 1733 + assert!(Scope::parse("profile?param=value").is_err()); 1734 + assert!(Scope::parse("email?param=value").is_err()); 1735 + } 1736 + 1737 + #[test] 1738 + fn test_openid_connect_scope_normalization() { 1739 + let scope = Scope::parse("openid").unwrap(); 1740 + assert_eq!(scope.to_string_normalized(), "openid"); 1741 + 1742 + let scope = Scope::parse("profile").unwrap(); 1743 + assert_eq!(scope.to_string_normalized(), "profile"); 1744 + 1745 + let scope = Scope::parse("email").unwrap(); 1746 + assert_eq!(scope.to_string_normalized(), "email"); 1747 + } 1748 + 1749 + #[test] 1750 + fn test_openid_connect_scope_grants() { 1751 + let openid = Scope::parse("openid").unwrap(); 1752 + let profile = Scope::parse("profile").unwrap(); 1753 + let email = Scope::parse("email").unwrap(); 1754 + let account = Scope::parse("account:email").unwrap(); 1755 + 1756 + // OpenID Connect scopes only grant themselves 1757 + assert!(openid.grants(&openid)); 1758 + assert!(!openid.grants(&profile)); 1759 + assert!(!openid.grants(&email)); 1760 + assert!(!openid.grants(&account)); 1761 + 1762 + assert!(profile.grants(&profile)); 1763 + assert!(!profile.grants(&openid)); 1764 + assert!(!profile.grants(&email)); 1765 + assert!(!profile.grants(&account)); 1766 + 1767 + assert!(email.grants(&email)); 1768 + assert!(!email.grants(&openid)); 1769 + assert!(!email.grants(&profile)); 1770 + assert!(!email.grants(&account)); 1771 + 1772 + // Other scopes don't grant OpenID Connect scopes 1773 + assert!(!account.grants(&openid)); 1774 + assert!(!account.grants(&profile)); 1775 + assert!(!account.grants(&email)); 1776 + } 1777 + 1778 + #[test] 1779 + fn test_parse_multiple_with_openid_connect() { 1780 + let scopes = Scope::parse_multiple("openid profile email atproto").unwrap(); 1781 + assert_eq!(scopes.len(), 4); 1782 + assert_eq!(scopes[0], Scope::OpenId); 1783 + assert_eq!(scopes[1], Scope::Profile); 1784 + assert_eq!(scopes[2], Scope::Email); 1785 + assert_eq!(scopes[3], Scope::Atproto); 1786 + 1787 + // Test with mixed scopes 1788 + let scopes = Scope::parse_multiple("openid account:email profile repo:*").unwrap(); 1789 + assert_eq!(scopes.len(), 4); 1790 + assert!(scopes.contains(&Scope::OpenId)); 1791 + assert!(scopes.contains(&Scope::Profile)); 1792 + } 1793 + 1794 + #[test] 1795 + fn test_parse_multiple_reduced_with_openid_connect() { 1796 + // OpenID Connect scopes don't grant each other, so no reduction 1797 + let scopes = Scope::parse_multiple_reduced("openid profile email openid").unwrap(); 1798 + assert_eq!(scopes.len(), 3); 1799 + assert!(scopes.contains(&Scope::OpenId)); 1800 + assert!(scopes.contains(&Scope::Profile)); 1801 + assert!(scopes.contains(&Scope::Email)); 1802 + 1803 + // Mixed with other scopes 1804 + let scopes = Scope::parse_multiple_reduced( 1805 + "openid account:email account:email?action=manage profile", 1806 + ) 1807 + .unwrap(); 1808 + assert_eq!(scopes.len(), 3); 1809 + assert!(scopes.contains(&Scope::OpenId)); 1810 + assert!(scopes.contains(&Scope::Profile)); 1811 + assert!(scopes.contains(&Scope::Account(AccountScope { 1812 + resource: AccountResource::Email, 1813 + action: AccountAction::Manage, 1814 + }))); 1815 + } 1816 + 1817 + #[test] 1818 + fn test_serialize_multiple() { 1819 + // Test empty list 1820 + let scopes: Vec<Scope> = vec![]; 1821 + assert_eq!(Scope::serialize_multiple(&scopes), ""); 1822 + 1823 + // Test single scope 1824 + let scopes = vec![Scope::Atproto]; 1825 + assert_eq!(Scope::serialize_multiple(&scopes), "atproto"); 1826 + 1827 + // Test multiple scopes - should be sorted alphabetically 1828 + let scopes = vec![ 1829 + Scope::parse("repo:*").unwrap(), 1830 + Scope::Atproto, 1831 + Scope::parse("account:email").unwrap(), 1832 + ]; 1833 + assert_eq!( 1834 + Scope::serialize_multiple(&scopes), 1835 + "account:email atproto repo:*" 1836 + ); 1837 + 1838 + // Test that sorting is consistent regardless of input order 1839 + let scopes = vec![ 1840 + Scope::parse("identity:handle").unwrap(), 1841 + Scope::parse("blob:image/png").unwrap(), 1842 + Scope::parse("account:repo?action=manage").unwrap(), 1843 + ]; 1844 + assert_eq!( 1845 + Scope::serialize_multiple(&scopes), 1846 + "account:repo?action=manage blob:image/png identity:handle" 1847 + ); 1848 + 1849 + // Test with OpenID Connect scopes 1850 + let scopes = vec![Scope::Email, Scope::OpenId, Scope::Profile, Scope::Atproto]; 1851 + assert_eq!( 1852 + Scope::serialize_multiple(&scopes), 1853 + "atproto email openid profile" 1854 + ); 1855 + 1856 + // Test with complex scopes including query parameters 1857 + let scopes = vec![ 1858 + Scope::parse("rpc:com.example.service?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.method") 1859 + .unwrap(), 1860 + Scope::parse("repo:app.bsky.feed.post?action=create&action=update").unwrap(), 1861 + Scope::parse("blob:image/*?accept=image/png&accept=image/jpeg").unwrap(), 1862 + ]; 1863 + let result = Scope::serialize_multiple(&scopes); 1864 + // The result should be sorted alphabetically 1865 + // Note: RPC scope with query params is serialized as "rpc?aud=...&lxm=..." 1866 + assert!(result.starts_with("blob:")); 1867 + assert!(result.contains(" repo:")); 1868 + assert!( 1869 + result.contains("rpc?aud=did:plc:yfvwmnlztr4dwkb7hwz55r2g&lxm=com.example.service") 1870 + ); 1871 + 1872 + // Test with transition scopes 1873 + let scopes = vec![ 1874 + Scope::Transition(TransitionScope::ChatBsky), 1875 + Scope::Transition(TransitionScope::Email), 1876 + Scope::Transition(TransitionScope::Generic), 1877 + Scope::Atproto, 1878 + ]; 1879 + assert_eq!( 1880 + Scope::serialize_multiple(&scopes), 1881 + "atproto transition:chat.bsky transition:email transition:generic" 1882 + ); 1883 + 1884 + // Test duplicates - they remain in the output (caller's responsibility to dedupe if needed) 1885 + let scopes = vec![ 1886 + Scope::Atproto, 1887 + Scope::Atproto, 1888 + Scope::parse("account:email").unwrap(), 1889 + ]; 1890 + assert_eq!( 1891 + Scope::serialize_multiple(&scopes), 1892 + "account:email atproto atproto" 1893 + ); 1894 + 1895 + // Test normalization is preserved in serialization 1896 + let scopes = vec![Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap()]; 1897 + // Should normalize query parameters alphabetically 1898 + assert_eq!( 1899 + Scope::serialize_multiple(&scopes), 1900 + "blob?accept=image/jpeg&accept=image/png" 1901 + ); 1902 + } 1903 + 1904 + #[test] 1905 + fn test_serialize_multiple_roundtrip() { 1906 + // Test that parse_multiple and serialize_multiple are inverses (when sorted) 1907 + let original = "account:email atproto blob:image/png identity:handle repo:*"; 1908 + let scopes = Scope::parse_multiple(original).unwrap(); 1909 + let serialized = Scope::serialize_multiple(&scopes); 1910 + assert_eq!(serialized, original); 1911 + 1912 + // Test with complex scopes 1913 + let original = "account:repo?action=manage blob?accept=image/jpeg&accept=image/png rpc:*"; 1914 + let scopes = Scope::parse_multiple(original).unwrap(); 1915 + let serialized = Scope::serialize_multiple(&scopes); 1916 + // Parse again to verify it's valid 1917 + let reparsed = Scope::parse_multiple(&serialized).unwrap(); 1918 + assert_eq!(scopes, reparsed); 1919 + 1920 + // Test with OpenID Connect scopes 1921 + let original = "email openid profile"; 1922 + let scopes = Scope::parse_multiple(original).unwrap(); 1923 + let serialized = Scope::serialize_multiple(&scopes); 1924 + assert_eq!(serialized, original); 1925 + } 1926 + 1927 + #[test] 1928 + fn test_remove_scope() { 1929 + // Test removing a scope that exists 1930 + let scopes = vec![ 1931 + Scope::parse("repo:*").unwrap(), 1932 + Scope::Atproto, 1933 + Scope::parse("account:email").unwrap(), 1934 + ]; 1935 + let to_remove = Scope::Atproto; 1936 + let result = Scope::remove_scope(&scopes, &to_remove); 1937 + assert_eq!(result.len(), 2); 1938 + assert!(!result.contains(&to_remove)); 1939 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1940 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1941 + 1942 + // Test removing a scope that doesn't exist 1943 + let scopes = vec![ 1944 + Scope::parse("repo:*").unwrap(), 1945 + Scope::parse("account:email").unwrap(), 1946 + ]; 1947 + let to_remove = Scope::parse("identity:handle").unwrap(); 1948 + let result = Scope::remove_scope(&scopes, &to_remove); 1949 + assert_eq!(result.len(), 2); 1950 + assert_eq!(result, scopes); 1951 + 1952 + // Test removing from empty list 1953 + let scopes: Vec<Scope> = vec![]; 1954 + let to_remove = Scope::Atproto; 1955 + let result = Scope::remove_scope(&scopes, &to_remove); 1956 + assert_eq!(result.len(), 0); 1957 + 1958 + // Test removing all instances of a duplicate scope 1959 + let scopes = vec![ 1960 + Scope::Atproto, 1961 + Scope::parse("account:email").unwrap(), 1962 + Scope::Atproto, 1963 + Scope::parse("repo:*").unwrap(), 1964 + Scope::Atproto, 1965 + ]; 1966 + let to_remove = Scope::Atproto; 1967 + let result = Scope::remove_scope(&scopes, &to_remove); 1968 + assert_eq!(result.len(), 2); 1969 + assert!(!result.contains(&to_remove)); 1970 + assert!(result.contains(&Scope::parse("account:email").unwrap())); 1971 + assert!(result.contains(&Scope::parse("repo:*").unwrap())); 1972 + 1973 + // Test removing complex scopes with query parameters 1974 + let scopes = vec![ 1975 + Scope::parse("account:email?action=manage").unwrap(), 1976 + Scope::parse("blob?accept=image/png&accept=image/jpeg").unwrap(), 1977 + Scope::parse("rpc:com.example.service?aud=did:example:123").unwrap(), 1978 + ]; 1979 + let to_remove = Scope::parse("blob?accept=image/jpeg&accept=image/png").unwrap(); // Note: normalized order 1980 + let result = Scope::remove_scope(&scopes, &to_remove); 1981 + assert_eq!(result.len(), 2); 1982 + assert!(!result.contains(&to_remove)); 1983 + 1984 + // Test with OpenID Connect scopes 1985 + let scopes = vec![Scope::OpenId, Scope::Profile, Scope::Email, Scope::Atproto]; 1986 + let to_remove = Scope::Profile; 1987 + let result = Scope::remove_scope(&scopes, &to_remove); 1988 + assert_eq!(result.len(), 3); 1989 + assert!(!result.contains(&to_remove)); 1990 + assert!(result.contains(&Scope::OpenId)); 1991 + assert!(result.contains(&Scope::Email)); 1992 + assert!(result.contains(&Scope::Atproto)); 1993 + 1994 + // Test with transition scopes 1995 + let scopes = vec![ 1996 + Scope::Transition(TransitionScope::Generic), 1997 + Scope::Transition(TransitionScope::Email), 1998 + Scope::Atproto, 1999 + ]; 2000 + let to_remove = Scope::Transition(TransitionScope::Email); 2001 + let result = Scope::remove_scope(&scopes, &to_remove); 2002 + assert_eq!(result.len(), 2); 2003 + assert!(!result.contains(&to_remove)); 2004 + assert!(result.contains(&Scope::Transition(TransitionScope::Generic))); 2005 + assert!(result.contains(&Scope::Atproto)); 2006 + 2007 + // Test that only exact matches are removed 2008 + let scopes = vec![ 2009 + Scope::parse("account:email").unwrap(), 2010 + Scope::parse("account:email?action=manage").unwrap(), 2011 + Scope::parse("account:repo").unwrap(), 2012 + ]; 2013 + let to_remove = Scope::parse("account:email").unwrap(); 2014 + let result = Scope::remove_scope(&scopes, &to_remove); 2015 + assert_eq!(result.len(), 2); 2016 + assert!(!result.contains(&Scope::parse("account:email").unwrap())); 2017 + assert!(result.contains(&Scope::parse("account:email?action=manage").unwrap())); 2018 + assert!(result.contains(&Scope::parse("account:repo").unwrap())); 2019 + } 2020 + }
+535
src-tauri/vendor/jacquard-oauth/src/session.rs
··· 1 + use std::sync::Arc; 2 + 3 + use chrono::TimeDelta; 4 + 5 + use crate::{ 6 + atproto::{AtprotoClientMetadata, atproto_client_metadata}, 7 + authstore::ClientAuthStore, 8 + dpop::DpopExt, 9 + keyset::Keyset, 10 + request::{OAuthMetadata, refresh}, 11 + resolver::OAuthResolver, 12 + scopes::Scope, 13 + types::TokenSet, 14 + }; 15 + 16 + use dashmap::DashMap; 17 + use jacquard_common::{ 18 + CowStr, IntoStatic, 19 + deps::fluent_uri::Uri, 20 + http_client::HttpClient, 21 + session::SessionStoreError, 22 + types::{did::Did, string::Datetime}, 23 + }; 24 + use jose_jwk::Key; 25 + use serde::{Deserialize, Serialize}; 26 + use smol_str::{SmolStr, format_smolstr}; 27 + use tokio::sync::Mutex; 28 + 29 + /// Provides DPoP key material and per-server nonces to the DPoP proof-building machinery. 30 + /// 31 + /// This trait abstracts over two different holders of DPoP state: [`DpopReqData`] (used 32 + /// during the initial authorization request, where only an authserver nonce is tracked) and 33 + /// [`DpopClientData`] (used in active sessions, where both authserver and host nonces are 34 + /// maintained). Implementors must store nonces durably so that the next request to the same 35 + /// server includes the most recently observed nonce. 36 + pub trait DpopDataSource { 37 + /// Return the private JWK used to sign DPoP proofs. 38 + fn key(&self) -> &Key; 39 + /// Return the most recently observed nonce from the authorization server, if any. 40 + fn authserver_nonce(&self) -> Option<CowStr<'_>>; 41 + /// Persist a new nonce received from the authorization server. 42 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>); 43 + /// Return the most recently observed nonce from the resource server (PDS), if any. 44 + fn host_nonce(&self) -> Option<CowStr<'_>>; 45 + /// Persist a new nonce received from the resource server (PDS). 46 + fn set_host_nonce(&mut self, nonce: CowStr<'_>); 47 + } 48 + 49 + /// Persisted information about an OAuth session. Used to resume an active session. 50 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 51 + pub struct ClientSessionData<'s> { 52 + /// DID of the authenticated account; serves as the primary key for session storage 53 + /// because only one active session per account is assumed. 54 + #[serde(borrow)] 55 + pub account_did: Did<'s>, 56 + 57 + /// Opaque identifier that distinguishes this session from other sessions for the same account. 58 + /// 59 + /// Reuses the random `state` token generated during the PAR flow. 60 + pub session_id: CowStr<'s>, 61 + 62 + /// Base URL of the resource server (PDS): scheme, host, and port only 63 + pub host_url: Uri<String>, 64 + 65 + /// Base URL of the authorization server (PDS or entryway): scheme, host, and port only 66 + pub authserver_url: CowStr<'s>, 67 + 68 + /// Full URL of the authorization server's token endpoint. 69 + pub authserver_token_endpoint: CowStr<'s>, 70 + 71 + /// Full URL of the authorization server's revocation endpoint, if advertised. 72 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 73 + pub authserver_revocation_endpoint: Option<CowStr<'s>>, 74 + 75 + /// The set of OAuth scopes approved for this session, as returned in the initial token response. 76 + pub scopes: Vec<Scope<'s>>, 77 + 78 + /// DPoP key and nonce state for ongoing requests in this session. 79 + #[serde(flatten)] 80 + pub dpop_data: DpopClientData<'s>, 81 + 82 + /// Current token set (access token, refresh token, expiry, etc.). 83 + #[serde(flatten)] 84 + pub token_set: TokenSet<'s>, 85 + } 86 + 87 + impl IntoStatic for ClientSessionData<'_> { 88 + type Output = ClientSessionData<'static>; 89 + 90 + fn into_static(self) -> Self::Output { 91 + ClientSessionData { 92 + authserver_url: self.authserver_url.into_static(), 93 + authserver_token_endpoint: self.authserver_token_endpoint.into_static(), 94 + authserver_revocation_endpoint: self 95 + .authserver_revocation_endpoint 96 + .map(IntoStatic::into_static), 97 + scopes: self.scopes.into_static(), 98 + dpop_data: self.dpop_data.into_static(), 99 + token_set: self.token_set.into_static(), 100 + account_did: self.account_did.into_static(), 101 + session_id: self.session_id.into_static(), 102 + host_url: self.host_url.clone(), 103 + } 104 + } 105 + } 106 + 107 + impl ClientSessionData<'_> { 108 + /// Update this session's token set and, if the new token set includes scopes, replace the scope list. 109 + /// 110 + /// Called after a successful token refresh so that any scope changes returned by the server 111 + /// are reflected in the persisted session without requiring a full re-authentication. 112 + pub fn update_with_tokens(&mut self, token_set: TokenSet<'_>) { 113 + if let Some(Ok(scopes)) = token_set 114 + .scope 115 + .as_ref() 116 + .map(|scope| Scope::parse_multiple_reduced(&scope).map(IntoStatic::into_static)) 117 + { 118 + self.scopes = scopes; 119 + } 120 + self.token_set = token_set.into_static(); 121 + } 122 + } 123 + 124 + /// DPoP state for an active OAuth session, persisted alongside the token set. 125 + /// 126 + /// Both nonces must be written back to the store after each request so that the next 127 + /// request to the same server includes the correct replay-protection nonce. 128 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 129 + pub struct DpopClientData<'s> { 130 + /// The private JWK bound to this session; used to sign all DPoP proofs. 131 + pub dpop_key: Key, 132 + /// Most recently observed DPoP nonce from the authorization server. 133 + #[serde(borrow)] 134 + pub dpop_authserver_nonce: CowStr<'s>, 135 + /// Most recently observed DPoP nonce from the resource server (PDS). 136 + pub dpop_host_nonce: CowStr<'s>, 137 + } 138 + 139 + impl IntoStatic for DpopClientData<'_> { 140 + type Output = DpopClientData<'static>; 141 + 142 + fn into_static(self) -> Self::Output { 143 + DpopClientData { 144 + dpop_key: self.dpop_key, 145 + dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(), 146 + dpop_host_nonce: self.dpop_host_nonce.into_static(), 147 + } 148 + } 149 + } 150 + 151 + impl DpopDataSource for DpopClientData<'_> { 152 + fn key(&self) -> &Key { 153 + &self.dpop_key 154 + } 155 + fn authserver_nonce(&self) -> Option<CowStr<'_>> { 156 + Some(self.dpop_authserver_nonce.clone()) 157 + } 158 + 159 + fn host_nonce(&self) -> Option<CowStr<'_>> { 160 + Some(self.dpop_host_nonce.clone()) 161 + } 162 + 163 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) { 164 + self.dpop_authserver_nonce = nonce.into_static(); 165 + } 166 + 167 + fn set_host_nonce(&mut self, nonce: CowStr<'_>) { 168 + self.dpop_host_nonce = nonce.into_static(); 169 + } 170 + } 171 + 172 + /// Transient state created during the PAR flow and consumed by the callback handler. 173 + /// 174 + /// This struct is persisted to the auth store between [`crate::request::par`] and 175 + /// [`crate::client::OAuthClient::callback`] so that the callback can verify the 176 + /// `state`, reconstruct the token exchange, and create a full [`ClientSessionData`]. 177 + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] 178 + pub struct AuthRequestData<'s> { 179 + /// Random identifier generated for this authorization request; used as the primary key 180 + /// for storing and looking up this record during the callback. 181 + #[serde(borrow)] 182 + pub state: CowStr<'s>, 183 + 184 + /// Base URL of the authorization server that was selected for this flow. 185 + pub authserver_url: CowStr<'s>, 186 + 187 + /// If the flow was initiated with a DID or handle, the resolved DID is stored here 188 + /// so it can be compared against the `sub` in the token response. 189 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 190 + pub account_did: Option<Did<'s>>, 191 + 192 + /// OAuth scopes requested for this authorization. 193 + pub scopes: Vec<Scope<'s>>, 194 + 195 + /// The PAR `request_uri` returned by the authorization server; included in the redirect URL. 196 + pub request_uri: CowStr<'s>, 197 + 198 + /// Full URL of the authorization server's token endpoint. 199 + pub authserver_token_endpoint: CowStr<'s>, 200 + 201 + /// Full URL of the authorization server's revocation endpoint, if advertised. 202 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 203 + pub authserver_revocation_endpoint: Option<CowStr<'s>>, 204 + 205 + /// The PKCE code verifier whose SHA-256 hash was sent as the code challenge; required 206 + /// at the token exchange step to prove the initiator of the auth request. 207 + pub pkce_verifier: CowStr<'s>, 208 + 209 + /// DPoP key and any authserver nonce observed during the PAR request. 210 + #[serde(flatten)] 211 + pub dpop_data: DpopReqData<'s>, 212 + } 213 + 214 + impl IntoStatic for AuthRequestData<'_> { 215 + type Output = AuthRequestData<'static>; 216 + fn into_static(self) -> AuthRequestData<'static> { 217 + AuthRequestData { 218 + request_uri: self.request_uri.into_static(), 219 + authserver_token_endpoint: self.authserver_token_endpoint.into_static(), 220 + authserver_revocation_endpoint: self 221 + .authserver_revocation_endpoint 222 + .map(|s| s.into_static()), 223 + pkce_verifier: self.pkce_verifier.into_static(), 224 + dpop_data: self.dpop_data.into_static(), 225 + state: self.state.into_static(), 226 + authserver_url: self.authserver_url.into_static(), 227 + account_did: self.account_did.into_static(), 228 + scopes: self.scopes.into_static(), 229 + } 230 + } 231 + } 232 + 233 + /// DPoP state for an in-progress authorization request (PAR through code exchange). 234 + /// 235 + /// Unlike [`DpopClientData`], this struct only tracks the authserver nonce—no resource-server 236 + /// nonce is needed until a full session is established. 237 + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 238 + pub struct DpopReqData<'s> { 239 + /// The private JWK generated fresh for this authorization request and session. 240 + pub dpop_key: Key, 241 + /// DPoP nonce received from the authorization server during the PAR exchange, if any. 242 + #[serde(borrow)] 243 + pub dpop_authserver_nonce: Option<CowStr<'s>>, 244 + } 245 + 246 + impl IntoStatic for DpopReqData<'_> { 247 + type Output = DpopReqData<'static>; 248 + fn into_static(self) -> DpopReqData<'static> { 249 + DpopReqData { 250 + dpop_key: self.dpop_key, 251 + dpop_authserver_nonce: self.dpop_authserver_nonce.into_static(), 252 + } 253 + } 254 + } 255 + 256 + impl DpopDataSource for DpopReqData<'_> { 257 + fn key(&self) -> &Key { 258 + &self.dpop_key 259 + } 260 + fn authserver_nonce(&self) -> Option<CowStr<'_>> { 261 + self.dpop_authserver_nonce.clone() 262 + } 263 + 264 + fn host_nonce(&self) -> Option<CowStr<'_>> { 265 + None 266 + } 267 + 268 + fn set_authserver_nonce(&mut self, nonce: CowStr<'_>) { 269 + self.dpop_authserver_nonce = Some(nonce.into_static()); 270 + } 271 + 272 + fn set_host_nonce(&mut self, _nonce: CowStr<'_>) {} 273 + } 274 + 275 + /// Static configuration for an OAuth client: the signing keyset and registered client metadata. 276 + /// 277 + /// `ClientData` is constructed once at startup and shared (via `Arc`) across all sessions 278 + /// managed by the same [`crate::client::OAuthClient`]. 279 + #[derive(Clone, Debug)] 280 + pub struct ClientData<'s> { 281 + /// Optional private key set used for `private_key_jwt` client authentication. 282 + /// When `None`, the `none` authentication method is used instead. 283 + pub keyset: Option<Keyset>, 284 + /// AT Protocol-specific client registration metadata (redirect URIs, scopes, etc.). 285 + pub config: AtprotoClientMetadata<'s>, 286 + } 287 + 288 + impl<'s> IntoStatic for ClientData<'s> { 289 + type Output = ClientData<'static>; 290 + fn into_static(self) -> ClientData<'static> { 291 + ClientData { 292 + keyset: self.keyset, 293 + config: self.config.into_static(), 294 + } 295 + } 296 + } 297 + 298 + impl<'s> ClientData<'s> { 299 + /// Create `ClientData` with an optional signing keyset and the given client metadata. 300 + pub fn new(keyset: Option<Keyset>, config: AtprotoClientMetadata<'s>) -> Self { 301 + Self { keyset, config } 302 + } 303 + 304 + /// Create `ClientData` without a signing keyset, relying on the `none` auth method. 305 + /// 306 + /// Suitable for public clients (e.g., single-page applications or native apps) that 307 + /// cannot securely store a private key. 308 + pub fn new_public(config: AtprotoClientMetadata<'s>) -> Self { 309 + Self { 310 + keyset: None, 311 + config, 312 + } 313 + } 314 + } 315 + 316 + /// A bundle of client configuration and an active session, used for operations that need both. 317 + /// 318 + /// `ClientSession` is a convenience type that pairs a [`ClientData`] with a 319 + /// [`ClientSessionData`] so that methods like `metadata` can access both without requiring 320 + /// callers to pass them separately. 321 + pub struct ClientSession<'s> { 322 + /// Optional signing keyset, forwarded from [`ClientData`]. 323 + pub keyset: Option<Keyset>, 324 + /// Client registration metadata, forwarded from [`ClientData`]. 325 + pub config: AtprotoClientMetadata<'s>, 326 + /// The session state for the authenticated account. 327 + pub session_data: ClientSessionData<'s>, 328 + } 329 + 330 + impl<'s> ClientSession<'s> { 331 + /// Construct a `ClientSession` from a [`ClientData`] and an active session. 332 + pub fn new( 333 + ClientData { keyset, config }: ClientData<'s>, 334 + session_data: ClientSessionData<'s>, 335 + ) -> Self { 336 + Self { 337 + keyset, 338 + config, 339 + session_data, 340 + } 341 + } 342 + 343 + /// Fetch and assemble an [`OAuthMetadata`] for the authorization server of this session. 344 + pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>( 345 + &self, 346 + client: &T, 347 + ) -> Result<OAuthMetadata, Error> { 348 + Ok(OAuthMetadata { 349 + server_metadata: client 350 + .get_authorization_server_metadata(&self.session_data.authserver_url) 351 + .await 352 + .map_err(|e| Error::ServerAgent(crate::request::RequestError::resolver(e)))?, 353 + client_metadata: atproto_client_metadata(self.config.clone(), &self.keyset) 354 + .unwrap() 355 + .into_static(), 356 + keyset: self.keyset.clone(), 357 + }) 358 + } 359 + } 360 + 361 + /// Errors that can occur during OAuth session management. 362 + #[derive(thiserror::Error, Debug, miette::Diagnostic)] 363 + #[non_exhaustive] 364 + pub enum Error { 365 + /// A token-endpoint or metadata operation failed. 366 + #[error(transparent)] 367 + #[diagnostic(code(jacquard_oauth::session::request))] 368 + ServerAgent(#[from] crate::request::RequestError), 369 + /// The backing session store returned an error. 370 + #[error(transparent)] 371 + #[diagnostic(code(jacquard_oauth::session::storage))] 372 + Store(#[from] SessionStoreError), 373 + /// The requested session does not exist in the store. 374 + #[error("session does not exist")] 375 + #[diagnostic(code(jacquard_oauth::session::not_found))] 376 + SessionNotFound, 377 + /// Token refresh failed with a permanent error (e.g., `invalid_grant`); the session 378 + /// has already been removed from the store and the user must re-authenticate. 379 + #[error("session refresh failed permanently")] 380 + #[diagnostic( 381 + code(jacquard_oauth::session::refresh_failed), 382 + help("the session has been cleared - user must re-authenticate") 383 + )] 384 + RefreshFailed(#[source] crate::request::RequestError), 385 + } 386 + 387 + impl Error { 388 + /// Returns true if this error indicates a permanent auth failure 389 + /// where the user needs to re-authenticate. 390 + pub fn is_permanent(&self) -> bool { 391 + match self { 392 + Error::RefreshFailed(_) => true, 393 + Error::SessionNotFound => true, 394 + Error::ServerAgent(e) => e.is_permanent(), 395 + Error::Store(_) => false, 396 + } 397 + } 398 + } 399 + 400 + /// Central coordinator for OAuth session storage and token refresh. 401 + /// 402 + /// `SessionRegistry` wraps the [`ClientAuthStore`] and provides serialized token refresh: 403 + /// concurrent refresh attempts for the same `(DID, session_id)` pair are coalesced behind 404 + /// a per-key `Mutex` stored in `pending`, so only one refresh request is issued to the 405 + /// authorization server even when many concurrent requests detect an expired token. 406 + pub struct SessionRegistry<T, S> 407 + where 408 + T: OAuthResolver, 409 + S: ClientAuthStore, 410 + { 411 + /// Backing store for persisting session data across process restarts. 412 + pub store: Arc<S>, 413 + /// Shared resolver used to fetch authorization server metadata during refresh. 414 + pub client: Arc<T>, 415 + /// Static client configuration (keyset and registration metadata). 416 + pub client_data: ClientData<'static>, 417 + /// Per-`(DID, session_id)` mutex that serializes concurrent refresh attempts. 418 + pending: DashMap<SmolStr, Arc<Mutex<()>>>, 419 + } 420 + 421 + impl<T, S> SessionRegistry<T, S> 422 + where 423 + S: ClientAuthStore, 424 + T: OAuthResolver, 425 + { 426 + /// Create a new registry, taking ownership of the store. 427 + pub fn new(store: S, client: Arc<T>, client_data: ClientData<'static>) -> Self { 428 + let store = Arc::new(store); 429 + Self { 430 + store: Arc::clone(&store), 431 + client, 432 + client_data, 433 + pending: DashMap::new(), 434 + } 435 + } 436 + 437 + /// Create a new registry from an already-`Arc`-wrapped store. 438 + /// 439 + /// Use this variant when the store needs to be accessed from outside the registry, 440 + /// for example to expose session listing or administration functionality. 441 + pub fn new_shared(store: Arc<S>, client: Arc<T>, client_data: ClientData<'static>) -> Self { 442 + Self { 443 + store, 444 + client, 445 + client_data, 446 + pending: DashMap::new(), 447 + } 448 + } 449 + } 450 + 451 + impl<T, S> SessionRegistry<T, S> 452 + where 453 + S: ClientAuthStore + Send + Sync + 'static, 454 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 455 + { 456 + async fn get_refreshed( 457 + &self, 458 + did: &Did<'_>, 459 + session_id: &str, 460 + ) -> Result<ClientSessionData<'_>, Error> { 461 + let key = format_smolstr!("{}_{}", did, session_id); 462 + let lock = self 463 + .pending 464 + .entry(key) 465 + .or_insert_with(|| Arc::new(Mutex::new(()))) 466 + .clone(); 467 + let _guard = lock.lock().await; 468 + 469 + let session = self 470 + .store 471 + .get_session(did, session_id) 472 + .await? 473 + .ok_or(Error::SessionNotFound)?; 474 + 475 + // Check if token is still valid with a 60-second buffer before expiry. 476 + // This triggers proactive refresh before the token actually expires, 477 + // avoiding the race condition where a token expires mid-request. 478 + const EXPIRY_BUFFER_SECS: i64 = 60; 479 + if let Some(expires_at) = &session.token_set.expires_at { 480 + let now_with_buffer = Datetime::now() 481 + .as_ref() 482 + .checked_add_signed(TimeDelta::seconds(EXPIRY_BUFFER_SECS)) 483 + .map(Datetime::new) 484 + .unwrap_or_else(Datetime::now); 485 + if expires_at > &now_with_buffer { 486 + return Ok(session); 487 + } 488 + } 489 + let metadata = 490 + OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?; 491 + match refresh(self.client.as_ref(), session, &metadata).await { 492 + Ok(refreshed) => { 493 + self.store.upsert_session(refreshed.clone()).await?; 494 + Ok(refreshed) 495 + } 496 + Err(e) if e.is_permanent() => { 497 + // Session is permanently dead - clean it up 498 + let _ = self.store.delete_session(did, session_id).await; 499 + Err(Error::RefreshFailed(e)) 500 + } 501 + Err(e) => Err(Error::ServerAgent(e)), 502 + } 503 + } 504 + /// Retrieve a session from the store, optionally refreshing it first. 505 + /// 506 + /// When `refresh` is `true`, proactively 507 + /// renews the token if it is within 60 seconds of expiry. When `false`, returns the session 508 + /// data as-is without contacting the authorization server. 509 + pub async fn get( 510 + &self, 511 + did: &Did<'_>, 512 + session_id: &str, 513 + refresh: bool, 514 + ) -> Result<ClientSessionData<'_>, Error> { 515 + if refresh { 516 + self.get_refreshed(did, session_id).await 517 + } else { 518 + // TODO: cached? 519 + self.store 520 + .get_session(did, session_id) 521 + .await? 522 + .ok_or(Error::SessionNotFound) 523 + } 524 + } 525 + /// Persist an updated session to the backing store. 526 + pub async fn set(&self, value: ClientSessionData<'_>) -> Result<(), Error> { 527 + self.store.upsert_session(value).await?; 528 + Ok(()) 529 + } 530 + /// Delete a session from the backing store. 531 + pub async fn del(&self, did: &Did<'_>, session_id: &str) -> Result<(), Error> { 532 + self.store.delete_session(did, session_id).await?; 533 + Ok(()) 534 + } 535 + }
+119
src-tauri/vendor/jacquard-oauth/src/types.rs
··· 1 + mod client_metadata; 2 + mod metadata; 3 + mod request; 4 + mod response; 5 + mod token; 6 + 7 + use crate::scopes::Scope; 8 + 9 + pub use self::client_metadata::*; 10 + pub use self::metadata::*; 11 + pub use self::request::*; 12 + pub use self::response::*; 13 + pub use self::token::*; 14 + use jacquard_common::CowStr; 15 + use jacquard_common::IntoStatic; 16 + use jacquard_common::deps::fluent_uri::Uri; 17 + use serde::Deserialize; 18 + 19 + /// The `prompt` parameter for an OAuth authorization request. 20 + /// 21 + /// Controls whether the authorization server prompts the user for 22 + /// re-authentication or re-consent, as defined in OpenID Connect Core §3.1.2.1. 23 + #[derive(Debug, Deserialize, Clone, Copy)] 24 + pub enum AuthorizeOptionPrompt { 25 + /// Prompt the user to re-authenticate. 26 + Login, 27 + /// Do not display any authentication or consent UI; fail if interaction is required. 28 + None, 29 + /// Prompt the user for explicit consent before issuing tokens. 30 + Consent, 31 + /// Prompt the user to select an account when multiple sessions are active. 32 + SelectAccount, 33 + } 34 + 35 + impl From<AuthorizeOptionPrompt> for CowStr<'static> { 36 + fn from(value: AuthorizeOptionPrompt) -> Self { 37 + match value { 38 + AuthorizeOptionPrompt::Login => CowStr::new_static("login"), 39 + AuthorizeOptionPrompt::None => CowStr::new_static("none"), 40 + AuthorizeOptionPrompt::Consent => CowStr::new_static("consent"), 41 + AuthorizeOptionPrompt::SelectAccount => CowStr::new_static("select_account"), 42 + } 43 + } 44 + } 45 + 46 + /// Options for initiating an OAuth authorization request. 47 + #[derive(Debug)] 48 + pub struct AuthorizeOptions<'s> { 49 + /// Override the redirect URI registered in the client metadata. 50 + pub redirect_uri: Option<Uri<String>>, 51 + /// Scopes to request. Defaults to an empty list (server-defined defaults apply). 52 + pub scopes: Vec<Scope<'s>>, 53 + /// Optional prompt hint for the authorization server's UI. 54 + pub prompt: Option<AuthorizeOptionPrompt>, 55 + /// Opaque client-provided state value, echoed back in the callback for CSRF protection. 56 + pub state: Option<CowStr<'s>>, 57 + } 58 + 59 + impl Default for AuthorizeOptions<'_> { 60 + fn default() -> Self { 61 + Self { 62 + redirect_uri: None, 63 + scopes: vec![], 64 + prompt: None, 65 + state: None, 66 + } 67 + } 68 + } 69 + 70 + impl<'s> AuthorizeOptions<'s> { 71 + /// Set the `prompt` parameter sent to the authorization server. 72 + pub fn with_prompt(mut self, prompt: AuthorizeOptionPrompt) -> Self { 73 + self.prompt = Some(prompt); 74 + self 75 + } 76 + 77 + /// Set a CSRF-protection `state` value to be echoed in the callback. 78 + pub fn with_state(mut self, state: CowStr<'s>) -> Self { 79 + self.state = Some(state); 80 + self 81 + } 82 + 83 + /// Override the redirect URI for this specific authorization request. 84 + pub fn with_redirect_uri(mut self, redirect_uri: Uri<String>) -> Self { 85 + self.redirect_uri = Some(redirect_uri); 86 + self 87 + } 88 + 89 + /// Set the OAuth scopes to request. 90 + pub fn with_scopes(mut self, scopes: Vec<Scope<'s>>) -> Self { 91 + self.scopes = scopes; 92 + self 93 + } 94 + } 95 + 96 + /// Query parameters delivered to the OAuth redirect URI after user authorization. 97 + #[derive(Debug, Deserialize)] 98 + pub struct CallbackParams<'s> { 99 + /// The authorization code issued by the authorization server. 100 + #[serde(borrow)] 101 + pub code: CowStr<'s>, 102 + /// The `state` value originally sent in the authorization request, used to 103 + /// verify the response belongs to this session. 104 + pub state: Option<CowStr<'s>>, 105 + /// The `iss` (issuer) parameter, required by RFC 9207 to prevent mix-up attacks. 106 + pub iss: Option<CowStr<'s>>, 107 + } 108 + 109 + impl IntoStatic for CallbackParams<'_> { 110 + type Output = CallbackParams<'static>; 111 + 112 + fn into_static(self) -> Self::Output { 113 + CallbackParams { 114 + code: self.code.into_static(), 115 + state: self.state.map(|s| s.into_static()), 116 + iss: self.iss.map(|s| s.into_static()), 117 + } 118 + } 119 + }
+98
src-tauri/vendor/jacquard-oauth/src/types/client_metadata.rs
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use jose_jwk::JwkSet; 3 + use serde::{Deserialize, Serialize}; 4 + use smol_str::SmolStr; 5 + 6 + /// OAuth 2.1 client metadata, used in the ATProto client ID metadata document. 7 + /// 8 + /// In ATProto's OAuth profile, clients are identified by a URL that serves this 9 + /// metadata document. Fields follow RFC 7591 (Dynamic Client Registration), 10 + /// RFC 9449 (DPoP), and OpenID Connect Registration. 11 + /// 12 + /// <https://atproto.com/specs/oauth> 13 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 14 + pub struct OAuthClientMetadata<'c> { 15 + /// The client identifier, typically a URL pointing to this metadata document. 16 + pub client_id: CowStr<'c>, 17 + /// URL of the client's home page, used for display purposes. 18 + #[serde(skip_serializing_if = "Option::is_none")] 19 + pub client_uri: Option<CowStr<'c>>, 20 + /// List of redirect URIs the authorization server may send callbacks to. 21 + pub redirect_uris: Vec<CowStr<'c>>, 22 + /// Space-separated list of scopes the client is allowed to request. 23 + #[serde(skip_serializing_if = "Option::is_none")] 24 + #[serde(borrow)] 25 + pub scope: Option<CowStr<'c>>, 26 + /// Application type (`web` or `native`), used to enforce redirect URI constraints. 27 + #[serde(skip_serializing_if = "Option::is_none")] 28 + pub application_type: Option<CowStr<'c>>, 29 + /// OAuth 2.0 grant types the client will use. 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub grant_types: Option<Vec<CowStr<'c>>>, 32 + /// Authentication method the client uses at the token endpoint. 33 + #[serde(skip_serializing_if = "Option::is_none")] 34 + pub token_endpoint_auth_method: Option<CowStr<'c>>, 35 + /// Response types the client will use in authorization requests. 36 + pub response_types: Vec<CowStr<'c>>, 37 + /// If `true`, the client requires DPoP-bound access tokens (RFC 9449 §5.2). 38 + /// 39 + /// <https://datatracker.ietf.org/doc/html/rfc9449#section-5.2> 40 + #[serde(skip_serializing_if = "Option::is_none")] 41 + pub dpop_bound_access_tokens: Option<bool>, 42 + /// URL of the client's JWK Set document for verifying signed requests (RFC 7591 §2). 43 + /// 44 + /// <https://datatracker.ietf.org/doc/html/rfc7591#section-2> 45 + #[serde(skip_serializing_if = "Option::is_none")] 46 + pub jwks_uri: Option<CowStr<'c>>, 47 + /// Inline JWK Set for verifying signed requests, alternative to `jwks_uri`. 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub jwks: Option<JwkSet>, 50 + /// JWS algorithm the client uses to sign token endpoint authentication assertions. 51 + /// 52 + /// <https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata> 53 + #[serde(skip_serializing_if = "Option::is_none")] 54 + pub token_endpoint_auth_signing_alg: Option<CowStr<'c>>, 55 + /// Human-readable name of the client, shown to users during authorization. 56 + #[serde(skip_serializing_if = "Option::is_none")] 57 + pub client_name: Option<SmolStr>, 58 + /// URL of the client's logo image. 59 + #[serde(skip_serializing_if = "Option::is_none")] 60 + pub logo_uri: Option<CowStr<'c>>, 61 + /// URL of the client's terms of service. 62 + #[serde(skip_serializing_if = "Option::is_none")] 63 + pub tos_uri: Option<CowStr<'c>>, 64 + /// URL of the client's privacy policy. 65 + #[serde(skip_serializing_if = "Option::is_none")] 66 + pub privacy_policy_uri: Option<CowStr<'c>>, 67 + } 68 + 69 + impl OAuthClientMetadata<'_> {} 70 + 71 + impl IntoStatic for OAuthClientMetadata<'_> { 72 + type Output = OAuthClientMetadata<'static>; 73 + 74 + fn into_static(self) -> Self::Output { 75 + OAuthClientMetadata { 76 + client_id: self.client_id.into_static(), 77 + client_uri: self.client_uri.into_static(), 78 + redirect_uris: self.redirect_uris.into_static(), 79 + scope: self.scope.map(|scope| scope.into_static()), 80 + application_type: self.application_type.map(|app_type| app_type.into_static()), 81 + grant_types: self.grant_types.map(|types| types.into_static()), 82 + response_types: self.response_types.into_static(), 83 + token_endpoint_auth_method: self 84 + .token_endpoint_auth_method 85 + .map(|method| method.into_static()), 86 + dpop_bound_access_tokens: self.dpop_bound_access_tokens, 87 + jwks_uri: self.jwks_uri.into_static(), 88 + jwks: self.jwks, 89 + token_endpoint_auth_signing_alg: self 90 + .token_endpoint_auth_signing_alg 91 + .map(|alg| alg.into_static()), 92 + client_name: self.client_name, 93 + logo_uri: self.logo_uri.into_static(), 94 + tos_uri: self.tos_uri.into_static(), 95 + privacy_policy_uri: self.privacy_policy_uri.into_static(), 96 + } 97 + } 98 + }
+198
src-tauri/vendor/jacquard-oauth/src/types/metadata.rs
··· 1 + use jacquard_common::{CowStr, IntoStatic, types::string::Language}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + /// Authorization server metadata, as returned from the 5 + /// `.well-known/oauth-authorization-server` discovery document. 6 + /// 7 + /// Defined by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414#section-2) 8 + /// with extensions from OpenID Connect Discovery, RFC 9126 (PAR), RFC 9207, 9 + /// RFC 9449 (DPoP), and the ATProto client ID metadata document draft. 10 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] 11 + pub struct OAuthAuthorizationServerMetadata<'s> { 12 + /// The issuer identifier URL of the authorization server. 13 + /// 14 + /// <https://datatracker.ietf.org/doc/html/rfc8414#section-2> 15 + #[serde(borrow)] 16 + pub issuer: CowStr<'s>, 17 + /// The URL of the authorization endpoint. 18 + pub authorization_endpoint: CowStr<'s>, // optional? 19 + /// The URL of the token endpoint. 20 + pub token_endpoint: CowStr<'s>, // optional? 21 + /// URL of the authorization server's JWK Set document. 22 + pub jwks_uri: Option<CowStr<'s>>, 23 + /// URL of the dynamic client registration endpoint, if supported. 24 + pub registration_endpoint: Option<CowStr<'s>>, 25 + /// List of OAuth 2.0 scope values the server supports. 26 + pub scopes_supported: Vec<CowStr<'s>>, 27 + /// List of OAuth 2.0 response type values the server supports. 28 + pub response_types_supported: Vec<CowStr<'s>>, 29 + /// List of OAuth 2.0 response mode values the server supports. 30 + pub response_modes_supported: Option<Vec<CowStr<'s>>>, 31 + /// List of OAuth 2.0 grant type values the server supports. 32 + pub grant_types_supported: Option<Vec<CowStr<'s>>>, 33 + /// List of client authentication methods supported at the token endpoint. 34 + pub token_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>, 35 + /// List of JWS signing algorithms supported for token endpoint auth. 36 + pub token_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 37 + /// URL of a page with human-readable information about the server. 38 + pub service_documentation: Option<CowStr<'s>>, 39 + /// BCP 47 language tags for UI locales the server supports. 40 + pub ui_locales_supported: Option<Vec<Language>>, 41 + /// URL of the authorization server's privacy policy. 42 + pub op_policy_uri: Option<CowStr<'s>>, 43 + /// URL of the authorization server's terms of service. 44 + pub op_tos_uri: Option<CowStr<'s>>, 45 + /// URL of the token revocation endpoint (RFC 7009). 46 + pub revocation_endpoint: Option<CowStr<'s>>, 47 + /// List of client authentication methods supported at the revocation endpoint. 48 + pub revocation_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>, 49 + /// List of JWS signing algorithms supported for revocation endpoint auth. 50 + pub revocation_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 51 + /// URL of the token introspection endpoint (RFC 7662). 52 + pub introspection_endpoint: Option<CowStr<'s>>, 53 + /// List of client authentication methods supported at the introspection endpoint. 54 + pub introspection_endpoint_auth_methods_supported: Option<Vec<CowStr<'s>>>, 55 + /// List of JWS signing algorithms supported for introspection endpoint auth. 56 + pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 57 + /// PKCE code challenge methods supported by the server. 58 + pub code_challenge_methods_supported: Option<Vec<CowStr<'s>>>, 59 + 60 + /// Subject identifier types supported (`public` or `pairwise`). 61 + /// 62 + /// <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata> 63 + pub subject_types_supported: Option<Vec<CowStr<'s>>>, 64 + /// If `true`, clients must pre-register `request_uri` values. 65 + pub require_request_uri_registration: Option<bool>, 66 + 67 + /// URL of the Pushed Authorization Request (PAR) endpoint (RFC 9126). 68 + /// 69 + /// <https://datatracker.ietf.org/doc/html/rfc9126#section-5> 70 + pub pushed_authorization_request_endpoint: Option<CowStr<'s>>, 71 + /// If `true`, all authorization requests must use PAR. 72 + pub require_pushed_authorization_requests: Option<bool>, 73 + 74 + /// If `true`, the server includes `iss` in authorization responses to prevent mix-up attacks. 75 + /// 76 + /// <https://datatracker.ietf.org/doc/html/rfc9207#section-3> 77 + pub authorization_response_iss_parameter_supported: Option<bool>, 78 + 79 + /// DPoP JWS signing algorithms supported by this server (RFC 9449). 80 + /// 81 + /// <https://datatracker.ietf.org/doc/html/rfc9449#section-5.1> 82 + pub dpop_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 83 + 84 + /// If `true`, the server supports the ATProto client ID metadata document extension. 85 + /// 86 + /// <https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-5> 87 + pub client_id_metadata_document_supported: Option<bool>, 88 + 89 + /// Protected resources associated with this authorization server. 90 + /// 91 + /// <https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada> 92 + pub protected_resources: Option<Vec<CowStr<'s>>>, 93 + } 94 + 95 + /// Protected resource metadata, returned from `.well-known/oauth-protected-resource`. 96 + /// 97 + /// Allows clients to discover which authorization servers protect a given resource 98 + /// and what scopes and bearer methods are accepted. Defined by 99 + /// [draft-ietf-oauth-resource-metadata](https://datatracker.ietf.org/doc/draft-ietf-oauth-resource-metadata/). 100 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default)] 101 + pub struct OAuthProtectedResourceMetadata<'s> { 102 + /// The URL of the protected resource itself. 103 + #[serde(borrow)] 104 + pub resource: CowStr<'s>, 105 + /// URLs of authorization servers that can issue tokens for this resource. 106 + pub authorization_servers: Option<Vec<CowStr<'s>>>, 107 + /// URL of the resource server's JWK Set document. 108 + pub jwks_uri: Option<CowStr<'s>>, 109 + /// List of OAuth 2.0 scope values the resource server supports. 110 + pub scopes_supported: Vec<CowStr<'s>>, 111 + /// Bearer token presentation methods supported (`header`, `body`, `query`). 112 + pub bearer_methods_supported: Option<Vec<CowStr<'s>>>, 113 + /// JWS signing algorithms supported for resource-bound tokens. 114 + pub resource_signing_alg_values_supported: Option<Vec<CowStr<'s>>>, 115 + /// URL of a page with human-readable information about the resource. 116 + pub resource_documentation: Option<CowStr<'s>>, 117 + /// URL of the resource server's privacy policy. 118 + pub resource_policy_uri: Option<CowStr<'s>>, 119 + /// URL of the resource server's terms of service. 120 + pub resource_tos_uri: Option<CowStr<'s>>, 121 + } 122 + 123 + impl IntoStatic for OAuthProtectedResourceMetadata<'_> { 124 + type Output = OAuthProtectedResourceMetadata<'static>; 125 + fn into_static(self) -> Self::Output { 126 + OAuthProtectedResourceMetadata { 127 + resource: self.resource.into_static(), 128 + authorization_servers: self.authorization_servers.into_static(), 129 + jwks_uri: self.jwks_uri.map(|v| v.into_static()), 130 + scopes_supported: self.scopes_supported.into_static(), 131 + bearer_methods_supported: self.bearer_methods_supported.map(|v| v.into_static()), 132 + resource_signing_alg_values_supported: self 133 + .resource_signing_alg_values_supported 134 + .map(|v| v.into_static()), 135 + resource_documentation: self.resource_documentation.map(|v| v.into_static()), 136 + resource_policy_uri: self.resource_policy_uri.map(|v| v.into_static()), 137 + resource_tos_uri: self.resource_tos_uri.map(|v| v.into_static()), 138 + } 139 + } 140 + } 141 + 142 + impl IntoStatic for OAuthAuthorizationServerMetadata<'_> { 143 + type Output = OAuthAuthorizationServerMetadata<'static>; 144 + fn into_static(self) -> Self::Output { 145 + OAuthAuthorizationServerMetadata { 146 + issuer: self.issuer.into_static(), 147 + authorization_endpoint: self.authorization_endpoint.into_static(), 148 + token_endpoint: self.token_endpoint.into_static(), 149 + jwks_uri: self.jwks_uri.into_static(), 150 + registration_endpoint: self.registration_endpoint.into_static(), 151 + scopes_supported: self.scopes_supported.into_static(), 152 + response_types_supported: self.response_types_supported.into_static(), 153 + response_modes_supported: self.response_modes_supported.into_static(), 154 + grant_types_supported: self.grant_types_supported.into_static(), 155 + token_endpoint_auth_methods_supported: self 156 + .token_endpoint_auth_methods_supported 157 + .into_static(), 158 + token_endpoint_auth_signing_alg_values_supported: self 159 + .token_endpoint_auth_signing_alg_values_supported 160 + .into_static(), 161 + service_documentation: self.service_documentation.into_static(), 162 + ui_locales_supported: self.ui_locales_supported.into_static(), 163 + op_policy_uri: self.op_policy_uri.into_static(), 164 + op_tos_uri: self.op_tos_uri.into_static(), 165 + revocation_endpoint: self.revocation_endpoint.into_static(), 166 + revocation_endpoint_auth_methods_supported: self 167 + .revocation_endpoint_auth_methods_supported 168 + .into_static(), 169 + revocation_endpoint_auth_signing_alg_values_supported: self 170 + .revocation_endpoint_auth_signing_alg_values_supported 171 + .into_static(), 172 + introspection_endpoint: self.introspection_endpoint.into_static(), 173 + introspection_endpoint_auth_methods_supported: self 174 + .introspection_endpoint_auth_methods_supported 175 + .into_static(), 176 + introspection_endpoint_auth_signing_alg_values_supported: self 177 + .introspection_endpoint_auth_signing_alg_values_supported 178 + .into_static(), 179 + code_challenge_methods_supported: self.code_challenge_methods_supported.into_static(), 180 + subject_types_supported: self.subject_types_supported.into_static(), 181 + require_request_uri_registration: self.require_request_uri_registration.into_static(), 182 + pushed_authorization_request_endpoint: self 183 + .pushed_authorization_request_endpoint 184 + .into_static(), 185 + require_pushed_authorization_requests: self 186 + .require_pushed_authorization_requests 187 + .into_static(), 188 + authorization_response_iss_parameter_supported: self 189 + .authorization_response_iss_parameter_supported 190 + .into_static(), 191 + dpop_signing_alg_values_supported: self.dpop_signing_alg_values_supported.into_static(), 192 + client_id_metadata_document_supported: self 193 + .client_id_metadata_document_supported 194 + .into_static(), 195 + protected_resources: self.protected_resources.into_static(), 196 + } 197 + } 198 + }
+192
src-tauri/vendor/jacquard-oauth/src/types/request.rs
··· 1 + use jacquard_common::{CowStr, IntoStatic}; 2 + use serde::{Deserialize, Serialize}; 3 + 4 + /// The `response_type` parameter for an OAuth 2.0 authorization request. 5 + /// 6 + /// Determines what the authorization server returns in the redirect response. 7 + #[derive(Serialize, Deserialize, Debug)] 8 + #[serde(rename_all = "snake_case")] 9 + pub enum AuthorizationResponseType { 10 + /// Authorization code flow — server returns a short-lived code for token exchange. 11 + Code, 12 + /// Implicit flow — server returns an access token directly (not recommended for new clients). 13 + Token, 14 + /// OpenID Connect ID token response (see the 15 + /// [multiple response types spec](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html)). 16 + IdToken, 17 + } 18 + 19 + /// The `response_mode` parameter controlling how the authorization response is returned. 20 + /// 21 + /// Defaults to `query` for `code` response type and `fragment` for `token`. 22 + #[derive(Serialize, Deserialize, Debug)] 23 + #[serde(rename_all = "snake_case")] 24 + pub enum AuthorizationResponseMode { 25 + /// Parameters are appended as query string components to the redirect URI. 26 + Query, 27 + /// Parameters are appended as URI fragment components to the redirect URI. 28 + Fragment, 29 + /// Parameters are encoded in an HTML form POSTed to the redirect URI. 30 + /// 31 + /// <https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode> 32 + FormPost, 33 + } 34 + 35 + /// PKCE code challenge method, as defined in RFC 7636. 36 + /// 37 + /// `S256` is strongly preferred; `Plain` should only be used when the client 38 + /// cannot perform SHA-256. 39 + #[derive(Serialize, Deserialize, Debug)] 40 + pub enum AuthorizationCodeChallengeMethod { 41 + /// SHA-256 hash of the code verifier, base64url-encoded (recommended). 42 + S256, 43 + /// Raw code verifier used as the challenge (not recommended). 44 + #[serde(rename = "plain")] 45 + Plain, 46 + } 47 + 48 + /// Parameters for a Pushed Authorization Request (PAR), as defined in RFC 9126. 49 + /// 50 + /// PAR allows clients to push their authorization parameters directly to the 51 + /// authorization server before redirecting the user, improving security by keeping 52 + /// parameters out of the browser URL. 53 + #[derive(Serialize, Deserialize, Debug)] 54 + pub struct ParParameters<'a> { 55 + /// The response type to request (e.g. `code`). 56 + /// 57 + /// <https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1> 58 + pub response_type: AuthorizationResponseType, 59 + /// The redirect URI where the authorization response will be sent. 60 + #[serde(borrow)] 61 + pub redirect_uri: CowStr<'a>, 62 + /// An opaque CSRF state value to be echoed back in the callback. 63 + pub state: CowStr<'a>, 64 + /// Space-separated list of requested scopes. 65 + pub scope: Option<CowStr<'a>>, 66 + /// How the authorization response parameters are delivered to the client. 67 + /// 68 + /// <https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes> 69 + pub response_mode: Option<AuthorizationResponseMode>, 70 + /// The PKCE code challenge derived from the code verifier. 71 + /// 72 + /// <https://datatracker.ietf.org/doc/html/rfc7636#section-4.3> 73 + pub code_challenge: CowStr<'a>, 74 + /// The method used to derive the code challenge. 75 + pub code_challenge_method: AuthorizationCodeChallengeMethod, 76 + /// Hint to pre-fill the login form with a handle or email. 77 + /// 78 + /// <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest> 79 + pub login_hint: Option<CowStr<'a>>, 80 + /// Prompt hint controlling authorization server UI behavior. 81 + pub prompt: Option<CowStr<'a>>, 82 + } 83 + 84 + /// The `grant_type` parameter for a token endpoint request. 85 + #[derive(Serialize, Deserialize)] 86 + #[serde(rename_all = "snake_case")] 87 + pub enum TokenGrantType { 88 + /// Exchange an authorization code for tokens. 89 + AuthorizationCode, 90 + /// Use a refresh token to obtain a new access token. 91 + RefreshToken, 92 + } 93 + 94 + /// Parameters for exchanging an authorization code for tokens (RFC 6749 §4.1.3). 95 + #[derive(Serialize, Deserialize)] 96 + pub struct TokenRequestParameters<'a> { 97 + /// Must be `authorization_code` for the authorization code grant. 98 + /// 99 + /// <https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3> 100 + pub grant_type: TokenGrantType, 101 + /// The authorization code received from the authorization server. 102 + #[serde(borrow)] 103 + pub code: CowStr<'a>, 104 + /// The redirect URI used in the original authorization request. 105 + pub redirect_uri: CowStr<'a>, 106 + /// The PKCE code verifier that was used to generate the code challenge (RFC 7636 §4.5). 107 + /// 108 + /// <https://datatracker.ietf.org/doc/html/rfc7636#section-4.5> 109 + pub code_verifier: CowStr<'a>, 110 + } 111 + 112 + /// Parameters for refreshing an access token using a refresh token (RFC 6749 §6). 113 + #[derive(Serialize, Deserialize)] 114 + pub struct RefreshRequestParameters<'a> { 115 + /// Must be `refresh_token` for the refresh grant. 116 + /// 117 + /// <https://datatracker.ietf.org/doc/html/rfc6749#section-6> 118 + pub grant_type: TokenGrantType, 119 + /// The refresh token previously issued to the client. 120 + #[serde(borrow)] 121 + pub refresh_token: CowStr<'a>, 122 + /// Optional scope to request; must not exceed the originally granted scope. 123 + pub scope: Option<CowStr<'a>>, 124 + } 125 + 126 + /// Parameters for a token revocation request (RFC 7009 §2.1). 127 + /// 128 + /// Sent to the revocation endpoint to invalidate an access or refresh token, 129 + /// for example on logout. 130 + /// 131 + /// <https://datatracker.ietf.org/doc/html/rfc7009#section-2.1> 132 + #[derive(Serialize, Deserialize)] 133 + pub struct RevocationRequestParameters<'a> { 134 + /// The token to be revoked. 135 + #[serde(borrow)] 136 + pub token: CowStr<'a>, 137 + // ? 138 + // pub token_type_hint: Option<String>, 139 + } 140 + 141 + impl IntoStatic for RevocationRequestParameters<'_> { 142 + type Output = RevocationRequestParameters<'static>; 143 + 144 + fn into_static(self) -> Self::Output { 145 + Self::Output { 146 + token: self.token.into_static(), 147 + } 148 + } 149 + } 150 + 151 + impl IntoStatic for TokenRequestParameters<'_> { 152 + type Output = TokenRequestParameters<'static>; 153 + 154 + fn into_static(self) -> Self::Output { 155 + Self::Output { 156 + grant_type: self.grant_type, 157 + code: self.code.into_static(), 158 + redirect_uri: self.redirect_uri.into_static(), 159 + code_verifier: self.code_verifier.into_static(), 160 + } 161 + } 162 + } 163 + 164 + impl IntoStatic for RefreshRequestParameters<'_> { 165 + type Output = RefreshRequestParameters<'static>; 166 + 167 + fn into_static(self) -> Self::Output { 168 + Self::Output { 169 + grant_type: self.grant_type, 170 + refresh_token: self.refresh_token.into_static(), 171 + scope: self.scope.map(CowStr::into_static), 172 + } 173 + } 174 + } 175 + 176 + impl IntoStatic for ParParameters<'_> { 177 + type Output = ParParameters<'static>; 178 + 179 + fn into_static(self) -> Self::Output { 180 + Self::Output { 181 + redirect_uri: self.redirect_uri.into_static(), 182 + response_type: self.response_type, 183 + scope: self.scope.into_static(), 184 + code_challenge: self.code_challenge.into_static(), 185 + code_challenge_method: self.code_challenge_method, 186 + state: self.state.into_static(), 187 + response_mode: self.response_mode, 188 + login_hint: self.login_hint.into_static(), 189 + prompt: self.prompt.into_static(), 190 + } 191 + } 192 + }
+54
src-tauri/vendor/jacquard-oauth/src/types/response.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use smol_str::SmolStr; 3 + 4 + /// The response from a Pushed Authorization Request (PAR) endpoint. 5 + /// 6 + /// The returned `request_uri` is used in place of inline authorization parameters 7 + /// when redirecting the user to the authorization server. 8 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 9 + pub struct OAuthParResponse { 10 + /// A short-lived URI representing the pushed authorization request. 11 + pub request_uri: SmolStr, 12 + /// Number of seconds until the `request_uri` expires. 13 + pub expires_in: Option<u32>, 14 + } 15 + 16 + /// The token type returned by the authorization server, indicating how to present the token. 17 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 18 + pub enum OAuthTokenType { 19 + /// Demonstration of Proof of Possession (DPoP) token (RFC 9449). Requires a DPoP proof header. 20 + DPoP, 21 + /// Standard Bearer token (RFC 6750). Sent as `Authorization: Bearer <token>`. 22 + Bearer, 23 + } 24 + 25 + impl OAuthTokenType { 26 + /// Returns the string representation used in HTTP `Authorization` headers. 27 + pub fn as_str(&self) -> &'static str { 28 + match self { 29 + OAuthTokenType::DPoP => "DPoP", 30 + OAuthTokenType::Bearer => "Bearer", 31 + } 32 + } 33 + } 34 + 35 + /// A successful token response from the authorization server (RFC 6749 §5.1). 36 + /// <https://datatracker.ietf.org/doc/html/rfc6749#section-5.1> 37 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 38 + pub struct OAuthTokenResponse { 39 + /// The issued access token. 40 + pub access_token: SmolStr, 41 + /// The type of token, indicating the presentation scheme to use. 42 + pub token_type: OAuthTokenType, 43 + /// Lifetime of the access token in seconds from the time of issuance. 44 + pub expires_in: Option<i64>, 45 + /// A refresh token that can be used to obtain new access tokens. 46 + pub refresh_token: Option<SmolStr>, 47 + /// The scopes actually granted, if different from those requested. 48 + pub scope: Option<SmolStr>, 49 + // ATPROTO extension: add the sub claim to the token response to allow 50 + // clients to resolve the PDS url (audience) using the did resolution 51 + // mechanism. 52 + /// The subject (DID) the token was issued for; ATProto extension for PDS discovery. 53 + pub sub: Option<SmolStr>, 54 + }
+49
src-tauri/vendor/jacquard-oauth/src/types/token.rs
··· 1 + use super::response::OAuthTokenType; 2 + use jacquard_common::types::string::{Datetime, Did}; 3 + use jacquard_common::{CowStr, IntoStatic}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + /// A complete set of OAuth tokens and associated claims for an authenticated session. 7 + /// 8 + /// Combines the token response with resolved identity claims to give the client 9 + /// everything it needs to make authorized requests. This is stored in the session 10 + /// and refreshed transparently by `OAuthSession`. 11 + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] 12 + pub struct TokenSet<'s> { 13 + /// The issuer URL of the authorization server that issued these tokens. 14 + #[serde(borrow)] 15 + pub iss: CowStr<'s>, 16 + /// The subject DID identifying the authenticated user. 17 + pub sub: Did<'s>, 18 + /// The audience (resource server URL or DID) the tokens are intended for. 19 + pub aud: CowStr<'s>, 20 + /// The scopes granted by the authorization server. 21 + pub scope: Option<CowStr<'s>>, 22 + 23 + /// A refresh token that can be exchanged for new access tokens. 24 + pub refresh_token: Option<CowStr<'s>>, 25 + /// The current access token to include in API requests. 26 + pub access_token: CowStr<'s>, 27 + /// Whether the access token must be presented as a DPoP or Bearer token. 28 + pub token_type: OAuthTokenType, 29 + 30 + /// The point in time at which the access token expires. 31 + pub expires_at: Option<Datetime>, 32 + } 33 + 34 + impl IntoStatic for TokenSet<'_> { 35 + type Output = TokenSet<'static>; 36 + 37 + fn into_static(self) -> Self::Output { 38 + TokenSet { 39 + iss: self.iss.into_static(), 40 + sub: self.sub.into_static(), 41 + aud: self.aud.into_static(), 42 + scope: self.scope.map(|s| s.into_static()), 43 + refresh_token: self.refresh_token.map(|s| s.into_static()), 44 + access_token: self.access_token.into_static(), 45 + token_type: self.token_type, 46 + expires_at: self.expires_at.map(|s| s.into_static()), 47 + } 48 + } 49 + }
+115
src-tauri/vendor/jacquard-oauth/src/utils.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use elliptic_curve::SecretKey; 4 + use jacquard_common::CowStr; 5 + use jose_jwk::{Key, crypto}; 6 + use rand::{CryptoRng, RngCore, rngs::ThreadRng}; 7 + use sha2::{Digest, Sha256}; 8 + use std::cmp::Ordering; 9 + 10 + use crate::{FALLBACK_ALG, types::OAuthAuthorizationServerMetadata}; 11 + 12 + /// Generate a fresh JWK secret key using the first algorithm from `allowed_algos` that is 13 + /// supported, returning `None` if none are supported. 14 + /// 15 + /// Currently only `ES256` (P-256 ECDSA) is implemented; other algorithm identifiers are skipped. 16 + pub fn generate_key(allowed_algos: &[CowStr]) -> Option<Key> { 17 + for alg in allowed_algos { 18 + #[allow(clippy::single_match)] 19 + match alg.as_ref() { 20 + "ES256" => { 21 + return Some(Key::from(&crypto::Key::from( 22 + SecretKey::<p256::NistP256>::random(&mut ThreadRng::default()), 23 + ))); 24 + } 25 + _ => { 26 + // TODO: Implement other algorithms? 27 + } 28 + } 29 + } 30 + None 31 + } 32 + 33 + /// Generate a cryptographically random 16-byte nonce encoded as base64url (no padding). 34 + pub fn generate_nonce() -> CowStr<'static> { 35 + URL_SAFE_NO_PAD 36 + .encode(get_random_values::<_, 16>(&mut ThreadRng::default())) 37 + .into() 38 + } 39 + 40 + /// Generate a cryptographically random 43-byte PKCE code verifier encoded as base64url (no padding). 41 + pub fn generate_verifier() -> CowStr<'static> { 42 + URL_SAFE_NO_PAD 43 + .encode(get_random_values::<_, 43>(&mut ThreadRng::default())) 44 + .into() 45 + } 46 + 47 + /// Fill a `LEN`-byte array with cryptographically random bytes from `rng`. 48 + pub fn get_random_values<R, const LEN: usize>(rng: &mut R) -> [u8; LEN] 49 + where 50 + R: RngCore + CryptoRng, 51 + { 52 + let mut bytes = [0u8; LEN]; 53 + rng.fill_bytes(&mut bytes); 54 + bytes 55 + } 56 + 57 + /// Compare two algorithm identifier strings by preference order for DPoP key generation. 58 + /// 59 + /// The ordering is: ES256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other. 60 + /// Algorithms within the same family are ordered by key length, preferring shorter (faster) keys first. 61 + pub fn compare_algos(a: &CowStr, b: &CowStr) -> Ordering { 62 + if a.as_ref() == "ES256K" { 63 + return Ordering::Less; 64 + } 65 + if b.as_ref() == "ES256K" { 66 + return Ordering::Greater; 67 + } 68 + for prefix in ["ES", "PS", "RS"] { 69 + if let Some(stripped_a) = a.strip_prefix(prefix) { 70 + if let Some(stripped_b) = b.strip_prefix(prefix) { 71 + if let (Ok(len_a), Ok(len_b)) = 72 + (stripped_a.parse::<u32>(), stripped_b.parse::<u32>()) 73 + { 74 + return len_a.cmp(&len_b); 75 + } 76 + } else { 77 + return Ordering::Less; 78 + } 79 + } else if b.starts_with(prefix) { 80 + return Ordering::Greater; 81 + } 82 + } 83 + Ordering::Equal 84 + } 85 + 86 + /// Generate a PKCE challenge/verifier pair. 87 + /// 88 + /// Returns `(challenge, verifier)` where `challenge` is the base64url-encoded SHA-256 hash 89 + /// of the verifier, per [RFC 7636 §4.1](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1). 90 + /// The verifier must be kept secret and sent at the token endpoint; the challenge is sent at 91 + /// the authorization endpoint. 92 + pub fn generate_pkce() -> (CowStr<'static>, CowStr<'static>) { 93 + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 94 + let verifier = generate_verifier(); 95 + ( 96 + URL_SAFE_NO_PAD 97 + .encode(Sha256::digest(&verifier.as_str())) 98 + .into(), 99 + verifier, 100 + ) 101 + } 102 + 103 + /// Generate a DPoP signing key compatible with the algorithms advertised by the authorization server. 104 + /// 105 + /// Reads `dpop_signing_alg_values_supported` from the server metadata, sorts by preference 106 + /// using [`compare_algos`], and attempts to generate a key for the most preferred supported 107 + /// algorithm. Falls back to [`crate::FALLBACK_ALG`] if the server does not advertise any algorithms. 108 + pub fn generate_dpop_key(metadata: &OAuthAuthorizationServerMetadata) -> Option<Key> { 109 + let mut algs = metadata 110 + .dpop_signing_alg_values_supported 111 + .clone() 112 + .unwrap_or(vec![FALLBACK_ALG.into()]); 113 + algs.sort_by(compare_algos); 114 + generate_key(&algs) 115 + }