···148148 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
149149 let state = create_test_state_with_prefs(prefs_repo);
150150151151- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
151151+ let user = UserContext {
152152+ did: "did:plc:test".to_string(),
153153+ handle: "test.handle".to_string(),
154154+ access_token: "test_token".to_string(),
155155+ pds_url: "https://bsky.social".to_string(),
156156+ has_dpop: false,
157157+ };
152158 let response = get_preferences(State(state), Some(Extension(user)))
153159 .await
154160 .into_response();
···161167 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
162168 let state = create_test_state_with_prefs(prefs_repo);
163169164164- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
170170+ let user = UserContext {
171171+ did: "did:plc:test".to_string(),
172172+ handle: "test.handle".to_string(),
173173+ access_token: "test_token".to_string(),
174174+ pds_url: "https://bsky.social".to_string(),
175175+ has_dpop: false,
176176+ };
165177 let payload = UpdatePreferencesRequest {
166178 persona: Some("creator".to_string()),
167179 complete_onboarding: Some(true),
···181193 let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc<dyn PreferencesRepository>;
182194 let state = create_test_state_with_prefs(prefs_repo);
183195184184- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
196196+ let user = UserContext {
197197+ did: "did:plc:test".to_string(),
198198+ handle: "test.handle".to_string(),
199199+ access_token: "test_token".to_string(),
200200+ pds_url: "https://bsky.social".to_string(),
201201+ has_dpop: false,
202202+ };
185203 let payload = UpdatePreferencesRequest {
186204 persona: Some("invalid".to_string()),
187205 complete_onboarding: None,
+28-4
crates/server/src/api/review.rs
···201201 let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>;
202202 let state = create_test_state_with_review(review_repo);
203203204204- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
204204+ let user = UserContext {
205205+ did: "did:plc:test".to_string(),
206206+ handle: "test.handle".to_string(),
207207+ access_token: "test_token".to_string(),
208208+ pds_url: "https://bsky.social".to_string(),
209209+ has_dpop: false,
210210+ };
205211 let response = get_due_cards(
206212 State(state),
207213 Some(Extension(user)),
···218224 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
219225 let state = create_test_state_with_review(review_repo);
220226221221- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
227227+ let user = UserContext {
228228+ did: "did:plc:test".to_string(),
229229+ handle: "test.handle".to_string(),
230230+ access_token: "test_token".to_string(),
231231+ pds_url: "https://bsky.social".to_string(),
232232+ has_dpop: false,
233233+ };
222234 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 };
223235224236 let response = submit_review(State(state), Some(Extension(user)), Json(payload))
···233245 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
234246 let state = create_test_state_with_review(review_repo);
235247236236- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
248248+ let user = UserContext {
249249+ did: "did:plc:test".to_string(),
250250+ handle: "test.handle".to_string(),
251251+ access_token: "test_token".to_string(),
252252+ pds_url: "https://bsky.social".to_string(),
253253+ has_dpop: false,
254254+ };
237255 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 };
238256239257 let response = submit_review(State(state), Some(Extension(user)), Json(payload))
···248266 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
249267 let state = create_test_state_with_review(review_repo);
250268251251- let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
269269+ let user = UserContext {
270270+ did: "did:plc:test".to_string(),
271271+ handle: "test.handle".to_string(),
272272+ access_token: "test_token".to_string(),
273273+ pds_url: "https://bsky.social".to_string(),
274274+ has_dpop: false,
275275+ };
252276 let response = get_stats(State(state), Some(Extension(user))).await.into_response();
253277254278 assert_eq!(response.status(), StatusCode::OK);
+7-1
crates/server/src/api/search.rs
···134134 .await;
135135136136 let state = create_test_state_with_search(search_repo.clone());
137137- let auth_ctx = Extension(UserContext { did: "did:alice".to_string(), handle: "alice.test".to_string() });
137137+ let auth_ctx = Extension(UserContext {
138138+ did: "did:alice".to_string(),
139139+ handle: "alice.test".to_string(),
140140+ access_token: "test_token".to_string(),
141141+ pds_url: "https://bsky.social".to_string(),
142142+ has_dpop: false,
143143+ });
138144 let response = search(
139145 State(state.clone()),
140146 Some(auth_ctx),
+21-3
crates/server/src/api/social.rs
···216216 async fn test_follow_success() {
217217 let social_repo = Arc::new(MockSocialRepository::new());
218218 let state = create_test_state_with_social(social_repo.clone());
219219- let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() };
219219+ let user = UserContext {
220220+ did: "did:plc:follower".to_string(),
221221+ handle: "follower".to_string(),
222222+ access_token: "test_token".to_string(),
223223+ pds_url: "https://bsky.social".to_string(),
224224+ has_dpop: false,
225225+ };
220226221227 let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string()))
222228 .await
···234240 social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap();
235241236242 let state = create_test_state_with_social(social_repo.clone());
237237- let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() };
243243+ let user = UserContext {
244244+ did: "did:plc:follower".to_string(),
245245+ handle: "follower".to_string(),
246246+ access_token: "test_token".to_string(),
247247+ pds_url: "https://bsky.social".to_string(),
248248+ has_dpop: false,
249249+ };
238250239251 let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string()))
240252 .await
···272284 async fn test_add_comment_success() {
273285 let social_repo = Arc::new(MockSocialRepository::new());
274286 let state = create_test_state_with_social(social_repo.clone());
275275- let user = UserContext { did: "did:plc:author".to_string(), handle: "author".to_string() };
287287+ let user = UserContext {
288288+ did: "did:plc:author".to_string(),
289289+ handle: "author".to_string(),
290290+ access_token: "test_token".to_string(),
291291+ pds_url: "https://bsky.social".to_string(),
292292+ has_dpop: false,
293293+ };
276294277295 let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None };
278296
+20-2
crates/server/src/middleware/auth.rs
···1010use serde_json::json;
1111use std::time::{Duration, Instant};
12121313+/// User context extracted from authentication.
1414+///
1515+/// Contains the user's identity and authentication details needed for PDS operations.
1316#[derive(Clone, Debug)]
1417pub struct UserContext {
1518 pub did: String,
1619 pub handle: String,
2020+ pub access_token: String,
2121+ pub pds_url: String,
2222+ pub has_dpop: bool,
1723}
18241925/// Cache expiry time (5 minutes)
···190196 let body: serde_json::Value = response.json().await.unwrap_or_default();
191197 let did = body["did"].as_str().unwrap_or("").to_string();
192198 let handle = body["handle"].as_str().unwrap_or("").to_string();
193193- let user_ctx = UserContext { did: did.clone(), handle };
199199+ let user_ctx = UserContext {
200200+ did: did.clone(),
201201+ handle,
202202+ access_token: token.to_string(),
203203+ pds_url: target_pds_url.to_string(),
204204+ has_dpop: stored_token.is_some(),
205205+ };
194206195207 tracing::debug!("PDS verification successful for DID: {}", did);
196208···262274 let did = body["did"].as_str().unwrap_or("").to_string();
263275 let handle = body["handle"].as_str().unwrap_or("").to_string();
264276265265- req.extensions_mut().insert(UserContext { did, handle });
277277+ req.extensions_mut().insert(UserContext {
278278+ did,
279279+ handle,
280280+ access_token: token.to_string(),
281281+ pds_url: pds_url.clone(),
282282+ has_dpop: false,
283283+ });
266284 }
267285 _ => {}
268286 }
+98-23
crates/server/src/pds/client.rs
···77use serde::{Deserialize, Serialize};
8899/// A client for interacting with a user's PDS.
1010+///
1111+/// Supports both DPoP-bound tokens (OAuth) and Bearer tokens (app passwords).
1012pub struct PdsClient {
1113 http_client: reqwest::Client,
1214 pds_url: String,
1315 access_token: String,
1414- dpop_keypair: DpopKeypair,
1616+ dpop_keypair: Option<DpopKeypair>,
1517}
16181719/// Request body for putRecord XRPC.
···101103impl std::error::Error for PdsError {}
102104103105impl PdsClient {
104104- /// Create a new PDS client.
106106+ /// Create a new PDS client with DPoP support (OAuth tokens).
107107+ ///
108108+ /// Uses DPoP proof-of-possession for enhanced security.
109109+ pub fn new_with_dpop(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self {
110110+ Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair: Some(dpop_keypair) }
111111+ }
112112+113113+ /// Create a new PDS client with Bearer authentication (app password tokens).
114114+ ///
115115+ /// Uses standard Bearer token authentication without DPoP.
116116+ pub fn new_bearer(pds_url: String, access_token: String) -> Self {
117117+ Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair: None }
118118+ }
119119+120120+ /// Create a new PDS client (deprecated - use new_with_dpop or new_bearer).
121121+ #[deprecated(since = "0.1.0", note = "Use new_with_dpop or new_bearer instead")]
105122 pub fn new(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self {
106106- Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair }
123123+ Self::new_with_dpop(pds_url, access_token, dpop_keypair)
107124 }
108125109126 /// Create or update a record in the repository.
···118135 &self, did: &str, collection: &str, rkey: &str, record: serde_json::Value,
119136 ) -> Result<AtUri, PdsError> {
120137 let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.pds_url);
121121-122122- let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
123138124139 let request = PutRecordRequest {
125140 repo: did.to_string(),
···131146 validate: Some(true),
132147 };
133148134134- let response = self
135135- .http_client
136136- .post(&url)
137137- .header("Authorization", format!("DPoP {}", self.access_token))
138138- .header("DPoP", dpop_proof)
149149+ let mut request_builder = self.http_client.post(&url);
150150+151151+ // Conditionally add DPoP or Bearer authentication
152152+ if let Some(ref dpop_keypair) = self.dpop_keypair {
153153+ // OAuth with DPoP
154154+ let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
155155+ request_builder = request_builder
156156+ .header("Authorization", format!("DPoP {}", self.access_token))
157157+ .header("DPoP", dpop_proof);
158158+ } else {
159159+ // App password with Bearer
160160+ request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token));
161161+ }
162162+163163+ let response = request_builder
139164 .json(&request)
140165 .send()
141166 .await
···148173 pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<(), PdsError> {
149174 let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds_url);
150175151151- let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
152152-153176 let request = DeleteRecordRequest {
154177 repo: did.to_string(),
155178 collection: collection.to_string(),
···158181 swap_commit: None,
159182 };
160183161161- let response = self
162162- .http_client
163163- .post(&url)
164164- .header("Authorization", format!("DPoP {}", self.access_token))
165165- .header("DPoP", dpop_proof)
184184+ let mut request_builder = self.http_client.post(&url);
185185+186186+ // Conditionally add DPoP or Bearer authentication
187187+ if let Some(ref dpop_keypair) = self.dpop_keypair {
188188+ // OAuth with DPoP
189189+ let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
190190+ request_builder = request_builder
191191+ .header("Authorization", format!("DPoP {}", self.access_token))
192192+ .header("DPoP", dpop_proof);
193193+ } else {
194194+ // App password with Bearer
195195+ request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token));
196196+ }
197197+198198+ let response = request_builder
166199 .json(&request)
167200 .send()
168201 .await
···181214 pub async fn upload_blob(&self, data: Vec<u8>, mime_type: &str) -> Result<BlobRef, PdsError> {
182215 let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url);
183216184184- let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
217217+ let mut request_builder = self.http_client.post(&url);
185218186186- let response = self
187187- .http_client
188188- .post(&url)
189189- .header("Authorization", format!("DPoP {}", self.access_token))
190190- .header("DPoP", dpop_proof)
219219+ // Conditionally add DPoP or Bearer authentication
220220+ if let Some(ref dpop_keypair) = self.dpop_keypair {
221221+ // OAuth with DPoP
222222+ let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token));
223223+ request_builder = request_builder
224224+ .header("Authorization", format!("DPoP {}", self.access_token))
225225+ .header("DPoP", dpop_proof);
226226+ } else {
227227+ // App password with Bearer
228228+ request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token));
229229+ }
230230+231231+ let response = request_builder
191232 .header("Content-Type", mime_type)
192233 .body(data)
193234 .send()
···298339299340 let err = PdsError::NetworkError("Connection refused".to_string());
300341 assert!(err.to_string().contains("Connection refused"));
342342+ }
343343+344344+ #[test]
345345+ fn test_pds_client_new_with_dpop() {
346346+ use crate::oauth::dpop::DpopKeypair;
347347+348348+ let keypair = DpopKeypair::generate();
349349+ let client = PdsClient::new_with_dpop("https://bsky.social".to_string(), "test_token".to_string(), keypair);
350350+351351+ assert_eq!(client.pds_url, "https://bsky.social");
352352+ assert_eq!(client.access_token, "test_token");
353353+ assert!(client.dpop_keypair.is_some());
354354+ }
355355+356356+ #[test]
357357+ fn test_pds_client_new_bearer() {
358358+ let client = PdsClient::new_bearer("https://bsky.social".to_string(), "test_token".to_string());
359359+360360+ assert_eq!(client.pds_url, "https://bsky.social");
361361+ assert_eq!(client.access_token, "test_token");
362362+ assert!(client.dpop_keypair.is_none());
363363+ }
364364+365365+ #[test]
366366+ #[allow(deprecated)]
367367+ fn test_pds_client_new_deprecated() {
368368+ use crate::oauth::dpop::DpopKeypair;
369369+370370+ let keypair = DpopKeypair::generate();
371371+ let client = PdsClient::new("https://bsky.social".to_string(), "test_token".to_string(), keypair);
372372+373373+ assert_eq!(client.pds_url, "https://bsky.social");
374374+ assert_eq!(client.access_token, "test_token");
375375+ assert!(client.dpop_keypair.is_some());
301376 }
302377}
+35-16
crates/server/src/pds/publish.rs
···22//!
33//! Encapsulates the logic for publishing records to a user's PDS.
4455+use crate::middleware::auth::UserContext;
56use crate::pds::client::{PdsClient, PdsError};
67use crate::pds::records::{prepare_card_record, prepare_deck_record};
77-use crate::repository::oauth::{OAuthRepoError, OAuthRepository, StoredToken};
88+use crate::repository::oauth::{OAuthRepoError, OAuthRepository};
89use malfestio_core::model::{Card, Deck};
910use std::sync::Arc;
1011···6364/// Publish a deck and its cards to the user's PDS.
6465///
6566/// This function:
6666-/// 1. Retrieves OAuth tokens for the user
6767-/// 2. Creates a PDS client
6868-/// 3. Publishes each card (with placeholder deck ref initially)
6969-/// 4. Publishes the deck with card AT-URIs
6767+/// 1. Tries to use OAuth tokens with DPoP if available
6868+/// 2. Falls back to current session (supports app passwords with Bearer auth)
6969+/// 3. Creates a PDS client with appropriate authentication
7070+/// 4. Publishes each card (with placeholder deck ref initially)
7171+/// 5. Publishes the deck with card AT-URIs
7072///
7173/// Note: Cards are published with an empty deck_ref since we don't have the
7274/// deck's AT-URI yet. This is acceptable per the Lexicon - the deck holds
7375/// the authoritative list of card references.
7476pub async fn publish_deck_to_pds(
7575- oauth_repo: Arc<dyn OAuthRepository>, did: &str, deck: &Deck, cards: &[Card],
7777+ oauth_repo: Arc<dyn OAuthRepository>, user_ctx: &UserContext, deck: &Deck, cards: &[Card],
7678) -> Result<PublishDeckResult, PublishError> {
7777- let stored_token: StoredToken = oauth_repo.get_tokens(did).await?;
7878- let dpop_keypair = stored_token.dpop_keypair().ok_or(PublishError::InvalidKeypair)?;
7979-8080- let pds_client = PdsClient::new(
8181- stored_token.pds_url.clone(),
8282- stored_token.access_token.clone(),
8383- dpop_keypair,
8484- );
7979+ let pds_client = if user_ctx.has_dpop {
8080+ if let Ok(stored_token) = oauth_repo.get_tokens(&user_ctx.did).await {
8181+ if let Some(dpop_keypair) = stored_token.dpop_keypair() {
8282+ tracing::info!("Using stored OAuth tokens with DPoP for publishing");
8383+ PdsClient::new_with_dpop(
8484+ stored_token.pds_url.clone(),
8585+ stored_token.access_token.clone(),
8686+ dpop_keypair,
8787+ )
8888+ } else {
8989+ tracing::info!(
9090+ "Current session has DPoP flag but stored token lacks keypair, using current session with Bearer auth"
9191+ );
9292+ PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone())
9393+ }
9494+ } else {
9595+ tracing::info!(
9696+ "Current session has DPoP flag but no stored tokens found, using current session with Bearer auth"
9797+ );
9898+ PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone())
9999+ }
100100+ } else {
101101+ tracing::info!("Using current session with Bearer auth for publishing (app password)");
102102+ PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone())
103103+ };
8510486105 let mut card_at_uris = Vec::with_capacity(cards.len());
87106 for card in cards {
88107 let prepared = prepare_card_record(card, "");
89108 let at_uri = pds_client
9090- .put_record(did, &prepared.collection, &prepared.rkey, prepared.record)
109109+ .put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record)
91110 .await?;
92111 card_at_uris.push(at_uri.to_string());
93112 }
9411395114 let prepared = prepare_deck_record(deck, card_at_uris.clone());
96115 let deck_at_uri = pds_client
9797- .put_record(did, &prepared.collection, &prepared.rkey, prepared.record)
116116+ .put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record)
98117 .await?;
99118100119 Ok(PublishDeckResult { deck_at_uri: deck_at_uri.to_string(), card_at_uris })