···44use std::sync::Arc;
5566use crate::AppState;
77-use crate::HappyViewOAuthSession;
87use crate::auth::Claims;
98use crate::db::{adapt_sql, now_rfc3339};
109use crate::record_refs::sync_refs;
1111-use crate::repo;
1010+use crate::repo::PdsAuth;
12111312use super::tid::generate_tid;
1413···2827 lua: &Lua,
2928 state: Arc<AppState>,
3029 claims: Arc<Claims>,
3131- session: Arc<HappyViewOAuthSession>,
3030+ pds_auth: Arc<PdsAuth>,
3231) -> LuaResult<()> {
3332 // -- methods table (shared by all Record instances) --
3433 let methods = lua.create_table()?;
···3736 {
3837 let state = state.clone();
3938 let claims = claims.clone();
4040- let session = session.clone();
3939+ let pds_auth = pds_auth.clone();
4140 let save_fn = lua.create_async_function(move |lua, this: mlua::Table| {
4241 let state = state.clone();
4342 let claims = claims.clone();
4444- let session = session.clone();
4343+ let pds_auth = pds_auth.clone();
4544 async move {
4645 let backend = state.db_backend;
4746 let collection: String = this.raw_get("_collection")?;
···7473 "record": data,
7574 });
76757777- let resp = repo::pds_post_json_raw(
7878- &state,
7979- &session,
8080- "com.atproto.repo.putRecord",
8181- &pds_body,
8282- )
8383- .await
8484- .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?;
7676+ let resp = pds_auth
7777+ .post_json(&state, repo, "com.atproto.repo.putRecord", &pds_body)
7878+ .await
7979+ .map_err(|e| mlua::Error::runtime(format!("PDS putRecord failed: {e}")))?;
85808681 if !resp.status().is_success() {
8782 let status = resp.status();
···141136 pds_body["rkey"] = json!(rkey);
142137 }
143138144144- let resp = repo::pds_post_json_raw(
145145- &state,
146146- &session,
147147- "com.atproto.repo.createRecord",
148148- &pds_body,
149149- )
150150- .await
151151- .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?;
139139+ let resp = pds_auth
140140+ .post_json(&state, repo, "com.atproto.repo.createRecord", &pds_body)
141141+ .await
142142+ .map_err(|e| mlua::Error::runtime(format!("PDS createRecord failed: {e}")))?;
152143153144 if !resp.status().is_success() {
154145 let status = resp.status();
···215206 {
216207 let state = state.clone();
217208 let claims = claims.clone();
218218- let session = session.clone();
209209+ let pds_auth = pds_auth.clone();
219210 let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| {
220211 let state = state.clone();
221212 let claims = claims.clone();
222222- let session = session.clone();
213213+ let pds_auth = pds_auth.clone();
223214 async move {
224215 let backend = state.db_backend;
225216 let uri: String = this.raw_get::<Option<String>>("_uri")?.ok_or_else(|| {
···241232 "rkey": rkey,
242233 });
243234244244- let resp = repo::pds_post_json_raw(
245245- &state,
246246- &session,
247247- "com.atproto.repo.deleteRecord",
248248- &pds_body,
249249- )
250250- .await
251251- .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?;
235235+ let resp = pds_auth
236236+ .post_json(&state, repo, "com.atproto.repo.deleteRecord", &pds_body)
237237+ .await
238238+ .map_err(|e| mlua::Error::runtime(format!("PDS deleteRecord failed: {e}")))?;
252239253240 if !resp.status().is_success() {
254241 let status = resp.status();
···451438 {
452439 let state = state.clone();
453440 let claims = claims.clone();
454454- let session = session.clone();
441441+ let pds_auth = pds_auth.clone();
455442 let save_all_fn =
456443 lua.create_async_function(move |lua, records_table: mlua::Table| {
457444 let state = state.clone();
458445 let claims = claims.clone();
459459- let session = session.clone();
446446+ let pds_auth = pds_auth.clone();
460447 async move {
461448 let backend = state.db_backend;
462449 // Extract save data from each record (sync)
···484471 let futs = save_items.iter().map(|(_, collection, existing_uri, rkey, repo_override, data)| {
485472 let state = state.clone();
486473 let claims = claims.clone();
487487- let session = session.clone();
474474+ let pds_auth = pds_auth.clone();
488475 let collection = collection.clone();
489476 let existing_uri = existing_uri.clone();
490477 let rkey = rkey.clone();
···506493 "record": data,
507494 });
508495509509- let resp = repo::pds_post_json_raw(
510510- &state,
511511- &session,
512512- "com.atproto.repo.putRecord",
513513- &pds_body,
514514- )
515515- .await
516516- .map_err(|e| {
517517- mlua::Error::runtime(format!("PDS putRecord failed: {e}"))
518518- })?;
496496+ let resp = pds_auth
497497+ .post_json(&state, repo, "com.atproto.repo.putRecord", &pds_body)
498498+ .await
499499+ .map_err(|e| {
500500+ mlua::Error::runtime(format!("PDS putRecord failed: {e}"))
501501+ })?;
519502520503 if !resp.status().is_success() {
521504 let status = resp.status();
···575558 pds_body["rkey"] = json!(rkey);
576559 }
577560578578- let resp = repo::pds_post_json_raw(
579579- &state,
580580- &session,
581581- "com.atproto.repo.createRecord",
582582- &pds_body,
583583- )
584584- .await
585585- .map_err(|e| {
586586- mlua::Error::runtime(format!(
587587- "PDS createRecord failed: {e}"
588588- ))
589589- })?;
561561+ let resp = pds_auth
562562+ .post_json(&state, repo, "com.atproto.repo.createRecord", &pds_body)
563563+ .await
564564+ .map_err(|e| {
565565+ mlua::Error::runtime(format!(
566566+ "PDS createRecord failed: {e}"
567567+ ))
568568+ })?;
590569591570 if !resp.status().is_success() {
592571 let status = resp.status();
+158-20
src/oauth/client_auth.rs
···156156/// Rules:
157157/// - `atproto` must be present in token scopes (always implicitly allowed)
158158/// - Every non-`atproto` scope in the token must appear in the client's registered scopes
159159-pub fn validate_scopes(token_scopes: &str, client_scopes: &str) -> Result<(), AppError> {
159159+/// - `include:X` client scopes are expanded by looking up the permission set
160160+/// lexicon `X` and extracting its `rpc:` and `repo:` permissions
161161+pub async fn validate_scopes(
162162+ token_scopes: &str,
163163+ client_scopes: &str,
164164+ lexicons: &crate::lexicon::LexiconRegistry,
165165+) -> Result<(), AppError> {
160166 let token_set: std::collections::HashSet<&str> = token_scopes.split_whitespace().collect();
161161- let client_set: std::collections::HashSet<&str> = client_scopes.split_whitespace().collect();
167167+ let mut client_set: std::collections::HashSet<String> = std::collections::HashSet::new();
168168+169169+ for scope in client_scopes.split_whitespace() {
170170+ if let Some(perm_set_id) = scope.strip_prefix("include:") {
171171+ expand_permission_set(perm_set_id, lexicons, &mut client_set).await;
172172+ }
173173+ client_set.insert(scope.to_string());
174174+ }
162175163176 if !token_set.contains("atproto") {
164177 return Err(AppError::BadRequest(
···168181169182 for scope in &token_set {
170183 if *scope == "atproto" {
171171- continue; // always allowed
184184+ continue;
172185 }
173173- if !client_set.contains(scope) {
186186+ if !client_set.contains(*scope) {
174187 return Err(AppError::BadRequest(format!(
175188 "scope '{}' is not allowed for this client",
176189 scope
···181194 Ok(())
182195}
183196197197+/// Expand a permission set lexicon into individual `rpc:` and `repo:` scopes.
198198+async fn expand_permission_set(
199199+ nsid: &str,
200200+ lexicons: &crate::lexicon::LexiconRegistry,
201201+ out: &mut std::collections::HashSet<String>,
202202+) {
203203+ let lexicon = match lexicons.get(nsid).await {
204204+ Some(l) => l,
205205+ None => {
206206+ tracing::warn!(nsid = %nsid, "permission set lexicon not found in registry");
207207+ return;
208208+ }
209209+ };
210210+211211+ let permissions = match lexicon
212212+ .raw
213213+ .get("defs")
214214+ .and_then(|d| d.get("main"))
215215+ .and_then(|m| m.get("permissions"))
216216+ .and_then(|p| p.as_array())
217217+ {
218218+ Some(p) => p,
219219+ None => return,
220220+ };
221221+222222+ for perm in permissions {
223223+ let resource = perm.get("resource").and_then(|r| r.as_str()).unwrap_or("");
224224+ match resource {
225225+ "rpc" => {
226226+ if let Some(lxms) = perm.get("lxm").and_then(|l| l.as_array()) {
227227+ for lxm in lxms {
228228+ if let Some(s) = lxm.as_str() {
229229+ out.insert(format!("rpc:{s}"));
230230+ }
231231+ }
232232+ }
233233+ }
234234+ "repo" => {
235235+ if let Some(collections) = perm.get("collection").and_then(|c| c.as_array()) {
236236+ for col in collections {
237237+ if let Some(s) = col.as_str() {
238238+ out.insert(format!("repo:{s}?action=create"));
239239+ out.insert(format!("repo:{s}?action=update"));
240240+ out.insert(format!("repo:{s}?action=delete"));
241241+ }
242242+ }
243243+ }
244244+ }
245245+ _ => {}
246246+ }
247247+ }
248248+}
249249+184250/// Verify a PKCE challenge against a verifier.
185251pub fn verify_pkce(challenge: &str, verifier: &str) -> bool {
186252 use base64::Engine;
···194260mod tests {
195261 use super::*;
196262197197- #[test]
198198- fn validate_scopes_requires_atproto() {
199199- let result = validate_scopes("transition:generic", "atproto transition:generic");
263263+ fn empty_registry() -> crate::lexicon::LexiconRegistry {
264264+ crate::lexicon::LexiconRegistry::new()
265265+ }
266266+267267+ #[tokio::test]
268268+ async fn validate_scopes_requires_atproto() {
269269+ let reg = empty_registry();
270270+ let result =
271271+ validate_scopes("transition:generic", "atproto transition:generic", ®).await;
200272 assert!(result.is_err());
201273 }
202274203203- #[test]
204204- fn validate_scopes_atproto_only_always_passes() {
205205- let result = validate_scopes("atproto", "com.example.whatever");
275275+ #[tokio::test]
276276+ async fn validate_scopes_atproto_only_always_passes() {
277277+ let reg = empty_registry();
278278+ let result = validate_scopes("atproto", "com.example.whatever", ®).await;
206279 assert!(result.is_ok());
207280 }
208281209209- #[test]
210210- fn validate_scopes_subset_passes() {
282282+ #[tokio::test]
283283+ async fn validate_scopes_subset_passes() {
284284+ let reg = empty_registry();
211285 let result = validate_scopes(
212286 "atproto com.example.basic",
213287 "atproto com.example.basic com.example.advanced",
214214- );
288288+ ®,
289289+ )
290290+ .await;
215291 assert!(result.is_ok());
216292 }
217293218218- #[test]
219219- fn validate_scopes_excess_scope_fails() {
294294+ #[tokio::test]
295295+ async fn validate_scopes_excess_scope_fails() {
296296+ let reg = empty_registry();
220297 let result = validate_scopes(
221298 "atproto com.example.basic com.example.advanced",
222299 "atproto com.example.basic",
223223- );
300300+ ®,
301301+ )
302302+ .await;
224303 assert!(result.is_err());
225304 }
226305227227- #[test]
228228- fn validate_scopes_transition_generic_requires_registration() {
229229- let result = validate_scopes("atproto transition:generic", "atproto");
306306+ #[tokio::test]
307307+ async fn validate_scopes_transition_generic_requires_registration() {
308308+ let reg = empty_registry();
309309+ let result = validate_scopes("atproto transition:generic", "atproto", ®).await;
230310 assert!(result.is_err());
231311232232- let result = validate_scopes("atproto transition:generic", "atproto transition:generic");
312312+ let result = validate_scopes(
313313+ "atproto transition:generic",
314314+ "atproto transition:generic",
315315+ ®,
316316+ )
317317+ .await;
318318+ assert!(result.is_ok());
319319+ }
320320+321321+ #[tokio::test]
322322+ async fn validate_scopes_expands_include_permission_set() {
323323+ let reg = empty_registry();
324324+ let raw = serde_json::json!({
325325+ "lexicon": 1,
326326+ "id": "com.example.authBasic",
327327+ "defs": {
328328+ "main": {
329329+ "type": "permission-set",
330330+ "permissions": [
331331+ {
332332+ "type": "permission",
333333+ "resource": "rpc",
334334+ "lxm": ["com.example.getProfile", "com.example.putProfile"]
335335+ },
336336+ {
337337+ "type": "permission",
338338+ "resource": "repo",
339339+ "collection": ["com.example.profile"]
340340+ }
341341+ ]
342342+ }
343343+ }
344344+ });
345345+ let parsed = crate::lexicon::ParsedLexicon::parse(
346346+ raw,
347347+ 1,
348348+ None,
349349+ crate::lexicon::ProcedureAction::Upsert,
350350+ None,
351351+ None,
352352+ None,
353353+ )
354354+ .unwrap();
355355+ reg.upsert(parsed).await;
356356+357357+ let result = validate_scopes(
358358+ "atproto rpc:com.example.getProfile repo:com.example.profile?action=create",
359359+ "atproto include:com.example.authBasic",
360360+ ®,
361361+ )
362362+ .await;
233363 assert!(result.is_ok());
364364+365365+ let result = validate_scopes(
366366+ "atproto rpc:com.example.notAllowed",
367367+ "atproto include:com.example.authBasic",
368368+ ®,
369369+ )
370370+ .await;
371371+ assert!(result.is_err());
234372 }
235373236374 #[test]
+497-52
src/oauth/pds_write.rs
···33use p256::ecdsa::{SigningKey, signature::Signer};
44use sha2::{Digest, Sha256};
5566+use std::sync::Arc;
77+88+use crate::auth::OAuthClientRegistry;
69use crate::db::DatabaseBackend;
710use crate::error::AppError;
811use crate::plugin::encryption::decrypt;
9121010-/// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session.
1111-#[allow(clippy::too_many_arguments)]
1212-pub async fn dpop_pds_post(
1313+use super::sessions::DpopSession;
1414+1515+/// Resolved DPoP credentials needed to make authenticated PDS requests.
1616+struct DpopCredentials {
1717+ session: DpopSession,
1818+ pds_url: String,
1919+ private_jwk: serde_json::Value,
2020+}
2121+2222+/// Resolve DPoP credentials: session, PDS URL, and decrypted private key.
2323+async fn resolve_credentials(
1324 http: &reqwest::Client,
1425 pool: &sqlx::AnyPool,
1526 backend: DatabaseBackend,
···1728 plc_url: &str,
1829 api_client_id: &str,
1930 user_did: &str,
2020- xrpc_method: &str,
2121- body: &serde_json::Value,
2222-) -> Result<reqwest::Response, AppError> {
3131+) -> Result<DpopCredentials, AppError> {
2332 let session =
2433 super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did)
2534 .await?;
···2938 None => resolve_pds_from_did(http, plc_url, user_did).await?,
3039 };
31403232- let target_url = format!("{}/xrpc/{}", pds_url.trim_end_matches('/'), xrpc_method);
3333-3434- // Decrypt the DPoP private key
3541 let key_sql = crate::db::adapt_sql(
3642 "SELECT private_key_enc FROM dpop_keys WHERE id = ?",
3743 backend,
···5056 let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes)
5157 .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?;
52585353- let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?;
5959+ Ok(DpopCredentials {
6060+ session,
6161+ pds_url,
6262+ private_jwk,
6363+ })
6464+}
6565+6666+/// Make an authenticated POST, handling DPoP nonce negotiation and token refresh.
6767+#[allow(clippy::too_many_arguments)]
6868+async fn dpop_post_with_retry(
6969+ http: &reqwest::Client,
7070+ pool: &sqlx::AnyPool,
7171+ backend: DatabaseBackend,
7272+ encryption_key: &[u8; 32],
7373+ oauth_registry: &Arc<OAuthClientRegistry>,
7474+ creds: &mut DpopCredentials,
7575+ target_url: &str,
7676+ request_builder: impl Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder,
7777+) -> Result<reqwest::Response, AppError> {
7878+ let proof = generate_dpop_proof(
7979+ &creds.private_jwk,
8080+ "POST",
8181+ target_url,
8282+ &creds.session.access_token,
8383+ None,
8484+ )?;
54855555- let resp = http
5656- .post(&target_url)
5757- .header("Authorization", format!("DPoP {}", session.access_token))
5858- .header("DPoP", proof)
5959- .header("Content-Type", "application/json")
6060- .json(body)
8686+ let resp = request_builder(http, &creds.session.access_token, &proof)
6187 .send()
6288 .await
6389 .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?;
64909191+ // Handle DPoP nonce requirement
9292+ if let Some(nonce) = extract_dpop_nonce(&resp) {
9393+ let proof = generate_dpop_proof(
9494+ &creds.private_jwk,
9595+ "POST",
9696+ target_url,
9797+ &creds.session.access_token,
9898+ Some(&nonce),
9999+ )?;
100100+101101+ let resp = request_builder(http, &creds.session.access_token, &proof)
102102+ .send()
103103+ .await
104104+ .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?;
105105+106106+ // If we still get invalid_token after nonce, try refresh
107107+ if is_expired_token(&resp) {
108108+ return retry_after_refresh(
109109+ http,
110110+ pool,
111111+ backend,
112112+ encryption_key,
113113+ oauth_registry,
114114+ creds,
115115+ target_url,
116116+ Some(&nonce),
117117+ &request_builder,
118118+ )
119119+ .await;
120120+ }
121121+122122+ return Ok(resp);
123123+ }
124124+125125+ // Handle expired token
126126+ if is_expired_token(&resp) {
127127+ return retry_after_refresh(
128128+ http,
129129+ pool,
130130+ backend,
131131+ encryption_key,
132132+ oauth_registry,
133133+ creds,
134134+ target_url,
135135+ None,
136136+ &request_builder,
137137+ )
138138+ .await;
139139+ }
140140+65141 Ok(resp)
66142}
67143144144+/// Refresh the access token and retry the PDS request.
145145+#[allow(clippy::too_many_arguments)]
146146+async fn retry_after_refresh(
147147+ http: &reqwest::Client,
148148+ pool: &sqlx::AnyPool,
149149+ backend: DatabaseBackend,
150150+ encryption_key: &[u8; 32],
151151+ oauth_registry: &Arc<OAuthClientRegistry>,
152152+ creds: &mut DpopCredentials,
153153+ target_url: &str,
154154+ nonce: Option<&str>,
155155+ request_builder: &impl Fn(&reqwest::Client, &str, &str) -> reqwest::RequestBuilder,
156156+) -> Result<reqwest::Response, AppError> {
157157+ refresh_access_token(http, pool, backend, encryption_key, oauth_registry, creds).await?;
158158+159159+ let proof = generate_dpop_proof(
160160+ &creds.private_jwk,
161161+ "POST",
162162+ target_url,
163163+ &creds.session.access_token,
164164+ nonce,
165165+ )?;
166166+167167+ let resp = request_builder(http, &creds.session.access_token, &proof)
168168+ .send()
169169+ .await
170170+ .map_err(|e| AppError::Internal(format!("PDS request failed after token refresh: {e}")))?;
171171+172172+ // One more nonce negotiation attempt after refresh
173173+ if let Some(new_nonce) = extract_dpop_nonce(&resp) {
174174+ let proof = generate_dpop_proof(
175175+ &creds.private_jwk,
176176+ "POST",
177177+ target_url,
178178+ &creds.session.access_token,
179179+ Some(&new_nonce),
180180+ )?;
181181+182182+ let resp = request_builder(http, &creds.session.access_token, &proof)
183183+ .send()
184184+ .await
185185+ .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?;
186186+187187+ return Ok(resp);
188188+ }
189189+190190+ Ok(resp)
191191+}
192192+193193+/// Make an authenticated POST to a PDS XRPC endpoint using a DPoP session.
194194+#[allow(clippy::too_many_arguments)]
195195+pub async fn dpop_pds_post(
196196+ http: &reqwest::Client,
197197+ pool: &sqlx::AnyPool,
198198+ backend: DatabaseBackend,
199199+ encryption_key: &[u8; 32],
200200+ oauth_registry: &Arc<OAuthClientRegistry>,
201201+ plc_url: &str,
202202+ api_client_id: &str,
203203+ user_did: &str,
204204+ xrpc_method: &str,
205205+ body: &serde_json::Value,
206206+) -> Result<reqwest::Response, AppError> {
207207+ let mut creds = resolve_credentials(
208208+ http,
209209+ pool,
210210+ backend,
211211+ encryption_key,
212212+ plc_url,
213213+ api_client_id,
214214+ user_did,
215215+ )
216216+ .await?;
217217+218218+ let target_url = format!(
219219+ "{}/xrpc/{}",
220220+ creds.pds_url.trim_end_matches('/'),
221221+ xrpc_method
222222+ );
223223+224224+ let body = body.clone();
225225+ let target = target_url.clone();
226226+ dpop_post_with_retry(
227227+ http,
228228+ pool,
229229+ backend,
230230+ encryption_key,
231231+ oauth_registry,
232232+ &mut creds,
233233+ &target_url,
234234+ |http, access_token, proof| {
235235+ http.post(&target)
236236+ .header("Authorization", format!("DPoP {access_token}"))
237237+ .header("DPoP", proof)
238238+ .header("Content-Type", "application/json")
239239+ .json(&body)
240240+ },
241241+ )
242242+ .await
243243+}
244244+68245/// Make an authenticated blob upload to a PDS using a DPoP session.
69246#[allow(clippy::too_many_arguments)]
70247pub async fn dpop_pds_post_blob(
···72249 pool: &sqlx::AnyPool,
73250 backend: DatabaseBackend,
74251 encryption_key: &[u8; 32],
252252+ oauth_registry: &Arc<OAuthClientRegistry>,
75253 plc_url: &str,
76254 api_client_id: &str,
77255 user_did: &str,
78256 content_type: &str,
79257 blob: bytes::Bytes,
80258) -> Result<reqwest::Response, AppError> {
8181- let session =
8282- super::sessions::get_dpop_session(pool, backend, encryption_key, api_client_id, user_did)
8383- .await?;
8484-8585- let pds_url = match session.pds_url {
8686- Some(ref url) => url.clone(),
8787- None => resolve_pds_from_did(http, plc_url, user_did).await?,
8888- };
259259+ let mut creds = resolve_credentials(
260260+ http,
261261+ pool,
262262+ backend,
263263+ encryption_key,
264264+ plc_url,
265265+ api_client_id,
266266+ user_did,
267267+ )
268268+ .await?;
8926990270 let target_url = format!(
91271 "{}/xrpc/com.atproto.repo.uploadBlob",
9292- pds_url.trim_end_matches('/')
272272+ creds.pds_url.trim_end_matches('/')
93273 );
942749595- // Decrypt the DPoP private key
9696- let key_sql = crate::db::adapt_sql(
9797- "SELECT private_key_enc FROM dpop_keys WHERE id = ?",
275275+ let content_type = content_type.to_string();
276276+ let target = target_url.clone();
277277+ dpop_post_with_retry(
278278+ http,
279279+ pool,
98280 backend,
9999- );
100100- let row: Option<(Vec<u8>,)> = sqlx::query_as(&key_sql)
101101- .bind(&session.dpop_key_id)
102102- .fetch_optional(pool)
103103- .await
104104- .map_err(|e| AppError::Internal(format!("failed to look up DPoP key: {e}")))?;
281281+ encryption_key,
282282+ oauth_registry,
283283+ &mut creds,
284284+ &target_url,
285285+ |http, access_token, proof| {
286286+ http.post(&target)
287287+ .header("Authorization", format!("DPoP {access_token}"))
288288+ .header("DPoP", proof)
289289+ .header("Content-Type", &content_type)
290290+ .body(blob.clone())
291291+ },
292292+ )
293293+ .await
294294+}
295295+296296+/// Check if a response is a 401 with an expired/invalid token error.
297297+fn is_expired_token(resp: &reqwest::Response) -> bool {
298298+ resp.status() == reqwest::StatusCode::UNAUTHORIZED
299299+}
300300+301301+/// Check if a response indicates that a DPoP nonce is required, and extract it.
302302+fn extract_dpop_nonce(resp: &reqwest::Response) -> Option<String> {
303303+ if resp.status() == reqwest::StatusCode::UNAUTHORIZED
304304+ || resp.status() == reqwest::StatusCode::BAD_REQUEST
305305+ {
306306+ resp.headers()
307307+ .get("dpop-nonce")
308308+ .and_then(|v| v.to_str().ok())
309309+ .map(|s| s.to_string())
310310+ } else {
311311+ None
312312+ }
313313+}
314314+315315+/// Refresh an expired access token using the session's refresh_token.
316316+///
317317+/// Discovers the token endpoint from the issuer's OAuth metadata, sends a
318318+/// `grant_type=refresh_token` request with a DPoP proof, and updates the
319319+/// stored session with the new tokens.
320320+async fn refresh_access_token(
321321+ http: &reqwest::Client,
322322+ pool: &sqlx::AnyPool,
323323+ backend: DatabaseBackend,
324324+ encryption_key: &[u8; 32],
325325+ oauth_registry: &Arc<OAuthClientRegistry>,
326326+ creds: &mut DpopCredentials,
327327+) -> Result<(), AppError> {
328328+ let refresh_token = creds
329329+ .session
330330+ .refresh_token
331331+ .as_deref()
332332+ .ok_or_else(|| AppError::Auth("token expired and no refresh_token available".into()))?;
105333106106- let (encrypted_key,) = row.ok_or_else(|| AppError::Internal("DPoP key not found".into()))?;
334334+ let issuer = creds
335335+ .session
336336+ .issuer
337337+ .as_deref()
338338+ .ok_or_else(|| AppError::Auth("token expired and no issuer URL stored".into()))?;
107339108108- let key_bytes = decrypt(encryption_key, &encrypted_key)
109109- .map_err(|e| AppError::Internal(format!("failed to decrypt DPoP key: {e}")))?;
340340+ let token_endpoint = discover_token_endpoint(http, issuer).await?;
110341111111- let private_jwk: serde_json::Value = serde_json::from_slice(&key_bytes)
112112- .map_err(|e| AppError::Internal(format!("failed to parse DPoP key: {e}")))?;
342342+ // Get the resolved client_id from the OAuth registry. For loopback clients
343343+ // this returns `http://localhost?scope=...` which auth servers handle inline,
344344+ // rather than the `client_id_url` from the DB which they'd try to fetch.
345345+ let client_id_url = lookup_client_id_url(pool, backend, &creds.session.api_client_id).await?;
346346+ let client_id = oauth_registry
347347+ .get_resolved_client_id(&client_id_url)
348348+ .unwrap_or(client_id_url);
113349114114- let proof = generate_dpop_proof(&private_jwk, "POST", &target_url, &session.access_token)?;
350350+ let proof = generate_dpop_proof_no_ath(&creds.private_jwk, "POST", &token_endpoint, None)?;
115351116352 let resp = http
117117- .post(&target_url)
118118- .header("Authorization", format!("DPoP {}", session.access_token))
119119- .header("DPoP", proof)
120120- .header("Content-Type", content_type)
121121- .body(blob)
353353+ .post(&token_endpoint)
354354+ .header("DPoP", &proof)
355355+ .header("Content-Type", "application/x-www-form-urlencoded")
356356+ .form(&[
357357+ ("grant_type", "refresh_token"),
358358+ ("refresh_token", refresh_token),
359359+ ("client_id", &client_id),
360360+ ])
122361 .send()
123362 .await
124124- .map_err(|e| AppError::Internal(format!("PDS uploadBlob request failed: {e}")))?;
363363+ .map_err(|e| AppError::Internal(format!("token refresh request failed: {e}")))?;
364364+365365+ // Handle nonce requirement on the token endpoint
366366+ if let Some(nonce) = extract_dpop_nonce(&resp) {
367367+ let proof =
368368+ generate_dpop_proof_no_ath(&creds.private_jwk, "POST", &token_endpoint, Some(&nonce))?;
369369+370370+ let resp = http
371371+ .post(&token_endpoint)
372372+ .header("DPoP", &proof)
373373+ .header("Content-Type", "application/x-www-form-urlencoded")
374374+ .form(&[
375375+ ("grant_type", "refresh_token"),
376376+ ("refresh_token", refresh_token),
377377+ ("client_id", &client_id),
378378+ ])
379379+ .send()
380380+ .await
381381+ .map_err(|e| AppError::Internal(format!("token refresh request failed: {e}")))?;
382382+383383+ return handle_refresh_response(http, pool, backend, encryption_key, creds, resp).await;
384384+ }
125385126126- Ok(resp)
386386+ handle_refresh_response(http, pool, backend, encryption_key, creds, resp).await
387387+}
388388+389389+/// Parse the token refresh response and update the stored session.
390390+async fn handle_refresh_response(
391391+ _http: &reqwest::Client,
392392+ pool: &sqlx::AnyPool,
393393+ backend: DatabaseBackend,
394394+ encryption_key: &[u8; 32],
395395+ creds: &mut DpopCredentials,
396396+ resp: reqwest::Response,
397397+) -> Result<(), AppError> {
398398+ if !resp.status().is_success() {
399399+ let status = resp.status();
400400+ let body = resp.text().await.unwrap_or_default();
401401+ return Err(AppError::Auth(format!(
402402+ "token refresh failed ({status}): {body}"
403403+ )));
404404+ }
405405+406406+ let token_resp: serde_json::Value = resp
407407+ .json()
408408+ .await
409409+ .map_err(|e| AppError::Internal(format!("invalid token refresh response: {e}")))?;
410410+411411+ let new_access_token = token_resp["access_token"]
412412+ .as_str()
413413+ .ok_or_else(|| AppError::Internal("refresh response missing access_token".into()))?;
414414+415415+ let new_refresh_token = token_resp["refresh_token"].as_str();
416416+417417+ let expires_in = token_resp["expires_in"].as_u64();
418418+ let new_expires_at = expires_in
419419+ .map(|secs| (chrono::Utc::now() + chrono::Duration::seconds(secs as i64)).to_rfc3339());
420420+421421+ // Update the stored session
422422+ super::sessions::store_dpop_session(
423423+ pool,
424424+ backend,
425425+ encryption_key,
426426+ &creds.session.id,
427427+ &creds.session.api_client_id,
428428+ &creds.session.dpop_key_id,
429429+ &creds.session.user_did,
430430+ new_access_token,
431431+ new_refresh_token.or(creds.session.refresh_token.as_deref()),
432432+ new_expires_at
433433+ .as_deref()
434434+ .or(creds.session.token_expires_at.as_deref()),
435435+ &creds.session.scopes,
436436+ creds.session.pds_url.as_deref(),
437437+ creds.session.issuer.as_deref(),
438438+ )
439439+ .await?;
440440+441441+ // Update the in-memory credentials
442442+ creds.session.access_token = new_access_token.to_string();
443443+ if let Some(rt) = new_refresh_token {
444444+ creds.session.refresh_token = Some(rt.to_string());
445445+ }
446446+ if let Some(ref exp) = new_expires_at {
447447+ creds.session.token_expires_at = Some(exp.clone());
448448+ }
449449+450450+ tracing::info!(
451451+ user_did = %creds.session.user_did,
452452+ api_client_id = %creds.session.api_client_id,
453453+ "refreshed DPoP access token"
454454+ );
455455+456456+ Ok(())
457457+}
458458+459459+/// Discover the token endpoint from an OAuth authorization server's metadata.
460460+async fn discover_token_endpoint(http: &reqwest::Client, issuer: &str) -> Result<String, AppError> {
461461+ let metadata_url = format!(
462462+ "{}/.well-known/oauth-authorization-server",
463463+ issuer.trim_end_matches('/')
464464+ );
465465+466466+ let resp =
467467+ http.get(&metadata_url).send().await.map_err(|e| {
468468+ AppError::Internal(format!("failed to fetch auth server metadata: {e}"))
469469+ })?;
470470+471471+ if !resp.status().is_success() {
472472+ return Err(AppError::Internal(format!(
473473+ "auth server metadata returned {}",
474474+ resp.status()
475475+ )));
476476+ }
477477+478478+ let metadata: serde_json::Value = resp
479479+ .json()
480480+ .await
481481+ .map_err(|e| AppError::Internal(format!("invalid auth server metadata: {e}")))?;
482482+483483+ metadata["token_endpoint"]
484484+ .as_str()
485485+ .map(|s| s.to_string())
486486+ .ok_or_else(|| AppError::Internal("auth server metadata missing token_endpoint".into()))
487487+}
488488+489489+/// Look up the client_id_url for an API client by its internal ID.
490490+async fn lookup_client_id_url(
491491+ pool: &sqlx::AnyPool,
492492+ backend: DatabaseBackend,
493493+ api_client_id: &str,
494494+) -> Result<String, AppError> {
495495+ let sql = crate::db::adapt_sql(
496496+ "SELECT client_id_url FROM api_clients WHERE id = ?",
497497+ backend,
498498+ );
499499+ let row: Option<(String,)> = sqlx::query_as(&sql)
500500+ .bind(api_client_id)
501501+ .fetch_optional(pool)
502502+ .await
503503+ .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?;
504504+505505+ row.map(|(url,)| url)
506506+ .ok_or_else(|| AppError::Internal("API client not found".into()))
127507}
128508129509/// Generate a DPoP proof JWT for a PDS request.
···132512 method: &str,
133513 url: &str,
134514 access_token: &str,
515515+ nonce: Option<&str>,
516516+) -> Result<String, AppError> {
517517+ let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes()));
518518+ generate_dpop_proof_inner(private_jwk, method, url, Some(&ath), nonce)
519519+}
520520+521521+/// Generate a DPoP proof JWT without an `ath` claim (for token endpoint requests).
522522+fn generate_dpop_proof_no_ath(
523523+ private_jwk: &serde_json::Value,
524524+ method: &str,
525525+ url: &str,
526526+ nonce: Option<&str>,
527527+) -> Result<String, AppError> {
528528+ generate_dpop_proof_inner(private_jwk, method, url, None, nonce)
529529+}
530530+531531+fn generate_dpop_proof_inner(
532532+ private_jwk: &serde_json::Value,
533533+ method: &str,
534534+ url: &str,
535535+ ath: Option<&str>,
536536+ nonce: Option<&str>,
135537) -> Result<String, AppError> {
136538 let d_b64 = private_jwk["d"]
137539 .as_str()
···161563 .duration_since(std::time::UNIX_EPOCH)
162564 .unwrap()
163565 .as_secs();
164164-165165- let ath = URL_SAFE_NO_PAD.encode(Sha256::digest(access_token.as_bytes()));
166566167567 let header = serde_json::json!({
168568 "alg": "ES256",
···170570 "jwk": public_jwk,
171571 });
172572173173- let payload = serde_json::json!({
573573+ let mut payload = serde_json::json!({
174574 "htm": method,
175575 "htu": url,
176576 "iat": now,
177177- "ath": ath,
178577 "jti": format!("{:x}", rand::random::<u64>()),
179578 });
579579+ if let Some(ath) = ath {
580580+ payload["ath"] = serde_json::json!(ath);
581581+ }
582582+ if let Some(nonce) = nonce {
583583+ payload["nonce"] = serde_json::json!(nonce);
584584+ }
180585181586 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
182587 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
···245650 "POST",
246651 "https://pds.example.com/xrpc/com.atproto.repo.createRecord",
247652 "test-access-token",
653653+ None,
248654 )
249655 .unwrap();
250656···270676 }
271677272678 #[test]
679679+ fn generate_dpop_proof_includes_nonce() {
680680+ let keypair = super::super::keys::generate_dpop_keypair().unwrap();
681681+682682+ let proof = generate_dpop_proof(
683683+ &keypair.private_jwk,
684684+ "POST",
685685+ "https://pds.example.com/xrpc/test",
686686+ "token",
687687+ Some("server-nonce-123"),
688688+ )
689689+ .unwrap();
690690+691691+ let parts: Vec<&str> = proof.split('.').collect();
692692+ let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
693693+ let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
694694+ assert_eq!(payload["nonce"], "server-nonce-123");
695695+ }
696696+697697+ #[test]
698698+ fn generate_dpop_proof_no_ath_omits_ath() {
699699+ let keypair = super::super::keys::generate_dpop_keypair().unwrap();
700700+701701+ let proof = generate_dpop_proof_no_ath(
702702+ &keypair.private_jwk,
703703+ "POST",
704704+ "https://auth.example.com/oauth/token",
705705+ None,
706706+ )
707707+ .unwrap();
708708+709709+ let parts: Vec<&str> = proof.split('.').collect();
710710+ let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
711711+ let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
712712+ assert!(payload.get("ath").is_none());
713713+ assert!(payload["htm"].is_string());
714714+ assert!(payload["htu"].is_string());
715715+ }
716716+717717+ #[test]
273718 fn generated_proof_validates_against_own_key() {
274719 let keypair = super::super::keys::generate_dpop_keypair().unwrap();
275720 let url = "https://pds.example.com/xrpc/test.method";
276721 let token = "my-access-token";
277722278278- let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token).unwrap();
723723+ let proof = generate_dpop_proof(&keypair.private_jwk, "POST", url, token, None).unwrap();
279724280725 let result = super::super::dpop_proof::validate_dpop_proof(
281726 &proof,
+1-1
src/oauth/routes.rs
···224224 }
225225226226 // Validate scopes
227227- client_auth::validate_scopes(&body.scopes, &client.scopes)?;
227227+ client_auth::validate_scopes(&body.scopes, &client.scopes, &state.lexicons).await?;
228228229229 // Clean up any existing session's DPoP key before upserting
230230 // (the ON CONFLICT upsert would orphan the old key otherwise)
+1-1
src/repo/mod.rs
···22pub(crate) mod session;
33mod upload_blob;
4455-pub(crate) use pds::{forward_pds_response, pds_post_json_raw};
55+pub(crate) use pds::{PdsAuth, forward_pds_response, pds_post_json_raw};
66pub(crate) use session::{get_dpop_client_id, get_oauth_session};
77pub use upload_blob::upload_blob;
···378378379379 // 3. Generate a DPoP proof for an XRPC GET request
380380 let request_url = "http://127.0.0.1:0/xrpc/com.example.test.getStuff";
381381- let proof = generate_dpop_proof(dpop_key, "GET", request_url, access_token)
381381+ let proof = generate_dpop_proof(dpop_key, "GET", request_url, access_token, None)
382382 .expect("failed to generate DPoP proof");
383383384384 // 4. Make an XRPC request with DPoP auth