From 3caa71632c00230caf0d2bbbe4803e258efe5507 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 2 Jan 2026 13:11:13 -0600 Subject: [PATCH] feat: enable app password sessions * PDS resolution * use optimistic validation --- crates/server/src/api/auth.rs | 74 +++++++++++++++++- crates/server/src/api/oauth.rs | 6 ++ crates/server/src/api/search.rs | 1 + crates/server/src/api/social.rs | 1 + crates/server/src/api/users.rs | 1 + crates/server/src/pds/client.rs | 13 +--- crates/server/src/repository/oauth.rs | 76 +++++++++++++++++-- crates/server/src/state.rs | 5 ++ .../014_2026_01_02_nullable_dpop_key.sql | 6 ++ 9 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 migrations/014_2026_01_02_nullable_dpop_key.sql diff --git a/crates/server/src/api/auth.rs b/crates/server/src/api/auth.rs index 2ec58a5..fe47106 100644 --- a/crates/server/src/api/auth.rs +++ b/crates/server/src/api/auth.rs @@ -18,10 +18,58 @@ pub struct LoginResponse { handle: String, } -/// TODO: Find user's PDS URL +/// Login with app password. +/// +/// Resolves the user's PDS from their handle/DID, authenticates with that PDS, +/// and stores the session for future requests. pub async fn login(State(state): State, Json(payload): Json) -> impl IntoResponse { + use crate::oauth::resolver::{is_valid_did, is_valid_handle}; + use crate::repository::oauth::StoreAppPasswordSessionRequest; + let client = reqwest::Client::new(); - let pds_url = &state.config.pds_url; + + let pds_url = if is_valid_did(&payload.identifier) { + match state.identity_resolver.resolve_did(&payload.identifier).await { + Ok(identity) => identity.pds_url, + Err(e) => { + tracing::error!("Failed to resolve DID {}: {}", payload.identifier, e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Failed to resolve DID: {}", e) })), + ) + .into_response(); + } + } + } else if is_valid_handle(&payload.identifier) { + let did = match state.identity_resolver.resolve_handle(&payload.identifier).await { + Ok(did) => did, + Err(e) => { + tracing::error!("Failed to resolve handle {}: {}", payload.identifier, e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Failed to resolve handle: {}", e) })), + ) + .into_response(); + } + }; + + match state.identity_resolver.resolve_did(&did).await { + Ok(identity) => identity.pds_url, + Err(e) => { + tracing::error!("Failed to resolve DID {} for handle {}: {}", did, payload.identifier, e); + return ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": format!("Failed to resolve DID: {}", e) })), + ) + .into_response(); + } + } + } else { + tracing::warn!("Invalid identifier format: {}, using default PDS", payload.identifier); + state.config.pds_url.clone() + }; + + tracing::info!("Authenticating {} with PDS: {}", payload.identifier, pds_url); let resp = client .post(format!("{}/xrpc/com.atproto.server.createSession", pds_url)) @@ -41,6 +89,22 @@ pub async fn login(State(state): State, Json(payload): Json, Json(payload): Json ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": e.to_string() })), - ), + ) + .into_response(), } } diff --git a/crates/server/src/api/oauth.rs b/crates/server/src/api/oauth.rs index 07ba755..7bbabdc 100644 --- a/crates/server/src/api/oauth.rs +++ b/crates/server/src/api/oauth.rs @@ -53,6 +53,12 @@ impl OAuthRepository for MockOAuthRepository { Ok(()) } + async fn store_app_password_session( + &self, _req: crate::repository::oauth::StoreAppPasswordSessionRequest<'_>, + ) -> Result<(), crate::repository::oauth::OAuthRepoError> { + Ok(()) + } + async fn get_tokens( &self, did: &str, ) -> Result { diff --git a/crates/server/src/api/search.rs b/crates/server/src/api/search.rs index 40d5ef9..95608ae 100644 --- a/crates/server/src/api/search.rs +++ b/crates/server/src/api/search.rs @@ -116,6 +116,7 @@ mod tests { config, auth_cache, dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + identity_resolver: crate::oauth::resolver::IdentityResolver::new(), }) } diff --git a/crates/server/src/api/social.rs b/crates/server/src/api/social.rs index 451289b..fd04d37 100644 --- a/crates/server/src/api/social.rs +++ b/crates/server/src/api/social.rs @@ -209,6 +209,7 @@ mod tests { config, auth_cache, dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + identity_resolver: crate::oauth::resolver::IdentityResolver::new(), }) } diff --git a/crates/server/src/api/users.rs b/crates/server/src/api/users.rs index f90b72d..f9e5485 100644 --- a/crates/server/src/api/users.rs +++ b/crates/server/src/api/users.rs @@ -55,6 +55,7 @@ mod tests { config: crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }, auth_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + identity_resolver: crate::oauth::resolver::IdentityResolver::new(), }) } diff --git a/crates/server/src/pds/client.rs b/crates/server/src/pds/client.rs index 623a9be..8750527 100644 --- a/crates/server/src/pds/client.rs +++ b/crates/server/src/pds/client.rs @@ -143,20 +143,17 @@ impl PdsClient { record, swap_record: None, swap_commit: None, - validate: Some(true), + validate: Some(false), }; let mut request_builder = self.http_client.post(&url); - // Conditionally add DPoP or Bearer authentication if let Some(ref dpop_keypair) = self.dpop_keypair { - // OAuth with DPoP let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); request_builder = request_builder .header("Authorization", format!("DPoP {}", self.access_token)) .header("DPoP", dpop_proof); } else { - // App password with Bearer request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); } @@ -183,15 +180,12 @@ impl PdsClient { let mut request_builder = self.http_client.post(&url); - // Conditionally add DPoP or Bearer authentication if let Some(ref dpop_keypair) = self.dpop_keypair { - // OAuth with DPoP let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); request_builder = request_builder .header("Authorization", format!("DPoP {}", self.access_token)) .header("DPoP", dpop_proof); } else { - // App password with Bearer request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); } @@ -216,15 +210,12 @@ impl PdsClient { let mut request_builder = self.http_client.post(&url); - // Conditionally add DPoP or Bearer authentication if let Some(ref dpop_keypair) = self.dpop_keypair { - // OAuth with DPoP let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); request_builder = request_builder .header("Authorization", format!("DPoP {}", self.access_token)) .header("DPoP", dpop_proof); } else { - // App password with Bearer request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); } @@ -314,7 +305,7 @@ mod tests { let json = serde_json::to_string(&request).unwrap(); assert!(json.contains("\"repo\":\"did:plc:abc123\"")); - assert!(!json.contains("swapRecord")); // Should be omitted when None + assert!(!json.contains("swapRecord")); } #[test] diff --git a/crates/server/src/repository/oauth.rs b/crates/server/src/repository/oauth.rs index e931a5f..55bee5a 100644 --- a/crates/server/src/repository/oauth.rs +++ b/crates/server/src/repository/oauth.rs @@ -10,6 +10,8 @@ use ed25519_dalek::SigningKey; use serde::{Deserialize, Serialize}; /// Stored OAuth token record. +/// +/// Supports both OAuth sessions (with DPoP) and app password sessions (without DPoP). #[derive(Clone, Serialize, Deserialize)] pub struct StoredToken { pub did: String, @@ -18,19 +20,22 @@ pub struct StoredToken { pub refresh_token: Option, pub token_type: String, pub expires_at: Option>, - pub dpop_private_key: Vec, + pub dpop_private_key: Option>, pub created_at: DateTime, pub updated_at: DateTime, } impl StoredToken { /// Reconstruct the DPoP keypair from stored bytes. + /// + /// Returns None for app password sessions (no DPoP) or if the key is invalid. pub fn dpop_keypair(&self) -> Option { - if self.dpop_private_key.len() != 32 { + let key_bytes_vec = self.dpop_private_key.as_ref()?; + if key_bytes_vec.len() != 32 { return None; } let mut key_bytes = [0u8; 32]; - key_bytes.copy_from_slice(&self.dpop_private_key); + key_bytes.copy_from_slice(key_bytes_vec); let signing_key = SigningKey::from_bytes(&key_bytes); Some(DpopKeypair::from_signing_key(signing_key)) } @@ -56,7 +61,7 @@ impl std::fmt::Display for OAuthRepoError { impl std::error::Error for OAuthRepoError {} -/// Request to store OAuth tokens. +/// Request to store OAuth tokens with DPoP. pub struct StoreTokensRequest<'a> { pub did: &'a str, pub pds_url: &'a str, @@ -67,12 +72,24 @@ pub struct StoreTokensRequest<'a> { pub dpop_keypair: &'a DpopKeypair, } +/// Request to store app password session (without DPoP). +pub struct StoreAppPasswordSessionRequest<'a> { + pub did: &'a str, + pub pds_url: &'a str, + pub access_token: &'a str, + pub refresh_token: Option<&'a str>, + pub expires_at: Option>, +} + /// Repository trait for OAuth token operations. #[async_trait] pub trait OAuthRepository: Send + Sync { - /// Store OAuth tokens for a user. + /// Store OAuth tokens for a user (with DPoP). async fn store_tokens(&self, req: StoreTokensRequest<'_>) -> Result<(), OAuthRepoError>; + /// Store app password session for a user (without DPoP). + async fn store_app_password_session(&self, req: StoreAppPasswordSessionRequest<'_>) -> Result<(), OAuthRepoError>; + /// Get stored tokens for a user. async fn get_tokens(&self, did: &str) -> Result; @@ -130,6 +147,32 @@ impl OAuthRepository for DbOAuthRepository { Ok(()) } + async fn store_app_password_session(&self, req: StoreAppPasswordSessionRequest<'_>) -> Result<(), OAuthRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?; + + client + .execute( + "INSERT INTO oauth_tokens (did, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key) + VALUES ($1, $2, $3, $4, 'Bearer', $5, NULL) + ON CONFLICT (did) DO UPDATE SET + pds_url = EXCLUDED.pds_url, + access_token = EXCLUDED.access_token, + refresh_token = EXCLUDED.refresh_token, + expires_at = EXCLUDED.expires_at, + dpop_private_key = NULL, + updated_at = NOW()", + &[&req.did, &req.pds_url, &req.access_token, &req.refresh_token, &req.expires_at], + ) + .await + .map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?; + + Ok(()) + } + async fn get_tokens(&self, did: &str) -> Result { let client = self .pool @@ -290,7 +333,28 @@ pub mod mock { refresh_token: req.refresh_token.map(String::from), token_type: req.token_type.to_string(), expires_at: req.expires_at, - dpop_private_key: req.dpop_keypair.private_key_bytes(), + dpop_private_key: Some(req.dpop_keypair.private_key_bytes()), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + self.tokens.lock().unwrap().push(token); + Ok(()) + } + + async fn store_app_password_session(&self, req: StoreAppPasswordSessionRequest<'_>) -> Result<(), OAuthRepoError> { + if *self.should_fail.lock().unwrap() { + return Err(OAuthRepoError::DatabaseError("Mock failure".to_string())); + } + + let token = StoredToken { + did: req.did.to_string(), + pds_url: req.pds_url.to_string(), + access_token: req.access_token.to_string(), + refresh_token: req.refresh_token.map(String::from), + token_type: "Bearer".to_string(), + expires_at: req.expires_at, + dpop_private_key: None, created_at: Utc::now(), updated_at: Utc::now(), }; diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 7d8824f..4eea747 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -1,5 +1,6 @@ use crate::db::DbPool; use crate::middleware::auth::UserContext; +use crate::oauth::resolver::IdentityResolver; use crate::repository; use crate::repository::card::CardRepository; use crate::repository::deck::DeckRepository; @@ -93,12 +94,15 @@ pub struct AppState { pub auth_cache: AuthCache, /// Cache of valid DPoP nonces. Nonces are single-use and expire after TTL. pub dpop_nonces: DpopNonceCache, + /// Identity resolver for AT Protocol handle/DID resolution. + pub identity_resolver: IdentityResolver, } impl AppState { pub fn new(pool: DbPool, repos: Repositories, config: AppConfig) -> SharedState { let auth_cache = Arc::new(RwLock::new(HashMap::new())); let dpop_nonces = Arc::new(RwLock::new(HashMap::new())); + let identity_resolver = IdentityResolver::new(); Arc::new(Self { pool, oauth_repo: repos.oauth, @@ -112,6 +116,7 @@ impl AppState { config, auth_cache, dpop_nonces, + identity_resolver, }) } diff --git a/migrations/014_2026_01_02_nullable_dpop_key.sql b/migrations/014_2026_01_02_nullable_dpop_key.sql new file mode 100644 index 0000000..80387e8 --- /dev/null +++ b/migrations/014_2026_01_02_nullable_dpop_key.sql @@ -0,0 +1,6 @@ +-- Make dpop_private_key nullable to support app password sessions +-- App password sessions don't use DPoP, only OAuth sessions do + +ALTER TABLE oauth_tokens ALTER COLUMN dpop_private_key DROP NOT NULL; + +COMMENT ON COLUMN oauth_tokens.dpop_private_key IS 'DPoP private key for OAuth sessions. NULL for app password sessions.'; -- 2.51.2