···11+//! AT-URI builder and parser for AT Protocol.
22+//!
33+//! AT-URIs are the canonical way to reference records in the AT Protocol.
44+//! Format: `at://<authority>/<collection>/<rkey>`
55+//!
66+//! - authority: DID or handle
77+//! - collection: NSID (e.g., "app.malfestio.deck")
88+//! - rkey: Record key (usually a TID)
99+1010+use std::fmt;
1111+1212+/// An AT-URI representing a record in the AT Protocol network.
1313+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1414+pub struct AtUri {
1515+ /// The authority (DID or handle)
1616+ pub authority: String,
1717+ /// The collection NSID (e.g., "app.malfestio.deck")
1818+ pub collection: String,
1919+ /// The record key
2020+ pub rkey: String,
2121+}
2222+2323+impl AtUri {
2424+ /// Create a new AT-URI.
2525+ ///
2626+ /// # Arguments
2727+ ///
2828+ /// * `authority` - The DID or handle
2929+ /// * `collection` - The collection NSID
3030+ /// * `rkey` - The record key
3131+ pub fn new(authority: impl Into<String>, collection: impl Into<String>, rkey: impl Into<String>) -> Self {
3232+ Self { authority: authority.into(), collection: collection.into(), rkey: rkey.into() }
3333+ }
3434+3535+ /// Create an AT-URI for a deck record.
3636+ pub fn deck(did: &str, rkey: &str) -> Self {
3737+ Self::new(did, "app.malfestio.deck", rkey)
3838+ }
3939+4040+ /// Create an AT-URI for a card record.
4141+ pub fn card(did: &str, rkey: &str) -> Self {
4242+ Self::new(did, "app.malfestio.card", rkey)
4343+ }
4444+4545+ /// Create an AT-URI for a note record.
4646+ pub fn note(did: &str, rkey: &str) -> Self {
4747+ Self::new(did, "app.malfestio.note", rkey)
4848+ }
4949+5050+ /// Parse an AT-URI string.
5151+ pub fn parse(s: &str) -> Result<Self, AtUriError> {
5252+ let s = s.strip_prefix("at://").ok_or(AtUriError::MissingScheme)?;
5353+5454+ let parts: Vec<&str> = s.splitn(3, '/').collect();
5555+ if parts.len() != 3 {
5656+ return Err(AtUriError::InvalidFormat);
5757+ }
5858+5959+ let authority = parts[0];
6060+ let collection = parts[1];
6161+ let rkey = parts[2];
6262+6363+ if authority.is_empty() {
6464+ return Err(AtUriError::EmptyAuthority);
6565+ }
6666+ if collection.is_empty() {
6767+ return Err(AtUriError::EmptyCollection);
6868+ }
6969+ if rkey.is_empty() {
7070+ return Err(AtUriError::EmptyRkey);
7171+ }
7272+7373+ if !collection.contains('.') {
7474+ return Err(AtUriError::InvalidNsid);
7575+ }
7676+7777+ Ok(Self { authority: authority.to_string(), collection: collection.to_string(), rkey: rkey.to_string() })
7878+ }
7979+8080+ /// Check if the authority is a DID.
8181+ pub fn is_did(&self) -> bool {
8282+ self.authority.starts_with("did:")
8383+ }
8484+8585+ /// Check if the authority is a handle.
8686+ pub fn is_handle(&self) -> bool {
8787+ !self.is_did()
8888+ }
8989+}
9090+9191+impl fmt::Display for AtUri {
9292+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
9393+ write!(f, "at://{}/{}/{}", self.authority, self.collection, self.rkey)
9494+ }
9595+}
9696+9797+/// Error type for AT-URI parsing.
9898+#[derive(Debug, Clone, PartialEq, Eq)]
9999+pub enum AtUriError {
100100+ MissingScheme,
101101+ InvalidFormat,
102102+ EmptyAuthority,
103103+ EmptyCollection,
104104+ EmptyRkey,
105105+ InvalidNsid,
106106+}
107107+108108+impl fmt::Display for AtUriError {
109109+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110110+ match self {
111111+ AtUriError::MissingScheme => write!(f, "AT-URI must start with 'at://'"),
112112+ AtUriError::InvalidFormat => write!(f, "AT-URI must have format at://authority/collection/rkey"),
113113+ AtUriError::EmptyAuthority => write!(f, "AT-URI authority cannot be empty"),
114114+ AtUriError::EmptyCollection => write!(f, "AT-URI collection cannot be empty"),
115115+ AtUriError::EmptyRkey => write!(f, "AT-URI rkey cannot be empty"),
116116+ AtUriError::InvalidNsid => write!(f, "Collection must be a valid NSID"),
117117+ }
118118+ }
119119+}
120120+121121+impl std::error::Error for AtUriError {}
122122+123123+#[cfg(test)]
124124+mod tests {
125125+ use super::*;
126126+127127+ #[test]
128128+ fn test_new_at_uri() {
129129+ let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123");
130130+ assert_eq!(uri.authority, "did:plc:abc123");
131131+ assert_eq!(uri.collection, "app.malfestio.deck");
132132+ assert_eq!(uri.rkey, "3k5abc123");
133133+ }
134134+135135+ #[test]
136136+ fn test_display() {
137137+ let uri = AtUri::new("did:plc:abc123", "app.malfestio.deck", "3k5abc123");
138138+ assert_eq!(uri.to_string(), "at://did:plc:abc123/app.malfestio.deck/3k5abc123");
139139+ }
140140+141141+ #[test]
142142+ fn test_parse_valid() {
143143+ let uri = AtUri::parse("at://did:plc:abc123/app.malfestio.deck/3k5abc123").unwrap();
144144+ assert_eq!(uri.authority, "did:plc:abc123");
145145+ assert_eq!(uri.collection, "app.malfestio.deck");
146146+ assert_eq!(uri.rkey, "3k5abc123");
147147+ }
148148+149149+ #[test]
150150+ fn test_parse_with_handle() {
151151+ let uri = AtUri::parse("at://alice.bsky.social/app.malfestio.note/abc123").unwrap();
152152+ assert_eq!(uri.authority, "alice.bsky.social");
153153+ assert!(uri.is_handle());
154154+ assert!(!uri.is_did());
155155+ }
156156+157157+ #[test]
158158+ fn test_parse_missing_scheme() {
159159+ let result = AtUri::parse("did:plc:abc123/app.malfestio.deck/3k5abc123");
160160+ assert_eq!(result, Err(AtUriError::MissingScheme));
161161+ }
162162+163163+ #[test]
164164+ fn test_parse_invalid_format() {
165165+ let result = AtUri::parse("at://did:plc:abc123/app.malfestio.deck");
166166+ assert_eq!(result, Err(AtUriError::InvalidFormat));
167167+ }
168168+169169+ #[test]
170170+ fn test_parse_empty_authority() {
171171+ let result = AtUri::parse("at:///app.malfestio.deck/rkey");
172172+ assert_eq!(result, Err(AtUriError::EmptyAuthority));
173173+ }
174174+175175+ #[test]
176176+ fn test_parse_invalid_nsid() {
177177+ let result = AtUri::parse("at://did:plc:abc123/notansid/rkey");
178178+ assert_eq!(result, Err(AtUriError::InvalidNsid));
179179+ }
180180+181181+ #[test]
182182+ fn test_roundtrip() {
183183+ let original = "at://did:plc:abc123/app.malfestio.deck/3k5abc123";
184184+ let uri = AtUri::parse(original).unwrap();
185185+ assert_eq!(uri.to_string(), original);
186186+ }
187187+188188+ #[test]
189189+ fn test_convenience_constructors() {
190190+ let deck = AtUri::deck("did:plc:abc", "tid123");
191191+ assert_eq!(deck.collection, "app.malfestio.deck");
192192+193193+ let card = AtUri::card("did:plc:abc", "tid456");
194194+ assert_eq!(card.collection, "app.malfestio.card");
195195+196196+ let note = AtUri::note("did:plc:abc", "tid789");
197197+ assert_eq!(note.collection, "app.malfestio.note");
198198+ }
199199+200200+ #[test]
201201+ fn test_is_did() {
202202+ let uri = AtUri::new("did:plc:abc123", "app.test", "rkey");
203203+ assert!(uri.is_did());
204204+205205+ let uri = AtUri::new("alice.bsky.social", "app.test", "rkey");
206206+ assert!(!uri.is_did());
207207+ }
208208+}
+2
crates/core/src/lib.rs
···11+pub mod at_uri;
12pub mod error;
23pub mod model;
44+pub mod tid;
3546pub use error::{Error, Result};
57pub use model::{Card, Deck, Note};
+161
crates/core/src/tid.rs
···11+//! TID (Timestamp Identifier) generation for AT Protocol.
22+//!
33+//! TIDs are used as record keys in the AT Protocol. They are 13-character
44+//! base32-sortable strings derived from timestamps with a clock identifier.
55+//!
66+//! Format: 13 characters encoding 64 bits:
77+//! - 53 bits: microseconds since Unix epoch
88+//! - 10 bits: clock identifier (for uniqueness within same microsecond)
99+//! - 1 bit: always 0 (reserved)
1010+1111+use std::sync::atomic::{AtomicU64, Ordering};
1212+use std::time::{SystemTime, UNIX_EPOCH};
1313+1414+/// Base32 "sort" alphabet used by AT Protocol TIDs.
1515+/// This alphabet maintains lexicographic sorting.
1616+const BASE32_SORT: &[u8; 32] = b"234567abcdefghijklmnopqrstuvwxyz";
1717+1818+/// Atomic counter for clock identifier within same microsecond.
1919+static CLOCK_ID: AtomicU64 = AtomicU64::new(0);
2020+static LAST_TIMESTAMP: AtomicU64 = AtomicU64::new(0);
2121+2222+/// Generate a new TID.
2323+///
2424+/// TIDs are guaranteed to be:
2525+/// - Unique within this process
2626+/// - Lexicographically sortable by creation time
2727+/// - Compatible with AT Protocol record key requirements
2828+pub fn generate_tid() -> String {
2929+ let now = SystemTime::now()
3030+ .duration_since(UNIX_EPOCH)
3131+ .expect("Time went backwards")
3232+ .as_micros() as u64;
3333+3434+ let last = LAST_TIMESTAMP.load(Ordering::SeqCst);
3535+ let clock_id = if now == last {
3636+ CLOCK_ID.fetch_add(1, Ordering::SeqCst) & 0x3FF
3737+ } else {
3838+ LAST_TIMESTAMP.store(now, Ordering::SeqCst);
3939+ CLOCK_ID.store(1, Ordering::SeqCst);
4040+ 0
4141+ };
4242+4343+ let combined = (now << 11) | (clock_id << 1);
4444+ encode_base32_sort(combined)
4545+}
4646+4747+/// Encode a 64-bit value as a 13-character base32-sort string.
4848+fn encode_base32_sort(mut value: u64) -> String {
4949+ let mut result = [0u8; 13];
5050+5151+ for i in (0..13).rev() {
5252+ result[i] = BASE32_SORT[(value & 0x1F) as usize];
5353+ value >>= 5;
5454+ }
5555+5656+ String::from_utf8(result.to_vec()).expect("Base32 encoding produced invalid UTF-8")
5757+}
5858+5959+/// Parse a TID string and extract the timestamp.
6060+///
6161+/// Returns the Unix timestamp in microseconds, or None if invalid.
6262+pub fn parse_tid_timestamp(tid: &str) -> Option<u64> {
6363+ if tid.len() != 13 {
6464+ return None;
6565+ }
6666+6767+ let decoded = decode_base32_sort(tid)?;
6868+ Some(decoded >> 11)
6969+}
7070+7171+/// Decode a base32-sort string to a 64-bit value.
7272+fn decode_base32_sort(s: &str) -> Option<u64> {
7373+ let mut value: u64 = 0;
7474+7575+ for c in s.chars() {
7676+ let idx = BASE32_SORT.iter().position(|&b| b == c as u8)?;
7777+ value = (value << 5) | (idx as u64);
7878+ }
7979+8080+ Some(value)
8181+}
8282+8383+/// Validate that a string is a valid TID format.
8484+pub fn is_valid_tid(tid: &str) -> bool {
8585+ if tid.len() != 13 {
8686+ return false;
8787+ }
8888+8989+ tid.chars().all(|c| BASE32_SORT.contains(&(c as u8)))
9090+}
9191+9292+#[cfg(test)]
9393+mod tests {
9494+ use super::*;
9595+9696+ #[test]
9797+ fn test_tid_length() {
9898+ let tid = generate_tid();
9999+ assert_eq!(tid.len(), 13);
100100+ }
101101+102102+ #[test]
103103+ fn test_tid_characters() {
104104+ let tid = generate_tid();
105105+ for c in tid.chars() {
106106+ assert!(BASE32_SORT.contains(&(c as u8)), "Invalid character '{}' in TID", c);
107107+ }
108108+ }
109109+110110+ #[test]
111111+ fn test_tid_uniqueness() {
112112+ let tids: Vec<String> = (0..100).map(|_| generate_tid()).collect();
113113+ let mut unique = tids.clone();
114114+ unique.sort();
115115+ unique.dedup();
116116+ assert_eq!(tids.len(), unique.len(), "TIDs should be unique");
117117+ }
118118+119119+ #[test]
120120+ fn test_tid_sortability() {
121121+ let tid1 = generate_tid();
122122+ std::thread::sleep(std::time::Duration::from_micros(10));
123123+ let tid2 = generate_tid();
124124+125125+ assert!(tid1 < tid2, "Later TIDs should sort after earlier ones");
126126+ }
127127+128128+ #[test]
129129+ fn test_parse_tid_timestamp() {
130130+ let tid = generate_tid();
131131+ let timestamp = parse_tid_timestamp(&tid);
132132+ assert!(timestamp.is_some());
133133+134134+ let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros() as u64;
135135+ let parsed = timestamp.unwrap();
136136+ assert!(
137137+ now.abs_diff(parsed) < 1_000_000,
138138+ "Parsed timestamp {} too far from now {}",
139139+ parsed,
140140+ now
141141+ );
142142+ }
143143+144144+ #[test]
145145+ fn test_is_valid_tid() {
146146+ let tid = generate_tid();
147147+ assert!(is_valid_tid(&tid));
148148+149149+ assert!(!is_valid_tid("short"));
150150+ assert!(!is_valid_tid("toolongstring!"));
151151+ assert!(!is_valid_tid("0123456789012"));
152152+ }
153153+154154+ #[test]
155155+ fn test_roundtrip_encoding() {
156156+ let value: u64 = 0x123456789ABCDEF0;
157157+ let encoded = encode_base32_sort(value);
158158+ let decoded = decode_base32_sort(&encoded);
159159+ assert_eq!(decoded, Some(value));
160160+ }
161161+}
+10-1
crates/server/Cargo.toml
···66[dependencies]
77async-trait = "0.1.83"
88axum = "0.8.8"
99+base64 = "0.22"
910chrono = { version = "0.4.42", features = ["serde"] }
1011deadpool-postgres = "0.14.0"
1112dotenvy = "0.15.7"
1313+ed25519-dalek = { version = "2.2.0", features = ["serde"] }
1414+getrandom = { version = "0.3", features = ["std"] }
1215malfestio-core = { version = "0.1.0", path = "../core" }
1316readability = "0.3.0"
1417regex = "1.12.2"
1518reqwest = { version = "0.12.28", features = ["json"] }
1619serde = "1.0.228"
1720serde_json = "1.0.148"
2121+sha2 = "0.10"
1822tokio = { version = "1.48.0", features = ["full"] }
1919-tokio-postgres = { version = "0.7.13", features = ["with-serde_json-1", "with-chrono-0_4", "with-uuid-1"] }
2323+urlencoding = "2.1"
2424+tokio-postgres = { version = "0.7.13", features = [
2525+ "with-serde_json-1",
2626+ "with-chrono-0_4",
2727+ "with-uuid-1",
2828+] }
2029tower = "0.5.2"
2130tower-cookies = "0.11.0"
2231tower-http = { version = "0.6.8", features = ["cors", "trace"] }
+2
crates/server/src/lib.rs
···11pub mod api;
22pub mod db;
33pub mod middleware;
44+pub mod oauth;
55+pub mod pds;
46pub mod repository;
57pub mod state;
68
···11+//! DPoP (Demonstrating Proof of Possession) implementation for OAuth 2.1.
22+//!
33+//! AT Protocol requires DPoP tokens to bind access tokens to specific clients.
44+55+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
66+use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
77+use serde::{Deserialize, Serialize};
88+use sha2::{Digest, Sha256};
99+use std::time::{SystemTime, UNIX_EPOCH};
1010+1111+/// A DPoP keypair for proof generation using Ed25519.
1212+#[derive(Clone)]
1313+pub struct DpopKeypair {
1414+ signing_key: SigningKey,
1515+}
1616+1717+/// DPoP proof JWT header.
1818+#[derive(Serialize, Deserialize)]
1919+struct DpopHeader {
2020+ typ: String,
2121+ alg: String,
2222+ jwk: DpopJwk,
2323+}
2424+2525+/// JWK representation for DPoP (Ed25519 public key).
2626+#[derive(Serialize, Deserialize, Clone)]
2727+pub struct DpopJwk {
2828+ kty: String,
2929+ crv: String,
3030+ x: String,
3131+}
3232+3333+/// DPoP proof JWT payload.
3434+#[derive(Serialize, Deserialize)]
3535+struct DpopPayload {
3636+ jti: String,
3737+ htm: String,
3838+ htu: String,
3939+ iat: u64,
4040+ #[serde(skip_serializing_if = "Option::is_none")]
4141+ ath: Option<String>,
4242+}
4343+4444+impl DpopKeypair {
4545+ /// Generate a new random Ed25519 DPoP keypair.
4646+ pub fn generate() -> Self {
4747+ let mut rng_bytes = [0u8; 32];
4848+ getrandom::fill(&mut rng_bytes).expect("Failed to generate random bytes");
4949+ let signing_key = SigningKey::from_bytes(&rng_bytes);
5050+ Self { signing_key }
5151+ }
5252+5353+ /// Get the verifying (public) key.
5454+ pub fn verifying_key(&self) -> VerifyingKey {
5555+ self.signing_key.verifying_key()
5656+ }
5757+5858+ /// Get the JWK representation of the public key.
5959+ pub fn public_jwk(&self) -> DpopJwk {
6060+ let public_bytes = self.verifying_key().to_bytes();
6161+ DpopJwk { kty: "OKP".to_string(), crv: "Ed25519".to_string(), x: URL_SAFE_NO_PAD.encode(public_bytes) }
6262+ }
6363+6464+ /// Generate a DPoP proof for a request.
6565+ pub fn generate_proof(&self, method: &str, url: &str, access_token: Option<&str>) -> String {
6666+ let header = DpopHeader { typ: "dpop+jwt".to_string(), alg: "EdDSA".to_string(), jwk: self.public_jwk() };
6767+6868+ let now = SystemTime::now()
6969+ .duration_since(UNIX_EPOCH)
7070+ .expect("Time went backwards")
7171+ .as_secs();
7272+7373+ let jti = generate_jti();
7474+7575+ let ath = access_token.map(|token| {
7676+ let hash = Sha256::digest(token.as_bytes());
7777+ URL_SAFE_NO_PAD.encode(hash)
7878+ });
7979+8080+ let payload = DpopPayload { jti, htm: method.to_uppercase(), htu: url.to_string(), iat: now, ath };
8181+8282+ let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
8383+ let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
8484+8585+ let signing_input = format!("{}.{}", header_b64, payload_b64);
8686+8787+ let signature = self.signing_key.sign(signing_input.as_bytes());
8888+ let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
8989+9090+ format!("{}.{}.{}", header_b64, payload_b64, signature_b64)
9191+ }
9292+}
9393+9494+/// Generate a unique JWT ID.
9595+fn generate_jti() -> String {
9696+ let mut bytes = [0u8; 16];
9797+ getrandom::fill(&mut bytes).expect("Failed to generate random bytes");
9898+ URL_SAFE_NO_PAD.encode(bytes)
9999+}
100100+101101+/// Compute the JWK thumbprint for key binding.
102102+pub fn jwk_thumbprint(jwk: &DpopJwk) -> String {
103103+ let canonical = format!(r#"{{"crv":"{}","kty":"{}","x":"{}"}}"#, jwk.crv, jwk.kty, jwk.x);
104104+ let hash = Sha256::digest(canonical.as_bytes());
105105+ URL_SAFE_NO_PAD.encode(hash)
106106+}
107107+108108+#[cfg(test)]
109109+mod tests {
110110+ use super::*;
111111+ use ed25519_dalek::Verifier;
112112+113113+ #[test]
114114+ fn test_generate_keypair() {
115115+ let kp = DpopKeypair::generate();
116116+ let _ = kp.verifying_key();
117117+ }
118118+119119+ #[test]
120120+ fn test_keypair_uniqueness() {
121121+ let kp1 = DpopKeypair::generate();
122122+ let kp2 = DpopKeypair::generate();
123123+ assert_ne!(kp1.verifying_key().to_bytes(), kp2.verifying_key().to_bytes());
124124+ }
125125+126126+ #[test]
127127+ fn test_public_jwk() {
128128+ let kp = DpopKeypair::generate();
129129+ let jwk = kp.public_jwk();
130130+131131+ assert_eq!(jwk.kty, "OKP");
132132+ assert_eq!(jwk.crv, "Ed25519");
133133+ assert!(!jwk.x.is_empty());
134134+ assert_eq!(jwk.x.len(), 43);
135135+ }
136136+137137+ #[test]
138138+ fn test_generate_proof() {
139139+ let kp = DpopKeypair::generate();
140140+ let proof = kp.generate_proof("POST", "https://example.com/token", None);
141141+142142+ let parts: Vec<&str> = proof.split('.').collect();
143143+ assert_eq!(parts.len(), 3);
144144+145145+ let header_json = URL_SAFE_NO_PAD.decode(parts[0]).unwrap();
146146+ let header: serde_json::Value = serde_json::from_slice(&header_json).unwrap();
147147+ assert_eq!(header["typ"], "dpop+jwt");
148148+ assert_eq!(header["alg"], "EdDSA");
149149+ }
150150+151151+ #[test]
152152+ fn test_proof_signature_verifies() {
153153+ let kp = DpopKeypair::generate();
154154+ let proof = kp.generate_proof("GET", "https://example.com/resource", None);
155155+156156+ let parts: Vec<&str> = proof.split('.').collect();
157157+ let signing_input = format!("{}.{}", parts[0], parts[1]);
158158+ let signature_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap();
159159+160160+ let signature = ed25519_dalek::Signature::from_slice(&signature_bytes).unwrap();
161161+ let result = kp.verifying_key().verify(signing_input.as_bytes(), &signature);
162162+163163+ assert!(result.is_ok(), "Signature should verify");
164164+ }
165165+166166+ #[test]
167167+ fn test_generate_proof_with_token() {
168168+ let kp = DpopKeypair::generate();
169169+ let proof = kp.generate_proof("GET", "https://example.com/resource", Some("access_token_123"));
170170+171171+ let parts: Vec<&str> = proof.split('.').collect();
172172+ let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
173173+ let payload: serde_json::Value = serde_json::from_slice(&payload_json).unwrap();
174174+175175+ assert!(payload.get("ath").is_some());
176176+ }
177177+178178+ #[test]
179179+ fn test_jwk_thumbprint() {
180180+ let kp = DpopKeypair::generate();
181181+ let jwk = kp.public_jwk();
182182+ let thumbprint = jwk_thumbprint(&jwk);
183183+184184+ assert_eq!(thumbprint.len(), 43);
185185+ }
186186+}
+336
crates/server/src/oauth/flow.rs
···11+//! OAuth 2.1 authorization flow for AT Protocol.
22+//!
33+//! Handles the complete OAuth flow including:
44+//! - Authorization URL generation
55+//! - Token exchange with PKCE + DPoP
66+//! - Token refresh
77+88+use super::dpop::DpopKeypair;
99+use super::pkce::{derive_code_challenge, generate_code_verifier};
1010+use super::resolver::{IdentityResolver, ResolveError};
1111+use serde::{Deserialize, Serialize};
1212+use std::collections::HashMap;
1313+use std::sync::{Arc, RwLock};
1414+1515+/// OAuth session state stored during the authorization flow.
1616+#[derive(Clone)]
1717+pub struct OAuthSession {
1818+ /// The PKCE code verifier
1919+ pub code_verifier: String,
2020+ /// The DPoP keypair for this session
2121+ pub dpop_keypair: DpopKeypair,
2222+ /// The user's DID after resolution
2323+ pub did: Option<String>,
2424+ /// The user's PDS URL
2525+ pub pds_url: Option<String>,
2626+ /// When this session was created (for expiry)
2727+ pub created_at: std::time::Instant,
2828+}
2929+3030+/// OAuth tokens received from the authorization server.
3131+#[derive(Clone, Serialize, Deserialize)]
3232+pub struct OAuthTokens {
3333+ pub access_token: String,
3434+ pub refresh_token: Option<String>,
3535+ pub token_type: String,
3636+ pub expires_in: Option<u64>,
3737+ pub scope: Option<String>,
3838+}
3939+4040+/// In-memory session storage (for development).
4141+/// In production, use a database-backed implementation.
4242+pub type SessionStore = Arc<RwLock<HashMap<String, OAuthSession>>>;
4343+4444+/// Create a new session store.
4545+pub fn new_session_store() -> SessionStore {
4646+ Arc::new(RwLock::new(HashMap::new()))
4747+}
4848+4949+/// OAuth flow manager.
5050+pub struct OAuthFlow {
5151+ resolver: IdentityResolver,
5252+ client: reqwest::Client,
5353+ client_id: String,
5454+ redirect_uri: String,
5555+}
5656+5757+impl OAuthFlow {
5858+ /// Create a new OAuth flow manager.
5959+ pub fn new() -> Self {
6060+ let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
6161+6262+ Self {
6363+ resolver: IdentityResolver::new(),
6464+ client: reqwest::Client::new(),
6565+ client_id: format!("{}/oauth/client-metadata.json", app_url),
6666+ redirect_uri: format!("{}/oauth/callback", app_url),
6767+ }
6868+ }
6969+7070+ /// Start the OAuth flow for a user handle or DID.
7171+ ///
7272+ /// Returns the authorization URL to redirect the user to.
7373+ pub async fn start_authorization(
7474+ &self, handle_or_did: &str, state: &str, sessions: &SessionStore,
7575+ ) -> Result<String, OAuthFlowError> {
7676+ let (did, pds_url) = if handle_or_did.starts_with("did:") {
7777+ let resolved = self.resolver.resolve_did(handle_or_did).await?;
7878+ (resolved.did, resolved.pds_url)
7979+ } else {
8080+ let did = self.resolver.resolve_handle(handle_or_did).await?;
8181+ let resolved = self.resolver.resolve_did(&did).await?;
8282+ (resolved.did, resolved.pds_url)
8383+ };
8484+8585+ let auth_server = self.get_auth_server_metadata(&pds_url).await?;
8686+8787+ let code_verifier = generate_code_verifier();
8888+ let code_challenge = derive_code_challenge(&code_verifier);
8989+9090+ let dpop_keypair = DpopKeypair::generate();
9191+9292+ let session = OAuthSession {
9393+ code_verifier,
9494+ dpop_keypair,
9595+ did: Some(did.clone()),
9696+ pds_url: Some(pds_url),
9797+ created_at: std::time::Instant::now(),
9898+ };
9999+100100+ sessions.write().unwrap().insert(state.to_string(), session);
101101+102102+ let auth_url = format!(
103103+ "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256&login_hint={}",
104104+ auth_server.authorization_endpoint,
105105+ urlencoding::encode(&self.client_id),
106106+ urlencoding::encode(&self.redirect_uri),
107107+ urlencoding::encode("atproto transition:generic"),
108108+ urlencoding::encode(state),
109109+ urlencoding::encode(&code_challenge),
110110+ urlencoding::encode(&did)
111111+ );
112112+113113+ Ok(auth_url)
114114+ }
115115+116116+ /// Exchange an authorization code for tokens.
117117+ pub async fn exchange_code(
118118+ &self, code: &str, state: &str, sessions: &SessionStore,
119119+ ) -> Result<OAuthTokens, OAuthFlowError> {
120120+ let session = sessions
121121+ .read()
122122+ .unwrap()
123123+ .get(state)
124124+ .cloned()
125125+ .ok_or(OAuthFlowError::SessionNotFound)?;
126126+127127+ let pds_url = session.pds_url.as_ref().ok_or(OAuthFlowError::SessionNotFound)?;
128128+129129+ let auth_server = self.get_auth_server_metadata(pds_url).await?;
130130+131131+ let dpop_proof = session
132132+ .dpop_keypair
133133+ .generate_proof("POST", &auth_server.token_endpoint, None);
134134+135135+ let response = self
136136+ .client
137137+ .post(&auth_server.token_endpoint)
138138+ .header("DPoP", dpop_proof)
139139+ .form(&[
140140+ ("grant_type", "authorization_code"),
141141+ ("code", code),
142142+ ("redirect_uri", &self.redirect_uri),
143143+ ("client_id", &self.client_id),
144144+ ("code_verifier", &session.code_verifier),
145145+ ])
146146+ .send()
147147+ .await
148148+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?;
149149+150150+ if !response.status().is_success() {
151151+ let error_body = response.text().await.unwrap_or_default();
152152+ return Err(OAuthFlowError::TokenExchangeFailed(error_body));
153153+ }
154154+155155+ let tokens: OAuthTokens = response
156156+ .json()
157157+ .await
158158+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?;
159159+160160+ sessions.write().unwrap().remove(state);
161161+162162+ Ok(tokens)
163163+ }
164164+165165+ /// Refresh an access token.
166166+ pub async fn refresh_token(
167167+ &self, refresh_token: &str, pds_url: &str, dpop_keypair: &DpopKeypair,
168168+ ) -> Result<OAuthTokens, OAuthFlowError> {
169169+ let auth_server = self.get_auth_server_metadata(pds_url).await?;
170170+171171+ let dpop_proof = dpop_keypair.generate_proof("POST", &auth_server.token_endpoint, None);
172172+173173+ let response = self
174174+ .client
175175+ .post(&auth_server.token_endpoint)
176176+ .header("DPoP", dpop_proof)
177177+ .form(&[
178178+ ("grant_type", "refresh_token"),
179179+ ("refresh_token", refresh_token),
180180+ ("client_id", &self.client_id),
181181+ ])
182182+ .send()
183183+ .await
184184+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?;
185185+186186+ if !response.status().is_success() {
187187+ let error_body = response.text().await.unwrap_or_default();
188188+ return Err(OAuthFlowError::TokenRefreshFailed(error_body));
189189+ }
190190+191191+ response
192192+ .json()
193193+ .await
194194+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))
195195+ }
196196+197197+ /// Get authorization server metadata from PDS.
198198+ async fn get_auth_server_metadata(&self, pds_url: &str) -> Result<AuthServerMetadata, OAuthFlowError> {
199199+ // First get the protected resource metadata
200200+ let resource_url = format!("{}/.well-known/oauth-protected-resource", pds_url);
201201+202202+ let resource_response = self
203203+ .client
204204+ .get(&resource_url)
205205+ .timeout(std::time::Duration::from_secs(10))
206206+ .send()
207207+ .await
208208+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?;
209209+210210+ if !resource_response.status().is_success() {
211211+ return Err(OAuthFlowError::MetadataFetchFailed(pds_url.to_string()));
212212+ }
213213+214214+ let resource: serde_json::Value = resource_response
215215+ .json()
216216+ .await
217217+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?;
218218+219219+ let auth_server_url = resource["authorization_servers"]
220220+ .as_array()
221221+ .and_then(|arr| arr.first())
222222+ .and_then(|v| v.as_str())
223223+ .ok_or_else(|| OAuthFlowError::MetadataFetchFailed(pds_url.to_string()))?;
224224+225225+ let auth_meta_url = format!("{}/.well-known/oauth-authorization-server", auth_server_url);
226226+227227+ let auth_response = self
228228+ .client
229229+ .get(&auth_meta_url)
230230+ .timeout(std::time::Duration::from_secs(10))
231231+ .send()
232232+ .await
233233+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))?;
234234+235235+ if !auth_response.status().is_success() {
236236+ return Err(OAuthFlowError::MetadataFetchFailed(auth_server_url.to_string()));
237237+ }
238238+239239+ auth_response
240240+ .json()
241241+ .await
242242+ .map_err(|e| OAuthFlowError::NetworkError(e.to_string()))
243243+ }
244244+}
245245+246246+impl Default for OAuthFlow {
247247+ fn default() -> Self {
248248+ Self::new()
249249+ }
250250+}
251251+252252+/// Authorization server metadata.
253253+#[derive(Deserialize)]
254254+pub struct AuthServerMetadata {
255255+ pub issuer: String,
256256+ pub authorization_endpoint: String,
257257+ pub token_endpoint: String,
258258+ pub pushed_authorization_request_endpoint: Option<String>,
259259+}
260260+261261+/// Error type for OAuth flow operations.
262262+#[derive(Debug, Clone)]
263263+pub enum OAuthFlowError {
264264+ SessionNotFound,
265265+ NetworkError(String),
266266+ MetadataFetchFailed(String),
267267+ TokenExchangeFailed(String),
268268+ TokenRefreshFailed(String),
269269+ ResolveError(String),
270270+}
271271+272272+impl From<ResolveError> for OAuthFlowError {
273273+ fn from(err: ResolveError) -> Self {
274274+ OAuthFlowError::ResolveError(err.to_string())
275275+ }
276276+}
277277+278278+impl std::fmt::Display for OAuthFlowError {
279279+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280280+ match self {
281281+ OAuthFlowError::SessionNotFound => write!(f, "OAuth session not found"),
282282+ OAuthFlowError::NetworkError(e) => write!(f, "Network error: {}", e),
283283+ OAuthFlowError::MetadataFetchFailed(url) => write!(f, "Failed to fetch metadata from {}", url),
284284+ OAuthFlowError::TokenExchangeFailed(e) => write!(f, "Token exchange failed: {}", e),
285285+ OAuthFlowError::TokenRefreshFailed(e) => write!(f, "Token refresh failed: {}", e),
286286+ OAuthFlowError::ResolveError(e) => write!(f, "Identity resolution failed: {}", e),
287287+ }
288288+ }
289289+}
290290+291291+impl std::error::Error for OAuthFlowError {}
292292+293293+/// Generate a secure random state parameter.
294294+pub fn generate_state() -> String {
295295+ use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
296296+297297+ let mut bytes = [0u8; 16];
298298+ getrandom::fill(&mut bytes).expect("Failed to generate random bytes");
299299+ URL_SAFE_NO_PAD.encode(bytes)
300300+}
301301+302302+#[cfg(test)]
303303+mod tests {
304304+ use super::*;
305305+306306+ #[test]
307307+ fn test_generate_state() {
308308+ let state1 = generate_state();
309309+ let state2 = generate_state();
310310+311311+ assert_ne!(state1, state2);
312312+ assert_eq!(state1.len(), 22);
313313+ }
314314+315315+ #[test]
316316+ fn test_new_session_store() {
317317+ let store = new_session_store();
318318+ assert!(store.read().unwrap().is_empty());
319319+ }
320320+321321+ #[test]
322322+ fn test_oauth_flow_creation() {
323323+ let flow = OAuthFlow::new();
324324+ assert!(flow.client_id.contains("client-metadata.json"));
325325+ assert!(flow.redirect_uri.contains("callback"));
326326+ }
327327+328328+ #[test]
329329+ fn test_oauth_flow_error_display() {
330330+ let err = OAuthFlowError::SessionNotFound;
331331+ assert!(err.to_string().contains("session not found"));
332332+333333+ let err = OAuthFlowError::NetworkError("timeout".to_string());
334334+ assert!(err.to_string().contains("timeout"));
335335+ }
336336+}
+15
crates/server/src/oauth/mod.rs
···11+//! OAuth 2.1 implementation for AT Protocol.
22+//!
33+//! This module provides the OAuth 2.1 client flow components required
44+//! for AT Protocol authentication:
55+//!
66+//! - PKCE (Proof Key for Code Exchange)
77+//! - DPoP (Demonstrating Proof of Possession)
88+//! - Handle/DID resolution
99+//! - Token management
1010+1111+pub mod client_metadata;
1212+pub mod dpop;
1313+pub mod flow;
1414+pub mod pkce;
1515+pub mod resolver;
+76
crates/server/src/oauth/pkce.rs
···11+//! PKCE (Proof Key for Code Exchange) implementation for OAuth 2.1.
22+//!
33+//! AT Protocol requires PKCE with S256 challenge method.
44+55+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
66+use sha2::{Digest, Sha256};
77+88+/// Length of the code verifier in bytes (before base64 encoding).
99+const CODE_VERIFIER_LENGTH: usize = 32;
1010+1111+/// Generate a cryptographically random code verifier.
1212+///
1313+/// The verifier is a high-entropy random string used in PKCE flow.
1414+pub fn generate_code_verifier() -> String {
1515+ let mut bytes = [0u8; CODE_VERIFIER_LENGTH];
1616+ getrandom::fill(&mut bytes).expect("Failed to generate random bytes");
1717+ URL_SAFE_NO_PAD.encode(bytes)
1818+}
1919+2020+/// Derive the S256 code challenge from a code verifier.
2121+///
2222+/// The challenge is the base64url-encoded SHA-256 hash of the verifier.
2323+pub fn derive_code_challenge(verifier: &str) -> String {
2424+ let hash = Sha256::digest(verifier.as_bytes());
2525+ URL_SAFE_NO_PAD.encode(hash)
2626+}
2727+2828+/// Verify that a code challenge matches a code verifier.
2929+pub fn verify_challenge(verifier: &str, challenge: &str) -> bool {
3030+ derive_code_challenge(verifier) == challenge
3131+}
3232+3333+#[cfg(test)]
3434+mod tests {
3535+ use super::*;
3636+3737+ #[test]
3838+ fn test_generate_verifier_length() {
3939+ let verifier = generate_code_verifier();
4040+ assert_eq!(verifier.len(), 43);
4141+ }
4242+4343+ #[test]
4444+ fn test_generate_verifier_uniqueness() {
4545+ let v1 = generate_code_verifier();
4646+ let v2 = generate_code_verifier();
4747+ assert_ne!(v1, v2);
4848+ }
4949+5050+ #[test]
5151+ fn test_challenge_derivation() {
5252+ let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
5353+ let challenge = derive_code_challenge(verifier);
5454+5555+ assert!(!challenge.is_empty());
5656+ assert_eq!(challenge.len(), 43);
5757+ }
5858+5959+ #[test]
6060+ fn test_verify_challenge() {
6161+ let verifier = generate_code_verifier();
6262+ let challenge = derive_code_challenge(&verifier);
6363+6464+ assert!(verify_challenge(&verifier, &challenge));
6565+ assert!(!verify_challenge(&verifier, "wrong_challenge"));
6666+ }
6767+6868+ #[test]
6969+ fn test_challenge_is_url_safe() {
7070+ let verifier = generate_code_verifier();
7171+ let challenge = derive_code_challenge(&verifier);
7272+ assert!(!challenge.contains('+'));
7373+ assert!(!challenge.contains('/'));
7474+ assert!(!challenge.contains('='));
7575+ }
7676+}
+261
crates/server/src/oauth/resolver.rs
···11+//! Handle and DID resolution for AT Protocol.
22+//!
33+//! Resolves user identities to discover their PDS (Personal Data Server).
44+55+use serde::{Deserialize, Serialize};
66+77+/// Result of resolving a handle or DID.
88+#[derive(Debug, Clone, Serialize, Deserialize)]
99+pub struct ResolvedIdentity {
1010+ /// The DID (always populated after resolution)
1111+ pub did: String,
1212+ /// The handle (if resolved from DID)
1313+ pub handle: Option<String>,
1414+ /// The PDS URL for this identity
1515+ pub pds_url: String,
1616+}
1717+1818+/// Error type for resolution failures.
1919+#[derive(Debug, Clone)]
2020+pub enum ResolveError {
2121+ /// Handle not found
2222+ HandleNotFound(String),
2323+ /// DID not found
2424+ DidNotFound(String),
2525+ /// Network error
2626+ NetworkError(String),
2727+ /// Invalid DID format
2828+ InvalidDid(String),
2929+}
3030+3131+impl std::fmt::Display for ResolveError {
3232+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3333+ match self {
3434+ ResolveError::HandleNotFound(h) => write!(f, "Handle not found: {}", h),
3535+ ResolveError::DidNotFound(d) => write!(f, "DID not found: {}", d),
3636+ ResolveError::NetworkError(e) => write!(f, "Network error: {}", e),
3737+ ResolveError::InvalidDid(d) => write!(f, "Invalid DID: {}", d),
3838+ }
3939+ }
4040+}
4141+4242+impl std::error::Error for ResolveError {}
4343+4444+/// Resolver for AT Protocol identities.
4545+///
4646+/// Handles resolution of:
4747+/// - Handle -> DID (via DNS TXT or HTTP well-known)
4848+/// - DID -> PDS URL (via PLC directory or did:web)
4949+pub struct IdentityResolver {
5050+ client: reqwest::Client,
5151+ plc_directory: String,
5252+}
5353+5454+impl Default for IdentityResolver {
5555+ fn default() -> Self {
5656+ Self::new()
5757+ }
5858+}
5959+6060+impl IdentityResolver {
6161+ /// Create a new resolver with default settings.
6262+ pub fn new() -> Self {
6363+ Self { client: reqwest::Client::new(), plc_directory: "https://plc.directory".to_string() }
6464+ }
6565+6666+ /// Create a resolver with a custom PLC directory URL.
6767+ pub fn with_plc_directory(plc_directory: &str) -> Self {
6868+ Self { client: reqwest::Client::new(), plc_directory: plc_directory.to_string() }
6969+ }
7070+7171+ /// Resolve a handle to a DID.
7272+ ///
7373+ /// Tries HTTP well-known first, then falls back to DNS TXT.
7474+ pub async fn resolve_handle(&self, handle: &str) -> Result<String, ResolveError> {
7575+ // Try HTTP well-known first
7676+ if let Ok(did) = self.resolve_handle_http(handle).await {
7777+ return Ok(did);
7878+ }
7979+8080+ // Fall back to DNS TXT (simplified - just return error for now)
8181+ Err(ResolveError::HandleNotFound(handle.to_string()))
8282+ }
8383+8484+ /// Resolve handle via HTTP well-known.
8585+ async fn resolve_handle_http(&self, handle: &str) -> Result<String, ResolveError> {
8686+ let url = format!("https://{}/.well-known/atproto-did", handle);
8787+8888+ let response = self
8989+ .client
9090+ .get(&url)
9191+ .timeout(std::time::Duration::from_secs(10))
9292+ .send()
9393+ .await
9494+ .map_err(|e| ResolveError::NetworkError(e.to_string()))?;
9595+9696+ if !response.status().is_success() {
9797+ return Err(ResolveError::HandleNotFound(handle.to_string()));
9898+ }
9999+100100+ let did = response
101101+ .text()
102102+ .await
103103+ .map_err(|e| ResolveError::NetworkError(e.to_string()))?
104104+ .trim()
105105+ .to_string();
106106+107107+ if !did.starts_with("did:") {
108108+ return Err(ResolveError::HandleNotFound(handle.to_string()));
109109+ }
110110+111111+ Ok(did)
112112+ }
113113+114114+ /// Resolve a DID to its PDS URL.
115115+ pub async fn resolve_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> {
116116+ if did.starts_with("did:plc:") {
117117+ self.resolve_plc_did(did).await
118118+ } else if did.starts_with("did:web:") {
119119+ self.resolve_web_did(did).await
120120+ } else {
121121+ Err(ResolveError::InvalidDid(did.to_string()))
122122+ }
123123+ }
124124+125125+ /// Resolve a did:plc via the PLC directory.
126126+ async fn resolve_plc_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> {
127127+ let url = format!("{}/{}", self.plc_directory, did);
128128+129129+ let response = self
130130+ .client
131131+ .get(&url)
132132+ .timeout(std::time::Duration::from_secs(10))
133133+ .send()
134134+ .await
135135+ .map_err(|e| ResolveError::NetworkError(e.to_string()))?;
136136+137137+ if !response.status().is_success() {
138138+ return Err(ResolveError::DidNotFound(did.to_string()));
139139+ }
140140+141141+ let doc: serde_json::Value = response
142142+ .json()
143143+ .await
144144+ .map_err(|e| ResolveError::NetworkError(e.to_string()))?;
145145+146146+ // Extract PDS URL from service array
147147+ let pds_url = doc["service"]
148148+ .as_array()
149149+ .and_then(|services| {
150150+ services.iter().find(|s| {
151151+ s["id"].as_str() == Some("#atproto_pds") || s["type"].as_str() == Some("AtprotoPersonalDataServer")
152152+ })
153153+ })
154154+ .and_then(|s| s["serviceEndpoint"].as_str())
155155+ .ok_or_else(|| ResolveError::DidNotFound(did.to_string()))?
156156+ .to_string();
157157+158158+ // Extract handle from alsoKnownAs
159159+ let handle = doc["alsoKnownAs"]
160160+ .as_array()
161161+ .and_then(|aka| {
162162+ aka.iter()
163163+ .find(|a| a.as_str().map(|s| s.starts_with("at://")).unwrap_or(false))
164164+ })
165165+ .and_then(|a| a.as_str())
166166+ .map(|s| s.strip_prefix("at://").unwrap_or(s).to_string());
167167+168168+ Ok(ResolvedIdentity { did: did.to_string(), handle, pds_url })
169169+ }
170170+171171+ /// Resolve a did:web via HTTP.
172172+ async fn resolve_web_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> {
173173+ // did:web:example.com -> https://example.com/.well-known/did.json
174174+ let domain = did
175175+ .strip_prefix("did:web:")
176176+ .ok_or_else(|| ResolveError::InvalidDid(did.to_string()))?;
177177+178178+ let url = format!("https://{}/.well-known/did.json", domain);
179179+180180+ let response = self
181181+ .client
182182+ .get(&url)
183183+ .timeout(std::time::Duration::from_secs(10))
184184+ .send()
185185+ .await
186186+ .map_err(|e| ResolveError::NetworkError(e.to_string()))?;
187187+188188+ if !response.status().is_success() {
189189+ return Err(ResolveError::DidNotFound(did.to_string()));
190190+ }
191191+192192+ let doc: serde_json::Value = response
193193+ .json()
194194+ .await
195195+ .map_err(|e| ResolveError::NetworkError(e.to_string()))?;
196196+197197+ let pds_url = doc["service"]
198198+ .as_array()
199199+ .and_then(|services| {
200200+ services
201201+ .iter()
202202+ .find(|s| s["type"].as_str() == Some("AtprotoPersonalDataServer"))
203203+ })
204204+ .and_then(|s| s["serviceEndpoint"].as_str())
205205+ .ok_or_else(|| ResolveError::DidNotFound(did.to_string()))?
206206+ .to_string();
207207+208208+ Ok(ResolvedIdentity { did: did.to_string(), handle: None, pds_url })
209209+ }
210210+}
211211+212212+/// Check if a string is a valid DID.
213213+pub fn is_valid_did(s: &str) -> bool {
214214+ s.starts_with("did:plc:") || s.starts_with("did:web:")
215215+}
216216+217217+/// Check if a string is a valid handle.
218218+pub fn is_valid_handle(s: &str) -> bool {
219219+ // Simple validation: contains at least one dot, no spaces
220220+ s.contains('.') && !s.contains(' ') && !s.starts_with("did:")
221221+}
222222+223223+#[cfg(test)]
224224+mod tests {
225225+ use super::*;
226226+227227+ #[test]
228228+ fn test_is_valid_did() {
229229+ assert!(is_valid_did("did:plc:abc123"));
230230+ assert!(is_valid_did("did:web:example.com"));
231231+ assert!(!is_valid_did("alice.bsky.social"));
232232+ assert!(!is_valid_did("did:other:xyz"));
233233+ }
234234+235235+ #[test]
236236+ fn test_is_valid_handle() {
237237+ assert!(is_valid_handle("alice.bsky.social"));
238238+ assert!(is_valid_handle("bob.example.com"));
239239+ assert!(!is_valid_handle("did:plc:abc123"));
240240+ assert!(!is_valid_handle("invalid handle"));
241241+ assert!(!is_valid_handle("nodots"));
242242+ }
243243+244244+ #[test]
245245+ fn test_resolver_creation() {
246246+ let resolver = IdentityResolver::new();
247247+ assert_eq!(resolver.plc_directory, "https://plc.directory");
248248+249249+ let custom = IdentityResolver::with_plc_directory("https://custom.plc");
250250+ assert_eq!(custom.plc_directory, "https://custom.plc");
251251+ }
252252+253253+ #[test]
254254+ fn test_resolve_error_display() {
255255+ let err = ResolveError::HandleNotFound("test.handle".to_string());
256256+ assert!(err.to_string().contains("test.handle"));
257257+258258+ let err = ResolveError::InvalidDid("bad:did".to_string());
259259+ assert!(err.to_string().contains("bad:did"));
260260+ }
261261+}
···11+//! PDS (Personal Data Server) client for AT Protocol.
22+//!
33+//! Provides record publishing operations:
44+//! - putRecord - Create or update records
55+//! - deleteRecord - Remove records
66+//! - uploadBlob - Upload media attachments
77+88+pub mod client;
99+pub mod records;
···11+# AT Protocol Research Notes
22+33+## OAuth 2.1 Specification
44+55+AT Protocol uses a specific profile of OAuth 2.1 for client↔PDS authorization.
66+77+### Required Components
88+99+- **Client Metadata Endpoint**: Serve `client_metadata.json` at a public HTTPS URL (this URL becomes the `client_id`)
1010+1111+ ```json
1212+ {
1313+ "client_id": "https://your-app.com/oauth/client-metadata.json",
1414+ "application_type": "web",
1515+ "grant_types": ["authorization_code", "refresh_token"],
1616+ "scope": "atproto transition:generic",
1717+ "response_types": ["code"],
1818+ "redirect_uris": ["https://your-app.com/oauth/callback"],
1919+ "client_name": "Malfestio",
2020+ "client_uri": "https://your-app.com"
2121+ }
2222+ ```
2323+2424+- **PKCE (Mandatory)**: Generate `code_verifier` and `code_challenge` (S256 only)
2525+- **DPoP (Mandatory)**: Bind tokens to client instances with proof-of-possession JWTs
2626+- **Handle/DID Resolution**: Resolve user identity to discover their PDS
2727+- **Token Exchange**: Authorization code flow with token refresh
2828+2929+## Record Publishing
3030+3131+### XRPC Endpoints
3232+3333+- `com.atproto.repo.putRecord` — Create or update records
3434+- `com.atproto.repo.deleteRecord` — Remove records
3535+- `com.atproto.repo.uploadBlob` — Upload media attachments
3636+3737+### Record Keys
3838+3939+Use TID (timestamp-based identifiers) per Lexicon spec.
4040+4141+### AT-URIs
4242+4343+Format: `at://<did>/<collection>/<rkey>`
4444+4545+Example: `at://did:plc:abc123/app.malfestio.deck/3k5abc123`
4646+4747+## Firehose Consumption
4848+4949+For social features (trending, discovery, feeds):
5050+5151+- **WebSocket Connection**: Subscribe to `com.atproto.sync.subscribeRepos` from a Relay
5252+- **CBOR Decoding**: Parse incoming events (or use Jetstream for JSON)
5353+- **Cursor Management**: Track position for reconnection
5454+5555+## AppView Pattern
5656+5757+Index network-wide records to power discovery features:
5858+5959+- Index `app.malfestio.*` records from firehose
6060+- Implement `getFeedSkeleton` for custom algorithmic feeds
6161+- Hydration service combines skeletons with full content from PDSes
6262+6363+## Well-Known Endpoints
6464+6565+- `/.well-known/atproto-did` — Domain verification for handle claims
6666+- `/.well-known/oauth-protected-resource` — PDS OAuth metadata
6767+- `/.well-known/oauth-authorization-server` — Auth server metadata
6868+6969+## Patterns from Real AT Protocol Apps
7070+7171+### plyr.fm (Music)
7272+7373+- OAuth 2.1 via `@atproto/oauth-client` library
7474+- Records synced to PDS: tracks, likes, playlists
7575+- Separate moderation service (Rust labeler)
7676+- Data ownership: "tracks, likes, playlists synced to your PDS as ATProto records"
7777+7878+### leaflet.pub (Writing)
7979+8080+- React/Next.js frontend with Supabase + Replicache for sync
8181+- Bluesky integration via dedicated `lexicons/` and `appview/` directories
8282+- Publications posted to Bluesky
8383+8484+### wisp.place (Static Sites)
8585+8686+- Stores site files as `place.wisp.fs` records in user's PDS
8787+- Firehose consumer to index and serve sites
8888+- CDN layer caches content from PDS
8989+9090+### Common Patterns
9191+9292+1. Local database for fast queries + PDS for portable, signed records
9393+2. Firehose consumption for discovery/aggregation
9494+3. OAuth 2.1 for production auth (app passwords only for development)
9595+4. Lexicons define the public contract; internal state stays private
9696+9797+## References
9898+9999+- [AT Protocol OAuth Spec](https://atproto.com/specs/oauth)
100100+- [Lexicon Schema Language](https://atproto.com/specs/lexicon)
101101+- [Repository & XRPC](https://atproto.com/specs/xrpc)
102102+- [Feed Generator Starter Kit](https://github.com/bluesky-social/feed-generator)
103103+- [atproto TypeScript SDK](https://github.com/bluesky-social/atproto)
-4
docs/todo.md
···84848585- A user can authenticate via OAuth, create a deck, and see it in their PDS repository.
86868787-#### Notes
8888-8989-- See [docs/at.md](at.md) for full AT Protocol integration research.
9090-9187### Milestone G - Study Engine (SRS) + Daily Review UX
92889389#### Deliverables