learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

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

feat: implement foundational AT Protocol integration

* OAuth 2.1
* PDS client
* AT-URI
* TID utilities.

+2185 -5
+135
Cargo.lock
··· 158 158 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 159 159 160 160 [[package]] 161 + name = "base64ct" 162 + version = "1.8.1" 163 + source = "registry+https://github.com/rust-lang/crates.io-index" 164 + checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" 165 + 166 + [[package]] 161 167 name = "bitflags" 162 168 version = "1.3.2" 163 169 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 273 279 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" 274 280 275 281 [[package]] 282 + name = "const-oid" 283 + version = "0.9.6" 284 + source = "registry+https://github.com/rust-lang/crates.io-index" 285 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" 286 + 287 + [[package]] 276 288 name = "cookie" 277 289 version = "0.18.1" 278 290 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 319 331 ] 320 332 321 333 [[package]] 334 + name = "curve25519-dalek" 335 + version = "4.1.3" 336 + source = "registry+https://github.com/rust-lang/crates.io-index" 337 + checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" 338 + dependencies = [ 339 + "cfg-if", 340 + "cpufeatures", 341 + "curve25519-dalek-derive", 342 + "digest", 343 + "fiat-crypto", 344 + "rustc_version", 345 + "subtle", 346 + "zeroize", 347 + ] 348 + 349 + [[package]] 350 + name = "curve25519-dalek-derive" 351 + version = "0.1.1" 352 + source = "registry+https://github.com/rust-lang/crates.io-index" 353 + checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" 354 + dependencies = [ 355 + "proc-macro2", 356 + "quote", 357 + "syn 2.0.111", 358 + ] 359 + 360 + [[package]] 322 361 name = "deadpool" 323 362 version = "0.12.3" 324 363 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 354 393 ] 355 394 356 395 [[package]] 396 + name = "der" 397 + version = "0.7.10" 398 + source = "registry+https://github.com/rust-lang/crates.io-index" 399 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" 400 + dependencies = [ 401 + "const-oid", 402 + "zeroize", 403 + ] 404 + 405 + [[package]] 357 406 name = "deranged" 358 407 version = "0.5.5" 359 408 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 391 440 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" 392 441 393 442 [[package]] 443 + name = "ed25519" 444 + version = "2.2.3" 445 + source = "registry+https://github.com/rust-lang/crates.io-index" 446 + checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" 447 + dependencies = [ 448 + "pkcs8", 449 + "serde", 450 + "signature", 451 + ] 452 + 453 + [[package]] 454 + name = "ed25519-dalek" 455 + version = "2.2.0" 456 + source = "registry+https://github.com/rust-lang/crates.io-index" 457 + checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" 458 + dependencies = [ 459 + "curve25519-dalek", 460 + "ed25519", 461 + "serde", 462 + "sha2", 463 + "subtle", 464 + "zeroize", 465 + ] 466 + 467 + [[package]] 394 468 name = "encoding_rs" 395 469 version = "0.8.35" 396 470 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 426 500 version = "2.3.0" 427 501 source = "registry+https://github.com/rust-lang/crates.io-index" 428 502 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 503 + 504 + [[package]] 505 + name = "fiat-crypto" 506 + version = "0.2.9" 507 + source = "registry+https://github.com/rust-lang/crates.io-index" 508 + checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" 429 509 430 510 [[package]] 431 511 name = "find-msvc-tools" ··· 1091 1171 dependencies = [ 1092 1172 "async-trait", 1093 1173 "axum", 1174 + "base64 0.22.1", 1094 1175 "chrono", 1095 1176 "deadpool-postgres", 1096 1177 "dotenvy", 1178 + "ed25519-dalek", 1179 + "getrandom 0.3.4", 1097 1180 "malfestio-core", 1098 1181 "readability", 1099 1182 "regex", 1100 1183 "reqwest 0.12.28", 1101 1184 "serde", 1102 1185 "serde_json", 1186 + "sha2", 1103 1187 "tokio", 1104 1188 "tokio-postgres", 1105 1189 "tower", ··· 1107 1191 "tower-http", 1108 1192 "tracing", 1109 1193 "tracing-subscriber", 1194 + "urlencoding", 1110 1195 "uuid", 1111 1196 ] 1112 1197 ··· 1415 1500 checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1416 1501 1417 1502 [[package]] 1503 + name = "pkcs8" 1504 + version = "0.10.2" 1505 + source = "registry+https://github.com/rust-lang/crates.io-index" 1506 + checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" 1507 + dependencies = [ 1508 + "der", 1509 + "spki", 1510 + ] 1511 + 1512 + [[package]] 1418 1513 name = "pkg-config" 1419 1514 version = "0.3.32" 1420 1515 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1722 1817 ] 1723 1818 1724 1819 [[package]] 1820 + name = "rustc_version" 1821 + version = "0.4.1" 1822 + source = "registry+https://github.com/rust-lang/crates.io-index" 1823 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 1824 + dependencies = [ 1825 + "semver", 1826 + ] 1827 + 1828 + [[package]] 1725 1829 name = "rustix" 1726 1830 version = "1.1.3" 1727 1831 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1827 1931 ] 1828 1932 1829 1933 [[package]] 1934 + name = "semver" 1935 + version = "1.0.27" 1936 + source = "registry+https://github.com/rust-lang/crates.io-index" 1937 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 1938 + 1939 + [[package]] 1830 1940 name = "serde" 1831 1941 version = "1.0.228" 1832 1942 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1929 2039 ] 1930 2040 1931 2041 [[package]] 2042 + name = "signature" 2043 + version = "2.2.0" 2044 + source = "registry+https://github.com/rust-lang/crates.io-index" 2045 + checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2046 + dependencies = [ 2047 + "rand_core 0.6.4", 2048 + ] 2049 + 2050 + [[package]] 1932 2051 name = "siphasher" 1933 2052 version = "0.3.11" 1934 2053 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1970 2089 dependencies = [ 1971 2090 "libc", 1972 2091 "windows-sys 0.60.2", 2092 + ] 2093 + 2094 + [[package]] 2095 + name = "spki" 2096 + version = "0.7.3" 2097 + source = "registry+https://github.com/rust-lang/crates.io-index" 2098 + checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" 2099 + dependencies = [ 2100 + "base64ct", 2101 + "der", 1973 2102 ] 1974 2103 1975 2104 [[package]] ··· 2493 2622 "percent-encoding", 2494 2623 "serde", 2495 2624 ] 2625 + 2626 + [[package]] 2627 + name = "urlencoding" 2628 + version = "2.1.3" 2629 + source = "registry+https://github.com/rust-lang/crates.io-index" 2630 + checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 2496 2631 2497 2632 [[package]] 2498 2633 name = "utf-8"
+208
crates/core/src/at_uri.rs
··· 1 + //! AT-URI builder and parser for AT Protocol. 2 + //! 3 + //! AT-URIs are the canonical way to reference records in the AT Protocol. 4 + //! Format: `at://<authority>/<collection>/<rkey>` 5 + //! 6 + //! - authority: DID or handle 7 + //! - collection: NSID (e.g., "app.malfestio.deck") 8 + //! - rkey: Record key (usually a TID) 9 + 10 + use std::fmt; 11 + 12 + /// An AT-URI representing a record in the AT Protocol network. 13 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 14 + pub struct AtUri { 15 + /// The authority (DID or handle) 16 + pub authority: String, 17 + /// The collection NSID (e.g., "app.malfestio.deck") 18 + pub collection: String, 19 + /// The record key 20 + pub rkey: String, 21 + } 22 + 23 + impl AtUri { 24 + /// Create a new AT-URI. 25 + /// 26 + /// # Arguments 27 + /// 28 + /// * `authority` - The DID or handle 29 + /// * `collection` - The collection NSID 30 + /// * `rkey` - The record key 31 + pub fn new(authority: impl Into<String>, collection: impl Into<String>, rkey: impl Into<String>) -> Self { 32 + Self { authority: authority.into(), collection: collection.into(), rkey: rkey.into() } 33 + } 34 + 35 + /// Create an AT-URI for a deck record. 36 + pub fn deck(did: &str, rkey: &str) -> Self { 37 + Self::new(did, "app.malfestio.deck", rkey) 38 + } 39 + 40 + /// Create an AT-URI for a card record. 41 + pub fn card(did: &str, rkey: &str) -> Self { 42 + Self::new(did, "app.malfestio.card", rkey) 43 + } 44 + 45 + /// Create an AT-URI for a note record. 46 + pub fn note(did: &str, rkey: &str) -> Self { 47 + Self::new(did, "app.malfestio.note", rkey) 48 + } 49 + 50 + /// Parse an AT-URI string. 51 + pub fn parse(s: &str) -> Result<Self, AtUriError> { 52 + let s = s.strip_prefix("at://").ok_or(AtUriError::MissingScheme)?; 53 + 54 + let parts: Vec<&str> = s.splitn(3, '/').collect(); 55 + if parts.len() != 3 { 56 + return Err(AtUriError::InvalidFormat); 57 + } 58 + 59 + let authority = parts[0]; 60 + let collection = parts[1]; 61 + let rkey = parts[2]; 62 + 63 + if authority.is_empty() { 64 + return Err(AtUriError::EmptyAuthority); 65 + } 66 + if collection.is_empty() { 67 + return Err(AtUriError::EmptyCollection); 68 + } 69 + if rkey.is_empty() { 70 + return Err(AtUriError::EmptyRkey); 71 + } 72 + 73 + if !collection.contains('.') { 74 + return Err(AtUriError::InvalidNsid); 75 + } 76 + 77 + Ok(Self { authority: authority.to_string(), collection: collection.to_string(), rkey: rkey.to_string() }) 78 + } 79 + 80 + /// Check if the authority is a DID. 81 + pub fn is_did(&self) -> bool { 82 + self.authority.starts_with("did:") 83 + } 84 + 85 + /// Check if the authority is a handle. 86 + pub fn is_handle(&self) -> bool { 87 + !self.is_did() 88 + } 89 + } 90 + 91 + impl fmt::Display for AtUri { 92 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 93 + write!(f, "at://{}/{}/{}", self.authority, self.collection, self.rkey) 94 + } 95 + } 96 + 97 + /// Error type for AT-URI parsing. 98 + #[derive(Debug, Clone, PartialEq, Eq)] 99 + pub enum AtUriError { 100 + MissingScheme, 101 + InvalidFormat, 102 + EmptyAuthority, 103 + EmptyCollection, 104 + EmptyRkey, 105 + InvalidNsid, 106 + } 107 + 108 + impl fmt::Display for AtUriError { 109 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 110 + match self { 111 + AtUriError::MissingScheme => write!(f, "AT-URI must start with 'at://'"), 112 + AtUriError::InvalidFormat => write!(f, "AT-URI must have format at://authority/collection/rkey"), 113 + AtUriError::EmptyAuthority => write!(f, "AT-URI authority cannot be empty"), 114 + AtUriError::EmptyCollection => write!(f, "AT-URI collection cannot be empty"), 115 + AtUriError::EmptyRkey => write!(f, "AT-URI rkey cannot be empty"), 116 + AtUriError::InvalidNsid => write!(f, "Collection must be a valid NSID"), 117 + } 118 + } 119 + } 120 + 121 + impl std::error::Error for AtUriError {} 122 + 123 + #[cfg(test)] 124 + mod tests { 125 + use super::*; 126 + 127 + #[test] 128 + fn test_new_at_uri() { 129 + let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); 130 + assert_eq!(uri.authority, "did:plc:abc123"); 131 + assert_eq!(uri.collection, "app.malfestio.deck"); 132 + assert_eq!(uri.rkey, "3k5abc123"); 133 + } 134 + 135 + #[test] 136 + fn test_display() { 137 + let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); 138 + assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123"); 139 + } 140 + 141 + #[test] 142 + fn test_parse_valid() { 143 + let uri = AtUri::parse("at://did:plc:abc123/app.malfestio.deck/3k5abc123").unwrap(); 144 + assert_eq!(uri.authority, "did:plc:abc123"); 145 + assert_eq!(uri.collection, "app.malfestio.deck"); 146 + assert_eq!(uri.rkey, "3k5abc123"); 147 + } 148 + 149 + #[test] 150 + fn test_parse_with_handle() { 151 + let uri = AtUri::parse("at://alice.bsky.social/app.malfestio.note/abc123").unwrap(); 152 + assert_eq!(uri.authority, "alice.bsky.social"); 153 + assert!(uri.is_handle()); 154 + assert!(!uri.is_did()); 155 + } 156 + 157 + #[test] 158 + fn test_parse_missing_scheme() { 159 + let result = AtUri::parse("did:plc:abc123/app.malfestio.deck/3k5abc123"); 160 + assert_eq!(result, Err(AtUriError::MissingScheme)); 161 + } 162 + 163 + #[test] 164 + fn test_parse_invalid_format() { 165 + let result = AtUri::parse("at://did:plc:abc123/app.malfestio.deck"); 166 + assert_eq!(result, Err(AtUriError::InvalidFormat)); 167 + } 168 + 169 + #[test] 170 + fn test_parse_empty_authority() { 171 + let result = AtUri::parse("at:///app.malfestio.deck/rkey"); 172 + assert_eq!(result, Err(AtUriError::EmptyAuthority)); 173 + } 174 + 175 + #[test] 176 + fn test_parse_invalid_nsid() { 177 + let result = AtUri::parse("at://did:plc:abc123/notansid/rkey"); 178 + assert_eq!(result, Err(AtUriError::InvalidNsid)); 179 + } 180 + 181 + #[test] 182 + fn test_roundtrip() { 183 + let original = "at://did:plc:abc123/app.malfestio.deck/3k5abc123"; 184 + let uri = AtUri::parse(original).unwrap(); 185 + assert_eq!(uri.to_string(), original); 186 + } 187 + 188 + #[test] 189 + fn test_convenience_constructors() { 190 + let deck = AtUri::deck("did:plc:abc", "tid123"); 191 + assert_eq!(deck.collection, "app.malfestio.deck"); 192 + 193 + let card = AtUri::card("did:plc:abc", "tid456"); 194 + assert_eq!(card.collection, "app.malfestio.card"); 195 + 196 + let note = AtUri::note("did:plc:abc", "tid789"); 197 + assert_eq!(note.collection, "app.malfestio.note"); 198 + } 199 + 200 + #[test] 201 + fn test_is_did() { 202 + let uri = AtUri::new("did:plc:abc123", "app.test", "rkey"); 203 + assert!(uri.is_did()); 204 + 205 + let uri = AtUri::new("alice.bsky.social", "app.test", "rkey"); 206 + assert!(!uri.is_did()); 207 + } 208 + }
+2
crates/core/src/lib.rs
··· 1 + pub mod at_uri; 1 2 pub mod error; 2 3 pub mod model; 4 + pub mod tid; 3 5 4 6 pub use error::{Error, Result}; 5 7 pub use model::{Card, Deck, Note};
+161
crates/core/src/tid.rs
··· 1 + //! TID (Timestamp Identifier) generation for AT Protocol. 2 + //! 3 + //! TIDs are used as record keys in the AT Protocol. They are 13-character 4 + //! base32-sortable strings derived from timestamps with a clock identifier. 5 + //! 6 + //! Format: 13 characters encoding 64 bits: 7 + //! - 53 bits: microseconds since Unix epoch 8 + //! - 10 bits: clock identifier (for uniqueness within same microsecond) 9 + //! - 1 bit: always 0 (reserved) 10 + 11 + use std::sync::atomic::{AtomicU64, Ordering}; 12 + use std::time::{SystemTime, UNIX_EPOCH}; 13 + 14 + /// Base32 "sort" alphabet used by AT Protocol TIDs. 15 + /// This alphabet maintains lexicographic sorting. 16 + const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz"; 17 + 18 + /// Atomic counter for clock identifier within same microsecond. 19 + static CLOCK_ID: AtomicU64 = AtomicU64::new(0); 20 + static LAST_TIMESTAMP: AtomicU64 = AtomicU64::new(0); 21 + 22 + /// Generate a new TID. 23 + /// 24 + /// TIDs are guaranteed to be: 25 + /// - Unique within this process 26 + /// - Lexicographically sortable by creation time 27 + /// - Compatible with AT Protocol record key requirements 28 + pub fn generate_tid() -> String { 29 + let now = SystemTime::now() 30 + .duration_since(UNIX_EPOCH) 31 + .expect("Time went backwards") 32 + .as_micros() as u64; 33 + 34 + let last = LAST_TIMESTAMP.load(Ordering::SeqCst); 35 + let clock_id = if now == last { 36 + CLOCK_ID.fetch_add(1, Ordering::SeqCst) & 0x3FF 37 + } else { 38 + LAST_TIMESTAMP.store(now, Ordering::SeqCst); 39 + CLOCK_ID.store(1, Ordering::SeqCst); 40 + 0 41 + }; 42 + 43 + let combined = (now << 11) | (clock_id << 1); 44 + encode_base32_sort(combined) 45 + } 46 + 47 + /// Encode a 64-bit value as a 13-character base32-sort string. 48 + fn encode_base32_sort(mut value: u64) -> String { 49 + let mut result = [0u8; 13]; 50 + 51 + for i in (0..13).rev() { 52 + result[i] = BASE32_SORT[(value & 0x1F) as usize]; 53 + value >>= 5; 54 + } 55 + 56 + String::from_utf8(result.to_vec()).expect("Base32 encoding produced invalid UTF-8") 57 + } 58 + 59 + /// Parse a TID string and extract the timestamp. 60 + /// 61 + /// Returns the Unix timestamp in microseconds, or None if invalid. 62 + pub fn parse_tid_timestamp(tid: &str) -> Option<u64> { 63 + if tid.len() != 13 { 64 + return None; 65 + } 66 + 67 + let decoded = decode_base32_sort(tid)?; 68 + Some(decoded >> 11) 69 + } 70 + 71 + /// Decode a base32-sort string to a 64-bit value. 72 + fn decode_base32_sort(s: &str) -> Option<u64> { 73 + let mut value: u64 = 0; 74 + 75 + for c in s.chars() { 76 + let idx = BASE32_SORT.iter().position(|&b| b == c as u8)?; 77 + value = (value << 5) | (idx as u64); 78 + } 79 + 80 + Some(value) 81 + } 82 + 83 + /// Validate that a string is a valid TID format. 84 + pub fn is_valid_tid(tid: &str) -> bool { 85 + if tid.len() != 13 { 86 + return false; 87 + } 88 + 89 + tid.chars().all(|c| BASE32_SORT.contains(&(c as u8))) 90 + } 91 + 92 + #[cfg(test)] 93 + mod tests { 94 + use super::*; 95 + 96 + #[test] 97 + fn test_tid_length() { 98 + let tid = generate_tid(); 99 + assert_eq!(tid.len(), 13); 100 + } 101 + 102 + #[test] 103 + fn test_tid_characters() { 104 + let tid = generate_tid(); 105 + for c in tid.chars() { 106 + assert!(BASE32_SORT.contains(&(c as u8)), "Invalid character '{}' in TID", c); 107 + } 108 + } 109 + 110 + #[test] 111 + fn test_tid_uniqueness() { 112 + let tids: Vec<String> = (0..100).map(|_| generate_tid()).collect(); 113 + let mut unique = tids.clone(); 114 + unique.sort(); 115 + unique.dedup(); 116 + assert_eq!(tids.len(), unique.len(), "TIDs should be unique"); 117 + } 118 + 119 + #[test] 120 + fn test_tid_sortability() { 121 + let tid1 = generate_tid(); 122 + std::thread::sleep(std::time::Duration::from_micros(10)); 123 + let tid2 = generate_tid(); 124 + 125 + assert!(tid1 < tid2, "Later TIDs should sort after earlier ones"); 126 + } 127 + 128 + #[test] 129 + fn test_parse_tid_timestamp() { 130 + let tid = generate_tid(); 131 + let timestamp = parse_tid_timestamp(&tid); 132 + assert!(timestamp.is_some()); 133 + 134 + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros() as u64; 135 + let parsed = timestamp.unwrap(); 136 + assert!( 137 + now.abs_diff(parsed) < 1_000_000, 138 + "Parsed timestamp {} too far from now {}", 139 + parsed, 140 + now 141 + ); 142 + } 143 + 144 + #[test] 145 + fn test_is_valid_tid() { 146 + let tid = generate_tid(); 147 + assert!(is_valid_tid(&tid)); 148 + 149 + assert!(!is_valid_tid("short")); 150 + assert!(!is_valid_tid("toolongstring!")); 151 + assert!(!is_valid_tid("0123456789012")); 152 + } 153 + 154 + #[test] 155 + fn test_roundtrip_encoding() { 156 + let value: u64 = 0x123456789ABCDEF0; 157 + let encoded = encode_base32_sort(value); 158 + let decoded = decode_base32_sort(&encoded); 159 + assert_eq!(decoded, Some(value)); 160 + } 161 + }
+10 -1
crates/server/Cargo.toml
··· 6 6 [dependencies] 7 7 async-trait = "0.1.83" 8 8 axum = "0.8.8" 9 + base64 = "0.22" 9 10 chrono = { version = "0.4.42", features = ["serde"] } 10 11 deadpool-postgres = "0.14.0" 11 12 dotenvy = "0.15.7" 13 + ed25519-dalek = { version = "2.2.0", features = ["serde"] } 14 + getrandom = { version = "0.3", features = ["std"] } 12 15 malfestio-core = { version = "0.1.0", path = "../core" } 13 16 readability = "0.3.0" 14 17 regex = "1.12.2" 15 18 reqwest = { version = "0.12.28", features = ["json"] } 16 19 serde = "1.0.228" 17 20 serde_json = "1.0.148" 21 + sha2 = "0.10" 18 22 tokio = { version = "1.48.0", features = ["full"] } 19 - tokio-postgres = { version = "0.7.13", features = ["with-serde_json-1", "with-chrono-0_4", "with-uuid-1"] } 23 + urlencoding = "2.1" 24 + tokio-postgres = { version = "0.7.13", features = [ 25 + "with-serde_json-1", 26 + "with-chrono-0_4", 27 + "with-uuid-1", 28 + ] } 20 29 tower = "0.5.2" 21 30 tower-cookies = "0.11.0" 22 31 tower-http = { version = "0.6.8", features = ["cors", "trace"] }
+2
crates/server/src/lib.rs
··· 1 1 pub mod api; 2 2 pub mod db; 3 3 pub mod middleware; 4 + pub mod oauth; 5 + pub mod pds; 4 6 pub mod repository; 5 7 pub mod state; 6 8
+77
crates/server/src/oauth/client_metadata.rs
··· 1 + //! OAuth client metadata endpoint. 2 + //! 3 + //! Serves the client_metadata.json for AT Protocol OAuth discovery. 4 + 5 + use axum::{Json, response::IntoResponse}; 6 + use serde::Serialize; 7 + 8 + /// OAuth client metadata for AT Protocol. 9 + #[derive(Serialize, Clone)] 10 + pub struct ClientMetadata { 11 + pub client_id: String, 12 + pub application_type: String, 13 + pub grant_types: Vec<String>, 14 + pub scope: String, 15 + pub response_types: Vec<String>, 16 + pub redirect_uris: Vec<String>, 17 + pub client_name: String, 18 + pub client_uri: String, 19 + pub token_endpoint_auth_method: String, 20 + pub dpop_bound_access_tokens: bool, 21 + } 22 + 23 + impl Default for ClientMetadata { 24 + fn default() -> Self { 25 + Self::from_env() 26 + } 27 + } 28 + 29 + impl ClientMetadata { 30 + /// Create client metadata from environment variables. 31 + pub fn from_env() -> Self { 32 + let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); 33 + let app_name = std::env::var("APP_NAME").unwrap_or_else(|_| "Malfestio".to_string()); 34 + 35 + Self { 36 + client_id: format!("{}/oauth/client-metadata.json", app_url), 37 + application_type: "web".to_string(), 38 + grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()], 39 + scope: "atproto transition:generic".to_string(), 40 + response_types: vec!["code".to_string()], 41 + redirect_uris: vec![format!("{}/oauth/callback", app_url)], 42 + client_name: app_name, 43 + client_uri: app_url, 44 + token_endpoint_auth_method: "none".to_string(), 45 + dpop_bound_access_tokens: true, 46 + } 47 + } 48 + } 49 + 50 + /// Handler for `/.well-known/oauth-client-metadata` endpoint. 51 + pub async fn client_metadata_handler() -> impl IntoResponse { 52 + Json(ClientMetadata::from_env()) 53 + } 54 + 55 + #[cfg(test)] 56 + mod tests { 57 + use super::*; 58 + 59 + #[test] 60 + fn test_default_metadata() { 61 + let meta = ClientMetadata::default(); 62 + assert!(meta.client_id.contains("client-metadata.json")); 63 + assert_eq!(meta.application_type, "web"); 64 + assert!(meta.grant_types.contains(&"authorization_code".to_string())); 65 + assert!(meta.dpop_bound_access_tokens); 66 + } 67 + 68 + #[test] 69 + fn test_metadata_serialization() { 70 + let meta = ClientMetadata::default(); 71 + let json = serde_json::to_string(&meta).unwrap(); 72 + 73 + assert!(json.contains("client_id")); 74 + assert!(json.contains("dpop_bound_access_tokens")); 75 + assert!(json.contains("atproto")); 76 + } 77 + }
+186
crates/server/src/oauth/dpop.rs
··· 1 + //! DPoP (Demonstrating Proof of Possession) implementation for OAuth 2.1. 2 + //! 3 + //! AT Protocol requires DPoP tokens to bind access tokens to specific clients. 4 + 5 + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 6 + use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; 7 + use serde::{Deserialize, Serialize}; 8 + use sha2::{Digest, Sha256}; 9 + use std::time::{SystemTime, UNIX_EPOCH}; 10 + 11 + /// A DPoP keypair for proof generation using Ed25519. 12 + #[derive(Clone)] 13 + pub struct DpopKeypair { 14 + signing_key: SigningKey, 15 + } 16 + 17 + /// DPoP proof JWT header. 18 + #[derive(Serialize, Deserialize)] 19 + struct DpopHeader { 20 + typ: String, 21 + alg: String, 22 + jwk: DpopJwk, 23 + } 24 + 25 + /// JWK representation for DPoP (Ed25519 public key). 26 + #[derive(Serialize, Deserialize, Clone)] 27 + pub struct DpopJwk { 28 + kty: String, 29 + crv: String, 30 + x: String, 31 + } 32 + 33 + /// DPoP proof JWT payload. 34 + #[derive(Serialize, Deserialize)] 35 + struct DpopPayload { 36 + jti: String, 37 + htm: String, 38 + htu: String, 39 + iat: u64, 40 + #[serde(skip_serializing_if = "Option::is_none")] 41 + ath: Option<String>, 42 + } 43 + 44 + impl DpopKeypair { 45 + /// Generate a new random Ed25519 DPoP keypair. 46 + pub fn generate() -> Self { 47 + let mut rng_bytes = [0u8; 32]; 48 + getrandom::fill(&mut rng_bytes).expect("Failed to generate random bytes"); 49 + let signing_key = SigningKey::from_bytes(&rng_bytes); 50 + Self { signing_key } 51 + } 52 + 53 + /// Get the verifying (public) key. 54 + pub fn verifying_key(&self) -> VerifyingKey { 55 + self.signing_key.verifying_key() 56 + } 57 + 58 + /// Get the JWK representation of the public key. 59 + pub fn public_jwk(&self) -> DpopJwk { 60 + let public_bytes = self.verifying_key().to_bytes(); 61 + DpopJwk { kty: "OKP".to_string(), crv: "Ed25519".to_string(), x: URL_SAFE_NO_PAD.encode(public_bytes) } 62 + } 63 + 64 + /// Generate a DPoP proof for a request. 65 + pub fn generate_proof(&self, method: &str, url: &str, access_token: Option<&str>) -> String { 66 + let header = DpopHeader { typ: "dpop+jwt".to_string(), alg: "EdDSA".to_string(), jwk: self.public_jwk() }; 67 + 68 + let now = SystemTime::now() 69 + .duration_since(UNIX_EPOCH) 70 + .expect("Time went backwards") 71 + .as_secs(); 72 + 73 + let jti = generate_jti(); 74 + 75 + let ath = access_token.map(|token| { 76 + let hash = Sha256::digest(token.as_bytes()); 77 + URL_SAFE_NO_PAD.encode(hash) 78 + }); 79 + 80 + let payload = DpopPayload { jti, htm: method.to_uppercase(), htu: url.to_string(), iat: now, ath }; 81 + 82 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 83 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 84 + 85 + let signing_input = format!("{}.{}", header_b64, payload_b64); 86 + 87 + let signature = self.signing_key.sign(signing_input.as_bytes()); 88 + let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 89 + 90 + format!("{}.{}.{}", header_b64, payload_b64, signature_b64) 91 + } 92 + } 93 + 94 + /// Generate a unique JWT ID. 95 + fn generate_jti() -> String { 96 + let mut bytes = [0u8; 16]; 97 + getrandom::fill(&mut bytes).expect("Failed to generate random bytes"); 98 + URL_SAFE_NO_PAD.encode(bytes) 99 + } 100 + 101 + /// Compute the JWK thumbprint for key binding. 102 + pub fn jwk_thumbprint(jwk: &DpopJwk) -> String { 103 + let canonical = format!(r#"{{"crv":"{}","kty":"{}","x":"{}"}}"#, jwk.crv, jwk.kty, jwk.x); 104 + let hash = Sha256::digest(canonical.as_bytes()); 105 + URL_SAFE_NO_PAD.encode(hash) 106 + } 107 + 108 + #[cfg(test)] 109 + mod tests { 110 + use super::*; 111 + use ed25519_dalek::Verifier; 112 + 113 + #[test] 114 + fn test_generate_keypair() { 115 + let kp = DpopKeypair::generate(); 116 + let _ = kp.verifying_key(); 117 + } 118 + 119 + #[test] 120 + fn test_keypair_uniqueness() { 121 + let kp1 = DpopKeypair::generate(); 122 + let kp2 = DpopKeypair::generate(); 123 + assert_ne!(kp1.verifying_key().to_bytes(), kp2.verifying_key().to_bytes()); 124 + } 125 + 126 + #[test] 127 + fn test_public_jwk() { 128 + let kp = DpopKeypair::generate(); 129 + let jwk = kp.public_jwk(); 130 + 131 + assert_eq!(jwk.kty, "OKP"); 132 + assert_eq!(jwk.crv, "Ed25519"); 133 + assert!(!jwk.x.is_empty()); 134 + assert_eq!(jwk.x.len(), 43); 135 + } 136 + 137 + #[test] 138 + fn test_generate_proof() { 139 + let kp = DpopKeypair::generate(); 140 + let proof = kp.generate_proof("POST", "https://example.com/token", None); 141 + 142 + let parts: Vec<&str> = proof.split('.').collect(); 143 + assert_eq!(parts.len(), 3); 144 + 145 + let header_json = URL_SAFE_NO_PAD.decode(parts[0]).unwrap(); 146 + let header: serde_json::Value = serde_json::from_slice(&header_json).unwrap(); 147 + assert_eq!(header["typ"], "dpop+jwt"); 148 + assert_eq!(header["alg"], "EdDSA"); 149 + } 150 + 151 + #[test] 152 + fn test_proof_signature_verifies() { 153 + let kp = DpopKeypair::generate(); 154 + let proof = kp.generate_proof("GET", "https://example.com/resource", None); 155 + 156 + let parts: Vec<&str> = proof.split('.').collect(); 157 + let signing_input = format!("{}.{}", parts[0], parts[1]); 158 + let signature_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); 159 + 160 + let signature = ed25519_dalek::Signature::from_slice(&signature_bytes).unwrap(); 161 + let result = kp.verifying_key().verify(signing_input.as_bytes(), &signature); 162 + 163 + assert!(result.is_ok(), "Signature should verify"); 164 + } 165 + 166 + #[test] 167 + fn test_generate_proof_with_token() { 168 + let kp = DpopKeypair::generate(); 169 + let proof = kp.generate_proof("GET", "https://example.com/resource", Some("access_token_123")); 170 + 171 + let parts: Vec<&str> = proof.split('.').collect(); 172 + let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 173 + let payload: serde_json::Value = serde_json::from_slice(&payload_json).unwrap(); 174 + 175 + assert!(payload.get("ath").is_some()); 176 + } 177 + 178 + #[test] 179 + fn test_jwk_thumbprint() { 180 + let kp = DpopKeypair::generate(); 181 + let jwk = kp.public_jwk(); 182 + let thumbprint = jwk_thumbprint(&jwk); 183 + 184 + assert_eq!(thumbprint.len(), 43); 185 + } 186 + }
+336
crates/server/src/oauth/flow.rs
··· 1 + //! OAuth 2.1 authorization flow for AT Protocol. 2 + //! 3 + //! Handles the complete OAuth flow including: 4 + //! - Authorization URL generation 5 + //! - Token exchange with PKCE + DPoP 6 + //! - Token refresh 7 + 8 + use super::dpop::DpopKeypair; 9 + use super::pkce::{derive_code_challenge, generate_code_verifier}; 10 + use super::resolver::{IdentityResolver, ResolveError}; 11 + use serde::{Deserialize, Serialize}; 12 + use std::collections::HashMap; 13 + use std::sync::{Arc, RwLock}; 14 + 15 + /// OAuth session state stored during the authorization flow. 16 + #[derive(Clone)] 17 + pub struct OAuthSession { 18 + /// The PKCE code verifier 19 + pub code_verifier: String, 20 + /// The DPoP keypair for this session 21 + pub dpop_keypair: DpopKeypair, 22 + /// The user's DID after resolution 23 + pub did: Option<String>, 24 + /// The user's PDS URL 25 + pub pds_url: Option<String>, 26 + /// When this session was created (for expiry) 27 + pub created_at: std::time::Instant, 28 + } 29 + 30 + /// OAuth tokens received from the authorization server. 31 + #[derive(Clone, Serialize, Deserialize)] 32 + pub struct OAuthTokens { 33 + pub access_token: String, 34 + pub refresh_token: Option<String>, 35 + pub token_type: String, 36 + pub expires_in: Option<u64>, 37 + pub scope: Option<String>, 38 + } 39 + 40 + /// In-memory session storage (for development). 41 + /// In production, use a database-backed implementation. 42 + pub type SessionStore = Arc<RwLock<HashMap<String, OAuthSession>>>; 43 + 44 + /// Create a new session store. 45 + pub fn new_session_store() -> SessionStore { 46 + Arc::new(RwLock::new(HashMap::new())) 47 + } 48 + 49 + /// OAuth flow manager. 50 + pub struct OAuthFlow { 51 + resolver: IdentityResolver, 52 + client: reqwest::Client, 53 + client_id: String, 54 + redirect_uri: String, 55 + } 56 + 57 + impl OAuthFlow { 58 + /// Create a new OAuth flow manager. 59 + pub fn new() -> Self { 60 + let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string()); 61 + 62 + Self { 63 + resolver: IdentityResolver::new(), 64 + client: reqwest::Client::new(), 65 + client_id: format!("{}/oauth/client-metadata.json", app_url), 66 + redirect_uri: format!("{}/oauth/callback", app_url), 67 + } 68 + } 69 + 70 + /// Start the OAuth flow for a user handle or DID. 71 + /// 72 + /// Returns the authorization URL to redirect the user to. 73 + pub async fn start_authorization( 74 + &self, handle_or_did: &str, state: &str, sessions: &SessionStore, 75 + ) -> Result<String, OAuthFlowError> { 76 + let (did, pds_url) = if handle_or_did.starts_with("did:") { 77 + let resolved = self.resolver.resolve_did(handle_or_did).await?; 78 + (resolved.did, resolved.pds_url) 79 + } else { 80 + let did = self.resolver.resolve_handle(handle_or_did).await?; 81 + let resolved = self.resolver.resolve_did(&did).await?; 82 + (resolved.did, resolved.pds_url) 83 + }; 84 + 85 + let auth_server = self.get_auth_server_metadata(&pds_url).await?; 86 + 87 + let code_verifier = generate_code_verifier(); 88 + let code_challenge = derive_code_challenge(&code_verifier); 89 + 90 + let dpop_keypair = DpopKeypair::generate(); 91 + 92 + let session = OAuthSession { 93 + code_verifier, 94 + dpop_keypair, 95 + did: Some(did.clone()), 96 + pds_url: Some(pds_url), 97 + created_at: std::time::Instant::now(), 98 + }; 99 + 100 + sessions.write().unwrap().insert(state.to_string(), session); 101 + 102 + let auth_url = format!( 103 + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256&login_hint={}", 104 + auth_server.authorization_endpoint, 105 + urlencoding::encode(&self.client_id), 106 + urlencoding::encode(&self.redirect_uri), 107 + urlencoding::encode("atproto transition:generic"), 108 + urlencoding::encode(state), 109 + urlencoding::encode(&code_challenge), 110 + urlencoding::encode(&did) 111 + ); 112 + 113 + Ok(auth_url) 114 + } 115 + 116 + /// Exchange an authorization code for tokens. 117 + pub async fn exchange_code( 118 + &self, code: &str, state: &str, sessions: &SessionStore, 119 + ) -> Result<OAuthTokens, OAuthFlowError> { 120 + let session = sessions 121 + .read() 122 + .unwrap() 123 + .get(state) 124 + .cloned() 125 + .ok_or(OAuthFlowError::SessionNotFound)?; 126 + 127 + let pds_url = session.pds_url.as_ref().ok_or(OAuthFlowError::SessionNotFound)?; 128 + 129 + let auth_server = self.get_auth_server_metadata(pds_url).await?; 130 + 131 + let dpop_proof = session 132 + .dpop_keypair 133 + .generate_proof("POST", &auth_server.token_endpoint, None); 134 + 135 + let response = self 136 + .client 137 + .post(&auth_server.token_endpoint) 138 + .header("DPoP", dpop_proof) 139 + .form(&[ 140 + ("grant_type", "authorization_code"), 141 + ("code", code), 142 + ("redirect_uri", &self.redirect_uri), 143 + ("client_id", &self.client_id), 144 + ("code_verifier", &session.code_verifier), 145 + ]) 146 + .send() 147 + .await 148 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 149 + 150 + if !response.status().is_success() { 151 + let error_body = response.text().await.unwrap_or_default(); 152 + return Err(OAuthFlowError::TokenExchangeFailed(error_body)); 153 + } 154 + 155 + let tokens: OAuthTokens = response 156 + .json() 157 + .await 158 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 159 + 160 + sessions.write().unwrap().remove(state); 161 + 162 + Ok(tokens) 163 + } 164 + 165 + /// Refresh an access token. 166 + pub async fn refresh_token( 167 + &self, refresh_token: &str, pds_url: &str, dpop_keypair: &DpopKeypair, 168 + ) -> Result<OAuthTokens, OAuthFlowError> { 169 + let auth_server = self.get_auth_server_metadata(pds_url).await?; 170 + 171 + let dpop_proof = dpop_keypair.generate_proof("POST", &auth_server.token_endpoint, None); 172 + 173 + let response = self 174 + .client 175 + .post(&auth_server.token_endpoint) 176 + .header("DPoP", dpop_proof) 177 + .form(&[ 178 + ("grant_type", "refresh_token"), 179 + ("refresh_token", refresh_token), 180 + ("client_id", &self.client_id), 181 + ]) 182 + .send() 183 + .await 184 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 185 + 186 + if !response.status().is_success() { 187 + let error_body = response.text().await.unwrap_or_default(); 188 + return Err(OAuthFlowError::TokenRefreshFailed(error_body)); 189 + } 190 + 191 + response 192 + .json() 193 + .await 194 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string())) 195 + } 196 + 197 + /// Get authorization server metadata from PDS. 198 + async fn get_auth_server_metadata(&self, pds_url: &str) -> Result<AuthServerMetadata, OAuthFlowError> { 199 + // First get the protected resource metadata 200 + let resource_url = format!("{}/.well-known/oauth-protected-resource", pds_url); 201 + 202 + let resource_response = self 203 + .client 204 + .get(&resource_url) 205 + .timeout(std::time::Duration::from_secs(10)) 206 + .send() 207 + .await 208 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 209 + 210 + if !resource_response.status().is_success() { 211 + return Err(OAuthFlowError::MetadataFetchFailed(pds_url.to_string())); 212 + } 213 + 214 + let resource: serde_json::Value = resource_response 215 + .json() 216 + .await 217 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 218 + 219 + let auth_server_url = resource["authorization_servers"] 220 + .as_array() 221 + .and_then(|arr| arr.first()) 222 + .and_then(|v| v.as_str()) 223 + .ok_or_else(|| OAuthFlowError::MetadataFetchFailed(pds_url.to_string()))?; 224 + 225 + let auth_meta_url = format!("{}/.well-known/oauth-authorization-server", auth_server_url); 226 + 227 + let auth_response = self 228 + .client 229 + .get(&auth_meta_url) 230 + .timeout(std::time::Duration::from_secs(10)) 231 + .send() 232 + .await 233 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?; 234 + 235 + if !auth_response.status().is_success() { 236 + return Err(OAuthFlowError::MetadataFetchFailed(auth_server_url.to_string())); 237 + } 238 + 239 + auth_response 240 + .json() 241 + .await 242 + .map_err(|e| OAuthFlowError::NetworkError(e.to_string())) 243 + } 244 + } 245 + 246 + impl Default for OAuthFlow { 247 + fn default() -> Self { 248 + Self::new() 249 + } 250 + } 251 + 252 + /// Authorization server metadata. 253 + #[derive(Deserialize)] 254 + pub struct AuthServerMetadata { 255 + pub issuer: String, 256 + pub authorization_endpoint: String, 257 + pub token_endpoint: String, 258 + pub pushed_authorization_request_endpoint: Option<String>, 259 + } 260 + 261 + /// Error type for OAuth flow operations. 262 + #[derive(Debug, Clone)] 263 + pub enum OAuthFlowError { 264 + SessionNotFound, 265 + NetworkError(String), 266 + MetadataFetchFailed(String), 267 + TokenExchangeFailed(String), 268 + TokenRefreshFailed(String), 269 + ResolveError(String), 270 + } 271 + 272 + impl From<ResolveError> for OAuthFlowError { 273 + fn from(err: ResolveError) -> Self { 274 + OAuthFlowError::ResolveError(err.to_string()) 275 + } 276 + } 277 + 278 + impl std::fmt::Display for OAuthFlowError { 279 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 280 + match self { 281 + OAuthFlowError::SessionNotFound => write!(f, "OAuth session not found"), 282 + OAuthFlowError::NetworkError(e) => write!(f, "Network error: {}", e), 283 + OAuthFlowError::MetadataFetchFailed(url) => write!(f, "Failed to fetch metadata from {}", url), 284 + OAuthFlowError::TokenExchangeFailed(e) => write!(f, "Token exchange failed: {}", e), 285 + OAuthFlowError::TokenRefreshFailed(e) => write!(f, "Token refresh failed: {}", e), 286 + OAuthFlowError::ResolveError(e) => write!(f, "Identity resolution failed: {}", e), 287 + } 288 + } 289 + } 290 + 291 + impl std::error::Error for OAuthFlowError {} 292 + 293 + /// Generate a secure random state parameter. 294 + pub fn generate_state() -> String { 295 + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 296 + 297 + let mut bytes = [0u8; 16]; 298 + getrandom::fill(&mut bytes).expect("Failed to generate random bytes"); 299 + URL_SAFE_NO_PAD.encode(bytes) 300 + } 301 + 302 + #[cfg(test)] 303 + mod tests { 304 + use super::*; 305 + 306 + #[test] 307 + fn test_generate_state() { 308 + let state1 = generate_state(); 309 + let state2 = generate_state(); 310 + 311 + assert_ne!(state1, state2); 312 + assert_eq!(state1.len(), 22); 313 + } 314 + 315 + #[test] 316 + fn test_new_session_store() { 317 + let store = new_session_store(); 318 + assert!(store.read().unwrap().is_empty()); 319 + } 320 + 321 + #[test] 322 + fn test_oauth_flow_creation() { 323 + let flow = OAuthFlow::new(); 324 + assert!(flow.client_id.contains("client-metadata.json")); 325 + assert!(flow.redirect_uri.contains("callback")); 326 + } 327 + 328 + #[test] 329 + fn test_oauth_flow_error_display() { 330 + let err = OAuthFlowError::SessionNotFound; 331 + assert!(err.to_string().contains("session not found")); 332 + 333 + let err = OAuthFlowError::NetworkError("timeout".to_string()); 334 + assert!(err.to_string().contains("timeout")); 335 + } 336 + }
+15
crates/server/src/oauth/mod.rs
··· 1 + //! OAuth 2.1 implementation for AT Protocol. 2 + //! 3 + //! This module provides the OAuth 2.1 client flow components required 4 + //! for AT Protocol authentication: 5 + //! 6 + //! - PKCE (Proof Key for Code Exchange) 7 + //! - DPoP (Demonstrating Proof of Possession) 8 + //! - Handle/DID resolution 9 + //! - Token management 10 + 11 + pub mod client_metadata; 12 + pub mod dpop; 13 + pub mod flow; 14 + pub mod pkce; 15 + pub mod resolver;
+76
crates/server/src/oauth/pkce.rs
··· 1 + //! PKCE (Proof Key for Code Exchange) implementation for OAuth 2.1. 2 + //! 3 + //! AT Protocol requires PKCE with S256 challenge method. 4 + 5 + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 6 + use sha2::{Digest, Sha256}; 7 + 8 + /// Length of the code verifier in bytes (before base64 encoding). 9 + const CODE_VERIFIER_LENGTH: usize = 32; 10 + 11 + /// Generate a cryptographically random code verifier. 12 + /// 13 + /// The verifier is a high-entropy random string used in PKCE flow. 14 + pub fn generate_code_verifier() -> String { 15 + let mut bytes = [0u8; CODE_VERIFIER_LENGTH]; 16 + getrandom::fill(&mut bytes).expect("Failed to generate random bytes"); 17 + URL_SAFE_NO_PAD.encode(bytes) 18 + } 19 + 20 + /// Derive the S256 code challenge from a code verifier. 21 + /// 22 + /// The challenge is the base64url-encoded SHA-256 hash of the verifier. 23 + pub fn derive_code_challenge(verifier: &str) -> String { 24 + let hash = Sha256::digest(verifier.as_bytes()); 25 + URL_SAFE_NO_PAD.encode(hash) 26 + } 27 + 28 + /// Verify that a code challenge matches a code verifier. 29 + pub fn verify_challenge(verifier: &str, challenge: &str) -> bool { 30 + derive_code_challenge(verifier) == challenge 31 + } 32 + 33 + #[cfg(test)] 34 + mod tests { 35 + use super::*; 36 + 37 + #[test] 38 + fn test_generate_verifier_length() { 39 + let verifier = generate_code_verifier(); 40 + assert_eq!(verifier.len(), 43); 41 + } 42 + 43 + #[test] 44 + fn test_generate_verifier_uniqueness() { 45 + let v1 = generate_code_verifier(); 46 + let v2 = generate_code_verifier(); 47 + assert_ne!(v1, v2); 48 + } 49 + 50 + #[test] 51 + fn test_challenge_derivation() { 52 + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; 53 + let challenge = derive_code_challenge(verifier); 54 + 55 + assert!(!challenge.is_empty()); 56 + assert_eq!(challenge.len(), 43); 57 + } 58 + 59 + #[test] 60 + fn test_verify_challenge() { 61 + let verifier = generate_code_verifier(); 62 + let challenge = derive_code_challenge(&verifier); 63 + 64 + assert!(verify_challenge(&verifier, &challenge)); 65 + assert!(!verify_challenge(&verifier, "wrong_challenge")); 66 + } 67 + 68 + #[test] 69 + fn test_challenge_is_url_safe() { 70 + let verifier = generate_code_verifier(); 71 + let challenge = derive_code_challenge(&verifier); 72 + assert!(!challenge.contains('+')); 73 + assert!(!challenge.contains('/')); 74 + assert!(!challenge.contains('=')); 75 + } 76 + }
+261
crates/server/src/oauth/resolver.rs
··· 1 + //! Handle and DID resolution for AT Protocol. 2 + //! 3 + //! Resolves user identities to discover their PDS (Personal Data Server). 4 + 5 + use serde::{Deserialize, Serialize}; 6 + 7 + /// Result of resolving a handle or DID. 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct ResolvedIdentity { 10 + /// The DID (always populated after resolution) 11 + pub did: String, 12 + /// The handle (if resolved from DID) 13 + pub handle: Option<String>, 14 + /// The PDS URL for this identity 15 + pub pds_url: String, 16 + } 17 + 18 + /// Error type for resolution failures. 19 + #[derive(Debug, Clone)] 20 + pub enum ResolveError { 21 + /// Handle not found 22 + HandleNotFound(String), 23 + /// DID not found 24 + DidNotFound(String), 25 + /// Network error 26 + NetworkError(String), 27 + /// Invalid DID format 28 + InvalidDid(String), 29 + } 30 + 31 + impl std::fmt::Display for ResolveError { 32 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 33 + match self { 34 + ResolveError::HandleNotFound(h) => write!(f, "Handle not found: {}", h), 35 + ResolveError::DidNotFound(d) => write!(f, "DID not found: {}", d), 36 + ResolveError::NetworkError(e) => write!(f, "Network error: {}", e), 37 + ResolveError::InvalidDid(d) => write!(f, "Invalid DID: {}", d), 38 + } 39 + } 40 + } 41 + 42 + impl std::error::Error for ResolveError {} 43 + 44 + /// Resolver for AT Protocol identities. 45 + /// 46 + /// Handles resolution of: 47 + /// - Handle -> DID (via DNS TXT or HTTP well-known) 48 + /// - DID -> PDS URL (via PLC directory or did:web) 49 + pub struct IdentityResolver { 50 + client: reqwest::Client, 51 + plc_directory: String, 52 + } 53 + 54 + impl Default for IdentityResolver { 55 + fn default() -> Self { 56 + Self::new() 57 + } 58 + } 59 + 60 + impl IdentityResolver { 61 + /// Create a new resolver with default settings. 62 + pub fn new() -> Self { 63 + Self { client: reqwest::Client::new(), plc_directory: "https://plc.directory".to_string() } 64 + } 65 + 66 + /// Create a resolver with a custom PLC directory URL. 67 + pub fn with_plc_directory(plc_directory: &str) -> Self { 68 + Self { client: reqwest::Client::new(), plc_directory: plc_directory.to_string() } 69 + } 70 + 71 + /// Resolve a handle to a DID. 72 + /// 73 + /// Tries HTTP well-known first, then falls back to DNS TXT. 74 + pub async fn resolve_handle(&self, handle: &str) -> Result<String, ResolveError> { 75 + // Try HTTP well-known first 76 + if let Ok(did) = self.resolve_handle_http(handle).await { 77 + return Ok(did); 78 + } 79 + 80 + // Fall back to DNS TXT (simplified - just return error for now) 81 + Err(ResolveError::HandleNotFound(handle.to_string())) 82 + } 83 + 84 + /// Resolve handle via HTTP well-known. 85 + async fn resolve_handle_http(&self, handle: &str) -> Result<String, ResolveError> { 86 + let url = format!("https://{}/.well-known/atproto-did", handle); 87 + 88 + let response = self 89 + .client 90 + .get(&url) 91 + .timeout(std::time::Duration::from_secs(10)) 92 + .send() 93 + .await 94 + .map_err(|e| ResolveError::NetworkError(e.to_string()))?; 95 + 96 + if !response.status().is_success() { 97 + return Err(ResolveError::HandleNotFound(handle.to_string())); 98 + } 99 + 100 + let did = response 101 + .text() 102 + .await 103 + .map_err(|e| ResolveError::NetworkError(e.to_string()))? 104 + .trim() 105 + .to_string(); 106 + 107 + if !did.starts_with("did:") { 108 + return Err(ResolveError::HandleNotFound(handle.to_string())); 109 + } 110 + 111 + Ok(did) 112 + } 113 + 114 + /// Resolve a DID to its PDS URL. 115 + pub async fn resolve_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> { 116 + if did.starts_with("did:plc:") { 117 + self.resolve_plc_did(did).await 118 + } else if did.starts_with("did:web:") { 119 + self.resolve_web_did(did).await 120 + } else { 121 + Err(ResolveError::InvalidDid(did.to_string())) 122 + } 123 + } 124 + 125 + /// Resolve a did:plc via the PLC directory. 126 + async fn resolve_plc_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> { 127 + let url = format!("{}/{}", self.plc_directory, did); 128 + 129 + let response = self 130 + .client 131 + .get(&url) 132 + .timeout(std::time::Duration::from_secs(10)) 133 + .send() 134 + .await 135 + .map_err(|e| ResolveError::NetworkError(e.to_string()))?; 136 + 137 + if !response.status().is_success() { 138 + return Err(ResolveError::DidNotFound(did.to_string())); 139 + } 140 + 141 + let doc: serde_json::Value = response 142 + .json() 143 + .await 144 + .map_err(|e| ResolveError::NetworkError(e.to_string()))?; 145 + 146 + // Extract PDS URL from service array 147 + let pds_url = doc["service"] 148 + .as_array() 149 + .and_then(|services| { 150 + services.iter().find(|s| { 151 + s["id"].as_str() == Some("#atproto_pds") || s["type"].as_str() == Some("AtprotoPersonalDataServer") 152 + }) 153 + }) 154 + .and_then(|s| s["serviceEndpoint"].as_str()) 155 + .ok_or_else(|| ResolveError::DidNotFound(did.to_string()))? 156 + .to_string(); 157 + 158 + // Extract handle from alsoKnownAs 159 + let handle = doc["alsoKnownAs"] 160 + .as_array() 161 + .and_then(|aka| { 162 + aka.iter() 163 + .find(|a| a.as_str().map(|s| s.starts_with("at://")).unwrap_or(false)) 164 + }) 165 + .and_then(|a| a.as_str()) 166 + .map(|s| s.strip_prefix("at://").unwrap_or(s).to_string()); 167 + 168 + Ok(ResolvedIdentity { did: did.to_string(), handle, pds_url }) 169 + } 170 + 171 + /// Resolve a did:web via HTTP. 172 + async fn resolve_web_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> { 173 + // did:web:example.com -> https://example.com/.well-known/did.json 174 + let domain = did 175 + .strip_prefix("did:web:") 176 + .ok_or_else(|| ResolveError::InvalidDid(did.to_string()))?; 177 + 178 + let url = format!("https://{}/.well-known/did.json", domain); 179 + 180 + let response = self 181 + .client 182 + .get(&url) 183 + .timeout(std::time::Duration::from_secs(10)) 184 + .send() 185 + .await 186 + .map_err(|e| ResolveError::NetworkError(e.to_string()))?; 187 + 188 + if !response.status().is_success() { 189 + return Err(ResolveError::DidNotFound(did.to_string())); 190 + } 191 + 192 + let doc: serde_json::Value = response 193 + .json() 194 + .await 195 + .map_err(|e| ResolveError::NetworkError(e.to_string()))?; 196 + 197 + let pds_url = doc["service"] 198 + .as_array() 199 + .and_then(|services| { 200 + services 201 + .iter() 202 + .find(|s| s["type"].as_str() == Some("AtprotoPersonalDataServer")) 203 + }) 204 + .and_then(|s| s["serviceEndpoint"].as_str()) 205 + .ok_or_else(|| ResolveError::DidNotFound(did.to_string()))? 206 + .to_string(); 207 + 208 + Ok(ResolvedIdentity { did: did.to_string(), handle: None, pds_url }) 209 + } 210 + } 211 + 212 + /// Check if a string is a valid DID. 213 + pub fn is_valid_did(s: &str) -> bool { 214 + s.starts_with("did:plc:") || s.starts_with("did:web:") 215 + } 216 + 217 + /// Check if a string is a valid handle. 218 + pub fn is_valid_handle(s: &str) -> bool { 219 + // Simple validation: contains at least one dot, no spaces 220 + s.contains('.') && !s.contains(' ') && !s.starts_with("did:") 221 + } 222 + 223 + #[cfg(test)] 224 + mod tests { 225 + use super::*; 226 + 227 + #[test] 228 + fn test_is_valid_did() { 229 + assert!(is_valid_did("did:plc:abc123")); 230 + assert!(is_valid_did("did:web:example.com")); 231 + assert!(!is_valid_did("alice.bsky.social")); 232 + assert!(!is_valid_did("did:other:xyz")); 233 + } 234 + 235 + #[test] 236 + fn test_is_valid_handle() { 237 + assert!(is_valid_handle("alice.bsky.social")); 238 + assert!(is_valid_handle("bob.example.com")); 239 + assert!(!is_valid_handle("did:plc:abc123")); 240 + assert!(!is_valid_handle("invalid handle")); 241 + assert!(!is_valid_handle("nodots")); 242 + } 243 + 244 + #[test] 245 + fn test_resolver_creation() { 246 + let resolver = IdentityResolver::new(); 247 + assert_eq!(resolver.plc_directory, "https://plc.directory"); 248 + 249 + let custom = IdentityResolver::with_plc_directory("https://custom.plc"); 250 + assert_eq!(custom.plc_directory, "https://custom.plc"); 251 + } 252 + 253 + #[test] 254 + fn test_resolve_error_display() { 255 + let err = ResolveError::HandleNotFound("test.handle".to_string()); 256 + assert!(err.to_string().contains("test.handle")); 257 + 258 + let err = ResolveError::InvalidDid("bad:did".to_string()); 259 + assert!(err.to_string().contains("bad:did")); 260 + } 261 + }
+302
crates/server/src/pds/client.rs
··· 1 + //! PDS client for XRPC operations. 2 + //! 3 + //! Handles communication with a user's Personal Data Server. 4 + 5 + use crate::oauth::dpop::DpopKeypair; 6 + use malfestio_core::at_uri::AtUri; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + /// A client for interacting with a user's PDS. 10 + pub struct PdsClient { 11 + http_client: reqwest::Client, 12 + pds_url: String, 13 + access_token: String, 14 + dpop_keypair: DpopKeypair, 15 + } 16 + 17 + /// Request body for putRecord XRPC. 18 + #[derive(Serialize)] 19 + #[serde(rename_all = "camelCase")] 20 + pub struct PutRecordRequest { 21 + pub repo: String, 22 + pub collection: String, 23 + pub rkey: String, 24 + pub record: serde_json::Value, 25 + #[serde(skip_serializing_if = "Option::is_none")] 26 + pub swap_record: Option<String>, 27 + #[serde(skip_serializing_if = "Option::is_none")] 28 + pub swap_commit: Option<String>, 29 + #[serde(skip_serializing_if = "Option::is_none")] 30 + pub validate: Option<bool>, 31 + } 32 + 33 + /// Response from putRecord XRPC. 34 + #[derive(Deserialize)] 35 + #[serde(rename_all = "camelCase")] 36 + pub struct PutRecordResponse { 37 + pub uri: String, 38 + pub cid: String, 39 + } 40 + 41 + /// Request body for deleteRecord XRPC. 42 + #[derive(Serialize)] 43 + #[serde(rename_all = "camelCase")] 44 + pub struct DeleteRecordRequest { 45 + pub repo: String, 46 + pub collection: String, 47 + pub rkey: String, 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub swap_record: Option<String>, 50 + #[serde(skip_serializing_if = "Option::is_none")] 51 + pub swap_commit: Option<String>, 52 + } 53 + 54 + /// Response from uploadBlob XRPC. 55 + #[derive(Deserialize)] 56 + pub struct UploadBlobResponse { 57 + pub blob: BlobRef, 58 + } 59 + 60 + /// A reference to an uploaded blob. 61 + #[derive(Clone, Serialize, Deserialize)] 62 + pub struct BlobRef { 63 + #[serde(rename = "$type")] 64 + pub blob_type: String, 65 + #[serde(rename = "ref")] 66 + pub cid: CidLink, 67 + #[serde(rename = "mimeType")] 68 + pub mime_type: String, 69 + pub size: u64, 70 + } 71 + 72 + /// A CID link. 73 + #[derive(Clone, Serialize, Deserialize)] 74 + pub struct CidLink { 75 + #[serde(rename = "$link")] 76 + pub link: String, 77 + } 78 + 79 + /// Error type for PDS operations. 80 + #[derive(Debug, Clone)] 81 + pub enum PdsError { 82 + NetworkError(String), 83 + AuthError(String), 84 + ValidationError(String), 85 + NotFound(String), 86 + ServerError(String), 87 + } 88 + 89 + impl std::fmt::Display for PdsError { 90 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 + match self { 92 + PdsError::NetworkError(e) => write!(f, "Network error: {}", e), 93 + PdsError::AuthError(e) => write!(f, "Authentication error: {}", e), 94 + PdsError::ValidationError(e) => write!(f, "Validation error: {}", e), 95 + PdsError::NotFound(e) => write!(f, "Not found: {}", e), 96 + PdsError::ServerError(e) => write!(f, "Server error: {}", e), 97 + } 98 + } 99 + } 100 + 101 + impl std::error::Error for PdsError {} 102 + 103 + impl PdsClient { 104 + /// Create a new PDS client. 105 + pub fn new(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self { 106 + Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair } 107 + } 108 + 109 + /// Create or update a record in the repository. 110 + /// 111 + /// # Arguments 112 + /// 113 + /// * `did` - The user's DID (repository owner) 114 + /// * `collection` - The collection NSID (e.g., "app.malfestio.deck") 115 + /// * `rkey` - The record key (TID) 116 + /// * `record` - The record data as JSON 117 + pub async fn put_record( 118 + &self, did: &str, collection: &str, rkey: &str, record: serde_json::Value, 119 + ) -> Result<AtUri, PdsError> { 120 + let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.pds_url); 121 + 122 + let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 123 + 124 + let request = PutRecordRequest { 125 + repo: did.to_string(), 126 + collection: collection.to_string(), 127 + rkey: rkey.to_string(), 128 + record, 129 + swap_record: None, 130 + swap_commit: None, 131 + validate: Some(true), 132 + }; 133 + 134 + let response = self 135 + .http_client 136 + .post(&url) 137 + .header("Authorization", format!("DPoP {}", self.access_token)) 138 + .header("DPoP", dpop_proof) 139 + .json(&request) 140 + .send() 141 + .await 142 + .map_err(|e| PdsError::NetworkError(e.to_string()))?; 143 + 144 + self.handle_response(response).await 145 + } 146 + 147 + /// Delete a record from the repository. 148 + pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<(), PdsError> { 149 + let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds_url); 150 + 151 + let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 152 + 153 + let request = DeleteRecordRequest { 154 + repo: did.to_string(), 155 + collection: collection.to_string(), 156 + rkey: rkey.to_string(), 157 + swap_record: None, 158 + swap_commit: None, 159 + }; 160 + 161 + let response = self 162 + .http_client 163 + .post(&url) 164 + .header("Authorization", format!("DPoP {}", self.access_token)) 165 + .header("DPoP", dpop_proof) 166 + .json(&request) 167 + .send() 168 + .await 169 + .map_err(|e| PdsError::NetworkError(e.to_string()))?; 170 + 171 + if response.status().is_success() { 172 + Ok(()) 173 + } else { 174 + let status = response.status(); 175 + let body = response.text().await.unwrap_or_default(); 176 + Err(self.map_error_status(status, body)) 177 + } 178 + } 179 + 180 + /// Upload a blob (media attachment) to the repository. 181 + pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, PdsError> { 182 + let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url); 183 + 184 + let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); 185 + 186 + let response = self 187 + .http_client 188 + .post(&url) 189 + .header("Authorization", format!("DPoP {}", self.access_token)) 190 + .header("DPoP", dpop_proof) 191 + .header("Content-Type", mime_type) 192 + .body(data) 193 + .send() 194 + .await 195 + .map_err(|e| PdsError::NetworkError(e.to_string()))?; 196 + 197 + if !response.status().is_success() { 198 + let status = response.status(); 199 + let body = response.text().await.unwrap_or_default(); 200 + return Err(self.map_error_status(status, body)); 201 + } 202 + 203 + let upload_response: UploadBlobResponse = response 204 + .json() 205 + .await 206 + .map_err(|e| PdsError::NetworkError(e.to_string()))?; 207 + 208 + Ok(upload_response.blob) 209 + } 210 + 211 + /// Handle response and parse AT-URI from success. 212 + async fn handle_response(&self, response: reqwest::Response) -> Result<AtUri, PdsError> { 213 + if !response.status().is_success() { 214 + let status = response.status(); 215 + let body = response.text().await.unwrap_or_default(); 216 + return Err(self.map_error_status(status, body)); 217 + } 218 + 219 + let put_response: PutRecordResponse = response 220 + .json() 221 + .await 222 + .map_err(|e| PdsError::NetworkError(e.to_string()))?; 223 + 224 + AtUri::parse(&put_response.uri).map_err(|e| PdsError::ValidationError(e.to_string())) 225 + } 226 + 227 + /// Map HTTP status to PdsError. 228 + fn map_error_status(&self, status: reqwest::StatusCode, body: String) -> PdsError { 229 + match status.as_u16() { 230 + 401 => PdsError::AuthError(body), 231 + 400 => PdsError::ValidationError(body), 232 + 404 => PdsError::NotFound(body), 233 + _ => PdsError::ServerError(format!("{}: {}", status, body)), 234 + } 235 + } 236 + } 237 + 238 + #[cfg(test)] 239 + mod tests { 240 + use super::*; 241 + 242 + #[test] 243 + fn test_put_record_request_serialization() { 244 + let request = PutRecordRequest { 245 + repo: "did:plc:abc123".to_string(), 246 + collection: "app.malfestio.deck".to_string(), 247 + rkey: "3k5abc123".to_string(), 248 + record: serde_json::json!({ 249 + "title": "Test Deck", 250 + "createdAt": "2024-01-01T00:00:00Z" 251 + }), 252 + swap_record: None, 253 + swap_commit: None, 254 + validate: Some(true), 255 + }; 256 + 257 + let json = serde_json::to_string(&request).unwrap(); 258 + assert!(json.contains("\"repo\":\"did:plc:abc123\"")); 259 + assert!(json.contains("\"collection\":\"app.malfestio.deck\"")); 260 + assert!(json.contains("\"rkey\":\"3k5abc123\"")); 261 + assert!(json.contains("\"validate\":true")); 262 + } 263 + 264 + #[test] 265 + fn test_delete_record_request_serialization() { 266 + let request = DeleteRecordRequest { 267 + repo: "did:plc:abc123".to_string(), 268 + collection: "app.malfestio.deck".to_string(), 269 + rkey: "3k5abc123".to_string(), 270 + swap_record: None, 271 + swap_commit: None, 272 + }; 273 + 274 + let json = serde_json::to_string(&request).unwrap(); 275 + assert!(json.contains("\"repo\":\"did:plc:abc123\"")); 276 + assert!(!json.contains("swapRecord")); // Should be omitted when None 277 + } 278 + 279 + #[test] 280 + fn test_blob_ref_serialization() { 281 + let blob_ref = BlobRef { 282 + blob_type: "blob".to_string(), 283 + cid: CidLink { link: "bafyreiabc123".to_string() }, 284 + mime_type: "image/jpeg".to_string(), 285 + size: 12345, 286 + }; 287 + 288 + let json = serde_json::to_string(&blob_ref).unwrap(); 289 + assert!(json.contains("\"$type\":\"blob\"")); 290 + assert!(json.contains("\"$link\":\"bafyreiabc123\"")); 291 + assert!(json.contains("\"mimeType\":\"image/jpeg\"")); 292 + } 293 + 294 + #[test] 295 + fn test_pds_error_display() { 296 + let err = PdsError::AuthError("Invalid token".to_string()); 297 + assert!(err.to_string().contains("Invalid token")); 298 + 299 + let err = PdsError::NetworkError("Connection refused".to_string()); 300 + assert!(err.to_string().contains("Connection refused")); 301 + } 302 + }
+9
crates/server/src/pds/mod.rs
··· 1 + //! PDS (Personal Data Server) client for AT Protocol. 2 + //! 3 + //! Provides record publishing operations: 4 + //! - putRecord - Create or update records 5 + //! - deleteRecord - Remove records 6 + //! - uploadBlob - Upload media attachments 7 + 8 + pub mod client; 9 + pub mod records;
+302
crates/server/src/pds/records.rs
··· 1 + //! Record serialization for AT Protocol Lexicons. 2 + //! 3 + //! Converts internal models to AT Protocol record format. 4 + 5 + use chrono::Utc; 6 + use malfestio_core::at_uri::AtUri; 7 + use malfestio_core::model::{Card, Deck, Note, Visibility}; 8 + use malfestio_core::tid::generate_tid; 9 + use serde::Serialize; 10 + use serde_json::Value; 11 + 12 + /// A deck record in Lexicon format. 13 + #[derive(Serialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct DeckRecord { 16 + #[serde(rename = "$type")] 17 + pub record_type: String, 18 + pub title: String, 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub description: Option<String>, 21 + pub tags: Vec<String>, 22 + #[serde(skip_serializing_if = "Vec::is_empty")] 23 + pub card_refs: Vec<String>, 24 + #[serde(skip_serializing_if = "Vec::is_empty")] 25 + pub source_refs: Vec<String>, 26 + #[serde(skip_serializing_if = "Option::is_none")] 27 + pub license: Option<String>, 28 + pub created_at: String, 29 + } 30 + 31 + /// A card record in Lexicon format. 32 + #[derive(Serialize)] 33 + #[serde(rename_all = "camelCase")] 34 + pub struct CardRecord { 35 + #[serde(rename = "$type")] 36 + pub record_type: String, 37 + pub deck_ref: String, 38 + pub front: String, 39 + pub back: String, 40 + pub card_type: String, 41 + #[serde(skip_serializing_if = "Vec::is_empty")] 42 + pub hints: Vec<String>, 43 + #[serde(skip_serializing_if = "Option::is_none")] 44 + pub media: Option<CardMedia>, 45 + pub created_at: String, 46 + } 47 + 48 + /// Media attachment for a card. 49 + #[derive(Serialize)] 50 + #[serde(rename_all = "camelCase")] 51 + pub struct CardMedia { 52 + pub image_ref: Option<String>, 53 + pub audio_ref: Option<String>, 54 + } 55 + 56 + /// A note record in Lexicon format. 57 + #[derive(Serialize)] 58 + #[serde(rename_all = "camelCase")] 59 + pub struct NoteRecord { 60 + #[serde(rename = "$type")] 61 + pub record_type: String, 62 + pub title: String, 63 + pub body: String, 64 + pub tags: Vec<String>, 65 + #[serde(skip_serializing_if = "Vec::is_empty")] 66 + pub links: Vec<String>, 67 + pub visibility: String, 68 + pub created_at: String, 69 + } 70 + 71 + /// Result of preparing a record for publishing. 72 + pub struct PreparedRecord { 73 + /// The record key (TID) 74 + pub rkey: String, 75 + /// The NSID collection 76 + pub collection: String, 77 + /// The serialized record 78 + pub record: Value, 79 + } 80 + 81 + impl DeckRecord { 82 + /// Create a DeckRecord from an internal Deck model. 83 + pub fn from_deck(deck: &Deck, card_at_uris: Vec<String>) -> Self { 84 + Self { 85 + record_type: "app.malfestio.deck".to_string(), 86 + title: deck.title.clone(), 87 + description: if deck.description.is_empty() { None } else { Some(deck.description.clone()) }, 88 + tags: deck.tags.clone(), 89 + card_refs: card_at_uris, 90 + source_refs: vec![], 91 + license: None, 92 + created_at: Utc::now().to_rfc3339(), 93 + } 94 + } 95 + } 96 + 97 + impl CardRecord { 98 + /// Create a CardRecord from an internal Card model. 99 + pub fn from_card(card: &Card, deck_at_uri: &str) -> Self { 100 + Self { 101 + record_type: "app.malfestio.card".to_string(), 102 + deck_ref: deck_at_uri.to_string(), 103 + front: card.front.clone(), 104 + back: card.back.clone(), 105 + card_type: "basic".to_string(), 106 + hints: vec![], 107 + media: card 108 + .media_url 109 + .as_ref() 110 + .map(|url| CardMedia { image_ref: Some(url.clone()), audio_ref: None }), 111 + created_at: Utc::now().to_rfc3339(), 112 + } 113 + } 114 + } 115 + 116 + impl NoteRecord { 117 + /// Create a NoteRecord from an internal Note model. 118 + pub fn from_note(note: &Note) -> Self { 119 + Self { 120 + record_type: "app.malfestio.note".to_string(), 121 + title: note.title.clone(), 122 + body: note.body.clone(), 123 + tags: note.tags.clone(), 124 + links: note.links.clone(), 125 + visibility: visibility_to_string(&note.visibility), 126 + created_at: Utc::now().to_rfc3339(), 127 + } 128 + } 129 + } 130 + 131 + /// Convert visibility enum to string for Lexicon. 132 + fn visibility_to_string(visibility: &Visibility) -> String { 133 + match visibility { 134 + Visibility::Private => "private".to_string(), 135 + Visibility::Unlisted => "unlisted".to_string(), 136 + Visibility::Public => "public".to_string(), 137 + Visibility::SharedWith(_) => "shared".to_string(), 138 + } 139 + } 140 + 141 + /// Prepare a deck for publishing to PDS. 142 + pub fn prepare_deck_record(deck: &Deck, card_at_uris: Vec<String>) -> PreparedRecord { 143 + let record = DeckRecord::from_deck(deck, card_at_uris); 144 + PreparedRecord { 145 + rkey: generate_tid(), 146 + collection: "app.malfestio.deck".to_string(), 147 + record: serde_json::to_value(record).expect("Failed to serialize deck record"), 148 + } 149 + } 150 + 151 + /// Prepare a card for publishing to PDS. 152 + pub fn prepare_card_record(card: &Card, deck_at_uri: &str) -> PreparedRecord { 153 + let record = CardRecord::from_card(card, deck_at_uri); 154 + PreparedRecord { 155 + rkey: generate_tid(), 156 + collection: "app.malfestio.card".to_string(), 157 + record: serde_json::to_value(record).expect("Failed to serialize card record"), 158 + } 159 + } 160 + 161 + /// Prepare a note for publishing to PDS. 162 + pub fn prepare_note_record(note: &Note) -> PreparedRecord { 163 + let record = NoteRecord::from_note(note); 164 + PreparedRecord { 165 + rkey: generate_tid(), 166 + collection: "app.malfestio.note".to_string(), 167 + record: serde_json::to_value(record).expect("Failed to serialize note record"), 168 + } 169 + } 170 + 171 + /// Generate an AT-URI for a record. 172 + pub fn make_at_uri(did: &str, collection: &str, rkey: &str) -> AtUri { 173 + AtUri::new(did, collection, rkey) 174 + } 175 + 176 + #[cfg(test)] 177 + mod tests { 178 + use super::*; 179 + 180 + fn sample_deck() -> Deck { 181 + Deck { 182 + id: "deck-123".to_string(), 183 + owner_did: "did:plc:abc123".to_string(), 184 + title: "Test Deck".to_string(), 185 + description: "A test deck".to_string(), 186 + tags: vec!["test".to_string(), "sample".to_string()], 187 + visibility: Visibility::Public, 188 + published_at: None, 189 + fork_of: None, 190 + } 191 + } 192 + 193 + fn sample_card() -> Card { 194 + Card { 195 + id: "card-123".to_string(), 196 + owner_did: "did:plc:abc123".to_string(), 197 + deck_id: "deck-123".to_string(), 198 + front: "What is the capital of France?".to_string(), 199 + back: "Paris".to_string(), 200 + media_url: None, 201 + } 202 + } 203 + 204 + fn sample_note() -> Note { 205 + Note { 206 + id: "note-123".to_string(), 207 + owner_did: "did:plc:abc123".to_string(), 208 + title: "Test Note".to_string(), 209 + body: "This is a test note with **markdown**.".to_string(), 210 + tags: vec!["notes".to_string()], 211 + visibility: Visibility::Public, 212 + published_at: None, 213 + links: vec![], 214 + } 215 + } 216 + 217 + #[test] 218 + fn test_deck_record_from_deck() { 219 + let deck = sample_deck(); 220 + let record = DeckRecord::from_deck(&deck, vec![]); 221 + 222 + assert_eq!(record.record_type, "app.malfestio.deck"); 223 + assert_eq!(record.title, "Test Deck"); 224 + assert_eq!(record.description, Some("A test deck".to_string())); 225 + assert_eq!(record.tags.len(), 2); 226 + } 227 + 228 + #[test] 229 + fn test_deck_record_serialization() { 230 + let deck = sample_deck(); 231 + let record = DeckRecord::from_deck(&deck, vec!["at://did:plc:abc/app.malfestio.card/tid1".to_string()]); 232 + 233 + let json = serde_json::to_string(&record).unwrap(); 234 + assert!(json.contains("\"$type\":\"app.malfestio.deck\"")); 235 + assert!(json.contains("\"title\":\"Test Deck\"")); 236 + assert!(json.contains("cardRefs")); 237 + } 238 + 239 + #[test] 240 + fn test_card_record_from_card() { 241 + let card = sample_card(); 242 + let deck_uri = "at://did:plc:abc123/app.malfestio.deck/tid123"; 243 + let record = CardRecord::from_card(&card, deck_uri); 244 + 245 + assert_eq!(record.record_type, "app.malfestio.card"); 246 + assert_eq!(record.deck_ref, deck_uri); 247 + assert_eq!(record.front, "What is the capital of France?"); 248 + assert_eq!(record.back, "Paris"); 249 + } 250 + 251 + #[test] 252 + fn test_note_record_from_note() { 253 + let note = sample_note(); 254 + let record = NoteRecord::from_note(&note); 255 + 256 + assert_eq!(record.record_type, "app.malfestio.note"); 257 + assert_eq!(record.title, "Test Note"); 258 + assert_eq!(record.visibility, "public"); 259 + } 260 + 261 + #[test] 262 + fn test_prepare_deck_record() { 263 + let deck = sample_deck(); 264 + let prepared = prepare_deck_record(&deck, vec![]); 265 + 266 + assert_eq!(prepared.collection, "app.malfestio.deck"); 267 + assert_eq!(prepared.rkey.len(), 13); // TID length 268 + assert!(prepared.record.is_object()); 269 + } 270 + 271 + #[test] 272 + fn test_prepare_card_record() { 273 + let card = sample_card(); 274 + let prepared = prepare_card_record(&card, "at://did:plc:abc/app.malfestio.deck/tid"); 275 + 276 + assert_eq!(prepared.collection, "app.malfestio.card"); 277 + assert_eq!(prepared.rkey.len(), 13); 278 + } 279 + 280 + #[test] 281 + fn test_prepare_note_record() { 282 + let note = sample_note(); 283 + let prepared = prepare_note_record(&note); 284 + 285 + assert_eq!(prepared.collection, "app.malfestio.note"); 286 + assert_eq!(prepared.rkey.len(), 13); 287 + } 288 + 289 + #[test] 290 + fn test_make_at_uri() { 291 + let uri = make_at_uri("did:plc:abc123", "app.malfestio.deck", "3k5abc123"); 292 + assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123"); 293 + } 294 + 295 + #[test] 296 + fn test_visibility_to_string() { 297 + assert_eq!(visibility_to_string(&Visibility::Private), "private"); 298 + assert_eq!(visibility_to_string(&Visibility::Public), "public"); 299 + assert_eq!(visibility_to_string(&Visibility::Unlisted), "unlisted"); 300 + assert_eq!(visibility_to_string(&Visibility::SharedWith(vec![])), "shared"); 301 + } 302 + }
+103
docs/at-notes.md
··· 1 + # AT Protocol Research Notes 2 + 3 + ## OAuth 2.1 Specification 4 + 5 + AT Protocol uses a specific profile of OAuth 2.1 for client↔PDS authorization. 6 + 7 + ### Required Components 8 + 9 + - **Client Metadata Endpoint**: Serve `client_metadata.json` at a public HTTPS URL (this URL becomes the `client_id`) 10 + 11 + ```json 12 + { 13 + "client_id": "https://your-app.com/oauth/client-metadata.json", 14 + "application_type": "web", 15 + "grant_types": ["authorization_code", "refresh_token"], 16 + "scope": "atproto transition:generic", 17 + "response_types": ["code"], 18 + "redirect_uris": ["https://your-app.com/oauth/callback"], 19 + "client_name": "Malfestio", 20 + "client_uri": "https://your-app.com" 21 + } 22 + ``` 23 + 24 + - **PKCE (Mandatory)**: Generate `code_verifier` and `code_challenge` (S256 only) 25 + - **DPoP (Mandatory)**: Bind tokens to client instances with proof-of-possession JWTs 26 + - **Handle/DID Resolution**: Resolve user identity to discover their PDS 27 + - **Token Exchange**: Authorization code flow with token refresh 28 + 29 + ## Record Publishing 30 + 31 + ### XRPC Endpoints 32 + 33 + - `com.atproto.repo.putRecord` — Create or update records 34 + - `com.atproto.repo.deleteRecord` — Remove records 35 + - `com.atproto.repo.uploadBlob` — Upload media attachments 36 + 37 + ### Record Keys 38 + 39 + Use TID (timestamp-based identifiers) per Lexicon spec. 40 + 41 + ### AT-URIs 42 + 43 + Format: `at://<did>/<collection>/<rkey>` 44 + 45 + Example: `at://did:plc:abc123/app.malfestio.deck/3k5abc123` 46 + 47 + ## Firehose Consumption 48 + 49 + For social features (trending, discovery, feeds): 50 + 51 + - **WebSocket Connection**: Subscribe to `com.atproto.sync.subscribeRepos` from a Relay 52 + - **CBOR Decoding**: Parse incoming events (or use Jetstream for JSON) 53 + - **Cursor Management**: Track position for reconnection 54 + 55 + ## AppView Pattern 56 + 57 + Index network-wide records to power discovery features: 58 + 59 + - Index `app.malfestio.*` records from firehose 60 + - Implement `getFeedSkeleton` for custom algorithmic feeds 61 + - Hydration service combines skeletons with full content from PDSes 62 + 63 + ## Well-Known Endpoints 64 + 65 + - `/.well-known/atproto-did` — Domain verification for handle claims 66 + - `/.well-known/oauth-protected-resource` — PDS OAuth metadata 67 + - `/.well-known/oauth-authorization-server` — Auth server metadata 68 + 69 + ## Patterns from Real AT Protocol Apps 70 + 71 + ### plyr.fm (Music) 72 + 73 + - OAuth 2.1 via `@atproto/oauth-client` library 74 + - Records synced to PDS: tracks, likes, playlists 75 + - Separate moderation service (Rust labeler) 76 + - Data ownership: "tracks, likes, playlists synced to your PDS as ATProto records" 77 + 78 + ### leaflet.pub (Writing) 79 + 80 + - React/Next.js frontend with Supabase + Replicache for sync 81 + - Bluesky integration via dedicated `lexicons/` and `appview/` directories 82 + - Publications posted to Bluesky 83 + 84 + ### wisp.place (Static Sites) 85 + 86 + - Stores site files as `place.wisp.fs` records in user's PDS 87 + - Firehose consumer to index and serve sites 88 + - CDN layer caches content from PDS 89 + 90 + ### Common Patterns 91 + 92 + 1. Local database for fast queries + PDS for portable, signed records 93 + 2. Firehose consumption for discovery/aggregation 94 + 3. OAuth 2.1 for production auth (app passwords only for development) 95 + 4. Lexicons define the public contract; internal state stays private 96 + 97 + ## References 98 + 99 + - [AT Protocol OAuth Spec](https://atproto.com/specs/oauth) 100 + - [Lexicon Schema Language](https://atproto.com/specs/lexicon) 101 + - [Repository & XRPC](https://atproto.com/specs/xrpc) 102 + - [Feed Generator Starter Kit](https://github.com/bluesky-social/feed-generator) 103 + - [atproto TypeScript SDK](https://github.com/bluesky-social/atproto)
-4
docs/todo.md
··· 84 84 85 85 - A user can authenticate via OAuth, create a deck, and see it in their PDS repository. 86 86 87 - #### Notes 88 - 89 - - See [docs/at.md](at.md) for full AT Protocol integration research. 90 - 91 87 ### Milestone G - Study Engine (SRS) + Daily Review UX 92 88 93 89 #### Deliverables