Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

fix: oauth consolidation, include-scope improvements #4

merged opened by lewis.moe targeting main from fix/oauth-on-niche-apps
  • auth extraction should be happening in the auth crate, yes, who coulda thought
  • include: scope should actually be doing the right thing and going out and requesting stuff to expand out the perms
  • more tests!!!1!
  • more correct parsing of the #bsky-appview or whatever suffixes on did webs that come through auth
Labels

None yet.

assignee
Participants 2
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3md3bluniqt22
+2632 -984
Diff #0
-77
.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc" 29 - ] 30 - } 31 - } 32 - } 33 - }, 34 - { 35 - "ordinal": 3, 36 - "name": "provider_user_id", 37 - "type_info": "Text" 38 - }, 39 - { 40 - "ordinal": 4, 41 - "name": "provider_username", 42 - "type_info": "Text" 43 - }, 44 - { 45 - "ordinal": 5, 46 - "name": "provider_email", 47 - "type_info": "Text" 48 - }, 49 - { 50 - "ordinal": 6, 51 - "name": "created_at", 52 - "type_info": "Timestamptz" 53 - }, 54 - { 55 - "ordinal": 7, 56 - "name": "expires_at", 57 - "type_info": "Timestamptz" 58 - } 59 - ], 60 - "parameters": { 61 - "Left": [ 62 - "Text" 63 - ] 64 - }, 65 - "nullable": [ 66 - false, 67 - false, 68 - false, 69 - false, 70 - true, 71 - true, 72 - false, 73 - false 74 - ] 75 - }, 76 - "hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82" 77 - }
-77
.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "token", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "request_uri", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "provider: SsoProviderType", 19 - "type_info": { 20 - "Custom": { 21 - "name": "sso_provider_type", 22 - "kind": { 23 - "Enum": [ 24 - "github", 25 - "discord", 26 - "google", 27 - "gitlab", 28 - "oidc" 29 - ] 30 - } 31 - } 32 - } 33 - }, 34 - { 35 - "ordinal": 3, 36 - "name": "provider_user_id", 37 - "type_info": "Text" 38 - }, 39 - { 40 - "ordinal": 4, 41 - "name": "provider_username", 42 - "type_info": "Text" 43 - }, 44 - { 45 - "ordinal": 5, 46 - "name": "provider_email", 47 - "type_info": "Text" 48 - }, 49 - { 50 - "ordinal": 6, 51 - "name": "created_at", 52 - "type_info": "Timestamptz" 53 - }, 54 - { 55 - "ordinal": 7, 56 - "name": "expires_at", 57 - "type_info": "Timestamptz" 58 - } 59 - ], 60 - "parameters": { 61 - "Left": [ 62 - "Text" 63 - ] 64 - }, 65 - "nullable": [ 66 - false, 67 - false, 68 - false, 69 - false, 70 - true, 71 - true, 72 - false, 73 - false 74 - ] 75 - }, 76 - "hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a" 77 - }
-22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "email_verified", 9 - "type_info": "Bool" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - false 19 - ] 20 - }, 21 - "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" 22 - }
-31
.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - { 10 - "Custom": { 11 - "name": "sso_provider_type", 12 - "kind": { 13 - "Enum": [ 14 - "github", 15 - "discord", 16 - "google", 17 - "gitlab", 18 - "oidc" 19 - ] 20 - } 21 - } 22 - }, 23 - "Text", 24 - "Text", 25 - "Text" 26 - ] 27 - }, 28 - "nullable": [] 29 - }, 30 - "hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6" 31 - }
-32
.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Text", 10 - { 11 - "Custom": { 12 - "name": "sso_provider_type", 13 - "kind": { 14 - "Enum": [ 15 - "github", 16 - "discord", 17 - "google", 18 - "gitlab", 19 - "oidc" 20 - ] 21 - } 22 - } 23 - }, 24 - "Text", 25 - "Text", 26 - "Text" 27 - ] 28 - }, 29 - "nullable": [] 30 - }, 31 - "hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a" 32 - }
+2
Cargo.lock
··· 6156 6156 dependencies = [ 6157 6157 "axum", 6158 6158 "futures", 6159 + "hickory-resolver", 6159 6160 "reqwest", 6160 6161 "serde", 6161 6162 "serde_json", 6162 6163 "tokio", 6163 6164 "tracing", 6165 + "urlencoding", 6164 6166 ] 6165 6167 6166 6168 [[package]]
+7
crates/tranquil-pds/src/api/error.rs
··· 543 543 crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated, 544 544 crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown, 545 545 crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired, 546 + crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => { 547 + Self::OAuthExpiredToken(Some(msg)) 548 + } 549 + crate::auth::extractor::AuthError::UseDpopNonce(_) 550 + | crate::auth::extractor::AuthError::InvalidDpopProof(_) => { 551 + Self::AuthenticationFailed(None) 552 + } 546 553 } 547 554 } 548 555 }
+4 -4
crates/tranquil-pds/src/api/identity/account.rs
··· 1 1 use super::did::verify_did_web; 2 2 use crate::api::error::ApiError; 3 3 use crate::api::repo::record::utils::create_signed_commit; 4 - use crate::auth::{ServiceTokenVerifier, is_service_token}; 4 + use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token}; 5 5 use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; 6 6 use crate::state::{AppState, RateLimitKind}; 7 7 use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; ··· 96 96 .into_response(); 97 97 } 98 98 99 - let migration_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header( 100 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 101 - ) { 99 + let migration_auth = if let Some(extracted) = 100 + extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) 101 + { 102 102 let token = extracted.token; 103 103 if is_service_token(&token) { 104 104 let verifier = ServiceTokenVerifier::new();
+12 -3
crates/tranquil-pds/src/api/proxy.rs
··· 267 267 } 268 268 } 269 269 Err(e) => { 270 - warn!("Token validation failed: {:?}", e); 271 - if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) { 272 - return ApiError::from(e).into_response(); 270 + info!(error = ?e, "Proxy token validation failed, returning error to client"); 271 + if matches!( 272 + e, 273 + crate::auth::TokenValidationError::OAuthTokenExpired 274 + | crate::auth::TokenValidationError::TokenExpired 275 + ) { 276 + let mut response = ApiError::from(e).into_response(); 277 + let nonce = crate::oauth::verify::generate_dpop_nonce(); 278 + if let Ok(nonce_val) = nonce.parse() { 279 + response.headers_mut().insert("DPoP-Nonce", nonce_val); 280 + } 281 + return response; 273 282 } 274 283 } 275 284 }
+17 -80
crates/tranquil-pds/src/api/repo/blob.rs
··· 1 1 use crate::api::error::ApiError; 2 - use crate::auth::{BearerAuthAllowDeactivated, ServiceTokenVerifier, is_service_token}; 2 + use crate::auth::{BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult}; 3 3 use crate::delegation::DelegationActionType; 4 4 use crate::state::AppState; 5 5 use crate::types::{CidLink, Did}; ··· 44 44 pub async fn upload_blob( 45 45 State(state): State<AppState>, 46 46 headers: axum::http::HeaderMap, 47 + auth: BlobAuth, 47 48 body: Body, 48 49 ) -> Response { 49 - let extracted = match crate::auth::extract_auth_token_from_header( 50 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 51 - ) { 52 - Some(t) => t, 53 - None => return ApiError::AuthenticationRequired.into_response(), 54 - }; 55 - let token = extracted.token; 56 - 57 - let is_service_auth = is_service_token(&token); 58 - 59 - let (did, _is_migration, controller_did): (Did, bool, Option<Did>) = if is_service_auth { 60 - debug!("Verifying service token for blob upload"); 61 - let verifier = ServiceTokenVerifier::new(); 62 - match verifier 63 - .verify_service_token(&token, Some("com.atproto.repo.uploadBlob")) 64 - .await 65 - { 66 - Ok(claims) => { 67 - debug!("Service token verified for DID: {}", claims.iss); 68 - let did: Did = match claims.iss.parse() { 69 - Ok(d) => d, 70 - Err(_) => { 71 - return ApiError::InvalidDid("Invalid DID format".into()).into_response(); 72 - } 73 - }; 74 - (did, false, None) 75 - } 76 - Err(e) => { 77 - error!("Service token verification failed: {:?}", e); 78 - return ApiError::AuthenticationFailed(Some(format!( 79 - "Service token verification failed: {}", 80 - e 81 - ))) 82 - .into_response(); 83 - } 84 - } 85 - } else { 86 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 87 - let http_uri = format!( 88 - "https://{}/xrpc/com.atproto.repo.uploadBlob", 89 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 90 - ); 91 - match crate::auth::validate_token_with_dpop( 92 - state.user_repo.as_ref(), 93 - state.oauth_repo.as_ref(), 94 - &token, 95 - extracted.is_dpop, 96 - dpop_proof, 97 - "POST", 98 - &http_uri, 99 - true, 100 - false, 101 - ) 102 - .await 103 - { 104 - Ok(user) => { 105 - let mime_type_for_check = headers 106 - .get("content-type") 107 - .and_then(|h| h.to_str().ok()) 108 - .unwrap_or("application/octet-stream"); 109 - if let Err(e) = crate::auth::scope_check::check_blob_scope( 110 - user.is_oauth, 111 - user.scope.as_deref(), 112 - mime_type_for_check, 113 - ) { 114 - return e; 115 - } 116 - let deactivated = state 117 - .user_repo 118 - .get_status_by_did(&user.did) 119 - .await 120 - .ok() 121 - .flatten() 122 - .and_then(|s| s.deactivated_at); 123 - let ctrl_did = user.controller_did.clone(); 124 - (user.did, deactivated.is_some(), ctrl_did) 125 - } 126 - Err(_) => { 127 - return ApiError::AuthenticationFailed(None).into_response(); 50 + let (did, controller_did): (Did, Option<Did>) = match auth.0 { 51 + BlobAuthResult::Service { did } => (did, None), 52 + BlobAuthResult::User(auth_user) => { 53 + let mime_type_for_check = headers 54 + .get("content-type") 55 + .and_then(|h| h.to_str().ok()) 56 + .unwrap_or("application/octet-stream"); 57 + if let Err(e) = crate::auth::scope_check::check_blob_scope( 58 + auth_user.is_oauth, 59 + auth_user.scope.as_deref(), 60 + mime_type_for_check, 61 + ) { 62 + return e; 128 63 } 64 + let ctrl_did = auth_user.controller_did.clone(); 65 + (auth_user.did, ctrl_did) 129 66 } 130 67 }; 131 68
+4 -12
crates/tranquil-pds/src/api/repo/record/delete.rs
··· 1 1 use crate::api::error::ApiError; 2 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 3 3 use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; 4 + use crate::auth::BearerAuth; 4 5 use crate::delegation::DelegationActionType; 5 6 use crate::repo::tracking::TrackingBlockStore; 6 7 use crate::state::AppState; ··· 8 9 use axum::{ 9 10 Json, 10 11 extract::State, 11 - http::{HeaderMap, StatusCode}, 12 + http::StatusCode, 12 13 response::{IntoResponse, Response}, 13 14 }; 14 15 use cid::Cid; ··· 39 40 40 41 pub async fn delete_record( 41 42 State(state): State<AppState>, 42 - headers: HeaderMap, 43 - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 43 + auth: BearerAuth, 44 44 Json(input): Json<DeleteRecordInput>, 45 45 ) -> Response { 46 - let auth = match prepare_repo_write( 47 - &state, 48 - &headers, 49 - &input.repo, 50 - "POST", 51 - &crate::util::build_full_url(&uri.to_string()), 52 - ) 53 - .await 54 - { 46 + let auth = match prepare_repo_write(&state, auth.0, &input.repo).await { 55 47 Ok(res) => res, 56 48 Err(err_res) => return err_res, 57 49 };
+7 -47
crates/tranquil-pds/src/api/repo/record/write.rs
··· 3 3 use crate::api::repo::record::utils::{ 4 4 CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, 5 5 }; 6 + use crate::auth::{AuthenticatedUser, BearerAuth}; 6 7 use crate::delegation::DelegationActionType; 7 8 use crate::repo::tracking::TrackingBlockStore; 8 9 use crate::state::AppState; ··· 10 11 use axum::{ 11 12 Json, 12 13 extract::State, 13 - http::{HeaderMap, StatusCode}, 14 + http::StatusCode, 14 15 response::{IntoResponse, Response}, 15 16 }; 16 17 use cid::Cid; ··· 33 34 34 35 pub async fn prepare_repo_write( 35 36 state: &AppState, 36 - headers: &HeaderMap, 37 + auth_user: AuthenticatedUser, 37 38 repo: &AtIdentifier, 38 - http_method: &str, 39 - http_uri: &str, 40 39 ) -> Result<RepoWriteAuth, Response> { 41 - let extracted = crate::auth::extract_auth_token_from_header( 42 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 43 - ) 44 - .ok_or_else(|| ApiError::AuthenticationRequired.into_response())?; 45 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 46 - let auth_user = crate::auth::validate_token_with_dpop( 47 - state.user_repo.as_ref(), 48 - state.oauth_repo.as_ref(), 49 - &extracted.token, 50 - extracted.is_dpop, 51 - dpop_proof, 52 - http_method, 53 - http_uri, 54 - false, 55 - false, 56 - ) 57 - .await 58 - .map_err(|e| { 59 - tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); 60 - ApiError::from(e).into_response() 61 - })?; 62 40 if repo.as_str() != auth_user.did.as_str() { 63 41 return Err( 64 42 ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), ··· 146 124 } 147 125 pub async fn create_record( 148 126 State(state): State<AppState>, 149 - headers: HeaderMap, 150 - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 127 + auth: BearerAuth, 151 128 Json(input): Json<CreateRecordInput>, 152 129 ) -> Response { 153 - let auth = match prepare_repo_write( 154 - &state, 155 - &headers, 156 - &input.repo, 157 - "POST", 158 - &crate::util::build_full_url(&uri.to_string()), 159 - ) 160 - .await 161 - { 130 + let auth = match prepare_repo_write(&state, auth.0, &input.repo).await { 162 131 Ok(res) => res, 163 132 Err(err_res) => return err_res, 164 133 }; ··· 445 414 } 446 415 pub async fn put_record( 447 416 State(state): State<AppState>, 448 - headers: HeaderMap, 449 - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, 417 + auth: BearerAuth, 450 418 Json(input): Json<PutRecordInput>, 451 419 ) -> Response { 452 - let auth = match prepare_repo_write( 453 - &state, 454 - &headers, 455 - &input.repo, 456 - "POST", 457 - &crate::util::build_full_url(&uri.to_string()), 458 - ) 459 - .await 460 - { 420 + let auth = match prepare_repo_write(&state, auth.0, &input.repo).await { 461 421 Ok(res) => res, 462 422 Err(err_res) => return err_res, 463 423 };
+8 -119
crates/tranquil-pds/src/api/server/account_status.rs
··· 40 40 41 41 pub async fn check_account_status( 42 42 State(state): State<AppState>, 43 - headers: axum::http::HeaderMap, 43 + auth: crate::auth::BearerAuthAllowDeactivated, 44 44 ) -> Response { 45 - let extracted = match crate::auth::extract_auth_token_from_header( 46 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 47 - ) { 48 - Some(t) => t, 49 - None => return ApiError::AuthenticationRequired.into_response(), 50 - }; 51 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 52 - let http_uri = format!( 53 - "https://{}/xrpc/com.atproto.server.checkAccountStatus", 54 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 55 - ); 56 - let did = match crate::auth::validate_token_with_dpop( 57 - state.user_repo.as_ref(), 58 - state.oauth_repo.as_ref(), 59 - &extracted.token, 60 - extracted.is_dpop, 61 - dpop_proof, 62 - "GET", 63 - &http_uri, 64 - true, 65 - false, 66 - ) 67 - .await 68 - { 69 - Ok(user) => user.did, 70 - Err(e) => return ApiError::from(e).into_response(), 71 - }; 45 + let did = auth.0.did; 72 46 let user_id = match state.user_repo.get_id_by_did(&did).await { 73 47 Ok(Some(id)) => id, 74 48 _ => { ··· 331 305 332 306 pub async fn activate_account( 333 307 State(state): State<AppState>, 334 - headers: axum::http::HeaderMap, 308 + auth: crate::auth::BearerAuthAllowDeactivated, 335 309 ) -> Response { 336 310 info!("[MIGRATION] activateAccount called"); 337 - let extracted = match crate::auth::extract_auth_token_from_header( 338 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 339 - ) { 340 - Some(t) => t, 341 - None => { 342 - info!("[MIGRATION] activateAccount: No auth token"); 343 - return ApiError::AuthenticationRequired.into_response(); 344 - } 345 - }; 346 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 347 - let http_uri = format!( 348 - "https://{}/xrpc/com.atproto.server.activateAccount", 349 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 350 - ); 351 - let auth_user = match crate::auth::validate_token_with_dpop( 352 - state.user_repo.as_ref(), 353 - state.oauth_repo.as_ref(), 354 - &extracted.token, 355 - extracted.is_dpop, 356 - dpop_proof, 357 - "POST", 358 - &http_uri, 359 - true, 360 - false, 361 - ) 362 - .await 363 - { 364 - Ok(user) => user, 365 - Err(e) => { 366 - info!("[MIGRATION] activateAccount: Auth failed: {:?}", e); 367 - return ApiError::from(e).into_response(); 368 - } 369 - }; 311 + let auth_user = auth.0; 370 312 info!( 371 313 "[MIGRATION] activateAccount: Authenticated user did={}", 372 314 auth_user.did ··· 528 470 529 471 pub async fn deactivate_account( 530 472 State(state): State<AppState>, 531 - headers: axum::http::HeaderMap, 473 + auth: crate::auth::BearerAuth, 532 474 Json(input): Json<DeactivateAccountInput>, 533 475 ) -> Response { 534 - let extracted = match crate::auth::extract_auth_token_from_header( 535 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 536 - ) { 537 - Some(t) => t, 538 - None => return ApiError::AuthenticationRequired.into_response(), 539 - }; 540 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 541 - let http_uri = format!( 542 - "https://{}/xrpc/com.atproto.server.deactivateAccount", 543 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 544 - ); 545 - let auth_user = match crate::auth::validate_token_with_dpop( 546 - state.user_repo.as_ref(), 547 - state.oauth_repo.as_ref(), 548 - &extracted.token, 549 - extracted.is_dpop, 550 - dpop_proof, 551 - "POST", 552 - &http_uri, 553 - false, 554 - false, 555 - ) 556 - .await 557 - { 558 - Ok(user) => user, 559 - Err(e) => return ApiError::from(e).into_response(), 560 - }; 476 + let auth_user = auth.0; 561 477 562 478 if let Err(e) = crate::auth::scope_check::check_account_scope( 563 479 auth_user.is_oauth, ··· 607 523 608 524 pub async fn request_account_delete( 609 525 State(state): State<AppState>, 610 - headers: axum::http::HeaderMap, 526 + auth: crate::auth::BearerAuthAllowDeactivated, 611 527 ) -> Response { 612 - let extracted = match crate::auth::extract_auth_token_from_header( 613 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 614 - ) { 615 - Some(t) => t, 616 - None => return ApiError::AuthenticationRequired.into_response(), 617 - }; 618 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 619 - let http_uri = format!( 620 - "https://{}/xrpc/com.atproto.server.requestAccountDelete", 621 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 622 - ); 623 - let validated = match crate::auth::validate_token_with_dpop( 624 - state.user_repo.as_ref(), 625 - state.oauth_repo.as_ref(), 626 - &extracted.token, 627 - extracted.is_dpop, 628 - dpop_proof, 629 - "POST", 630 - &http_uri, 631 - true, 632 - false, 633 - ) 634 - .await 635 - { 636 - Ok(user) => user, 637 - Err(e) => return ApiError::from(e).into_response(), 638 - }; 639 - let did = validated.did.clone(); 528 + let did = auth.0.did.clone(); 640 529 641 530 if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &did).await { 642 531 return crate::api::server::reauth::legacy_mfa_required_response(
+5 -59
crates/tranquil-pds/src/api/server/migration.rs
··· 1 1 use crate::api::ApiError; 2 + use crate::auth::BearerAuth; 2 3 use crate::state::AppState; 3 4 use axum::{ 4 5 Json, ··· 35 36 36 37 pub async fn update_did_document( 37 38 State(state): State<AppState>, 38 - headers: axum::http::HeaderMap, 39 + auth: BearerAuth, 39 40 Json(input): Json<UpdateDidDocumentInput>, 40 41 ) -> Response { 41 - let extracted = match crate::auth::extract_auth_token_from_header( 42 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 43 - ) { 44 - Some(t) => t, 45 - None => return ApiError::AuthenticationRequired.into_response(), 46 - }; 47 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 48 - let http_uri = format!( 49 - "https://{}/xrpc/_account.updateDidDocument", 50 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 51 - ); 52 - let auth_user = match crate::auth::validate_token_with_dpop( 53 - state.user_repo.as_ref(), 54 - state.oauth_repo.as_ref(), 55 - &extracted.token, 56 - extracted.is_dpop, 57 - dpop_proof, 58 - "POST", 59 - &http_uri, 60 - true, 61 - false, 62 - ) 63 - .await 64 - { 65 - Ok(user) => user, 66 - Err(e) => return ApiError::from(e).into_response(), 67 - }; 42 + let auth_user = auth.0; 68 43 69 44 if !auth_user.did.starts_with("did:web:") { 70 45 return ApiError::InvalidRequest( ··· 166 141 .into_response() 167 142 } 168 143 169 - pub async fn get_did_document( 170 - State(state): State<AppState>, 171 - headers: axum::http::HeaderMap, 172 - ) -> Response { 173 - let extracted = match crate::auth::extract_auth_token_from_header( 174 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 175 - ) { 176 - Some(t) => t, 177 - None => return ApiError::AuthenticationRequired.into_response(), 178 - }; 179 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 180 - let http_uri = format!( 181 - "https://{}/xrpc/_account.getDidDocument", 182 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 183 - ); 184 - let auth_user = match crate::auth::validate_token_with_dpop( 185 - state.user_repo.as_ref(), 186 - state.oauth_repo.as_ref(), 187 - &extracted.token, 188 - extracted.is_dpop, 189 - dpop_proof, 190 - "GET", 191 - &http_uri, 192 - true, 193 - false, 194 - ) 195 - .await 196 - { 197 - Ok(user) => user, 198 - Err(e) => return ApiError::from(e).into_response(), 199 - }; 144 + pub async fn get_did_document(State(state): State<AppState>, auth: BearerAuth) -> Response { 145 + let auth_user = auth.0; 200 146 201 147 if !auth_user.did.starts_with("did:web:") { 202 148 return ApiError::InvalidRequest(
+5 -22
crates/tranquil-pds/src/api/temp.rs
··· 1 1 use crate::api::error::ApiError; 2 - use crate::auth::{BearerAuth, extract_auth_token_from_header, validate_token_with_dpop}; 2 + use crate::auth::{BearerAuth, OptionalBearerAuth}; 3 3 use crate::state::AppState; 4 4 use axum::{ 5 5 Json, 6 6 extract::State, 7 - http::HeaderMap, 8 7 response::{IntoResponse, Response}, 9 8 }; 10 9 use cid::Cid; ··· 22 21 pub estimated_time_ms: Option<i64>, 23 22 } 24 23 25 - pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMap) -> Response { 26 - if let Some(extracted) = 27 - extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) 24 + pub async fn check_signup_queue(auth: OptionalBearerAuth) -> Response { 25 + if let Some(user) = auth.0 26 + && user.is_oauth 28 27 { 29 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 30 - if let Ok(user) = validate_token_with_dpop( 31 - state.user_repo.as_ref(), 32 - state.oauth_repo.as_ref(), 33 - &extracted.token, 34 - extracted.is_dpop, 35 - dpop_proof, 36 - "GET", 37 - "/", 38 - false, 39 - false, 40 - ) 41 - .await 42 - && user.is_oauth 43 - { 44 - return ApiError::Forbidden.into_response(); 45 - } 28 + return ApiError::Forbidden.into_response(); 46 29 } 47 30 Json(CheckSignupQueueOutput { 48 31 activated: true,
+547
crates/tranquil-pds/src/auth/auth_extractor.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 + use chrono::Utc; 6 + use common::{base_url, client, create_account_and_login, pds_endpoint}; 7 + use helpers::verify_new_account; 8 + use reqwest::StatusCode; 9 + use serde_json::{Value, json}; 10 + use sha2::{Digest, Sha256}; 11 + use wiremock::matchers::{method, path}; 12 + use wiremock::{Mock, MockServer, ResponseTemplate}; 13 + 14 + fn generate_pkce() -> (String, String) { 15 + let verifier_bytes: [u8; 32] = rand::random(); 16 + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 17 + let mut hasher = Sha256::new(); 18 + hasher.update(code_verifier.as_bytes()); 19 + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); 20 + (code_verifier, code_challenge) 21 + } 22 + 23 + async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer { 24 + let mock_server = MockServer::start().await; 25 + let metadata = json!({ 26 + "client_id": mock_server.uri(), 27 + "client_name": "Auth Extractor Test Client", 28 + "redirect_uris": [redirect_uri], 29 + "grant_types": ["authorization_code", "refresh_token"], 30 + "response_types": ["code"], 31 + "token_endpoint_auth_method": "none", 32 + "dpop_bound_access_tokens": dpop_bound 33 + }); 34 + Mock::given(method("GET")) 35 + .and(path("/")) 36 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 37 + .mount(&mock_server) 38 + .await; 39 + mock_server 40 + } 41 + 42 + async fn get_oauth_session( 43 + http_client: &reqwest::Client, 44 + url: &str, 45 + dpop_bound: bool, 46 + ) -> (String, String, String, String) { 47 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 48 + let handle = format!("ae{}", suffix); 49 + let password = "AuthExtract123!"; 50 + let create_res = http_client 51 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 52 + .json(&json!({ 53 + "handle": handle, 54 + "email": format!("{}@example.com", handle), 55 + "password": password 56 + })) 57 + .send() 58 + .await 59 + .unwrap(); 60 + assert_eq!(create_res.status(), StatusCode::OK); 61 + let account: Value = create_res.json().await.unwrap(); 62 + let did = account["did"].as_str().unwrap().to_string(); 63 + verify_new_account(http_client, &did).await; 64 + 65 + let redirect_uri = "https://example.com/auth-callback"; 66 + let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await; 67 + let client_id = mock_client.uri(); 68 + let (code_verifier, code_challenge) = generate_pkce(); 69 + 70 + let par_body: Value = http_client 71 + .post(format!("{}/oauth/par", url)) 72 + .form(&[ 73 + ("response_type", "code"), 74 + ("client_id", &client_id), 75 + ("redirect_uri", redirect_uri), 76 + ("code_challenge", &code_challenge), 77 + ("code_challenge_method", "S256"), 78 + ]) 79 + .send() 80 + .await 81 + .unwrap() 82 + .json() 83 + .await 84 + .unwrap(); 85 + let request_uri = par_body["request_uri"].as_str().unwrap(); 86 + 87 + let auth_res = http_client 88 + .post(format!("{}/oauth/authorize", url)) 89 + .header("Content-Type", "application/json") 90 + .header("Accept", "application/json") 91 + .json(&json!({ 92 + "request_uri": request_uri, 93 + "username": &handle, 94 + "password": password, 95 + "remember_device": false 96 + })) 97 + .send() 98 + .await 99 + .unwrap(); 100 + let auth_body: Value = auth_res.json().await.unwrap(); 101 + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 102 + 103 + if location.contains("/oauth/consent") { 104 + let consent_res = http_client 105 + .post(format!("{}/oauth/authorize/consent", url)) 106 + .header("Content-Type", "application/json") 107 + .json(&json!({ 108 + "request_uri": request_uri, 109 + "approved_scopes": ["atproto"], 110 + "remember": false 111 + })) 112 + .send() 113 + .await 114 + .unwrap(); 115 + let consent_body: Value = consent_res.json().await.unwrap(); 116 + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 117 + } 118 + 119 + let code = location 120 + .split("code=") 121 + .nth(1) 122 + .unwrap() 123 + .split('&') 124 + .next() 125 + .unwrap(); 126 + 127 + let token_body: Value = http_client 128 + .post(format!("{}/oauth/token", url)) 129 + .form(&[ 130 + ("grant_type", "authorization_code"), 131 + ("code", code), 132 + ("redirect_uri", redirect_uri), 133 + ("code_verifier", &code_verifier), 134 + ("client_id", &client_id), 135 + ]) 136 + .send() 137 + .await 138 + .unwrap() 139 + .json() 140 + .await 141 + .unwrap(); 142 + 143 + ( 144 + token_body["access_token"].as_str().unwrap().to_string(), 145 + token_body["refresh_token"].as_str().unwrap().to_string(), 146 + client_id, 147 + did, 148 + ) 149 + } 150 + 151 + #[tokio::test] 152 + async fn test_oauth_token_works_with_bearer_auth() { 153 + let url = base_url().await; 154 + let http_client = client(); 155 + let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await; 156 + 157 + let res = http_client 158 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 159 + .bearer_auth(&access_token) 160 + .send() 161 + .await 162 + .unwrap(); 163 + 164 + assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with BearerAuth extractor"); 165 + let body: Value = res.json().await.unwrap(); 166 + assert_eq!(body["did"].as_str().unwrap(), did); 167 + } 168 + 169 + #[tokio::test] 170 + async fn test_session_token_still_works() { 171 + let url = base_url().await; 172 + let http_client = client(); 173 + let (jwt, did) = create_account_and_login(&http_client).await; 174 + 175 + let res = http_client 176 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 177 + .bearer_auth(&jwt) 178 + .send() 179 + .await 180 + .unwrap(); 181 + 182 + assert_eq!(res.status(), StatusCode::OK, "Session token should still work"); 183 + let body: Value = res.json().await.unwrap(); 184 + assert_eq!(body["did"].as_str().unwrap(), did); 185 + } 186 + 187 + 188 + #[tokio::test] 189 + async fn test_oauth_admin_extractor_allows_oauth_tokens() { 190 + let url = base_url().await; 191 + let http_client = client(); 192 + 193 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 194 + let handle = format!("adm{}", suffix); 195 + let password = "AdminOAuth123!"; 196 + let create_res = http_client 197 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 198 + .json(&json!({ 199 + "handle": handle, 200 + "email": format!("{}@example.com", handle), 201 + "password": password 202 + })) 203 + .send() 204 + .await 205 + .unwrap(); 206 + assert_eq!(create_res.status(), StatusCode::OK); 207 + let account: Value = create_res.json().await.unwrap(); 208 + let did = account["did"].as_str().unwrap().to_string(); 209 + verify_new_account(&http_client, &did).await; 210 + 211 + let pool = common::get_test_db_pool().await; 212 + sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) 213 + .execute(pool) 214 + .await 215 + .expect("Failed to mark user as admin"); 216 + 217 + let redirect_uri = "https://example.com/admin-callback"; 218 + let mock_client = setup_mock_client_metadata(redirect_uri, false).await; 219 + let client_id = mock_client.uri(); 220 + let (code_verifier, code_challenge) = generate_pkce(); 221 + 222 + let par_body: Value = http_client 223 + .post(format!("{}/oauth/par", url)) 224 + .form(&[ 225 + ("response_type", "code"), 226 + ("client_id", &client_id), 227 + ("redirect_uri", redirect_uri), 228 + ("code_challenge", &code_challenge), 229 + ("code_challenge_method", "S256"), 230 + ]) 231 + .send() 232 + .await 233 + .unwrap() 234 + .json() 235 + .await 236 + .unwrap(); 237 + let request_uri = par_body["request_uri"].as_str().unwrap(); 238 + 239 + let auth_res = http_client 240 + .post(format!("{}/oauth/authorize", url)) 241 + .header("Content-Type", "application/json") 242 + .header("Accept", "application/json") 243 + .json(&json!({ 244 + "request_uri": request_uri, 245 + "username": &handle, 246 + "password": password, 247 + "remember_device": false 248 + })) 249 + .send() 250 + .await 251 + .unwrap(); 252 + let auth_body: Value = auth_res.json().await.unwrap(); 253 + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 254 + if location.contains("/oauth/consent") { 255 + let consent_res = http_client 256 + .post(format!("{}/oauth/authorize/consent", url)) 257 + .header("Content-Type", "application/json") 258 + .json(&json!({ 259 + "request_uri": request_uri, 260 + "approved_scopes": ["atproto"], 261 + "remember": false 262 + })) 263 + .send() 264 + .await 265 + .unwrap(); 266 + let consent_body: Value = consent_res.json().await.unwrap(); 267 + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 268 + } 269 + 270 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 271 + let token_body: Value = http_client 272 + .post(format!("{}/oauth/token", url)) 273 + .form(&[ 274 + ("grant_type", "authorization_code"), 275 + ("code", code), 276 + ("redirect_uri", redirect_uri), 277 + ("code_verifier", &code_verifier), 278 + ("client_id", &client_id), 279 + ]) 280 + .send() 281 + .await 282 + .unwrap() 283 + .json() 284 + .await 285 + .unwrap(); 286 + let access_token = token_body["access_token"].as_str().unwrap(); 287 + 288 + let res = http_client 289 + .get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did)) 290 + .bearer_auth(access_token) 291 + .send() 292 + .await 293 + .unwrap(); 294 + 295 + assert_eq!( 296 + res.status(), 297 + StatusCode::OK, 298 + "OAuth token for admin user should work with admin endpoint" 299 + ); 300 + } 301 + 302 + #[tokio::test] 303 + async fn test_expired_oauth_token_returns_proper_error() { 304 + let url = base_url().await; 305 + let http_client = client(); 306 + 307 + let now = Utc::now().timestamp(); 308 + let header = json!({"alg": "HS256", "typ": "at+jwt"}); 309 + let payload = json!({ 310 + "iss": url, 311 + "sub": "did:plc:test123", 312 + "aud": url, 313 + "iat": now - 7200, 314 + "exp": now - 3600, 315 + "jti": "expired-token", 316 + "sid": "expired-session", 317 + "scope": "atproto", 318 + "client_id": "https://example.com" 319 + }); 320 + let fake_token = format!( 321 + "{}.{}.{}", 322 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), 323 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), 324 + URL_SAFE_NO_PAD.encode([1u8; 32]) 325 + ); 326 + 327 + let res = http_client 328 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 329 + .bearer_auth(&fake_token) 330 + .send() 331 + .await 332 + .unwrap(); 333 + 334 + assert_eq!( 335 + res.status(), 336 + StatusCode::UNAUTHORIZED, 337 + "Expired token should be rejected" 338 + ); 339 + } 340 + 341 + #[tokio::test] 342 + async fn test_dpop_nonce_error_has_proper_headers() { 343 + let url = base_url().await; 344 + let pds_url = pds_endpoint(); 345 + let http_client = client(); 346 + 347 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 348 + let handle = format!("dpop{}", suffix); 349 + let create_res = http_client 350 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 351 + .json(&json!({ 352 + "handle": handle, 353 + "email": format!("{}@test.com", handle), 354 + "password": "DpopTest123!" 355 + })) 356 + .send() 357 + .await 358 + .unwrap(); 359 + assert_eq!(create_res.status(), StatusCode::OK); 360 + let account: Value = create_res.json().await.unwrap(); 361 + let did = account["did"].as_str().unwrap(); 362 + verify_new_account(&http_client, did).await; 363 + 364 + let redirect_uri = "https://example.com/dpop-callback"; 365 + let mock_server = MockServer::start().await; 366 + let client_id = mock_server.uri(); 367 + let metadata = json!({ 368 + "client_id": &client_id, 369 + "client_name": "DPoP Test Client", 370 + "redirect_uris": [redirect_uri], 371 + "grant_types": ["authorization_code", "refresh_token"], 372 + "response_types": ["code"], 373 + "token_endpoint_auth_method": "none", 374 + "dpop_bound_access_tokens": true 375 + }); 376 + Mock::given(method("GET")) 377 + .and(path("/")) 378 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 379 + .mount(&mock_server) 380 + .await; 381 + 382 + let (code_verifier, code_challenge) = generate_pkce(); 383 + let par_body: Value = http_client 384 + .post(format!("{}/oauth/par", url)) 385 + .form(&[ 386 + ("response_type", "code"), 387 + ("client_id", &client_id), 388 + ("redirect_uri", redirect_uri), 389 + ("code_challenge", &code_challenge), 390 + ("code_challenge_method", "S256"), 391 + ]) 392 + .send() 393 + .await 394 + .unwrap() 395 + .json() 396 + .await 397 + .unwrap(); 398 + 399 + let request_uri = par_body["request_uri"].as_str().unwrap(); 400 + let auth_res = http_client 401 + .post(format!("{}/oauth/authorize", url)) 402 + .header("Content-Type", "application/json") 403 + .header("Accept", "application/json") 404 + .json(&json!({ 405 + "request_uri": request_uri, 406 + "username": &handle, 407 + "password": "DpopTest123!", 408 + "remember_device": false 409 + })) 410 + .send() 411 + .await 412 + .unwrap(); 413 + let auth_body: Value = auth_res.json().await.unwrap(); 414 + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 415 + if location.contains("/oauth/consent") { 416 + let consent_res = http_client 417 + .post(format!("{}/oauth/authorize/consent", url)) 418 + .header("Content-Type", "application/json") 419 + .json(&json!({ 420 + "request_uri": request_uri, 421 + "approved_scopes": ["atproto"], 422 + "remember": false 423 + })) 424 + .send() 425 + .await 426 + .unwrap(); 427 + let consent_body: Value = consent_res.json().await.unwrap(); 428 + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 429 + } 430 + 431 + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); 432 + 433 + let token_endpoint = format!("{}/oauth/token", pds_url); 434 + let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None); 435 + 436 + let token_res = http_client 437 + .post(format!("{}/oauth/token", url)) 438 + .header("DPoP", &dpop_proof) 439 + .form(&[ 440 + ("grant_type", "authorization_code"), 441 + ("code", code), 442 + ("redirect_uri", redirect_uri), 443 + ("code_verifier", &code_verifier), 444 + ("client_id", &client_id), 445 + ]) 446 + .send() 447 + .await 448 + .unwrap(); 449 + 450 + let token_status = token_res.status(); 451 + let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string()); 452 + let token_body: Value = token_res.json().await.unwrap(); 453 + 454 + let access_token = if token_status == StatusCode::OK { 455 + token_body["access_token"].as_str().unwrap().to_string() 456 + } else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") { 457 + let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error"); 458 + let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce)); 459 + 460 + let retry_res = http_client 461 + .post(format!("{}/oauth/token", url)) 462 + .header("DPoP", &dpop_proof_with_nonce) 463 + .form(&[ 464 + ("grant_type", "authorization_code"), 465 + ("code", code), 466 + ("redirect_uri", redirect_uri), 467 + ("code_verifier", &code_verifier), 468 + ("client_id", &client_id), 469 + ]) 470 + .send() 471 + .await 472 + .unwrap(); 473 + let retry_body: Value = retry_res.json().await.unwrap(); 474 + retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string() 475 + } else { 476 + panic!("Token exchange failed unexpectedly: {:?}", token_body); 477 + }; 478 + 479 + let res = http_client 480 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 481 + .header("Authorization", format!("DPoP {}", access_token)) 482 + .send() 483 + .await 484 + .unwrap(); 485 + 486 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail"); 487 + 488 + let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap()); 489 + assert!(www_auth.is_some(), "Should have WWW-Authenticate header"); 490 + assert!( 491 + www_auth.unwrap().contains("use_dpop_nonce"), 492 + "WWW-Authenticate should indicate dpop nonce required" 493 + ); 494 + 495 + let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap()); 496 + assert!(nonce.is_some(), "Should return DPoP-Nonce header"); 497 + 498 + let body: Value = res.json().await.unwrap(); 499 + assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce"); 500 + } 501 + 502 + fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) { 503 + use p256::ecdsa::{SigningKey, signature::Signer}; 504 + use p256::elliptic_curve::rand_core::OsRng; 505 + 506 + let signing_key = SigningKey::random(&mut OsRng); 507 + let verifying_key = signing_key.verifying_key(); 508 + let point = verifying_key.to_encoded_point(false); 509 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 510 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 511 + 512 + let jwk = json!({ 513 + "kty": "EC", 514 + "crv": "P-256", 515 + "x": x, 516 + "y": y 517 + }); 518 + 519 + let header = { 520 + let h = json!({ 521 + "typ": "dpop+jwt", 522 + "alg": "ES256", 523 + "jwk": jwk.clone() 524 + }); 525 + h 526 + }; 527 + 528 + let mut payload = json!({ 529 + "jti": uuid::Uuid::new_v4().to_string(), 530 + "htm": method, 531 + "htu": uri, 532 + "iat": Utc::now().timestamp() 533 + }); 534 + if let Some(n) = nonce { 535 + payload["nonce"] = json!(n); 536 + } 537 + 538 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 539 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 540 + let signing_input = format!("{}.{}", header_b64, payload_b64); 541 + 542 + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 543 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 544 + 545 + let proof = format!("{}.{}", signing_input, sig_b64); 546 + (jwk, proof) 547 + }
+442 -144
crates/tranquil-pds/src/auth/extractor.rs
··· 1 1 use axum::{ 2 2 extract::FromRequestParts, 3 - http::{header::AUTHORIZATION, request::Parts}, 3 + http::{StatusCode, header::AUTHORIZATION, request::Parts}, 4 4 response::{IntoResponse, Response}, 5 5 }; 6 + use tracing::{debug, error, info}; 6 7 7 8 use super::{ 8 - AuthenticatedUser, TokenValidationError, validate_bearer_token_allow_takendown, 9 - validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated, 10 - validate_token_with_dpop, 9 + AccountStatus, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier, is_service_token, 10 + validate_bearer_token, validate_bearer_token_allow_deactivated, 11 + validate_bearer_token_allow_takendown, 11 12 }; 12 13 use crate::api::error::ApiError; 13 14 use crate::state::AppState; 15 + use crate::types::Did; 14 16 use crate::util::build_full_url; 15 17 16 18 pub struct BearerAuth(pub AuthenticatedUser); ··· 24 26 AccountDeactivated, 25 27 AccountTakedown, 26 28 AdminRequired, 29 + OAuthExpiredToken(String), 30 + UseDpopNonce(String), 31 + InvalidDpopProof(String), 27 32 } 28 33 29 34 impl IntoResponse for AuthError { 30 35 fn into_response(self) -> Response { 31 - ApiError::from(self).into_response() 36 + match self { 37 + Self::UseDpopNonce(nonce) => ( 38 + StatusCode::UNAUTHORIZED, 39 + [ 40 + ("DPoP-Nonce", nonce.as_str()), 41 + ("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""), 42 + ], 43 + axum::Json(serde_json::json!({ 44 + "error": "use_dpop_nonce", 45 + "message": "DPoP nonce required" 46 + })), 47 + ) 48 + .into_response(), 49 + Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(), 50 + Self::InvalidDpopProof(msg) => ( 51 + StatusCode::UNAUTHORIZED, 52 + [("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")], 53 + axum::Json(serde_json::json!({ 54 + "error": "invalid_dpop_proof", 55 + "message": msg 56 + })), 57 + ) 58 + .into_response(), 59 + other => ApiError::from(other).into_response(), 60 + } 32 61 } 33 62 } 34 63 ··· 107 136 None 108 137 } 109 138 139 + #[derive(Default)] 140 + struct StatusCheckFlags { 141 + allow_deactivated: bool, 142 + allow_takendown: bool, 143 + } 144 + 145 + async fn verify_oauth_token_and_build_user( 146 + state: &AppState, 147 + token: &str, 148 + dpop_proof: Option<&str>, 149 + method: &str, 150 + uri: &str, 151 + flags: StatusCheckFlags, 152 + ) -> Result<AuthenticatedUser, AuthError> { 153 + match crate::oauth::verify::verify_oauth_access_token( 154 + state.oauth_repo.as_ref(), 155 + token, 156 + dpop_proof, 157 + method, 158 + uri, 159 + ) 160 + .await 161 + { 162 + Ok(result) => { 163 + let result_did: Did = result 164 + .did 165 + .parse() 166 + .map_err(|_| AuthError::AuthenticationFailed)?; 167 + let user_info = state 168 + .user_repo 169 + .get_user_info_by_did(&result_did) 170 + .await 171 + .ok() 172 + .flatten() 173 + .ok_or(AuthError::AuthenticationFailed)?; 174 + let status = AccountStatus::from_db_fields( 175 + user_info.takedown_ref.as_deref(), 176 + user_info.deactivated_at, 177 + ); 178 + if !flags.allow_deactivated && status.is_deactivated() { 179 + return Err(AuthError::AccountDeactivated); 180 + } 181 + if !flags.allow_takendown && status.is_takendown() { 182 + return Err(AuthError::AccountTakedown); 183 + } 184 + Ok(AuthenticatedUser { 185 + did: result_did, 186 + key_bytes: user_info.key_bytes.and_then(|kb| { 187 + crate::config::decrypt_key(&kb, user_info.encryption_version).ok() 188 + }), 189 + is_oauth: true, 190 + is_admin: user_info.is_admin, 191 + status, 192 + scope: result.scope, 193 + controller_did: None, 194 + }) 195 + } 196 + Err(crate::oauth::OAuthError::ExpiredToken(msg)) => Err(AuthError::OAuthExpiredToken(msg)), 197 + Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => Err(AuthError::UseDpopNonce(nonce)), 198 + Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => { 199 + Err(AuthError::InvalidDpopProof(msg)) 200 + } 201 + Err(_) => Err(AuthError::AuthenticationFailed), 202 + } 203 + } 204 + 110 205 impl FromRequestParts<AppState> for BearerAuth { 111 206 type Rejection = AuthError; 112 207 ··· 124 219 let extracted = 125 220 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 126 221 127 - if extracted.is_dpop { 128 - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); 129 - let method = parts.method.as_str(); 130 - let uri = build_full_url(&parts.uri.to_string()); 131 - 132 - match validate_token_with_dpop( 133 - state.user_repo.as_ref(), 134 - state.oauth_repo.as_ref(), 135 - &extracted.token, 136 - true, 137 - dpop_proof, 138 - method, 139 - &uri, 140 - false, 141 - false, 142 - ) 143 - .await 144 - { 145 - Ok(user) => Ok(BearerAuth(user)), 146 - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 147 - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), 148 - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), 149 - Err(_) => Err(AuthError::AuthenticationFailed), 222 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 223 + let method = parts.method.as_str(); 224 + let uri = build_full_url(&parts.uri.to_string()); 225 + 226 + match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await { 227 + Ok(user) if !user.is_oauth => { 228 + return if user.status.is_deactivated() { 229 + Err(AuthError::AccountDeactivated) 230 + } else if user.status.is_takendown() { 231 + Err(AuthError::AccountTakedown) 232 + } else { 233 + Ok(BearerAuth(user)) 234 + }; 150 235 } 151 - } else { 152 - match validate_bearer_token_cached( 153 - state.user_repo.as_ref(), 154 - state.cache.as_ref(), 155 - &extracted.token, 156 - ) 157 - .await 158 - { 159 - Ok(user) => Ok(BearerAuth(user)), 160 - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 161 - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), 162 - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), 163 - Err(_) => Err(AuthError::AuthenticationFailed), 236 + Ok(_) => {} 237 + Err(super::TokenValidationError::AccountDeactivated) => { 238 + return Err(AuthError::AccountDeactivated); 239 + } 240 + Err(super::TokenValidationError::AccountTakedown) => { 241 + return Err(AuthError::AccountTakedown); 242 + } 243 + Err(super::TokenValidationError::TokenExpired) => { 244 + info!("JWT access token expired in BearerAuth, returning ExpiredToken"); 245 + return Err(AuthError::TokenExpired); 164 246 } 247 + Err(_) => {} 165 248 } 249 + 250 + verify_oauth_token_and_build_user( 251 + state, 252 + &extracted.token, 253 + dpop_proof, 254 + method, 255 + &uri, 256 + StatusCheckFlags::default(), 257 + ) 258 + .await 259 + .map(BearerAuth) 166 260 } 167 261 } 168 262 ··· 185 279 let extracted = 186 280 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 187 281 188 - if extracted.is_dpop { 189 - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); 190 - let method = parts.method.as_str(); 191 - let uri = build_full_url(&parts.uri.to_string()); 282 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 283 + let method = parts.method.as_str(); 284 + let uri = build_full_url(&parts.uri.to_string()); 192 285 193 - match validate_token_with_dpop( 194 - state.user_repo.as_ref(), 195 - state.oauth_repo.as_ref(), 196 - &extracted.token, 197 - true, 198 - dpop_proof, 199 - method, 200 - &uri, 201 - true, 202 - false, 203 - ) 286 + match validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token) 204 287 .await 205 - { 206 - Ok(user) => Ok(BearerAuthAllowDeactivated(user)), 207 - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), 208 - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), 209 - Err(_) => Err(AuthError::AuthenticationFailed), 288 + { 289 + Ok(user) if !user.is_oauth => { 290 + return if user.status.is_takendown() { 291 + Err(AuthError::AccountTakedown) 292 + } else { 293 + Ok(BearerAuthAllowDeactivated(user)) 294 + }; 210 295 } 211 - } else { 212 - match validate_bearer_token_cached_allow_deactivated( 213 - state.user_repo.as_ref(), 214 - state.cache.as_ref(), 215 - &extracted.token, 216 - ) 217 - .await 218 - { 219 - Ok(user) => Ok(BearerAuthAllowDeactivated(user)), 220 - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), 221 - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), 222 - Err(_) => Err(AuthError::AuthenticationFailed), 296 + Ok(_) => {} 297 + Err(super::TokenValidationError::AccountTakedown) => { 298 + return Err(AuthError::AccountTakedown); 299 + } 300 + Err(super::TokenValidationError::TokenExpired) => { 301 + return Err(AuthError::TokenExpired); 223 302 } 303 + Err(_) => {} 224 304 } 305 + 306 + verify_oauth_token_and_build_user( 307 + state, 308 + &extracted.token, 309 + dpop_proof, 310 + method, 311 + &uri, 312 + StatusCheckFlags { 313 + allow_deactivated: true, 314 + allow_takendown: false, 315 + }, 316 + ) 317 + .await 318 + .map(BearerAuthAllowDeactivated) 225 319 } 226 320 } 227 321 ··· 244 338 let extracted = 245 339 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 246 340 247 - if extracted.is_dpop { 248 - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); 249 - let method = parts.method.as_str(); 250 - let uri = build_full_url(&parts.uri.to_string()); 341 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 342 + let method = parts.method.as_str(); 343 + let uri = build_full_url(&parts.uri.to_string()); 251 344 252 - match validate_token_with_dpop( 253 - state.user_repo.as_ref(), 254 - state.oauth_repo.as_ref(), 255 - &extracted.token, 256 - true, 257 - dpop_proof, 258 - method, 259 - &uri, 260 - false, 261 - true, 262 - ) 345 + match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token) 263 346 .await 264 - { 265 - Ok(user) => Ok(BearerAuthAllowTakendown(user)), 266 - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 267 - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), 268 - Err(_) => Err(AuthError::AuthenticationFailed), 347 + { 348 + Ok(user) if !user.is_oauth => { 349 + return if user.status.is_deactivated() { 350 + Err(AuthError::AccountDeactivated) 351 + } else { 352 + Ok(BearerAuthAllowTakendown(user)) 353 + }; 269 354 } 270 - } else { 271 - match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token) 272 - .await 273 - { 274 - Ok(user) => Ok(BearerAuthAllowTakendown(user)), 275 - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), 276 - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), 277 - Err(_) => Err(AuthError::AuthenticationFailed), 355 + Ok(_) => {} 356 + Err(super::TokenValidationError::AccountDeactivated) => { 357 + return Err(AuthError::AccountDeactivated); 358 + } 359 + Err(super::TokenValidationError::TokenExpired) => { 360 + return Err(AuthError::TokenExpired); 278 361 } 362 + Err(_) => {} 279 363 } 364 + 365 + verify_oauth_token_and_build_user( 366 + state, 367 + &extracted.token, 368 + dpop_proof, 369 + method, 370 + &uri, 371 + StatusCheckFlags { 372 + allow_deactivated: false, 373 + allow_takendown: true, 374 + }, 375 + ) 376 + .await 377 + .map(BearerAuthAllowTakendown) 280 378 } 281 379 } 282 380 ··· 299 397 let extracted = 300 398 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 301 399 302 - let user = if extracted.is_dpop { 303 - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); 304 - let method = parts.method.as_str(); 305 - let uri = build_full_url(&parts.uri.to_string()); 400 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 401 + let method = parts.method.as_str(); 402 + let uri = build_full_url(&parts.uri.to_string()); 306 403 307 - match validate_token_with_dpop( 308 - state.user_repo.as_ref(), 309 - state.oauth_repo.as_ref(), 310 - &extracted.token, 311 - true, 312 - dpop_proof, 313 - method, 314 - &uri, 315 - false, 316 - false, 317 - ) 318 - .await 319 - { 320 - Ok(user) => user, 321 - Err(TokenValidationError::AccountDeactivated) => { 404 + match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await { 405 + Ok(user) if !user.is_oauth => { 406 + if user.status.is_deactivated() { 322 407 return Err(AuthError::AccountDeactivated); 323 408 } 324 - Err(TokenValidationError::AccountTakedown) => { 409 + if user.status.is_takendown() { 325 410 return Err(AuthError::AccountTakedown); 326 411 } 327 - Err(TokenValidationError::TokenExpired) => { 328 - return Err(AuthError::TokenExpired); 412 + if !user.is_admin { 413 + return Err(AuthError::AdminRequired); 329 414 } 330 - Err(_) => return Err(AuthError::AuthenticationFailed), 415 + return Ok(BearerAuthAdmin(user)); 331 416 } 332 - } else { 333 - match validate_bearer_token_cached( 334 - state.user_repo.as_ref(), 335 - state.cache.as_ref(), 336 - &extracted.token, 337 - ) 338 - .await 339 - { 340 - Ok(user) => user, 341 - Err(TokenValidationError::AccountDeactivated) => { 342 - return Err(AuthError::AccountDeactivated); 343 - } 344 - Err(TokenValidationError::AccountTakedown) => { 345 - return Err(AuthError::AccountTakedown); 346 - } 347 - Err(TokenValidationError::TokenExpired) => { 348 - return Err(AuthError::TokenExpired); 349 - } 350 - Err(_) => return Err(AuthError::AuthenticationFailed), 417 + Ok(_) => {} 418 + Err(super::TokenValidationError::AccountDeactivated) => { 419 + return Err(AuthError::AccountDeactivated); 351 420 } 352 - }; 421 + Err(super::TokenValidationError::AccountTakedown) => { 422 + return Err(AuthError::AccountTakedown); 423 + } 424 + Err(super::TokenValidationError::TokenExpired) => { 425 + return Err(AuthError::TokenExpired); 426 + } 427 + Err(_) => {} 428 + } 429 + 430 + let user = verify_oauth_token_and_build_user( 431 + state, 432 + &extracted.token, 433 + dpop_proof, 434 + method, 435 + &uri, 436 + StatusCheckFlags::default(), 437 + ) 438 + .await?; 353 439 354 440 if !user.is_admin { 355 441 return Err(AuthError::AdminRequired); ··· 358 444 } 359 445 } 360 446 447 + pub struct OptionalBearerAuth(pub Option<AuthenticatedUser>); 448 + 449 + impl FromRequestParts<AppState> for OptionalBearerAuth { 450 + type Rejection = AuthError; 451 + 452 + async fn from_request_parts( 453 + parts: &mut Parts, 454 + state: &AppState, 455 + ) -> Result<Self, Self::Rejection> { 456 + let auth_header = match parts.headers.get(AUTHORIZATION) { 457 + Some(h) => match h.to_str() { 458 + Ok(s) => s, 459 + Err(_) => return Ok(OptionalBearerAuth(None)), 460 + }, 461 + None => return Ok(OptionalBearerAuth(None)), 462 + }; 463 + 464 + let extracted = match extract_auth_token_from_header(Some(auth_header)) { 465 + Some(e) => e, 466 + None => return Ok(OptionalBearerAuth(None)), 467 + }; 468 + 469 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 470 + let method = parts.method.as_str(); 471 + let uri = build_full_url(&parts.uri.to_string()); 472 + 473 + if let Ok(user) = validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await 474 + && !user.is_oauth 475 + { 476 + return if user.status.is_deactivated() || user.status.is_takendown() { 477 + Ok(OptionalBearerAuth(None)) 478 + } else { 479 + Ok(OptionalBearerAuth(Some(user))) 480 + }; 481 + } 482 + 483 + Ok(OptionalBearerAuth( 484 + verify_oauth_token_and_build_user( 485 + state, 486 + &extracted.token, 487 + dpop_proof, 488 + method, 489 + &uri, 490 + StatusCheckFlags::default(), 491 + ) 492 + .await 493 + .ok(), 494 + )) 495 + } 496 + } 497 + 498 + pub struct ServiceAuth { 499 + pub claims: ServiceTokenClaims, 500 + pub did: Did, 501 + } 502 + 503 + impl FromRequestParts<AppState> for ServiceAuth { 504 + type Rejection = AuthError; 505 + 506 + async fn from_request_parts( 507 + parts: &mut Parts, 508 + _state: &AppState, 509 + ) -> Result<Self, Self::Rejection> { 510 + let auth_header = parts 511 + .headers 512 + .get(AUTHORIZATION) 513 + .ok_or(AuthError::MissingToken)? 514 + .to_str() 515 + .map_err(|_| AuthError::InvalidFormat)?; 516 + 517 + let extracted = 518 + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 519 + 520 + if !is_service_token(&extracted.token) { 521 + return Err(AuthError::InvalidFormat); 522 + } 523 + 524 + let verifier = ServiceTokenVerifier::new(); 525 + let claims = verifier 526 + .verify_service_token(&extracted.token, None) 527 + .await 528 + .map_err(|e| { 529 + error!("Service token verification failed: {:?}", e); 530 + AuthError::AuthenticationFailed 531 + })?; 532 + 533 + let did: Did = claims 534 + .iss 535 + .parse() 536 + .map_err(|_| AuthError::AuthenticationFailed)?; 537 + 538 + debug!("Service token verified for DID: {}", did); 539 + 540 + Ok(ServiceAuth { claims, did }) 541 + } 542 + } 543 + 544 + pub struct OptionalServiceAuth(pub Option<ServiceTokenClaims>); 545 + 546 + impl FromRequestParts<AppState> for OptionalServiceAuth { 547 + type Rejection = std::convert::Infallible; 548 + 549 + async fn from_request_parts( 550 + parts: &mut Parts, 551 + _state: &AppState, 552 + ) -> Result<Self, Self::Rejection> { 553 + let auth_header = match parts.headers.get(AUTHORIZATION) { 554 + Some(h) => match h.to_str() { 555 + Ok(s) => s, 556 + Err(_) => return Ok(OptionalServiceAuth(None)), 557 + }, 558 + None => return Ok(OptionalServiceAuth(None)), 559 + }; 560 + 561 + let extracted = match extract_auth_token_from_header(Some(auth_header)) { 562 + Some(e) => e, 563 + None => return Ok(OptionalServiceAuth(None)), 564 + }; 565 + 566 + if !is_service_token(&extracted.token) { 567 + return Ok(OptionalServiceAuth(None)); 568 + } 569 + 570 + let verifier = ServiceTokenVerifier::new(); 571 + match verifier.verify_service_token(&extracted.token, None).await { 572 + Ok(claims) => { 573 + debug!("Service token verified for DID: {}", claims.iss); 574 + Ok(OptionalServiceAuth(Some(claims))) 575 + } 576 + Err(e) => { 577 + debug!("Service token verification failed (optional): {:?}", e); 578 + Ok(OptionalServiceAuth(None)) 579 + } 580 + } 581 + } 582 + } 583 + 584 + pub enum BlobAuthResult { 585 + Service { did: Did }, 586 + User(AuthenticatedUser), 587 + } 588 + 589 + pub struct BlobAuth(pub BlobAuthResult); 590 + 591 + impl FromRequestParts<AppState> for BlobAuth { 592 + type Rejection = AuthError; 593 + 594 + async fn from_request_parts( 595 + parts: &mut Parts, 596 + state: &AppState, 597 + ) -> Result<Self, Self::Rejection> { 598 + let auth_header = parts 599 + .headers 600 + .get(AUTHORIZATION) 601 + .ok_or(AuthError::MissingToken)? 602 + .to_str() 603 + .map_err(|_| AuthError::InvalidFormat)?; 604 + 605 + let extracted = 606 + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 607 + 608 + if is_service_token(&extracted.token) { 609 + debug!("Verifying service token for blob upload"); 610 + let verifier = ServiceTokenVerifier::new(); 611 + let claims = verifier 612 + .verify_service_token(&extracted.token, Some("com.atproto.repo.uploadBlob")) 613 + .await 614 + .map_err(|e| { 615 + error!("Service token verification failed: {:?}", e); 616 + AuthError::AuthenticationFailed 617 + })?; 618 + 619 + let did: Did = claims 620 + .iss 621 + .parse() 622 + .map_err(|_| AuthError::AuthenticationFailed)?; 623 + 624 + debug!("Service token verified for DID: {}", did); 625 + return Ok(BlobAuth(BlobAuthResult::Service { did })); 626 + } 627 + 628 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 629 + let uri = build_full_url("/xrpc/com.atproto.repo.uploadBlob"); 630 + 631 + if let Ok(user) = 632 + validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token) 633 + .await 634 + && !user.is_oauth 635 + { 636 + return if user.status.is_takendown() { 637 + Err(AuthError::AccountTakedown) 638 + } else { 639 + Ok(BlobAuth(BlobAuthResult::User(user))) 640 + }; 641 + } 642 + 643 + verify_oauth_token_and_build_user( 644 + state, 645 + &extracted.token, 646 + dpop_proof, 647 + "POST", 648 + &uri, 649 + StatusCheckFlags { 650 + allow_deactivated: true, 651 + allow_takendown: false, 652 + }, 653 + ) 654 + .await 655 + .map(|user| BlobAuth(BlobAuthResult::User(user))) 656 + } 657 + } 658 + 361 659 #[cfg(test)] 362 660 mod tests { 363 661 use super::*;
+2 -1
crates/tranquil-pds/src/auth/mod.rs
··· 16 16 pub mod webauthn; 17 17 18 18 pub use extractor::{ 19 - AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken, 19 + AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult, 20 + ExtractedToken, OptionalBearerAuth, OptionalServiceAuth, ServiceAuth, 20 21 extract_auth_token_from_header, extract_bearer_token_from_header, 21 22 }; 22 23 pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
+11 -2
crates/tranquil-pds/src/lib.rs
··· 528 528 )); 529 529 let xrpc_service = ServiceBuilder::new() 530 530 .layer(XrpcProxyLayer::new(state.clone())) 531 - .service(xrpc_router.with_state(state.clone())); 531 + .service( 532 + xrpc_router 533 + .layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware)) 534 + .with_state(state.clone()), 535 + ); 532 536 533 537 let oauth_router = Router::new() 534 538 .route("/jwks", get(oauth::endpoints::oauth_jwks)) ··· 568 572 "/register/complete", 569 573 post(oauth::endpoints::register_complete), 570 574 ) 575 + .route( 576 + "/establish-session", 577 + post(oauth::endpoints::establish_session), 578 + ) 571 579 .route("/authorize/consent", get(oauth::endpoints::consent_get)) 572 580 .route("/authorize/consent", post(oauth::endpoints::consent_post)) 573 581 .route( ··· 605 613 .route( 606 614 "/sso/check-handle-available", 607 615 get(sso::endpoints::check_handle_available), 608 - ); 616 + ) 617 + .layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware)); 609 618 610 619 let well_known_router = Router::new() 611 620 .route("/did.json", get(api::identity::well_known_did))
+142 -24
crates/tranquil-pds/src/oauth/endpoints/authorize.rs
··· 2 2 use crate::oauth::{ 3 3 AuthFlowState, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, SessionId, 4 4 db::should_show_consent, 5 + scopes::expand_include_scopes, 5 6 }; 6 7 use crate::state::{AppState, RateLimitKind}; 7 8 use crate::types::{Did, Handle, PlainPassword}; ··· 1106 1107 .oauth_repo 1107 1108 .upsert_account_device(&did, &select_device_typed) 1108 1109 .await; 1110 + 1111 + let requested_scope_str = request_data 1112 + .parameters 1113 + .scope 1114 + .as_deref() 1115 + .unwrap_or("atproto"); 1116 + let requested_scopes: Vec<String> = requested_scope_str 1117 + .split_whitespace() 1118 + .map(|s| s.to_string()) 1119 + .collect(); 1120 + let client_id_typed = ClientId::from(request_data.parameters.client_id.clone()); 1121 + let did_typed = tranquil_types::Did::from(did.to_string()); 1122 + let needs_consent = should_show_consent( 1123 + state.oauth_repo.as_ref(), 1124 + &did_typed, 1125 + &client_id_typed, 1126 + &requested_scopes, 1127 + ) 1128 + .await 1129 + .unwrap_or(true); 1130 + 1131 + if needs_consent { 1132 + if state 1133 + .oauth_repo 1134 + .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) 1135 + .await 1136 + .is_err() 1137 + { 1138 + return json_error( 1139 + StatusCode::INTERNAL_SERVER_ERROR, 1140 + "server_error", 1141 + "An error occurred. Please try again.", 1142 + ); 1143 + } 1144 + let consent_url = format!( 1145 + "/app/oauth/consent?request_uri={}", 1146 + url_encode(&form.request_uri) 1147 + ); 1148 + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 1149 + } 1150 + 1109 1151 let code = Code::generate(); 1110 1152 let select_code = AuthorizationCode::from(code.0.clone()); 1111 1153 if state ··· 1475 1517 requested_scope_str.to_string() 1476 1518 }; 1477 1519 1478 - let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); 1520 + let expanded_scope_str = expand_include_scopes(&effective_scope_str).await; 1521 + let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); 1479 1522 let consent_client_id = ClientId::from(request_data.parameters.client_id.clone()); 1480 1523 let preferences = state 1481 1524 .oauth_repo ··· 2407 2450 } 2408 2451 2409 2452 let delegation_from_param = match &form.delegated_did { 2410 - Some(delegated_did_str) => { 2411 - match delegated_did_str.parse::<tranquil_types::Did>() { 2412 - Ok(delegated_did) if delegated_did != user.did => { 2413 - match state 2414 - .delegation_repo 2415 - .get_delegation(&delegated_did, &user.did) 2416 - .await 2417 - { 2418 - Ok(Some(_)) => Some(delegated_did), 2419 - Ok(None) => None, 2420 - Err(e) => { 2421 - tracing::warn!( 2422 - error = %e, 2423 - delegated_did = %delegated_did, 2424 - controller_did = %user.did, 2425 - "Failed to verify delegation relationship" 2426 - ); 2427 - None 2428 - } 2453 + Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() { 2454 + Ok(delegated_did) if delegated_did != user.did => { 2455 + match state 2456 + .delegation_repo 2457 + .get_delegation(&delegated_did, &user.did) 2458 + .await 2459 + { 2460 + Ok(Some(_)) => Some(delegated_did), 2461 + Ok(None) => None, 2462 + Err(e) => { 2463 + tracing::warn!( 2464 + error = %e, 2465 + delegated_did = %delegated_did, 2466 + controller_did = %user.did, 2467 + "Failed to verify delegation relationship" 2468 + ); 2469 + None 2429 2470 } 2430 2471 } 2431 - _ => None, 2432 2472 } 2433 - } 2473 + _ => None, 2474 + }, 2434 2475 None => None, 2435 2476 }; 2436 2477 2437 2478 let is_delegation_flow = delegation_from_param.is_some() 2438 - || request_data.did.as_ref().map_or(false, |existing_did| { 2479 + || request_data.did.as_ref().is_some_and(|existing_did| { 2439 2480 existing_did 2440 2481 .parse::<tranquil_types::Did>() 2441 2482 .ok() 2442 - .map_or(false, |parsed| parsed != user.did) 2483 + .is_some_and(|parsed| parsed != user.did) 2443 2484 }); 2444 2485 2445 2486 if let Some(delegated_did) = delegation_from_param { ··· 3601 3642 ); 3602 3643 Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() 3603 3644 } 3645 + 3646 + pub async fn establish_session( 3647 + State(state): State<AppState>, 3648 + headers: HeaderMap, 3649 + auth: crate::auth::BearerAuth, 3650 + ) -> Response { 3651 + let did = auth.0.did.clone(); 3652 + let did_typed = tranquil_types::Did::from(did.to_string()); 3653 + 3654 + let existing_device = extract_device_cookie(&headers); 3655 + 3656 + let (device_id, new_cookie) = match existing_device { 3657 + Some(id) => { 3658 + let device_typed = DeviceIdType::from(id.clone()); 3659 + let _ = state 3660 + .oauth_repo 3661 + .upsert_account_device(&did_typed, &device_typed) 3662 + .await; 3663 + (id, None) 3664 + } 3665 + None => { 3666 + let new_id = DeviceId::generate(); 3667 + let device_data = DeviceData { 3668 + session_id: SessionId::generate().0, 3669 + user_agent: extract_user_agent(&headers), 3670 + ip_address: extract_client_ip(&headers), 3671 + last_seen_at: Utc::now(), 3672 + }; 3673 + let device_typed = DeviceIdType::from(new_id.0.clone()); 3674 + 3675 + if let Err(e) = state.oauth_repo.create_device(&device_typed, &device_data).await { 3676 + tracing::error!(error = ?e, "Failed to create device"); 3677 + return ( 3678 + StatusCode::INTERNAL_SERVER_ERROR, 3679 + Json(serde_json::json!({ 3680 + "error": "server_error", 3681 + "error_description": "Failed to establish session" 3682 + })), 3683 + ) 3684 + .into_response(); 3685 + } 3686 + 3687 + if let Err(e) = state.oauth_repo.upsert_account_device(&did_typed, &device_typed).await { 3688 + tracing::error!(error = ?e, "Failed to link device to account"); 3689 + return ( 3690 + StatusCode::INTERNAL_SERVER_ERROR, 3691 + Json(serde_json::json!({ 3692 + "error": "server_error", 3693 + "error_description": "Failed to establish session" 3694 + })), 3695 + ) 3696 + .into_response(); 3697 + } 3698 + 3699 + (new_id.0.clone(), Some(make_device_cookie(&new_id.0))) 3700 + } 3701 + }; 3702 + 3703 + tracing::info!(did = %did, device_id = %device_id, "Device session established"); 3704 + 3705 + match new_cookie { 3706 + Some(cookie) => ( 3707 + StatusCode::OK, 3708 + [(SET_COOKIE, cookie)], 3709 + Json(serde_json::json!({ 3710 + "success": true, 3711 + "device_id": device_id 3712 + })), 3713 + ) 3714 + .into_response(), 3715 + None => Json(serde_json::json!({ 3716 + "success": true, 3717 + "device_id": device_id 3718 + })) 3719 + .into_response(), 3720 + } 3721 + }
+4 -52
crates/tranquil-pds/src/oauth/endpoints/delegation.rs
··· 1 - use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop}; 1 + use crate::auth::BearerAuth; 2 2 use crate::delegation::DelegationActionType; 3 3 use crate::state::{AppState, RateLimitKind}; 4 4 use crate::types::PlainPassword; 5 - use crate::util::{build_full_url, extract_client_ip}; 5 + use crate::util::extract_client_ip; 6 6 use axum::{ 7 7 Json, 8 8 extract::State, ··· 463 463 pub async fn delegation_auth_token( 464 464 State(state): State<AppState>, 465 465 headers: HeaderMap, 466 + auth: BearerAuth, 466 467 Json(form): Json<DelegationTokenAuthSubmit>, 467 468 ) -> Response { 468 - let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); 469 - 470 - let extracted = match extract_auth_token_from_header(auth_header) { 471 - Some(e) => e, 472 - None => { 473 - return ( 474 - StatusCode::UNAUTHORIZED, 475 - Json(DelegationAuthResponse { 476 - success: false, 477 - needs_totp: None, 478 - redirect_uri: None, 479 - error: Some("Missing or invalid authorization header".to_string()), 480 - }), 481 - ) 482 - .into_response(); 483 - } 484 - }; 485 - 486 - let dpop_proof = headers.get("dpop").and_then(|h| h.to_str().ok()); 487 - let uri = build_full_url("/oauth/delegation/auth-token"); 488 - 489 - let auth_user = match validate_token_with_dpop( 490 - state.user_repo.as_ref(), 491 - state.oauth_repo.as_ref(), 492 - &extracted.token, 493 - extracted.is_dpop, 494 - dpop_proof, 495 - "POST", 496 - &uri, 497 - false, 498 - false, 499 - ) 500 - .await 501 - { 502 - Ok(user) => user, 503 - Err(_) => { 504 - return ( 505 - StatusCode::UNAUTHORIZED, 506 - Json(DelegationAuthResponse { 507 - success: false, 508 - needs_totp: None, 509 - redirect_uri: None, 510 - error: Some("Invalid or expired access token".to_string()), 511 - }), 512 - ) 513 - .into_response(); 514 - } 515 - }; 516 - 517 - let controller_did = auth_user.did; 469 + let controller_did = auth.0.did; 518 470 519 471 let delegated_did: Did = match form.delegated_did.parse() { 520 472 Ok(d) => d,
+14
crates/tranquil-pds/src/oauth/verify.rs
··· 396 396 _ => Err(()), 397 397 } 398 398 } 399 + 400 + pub async fn dpop_nonce_middleware( 401 + req: axum::http::Request<axum::body::Body>, 402 + next: axum::middleware::Next, 403 + ) -> Response { 404 + let mut response = next.run(req).await; 405 + let config = AuthConfig::get(); 406 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 407 + let nonce = verifier.generate_nonce(); 408 + if let Ok(nonce_val) = nonce.parse() { 409 + response.headers_mut().insert("DPoP-Nonce", nonce_val); 410 + } 411 + response 412 + }
+583
crates/tranquil-pds/tests/auth_extractor.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 + use chrono::Utc; 6 + use common::{base_url, client, create_account_and_login, pds_endpoint}; 7 + use helpers::verify_new_account; 8 + use reqwest::StatusCode; 9 + use serde_json::{Value, json}; 10 + use sha2::{Digest, Sha256}; 11 + use wiremock::matchers::{method, path}; 12 + use wiremock::{Mock, MockServer, ResponseTemplate}; 13 + 14 + fn generate_pkce() -> (String, String) { 15 + let verifier_bytes: [u8; 32] = rand::random(); 16 + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); 17 + let mut hasher = Sha256::new(); 18 + hasher.update(code_verifier.as_bytes()); 19 + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); 20 + (code_verifier, code_challenge) 21 + } 22 + 23 + async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer { 24 + let mock_server = MockServer::start().await; 25 + let metadata = json!({ 26 + "client_id": mock_server.uri(), 27 + "client_name": "Auth Extractor Test Client", 28 + "redirect_uris": [redirect_uri], 29 + "grant_types": ["authorization_code", "refresh_token"], 30 + "response_types": ["code"], 31 + "token_endpoint_auth_method": "none", 32 + "dpop_bound_access_tokens": dpop_bound 33 + }); 34 + Mock::given(method("GET")) 35 + .and(path("/")) 36 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 37 + .mount(&mock_server) 38 + .await; 39 + mock_server 40 + } 41 + 42 + async fn get_oauth_session( 43 + http_client: &reqwest::Client, 44 + url: &str, 45 + dpop_bound: bool, 46 + ) -> (String, String, String, String) { 47 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 48 + let handle = format!("ae{}", suffix); 49 + let password = "AuthExtract123!"; 50 + let create_res = http_client 51 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 52 + .json(&json!({ 53 + "handle": handle, 54 + "email": format!("{}@example.com", handle), 55 + "password": password 56 + })) 57 + .send() 58 + .await 59 + .unwrap(); 60 + assert_eq!(create_res.status(), StatusCode::OK); 61 + let account: Value = create_res.json().await.unwrap(); 62 + let did = account["did"].as_str().unwrap().to_string(); 63 + verify_new_account(http_client, &did).await; 64 + 65 + let redirect_uri = "https://example.com/auth-callback"; 66 + let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await; 67 + let client_id = mock_client.uri(); 68 + let (code_verifier, code_challenge) = generate_pkce(); 69 + 70 + let par_body: Value = http_client 71 + .post(format!("{}/oauth/par", url)) 72 + .form(&[ 73 + ("response_type", "code"), 74 + ("client_id", &client_id), 75 + ("redirect_uri", redirect_uri), 76 + ("code_challenge", &code_challenge), 77 + ("code_challenge_method", "S256"), 78 + ]) 79 + .send() 80 + .await 81 + .unwrap() 82 + .json() 83 + .await 84 + .unwrap(); 85 + let request_uri = par_body["request_uri"].as_str().unwrap(); 86 + 87 + let auth_res = http_client 88 + .post(format!("{}/oauth/authorize", url)) 89 + .header("Content-Type", "application/json") 90 + .header("Accept", "application/json") 91 + .json(&json!({ 92 + "request_uri": request_uri, 93 + "username": &handle, 94 + "password": password, 95 + "remember_device": false 96 + })) 97 + .send() 98 + .await 99 + .unwrap(); 100 + let auth_body: Value = auth_res.json().await.unwrap(); 101 + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 102 + 103 + if location.contains("/oauth/consent") { 104 + let consent_res = http_client 105 + .post(format!("{}/oauth/authorize/consent", url)) 106 + .header("Content-Type", "application/json") 107 + .json(&json!({ 108 + "request_uri": request_uri, 109 + "approved_scopes": ["atproto"], 110 + "remember": false 111 + })) 112 + .send() 113 + .await 114 + .unwrap(); 115 + let consent_body: Value = consent_res.json().await.unwrap(); 116 + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 117 + } 118 + 119 + let code = location 120 + .split("code=") 121 + .nth(1) 122 + .unwrap() 123 + .split('&') 124 + .next() 125 + .unwrap(); 126 + 127 + let token_body: Value = http_client 128 + .post(format!("{}/oauth/token", url)) 129 + .form(&[ 130 + ("grant_type", "authorization_code"), 131 + ("code", code), 132 + ("redirect_uri", redirect_uri), 133 + ("code_verifier", &code_verifier), 134 + ("client_id", &client_id), 135 + ]) 136 + .send() 137 + .await 138 + .unwrap() 139 + .json() 140 + .await 141 + .unwrap(); 142 + 143 + ( 144 + token_body["access_token"].as_str().unwrap().to_string(), 145 + token_body["refresh_token"].as_str().unwrap().to_string(), 146 + client_id, 147 + did, 148 + ) 149 + } 150 + 151 + #[tokio::test] 152 + async fn test_oauth_token_works_with_bearer_auth() { 153 + let url = base_url().await; 154 + let http_client = client(); 155 + let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await; 156 + 157 + let res = http_client 158 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 159 + .bearer_auth(&access_token) 160 + .send() 161 + .await 162 + .unwrap(); 163 + 164 + assert_eq!( 165 + res.status(), 166 + StatusCode::OK, 167 + "OAuth token should work with BearerAuth extractor" 168 + ); 169 + let body: Value = res.json().await.unwrap(); 170 + assert_eq!(body["did"].as_str().unwrap(), did); 171 + } 172 + 173 + #[tokio::test] 174 + async fn test_session_token_still_works() { 175 + let url = base_url().await; 176 + let http_client = client(); 177 + let (jwt, did) = create_account_and_login(&http_client).await; 178 + 179 + let res = http_client 180 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 181 + .bearer_auth(&jwt) 182 + .send() 183 + .await 184 + .unwrap(); 185 + 186 + assert_eq!( 187 + res.status(), 188 + StatusCode::OK, 189 + "Session token should still work" 190 + ); 191 + let body: Value = res.json().await.unwrap(); 192 + assert_eq!(body["did"].as_str().unwrap(), did); 193 + } 194 + 195 + #[tokio::test] 196 + async fn test_oauth_admin_extractor_allows_oauth_tokens() { 197 + let url = base_url().await; 198 + let http_client = client(); 199 + 200 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 201 + let handle = format!("adm{}", suffix); 202 + let password = "AdminOAuth123!"; 203 + let create_res = http_client 204 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 205 + .json(&json!({ 206 + "handle": handle, 207 + "email": format!("{}@example.com", handle), 208 + "password": password 209 + })) 210 + .send() 211 + .await 212 + .unwrap(); 213 + assert_eq!(create_res.status(), StatusCode::OK); 214 + let account: Value = create_res.json().await.unwrap(); 215 + let did = account["did"].as_str().unwrap().to_string(); 216 + verify_new_account(&http_client, &did).await; 217 + 218 + let pool = common::get_test_db_pool().await; 219 + sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) 220 + .execute(pool) 221 + .await 222 + .expect("Failed to mark user as admin"); 223 + 224 + let redirect_uri = "https://example.com/admin-callback"; 225 + let mock_client = setup_mock_client_metadata(redirect_uri, false).await; 226 + let client_id = mock_client.uri(); 227 + let (code_verifier, code_challenge) = generate_pkce(); 228 + 229 + let par_body: Value = http_client 230 + .post(format!("{}/oauth/par", url)) 231 + .form(&[ 232 + ("response_type", "code"), 233 + ("client_id", &client_id), 234 + ("redirect_uri", redirect_uri), 235 + ("code_challenge", &code_challenge), 236 + ("code_challenge_method", "S256"), 237 + ]) 238 + .send() 239 + .await 240 + .unwrap() 241 + .json() 242 + .await 243 + .unwrap(); 244 + let request_uri = par_body["request_uri"].as_str().unwrap(); 245 + 246 + let auth_res = http_client 247 + .post(format!("{}/oauth/authorize", url)) 248 + .header("Content-Type", "application/json") 249 + .header("Accept", "application/json") 250 + .json(&json!({ 251 + "request_uri": request_uri, 252 + "username": &handle, 253 + "password": password, 254 + "remember_device": false 255 + })) 256 + .send() 257 + .await 258 + .unwrap(); 259 + let auth_body: Value = auth_res.json().await.unwrap(); 260 + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 261 + if location.contains("/oauth/consent") { 262 + let consent_res = http_client 263 + .post(format!("{}/oauth/authorize/consent", url)) 264 + .header("Content-Type", "application/json") 265 + .json(&json!({ 266 + "request_uri": request_uri, 267 + "approved_scopes": ["atproto"], 268 + "remember": false 269 + })) 270 + .send() 271 + .await 272 + .unwrap(); 273 + let consent_body: Value = consent_res.json().await.unwrap(); 274 + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 275 + } 276 + 277 + let code = location 278 + .split("code=") 279 + .nth(1) 280 + .unwrap() 281 + .split('&') 282 + .next() 283 + .unwrap(); 284 + let token_body: Value = http_client 285 + .post(format!("{}/oauth/token", url)) 286 + .form(&[ 287 + ("grant_type", "authorization_code"), 288 + ("code", code), 289 + ("redirect_uri", redirect_uri), 290 + ("code_verifier", &code_verifier), 291 + ("client_id", &client_id), 292 + ]) 293 + .send() 294 + .await 295 + .unwrap() 296 + .json() 297 + .await 298 + .unwrap(); 299 + let access_token = token_body["access_token"].as_str().unwrap(); 300 + 301 + let res = http_client 302 + .get(format!( 303 + "{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", 304 + url, did 305 + )) 306 + .bearer_auth(access_token) 307 + .send() 308 + .await 309 + .unwrap(); 310 + 311 + assert_eq!( 312 + res.status(), 313 + StatusCode::OK, 314 + "OAuth token for admin user should work with admin endpoint" 315 + ); 316 + } 317 + 318 + #[tokio::test] 319 + async fn test_expired_oauth_token_returns_proper_error() { 320 + let url = base_url().await; 321 + let http_client = client(); 322 + 323 + let now = Utc::now().timestamp(); 324 + let header = json!({"alg": "HS256", "typ": "at+jwt"}); 325 + let payload = json!({ 326 + "iss": url, 327 + "sub": "did:plc:test123", 328 + "aud": url, 329 + "iat": now - 7200, 330 + "exp": now - 3600, 331 + "jti": "expired-token", 332 + "sid": "expired-session", 333 + "scope": "atproto", 334 + "client_id": "https://example.com" 335 + }); 336 + let fake_token = format!( 337 + "{}.{}.{}", 338 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), 339 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), 340 + URL_SAFE_NO_PAD.encode([1u8; 32]) 341 + ); 342 + 343 + let res = http_client 344 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 345 + .bearer_auth(&fake_token) 346 + .send() 347 + .await 348 + .unwrap(); 349 + 350 + assert_eq!( 351 + res.status(), 352 + StatusCode::UNAUTHORIZED, 353 + "Expired token should be rejected" 354 + ); 355 + } 356 + 357 + #[tokio::test] 358 + async fn test_dpop_nonce_error_has_proper_headers() { 359 + let url = base_url().await; 360 + let pds_url = pds_endpoint(); 361 + let http_client = client(); 362 + 363 + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; 364 + let handle = format!("dpop{}", suffix); 365 + let create_res = http_client 366 + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 367 + .json(&json!({ 368 + "handle": handle, 369 + "email": format!("{}@test.com", handle), 370 + "password": "DpopTest123!" 371 + })) 372 + .send() 373 + .await 374 + .unwrap(); 375 + assert_eq!(create_res.status(), StatusCode::OK); 376 + let account: Value = create_res.json().await.unwrap(); 377 + let did = account["did"].as_str().unwrap(); 378 + verify_new_account(&http_client, did).await; 379 + 380 + let redirect_uri = "https://example.com/dpop-callback"; 381 + let mock_server = MockServer::start().await; 382 + let client_id = mock_server.uri(); 383 + let metadata = json!({ 384 + "client_id": &client_id, 385 + "client_name": "DPoP Test Client", 386 + "redirect_uris": [redirect_uri], 387 + "grant_types": ["authorization_code", "refresh_token"], 388 + "response_types": ["code"], 389 + "token_endpoint_auth_method": "none", 390 + "dpop_bound_access_tokens": true 391 + }); 392 + Mock::given(method("GET")) 393 + .and(path("/")) 394 + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) 395 + .mount(&mock_server) 396 + .await; 397 + 398 + let (code_verifier, code_challenge) = generate_pkce(); 399 + let par_body: Value = http_client 400 + .post(format!("{}/oauth/par", url)) 401 + .form(&[ 402 + ("response_type", "code"), 403 + ("client_id", &client_id), 404 + ("redirect_uri", redirect_uri), 405 + ("code_challenge", &code_challenge), 406 + ("code_challenge_method", "S256"), 407 + ]) 408 + .send() 409 + .await 410 + .unwrap() 411 + .json() 412 + .await 413 + .unwrap(); 414 + 415 + let request_uri = par_body["request_uri"].as_str().unwrap(); 416 + let auth_res = http_client 417 + .post(format!("{}/oauth/authorize", url)) 418 + .header("Content-Type", "application/json") 419 + .header("Accept", "application/json") 420 + .json(&json!({ 421 + "request_uri": request_uri, 422 + "username": &handle, 423 + "password": "DpopTest123!", 424 + "remember_device": false 425 + })) 426 + .send() 427 + .await 428 + .unwrap(); 429 + let auth_body: Value = auth_res.json().await.unwrap(); 430 + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); 431 + if location.contains("/oauth/consent") { 432 + let consent_res = http_client 433 + .post(format!("{}/oauth/authorize/consent", url)) 434 + .header("Content-Type", "application/json") 435 + .json(&json!({ 436 + "request_uri": request_uri, 437 + "approved_scopes": ["atproto"], 438 + "remember": false 439 + })) 440 + .send() 441 + .await 442 + .unwrap(); 443 + let consent_body: Value = consent_res.json().await.unwrap(); 444 + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); 445 + } 446 + 447 + let code = location 448 + .split("code=") 449 + .nth(1) 450 + .unwrap() 451 + .split('&') 452 + .next() 453 + .unwrap(); 454 + 455 + let token_endpoint = format!("{}/oauth/token", pds_url); 456 + let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None); 457 + 458 + let token_res = http_client 459 + .post(format!("{}/oauth/token", url)) 460 + .header("DPoP", &dpop_proof) 461 + .form(&[ 462 + ("grant_type", "authorization_code"), 463 + ("code", code), 464 + ("redirect_uri", redirect_uri), 465 + ("code_verifier", &code_verifier), 466 + ("client_id", &client_id), 467 + ]) 468 + .send() 469 + .await 470 + .unwrap(); 471 + 472 + let token_status = token_res.status(); 473 + let token_nonce = token_res 474 + .headers() 475 + .get("dpop-nonce") 476 + .map(|h| h.to_str().unwrap().to_string()); 477 + let token_body: Value = token_res.json().await.unwrap(); 478 + 479 + let access_token = if token_status == StatusCode::OK { 480 + token_body["access_token"].as_str().unwrap().to_string() 481 + } else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") { 482 + let nonce = 483 + token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error"); 484 + let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce)); 485 + 486 + let retry_res = http_client 487 + .post(format!("{}/oauth/token", url)) 488 + .header("DPoP", &dpop_proof_with_nonce) 489 + .form(&[ 490 + ("grant_type", "authorization_code"), 491 + ("code", code), 492 + ("redirect_uri", redirect_uri), 493 + ("code_verifier", &code_verifier), 494 + ("client_id", &client_id), 495 + ]) 496 + .send() 497 + .await 498 + .unwrap(); 499 + let retry_body: Value = retry_res.json().await.unwrap(); 500 + retry_body["access_token"] 501 + .as_str() 502 + .expect("Should get access_token after nonce retry") 503 + .to_string() 504 + } else { 505 + panic!("Token exchange failed unexpectedly: {:?}", token_body); 506 + }; 507 + 508 + let res = http_client 509 + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) 510 + .header("Authorization", format!("DPoP {}", access_token)) 511 + .send() 512 + .await 513 + .unwrap(); 514 + 515 + assert_eq!( 516 + res.status(), 517 + StatusCode::UNAUTHORIZED, 518 + "DPoP token without proof should fail" 519 + ); 520 + 521 + let www_auth = res 522 + .headers() 523 + .get("www-authenticate") 524 + .map(|h| h.to_str().unwrap()); 525 + assert!(www_auth.is_some(), "Should have WWW-Authenticate header"); 526 + assert!( 527 + www_auth.unwrap().contains("use_dpop_nonce"), 528 + "WWW-Authenticate should indicate dpop nonce required" 529 + ); 530 + 531 + let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap()); 532 + assert!(nonce.is_some(), "Should return DPoP-Nonce header"); 533 + 534 + let body: Value = res.json().await.unwrap(); 535 + assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce"); 536 + } 537 + 538 + fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) { 539 + use p256::ecdsa::{SigningKey, signature::Signer}; 540 + use p256::elliptic_curve::rand_core::OsRng; 541 + 542 + let signing_key = SigningKey::random(&mut OsRng); 543 + let verifying_key = signing_key.verifying_key(); 544 + let point = verifying_key.to_encoded_point(false); 545 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 546 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 547 + 548 + let jwk = json!({ 549 + "kty": "EC", 550 + "crv": "P-256", 551 + "x": x, 552 + "y": y 553 + }); 554 + 555 + let header = { 556 + let h = json!({ 557 + "typ": "dpop+jwt", 558 + "alg": "ES256", 559 + "jwk": jwk.clone() 560 + }); 561 + h 562 + }; 563 + 564 + let mut payload = json!({ 565 + "jti": uuid::Uuid::new_v4().to_string(), 566 + "htm": method, 567 + "htu": uri, 568 + "iat": Utc::now().timestamp() 569 + }); 570 + if let Some(n) = nonce { 571 + payload["nonce"] = json!(n); 572 + } 573 + 574 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 575 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); 576 + let signing_input = format!("{}.{}", header_b64, payload_b64); 577 + 578 + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); 579 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 580 + 581 + let proof = format!("{}.{}", signing_input, sig_b64); 582 + (jwk, proof) 583 + }
+3 -3
crates/tranquil-pds/tests/common/mod.rs
··· 1 - #[cfg(feature = "s3-storage")] 1 + #[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))] 2 2 use aws_config::BehaviorVersion; 3 - #[cfg(feature = "s3-storage")] 3 + #[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))] 4 4 use aws_sdk_s3::Client as S3Client; 5 - #[cfg(feature = "s3-storage")] 5 + #[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))] 6 6 use aws_sdk_s3::config::Credentials; 7 7 use chrono::Utc; 8 8 use reqwest::{Client, StatusCode, header};
+8 -2
crates/tranquil-pds/tests/oauth_security.rs
··· 1373 1373 .send() 1374 1374 .await 1375 1375 .unwrap(); 1376 - assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed"); 1376 + assert_eq!( 1377 + token_res.status(), 1378 + StatusCode::OK, 1379 + "Token exchange should succeed" 1380 + ); 1377 1381 let tokens: Value = token_res.json().await.unwrap(); 1378 1382 1379 - let sub = tokens["sub"].as_str().expect("Token response should have sub claim"); 1383 + let sub = tokens["sub"] 1384 + .as_str() 1385 + .expect("Token response should have sub claim"); 1380 1386 1381 1387 assert_eq!( 1382 1388 sub, delegated_did,
+2
crates/tranquil-scopes/Cargo.toml
··· 7 7 [dependencies] 8 8 axum = { workspace = true } 9 9 futures = { workspace = true } 10 + hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } 10 11 reqwest = { workspace = true } 11 12 serde = { workspace = true } 12 13 serde_json = { workspace = true } 13 14 tokio = { workspace = true } 14 15 tracing = { workspace = true } 16 + urlencoding = "2"
+502 -60
crates/tranquil-scopes/src/permission_set.rs
··· 1 + use hickory_resolver::TokioAsyncResolver; 1 2 use reqwest::Client; 2 3 use serde::Deserialize; 3 4 use std::collections::HashMap; ··· 16 17 17 18 const CACHE_TTL_SECS: u64 = 3600; 18 19 20 + #[derive(Debug, Deserialize)] 21 + struct PlcDocument { 22 + service: Vec<PlcService>, 23 + } 24 + 25 + #[derive(Debug, Deserialize)] 26 + struct PlcService { 27 + id: String, 28 + #[serde(rename = "serviceEndpoint")] 29 + service_endpoint: String, 30 + } 31 + 32 + #[derive(Debug, Deserialize)] 33 + struct GetRecordResponse { 34 + value: LexiconDoc, 35 + } 36 + 19 37 #[derive(Debug, Deserialize)] 20 38 struct LexiconDoc { 21 39 defs: HashMap<String, LexiconDef>, ··· 31 49 #[derive(Debug, Deserialize)] 32 50 struct PermissionEntry { 33 51 resource: String, 52 + action: Option<Vec<String>>, 34 53 collection: Option<Vec<String>>, 54 + lxm: Option<Vec<String>>, 55 + aud: Option<String>, 35 56 } 36 57 37 58 pub async fn expand_include_scopes(scope_string: &str) -> String { ··· 39 60 .split_whitespace() 40 61 .map(|scope| async move { 41 62 match scope.strip_prefix("include:") { 42 - Some(nsid) => { 43 - let nsid_base = nsid.split('?').next().unwrap_or(nsid); 44 - expand_permission_set(nsid_base).await.unwrap_or_else(|e| { 45 - warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original"); 46 - scope.to_string() 47 - }) 63 + Some(rest) => { 64 + let (nsid_base, aud) = parse_include_scope(rest); 65 + expand_permission_set(nsid_base, aud) 66 + .await 67 + .unwrap_or_else(|e| { 68 + warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original"); 69 + scope.to_string() 70 + }) 48 71 } 49 72 None => scope.to_string(), 50 73 } ··· 54 77 futures::future::join_all(futures).await.join(" ") 55 78 } 56 79 57 - async fn expand_permission_set(nsid: &str) -> Result<String, String> { 80 + fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { 81 + rest.split_once('?') 82 + .map(|(nsid, params)| { 83 + let aud = params.split('&').find_map(|p| p.strip_prefix("aud=")); 84 + (nsid, aud) 85 + }) 86 + .unwrap_or((rest, None)) 87 + } 88 + 89 + async fn expand_permission_set(nsid: &str, aud: Option<&str>) -> Result<String, String> { 90 + let cache_key = match aud { 91 + Some(a) => format!("{}?aud={}", nsid, a), 92 + None => nsid.to_string(), 93 + }; 94 + 58 95 { 59 96 let cache = LEXICON_CACHE.read().await; 60 - if let Some(cached) = cache.get(nsid) 97 + if let Some(cached) = cache.get(&cache_key) 61 98 && cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS 62 99 { 63 100 debug!(nsid, "Using cached permission set expansion"); ··· 65 102 } 66 103 } 67 104 105 + let lexicon = fetch_lexicon_via_atproto(nsid).await?; 106 + 107 + let main_def = lexicon 108 + .defs 109 + .get("main") 110 + .ok_or("Missing 'main' definition in lexicon")?; 111 + 112 + if main_def.def_type != "permission-set" { 113 + return Err(format!( 114 + "Expected permission-set type, got: {}", 115 + main_def.def_type 116 + )); 117 + } 118 + 119 + let permissions = main_def 120 + .permissions 121 + .as_ref() 122 + .ok_or("Missing permissions in permission-set")?; 123 + 124 + let namespace_authority = extract_namespace_authority(nsid); 125 + let expanded = build_expanded_scopes(permissions, aud, &namespace_authority); 126 + 127 + if expanded.is_empty() { 128 + return Err("No valid permissions found in permission-set".to_string()); 129 + } 130 + 131 + { 132 + let mut cache = LEXICON_CACHE.write().await; 133 + cache.insert( 134 + cache_key, 135 + CachedLexicon { 136 + expanded_scope: expanded.clone(), 137 + cached_at: std::time::Instant::now(), 138 + }, 139 + ); 140 + } 141 + 142 + debug!(nsid, expanded = %expanded, "Successfully expanded permission set"); 143 + Ok(expanded) 144 + } 145 + 146 + async fn fetch_lexicon_via_atproto(nsid: &str) -> Result<LexiconDoc, String> { 68 147 let parts: Vec<&str> = nsid.split('.').collect(); 69 148 if parts.len() < 3 { 70 149 return Err(format!("Invalid NSID format: {}", nsid)); 71 150 } 72 151 73 - let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); 74 - let domain = domain_parts.join("."); 75 - let path = parts[2..].join("/"); 152 + let authority = parts[..2].iter().rev().cloned().collect::<Vec<_>>().join("."); 153 + debug!(nsid, authority = %authority, "Resolving lexicon DID authority via DNS"); 76 154 77 - let url = format!("https://{}/lexicons/{}.json", domain, path); 78 - debug!(nsid, url = %url, "Fetching permission set lexicon"); 155 + let did = resolve_lexicon_did_authority(&authority).await?; 156 + debug!(nsid, did = %did, "Resolved lexicon DID authority"); 157 + 158 + let pds_endpoint = resolve_did_to_pds(&did).await?; 159 + debug!(nsid, pds = %pds_endpoint, "Resolved DID to PDS endpoint"); 79 160 80 161 let client = Client::builder() 81 162 .timeout(std::time::Duration::from_secs(10)) 82 163 .build() 83 164 .map_err(|e| format!("Failed to create HTTP client: {}", e))?; 84 165 166 + let url = format!( 167 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}", 168 + pds_endpoint, 169 + urlencoding::encode(&did), 170 + urlencoding::encode(nsid) 171 + ); 172 + debug!(nsid, url = %url, "Fetching lexicon from PDS"); 173 + 85 174 let response = client 86 175 .get(&url) 87 176 .header("Accept", "application/json") ··· 96 185 )); 97 186 } 98 187 99 - let lexicon: LexiconDoc = response 188 + let record: GetRecordResponse = response 100 189 .json() 101 190 .await 102 - .map_err(|e| format!("Failed to parse lexicon: {}", e))?; 191 + .map_err(|e| format!("Failed to parse lexicon response: {}", e))?; 103 192 104 - let main_def = lexicon 105 - .defs 106 - .get("main") 107 - .ok_or("Missing 'main' definition in lexicon")?; 193 + Ok(record.value) 194 + } 108 195 109 - if main_def.def_type != "permission-set" { 110 - return Err(format!( 111 - "Expected permission-set type, got: {}", 112 - main_def.def_type 113 - )); 114 - } 196 + async fn resolve_lexicon_did_authority(authority: &str) -> Result<String, String> { 197 + let resolver = TokioAsyncResolver::tokio_from_system_conf() 198 + .map_err(|e| format!("Failed to create DNS resolver: {}", e))?; 115 199 116 - let permissions = main_def 117 - .permissions 118 - .as_ref() 119 - .ok_or("Missing permissions in permission-set")?; 200 + let dns_name = format!("_lexicon.{}", authority); 201 + debug!(dns_name = %dns_name, "Looking up DNS TXT record"); 202 + 203 + let txt_records = resolver 204 + .txt_lookup(&dns_name) 205 + .await 206 + .map_err(|e| format!("DNS lookup failed for {}: {}", dns_name, e))?; 120 207 121 - let mut collections: Vec<String> = permissions 208 + txt_records 122 209 .iter() 123 - .filter(|perm| perm.resource == "repo") 124 - .filter_map(|perm| perm.collection.as_ref()) 125 - .flatten() 126 - .cloned() 127 - .collect(); 210 + .flat_map(|record| record.iter()) 211 + .find_map(|data| { 212 + let txt = String::from_utf8_lossy(data); 213 + txt.strip_prefix("did=").map(|did| did.to_string()) 214 + }) 215 + .ok_or_else(|| format!("No valid did= TXT record found at {}", dns_name)) 216 + } 217 + 218 + async fn resolve_did_to_pds(did: &str) -> Result<String, String> { 219 + let client = Client::builder() 220 + .timeout(std::time::Duration::from_secs(10)) 221 + .build() 222 + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; 223 + 224 + let url = if did.starts_with("did:plc:") { 225 + format!("https://plc.directory/{}", did) 226 + } else if did.starts_with("did:web:") { 227 + let domain = did.strip_prefix("did:web:").unwrap(); 228 + format!("https://{}/.well-known/did.json", domain) 229 + } else { 230 + return Err(format!("Unsupported DID method: {}", did)); 231 + }; 128 232 129 - if collections.is_empty() { 130 - return Err("No repo collections found in permission-set".to_string()); 233 + let response = client 234 + .get(&url) 235 + .header("Accept", "application/json") 236 + .send() 237 + .await 238 + .map_err(|e| format!("Failed to resolve DID: {}", e))?; 239 + 240 + if !response.status().is_success() { 241 + return Err(format!("Failed to resolve DID: HTTP {}", response.status())); 131 242 } 132 243 133 - collections.sort(); 244 + let doc: PlcDocument = response 245 + .json() 246 + .await 247 + .map_err(|e| format!("Failed to parse DID document: {}", e))?; 134 248 135 - let collection_params: Vec<String> = collections 249 + doc.service 136 250 .iter() 137 - .map(|c| format!("collection={}", c)) 138 - .collect(); 139 - 140 - let expanded = format!("repo?{}", collection_params.join("&")); 251 + .find(|s| s.id == "#atproto_pds") 252 + .map(|s| s.service_endpoint.clone()) 253 + .ok_or_else(|| "No #atproto_pds service found in DID document".to_string()) 254 + } 141 255 142 - { 143 - let mut cache = LEXICON_CACHE.write().await; 144 - cache.insert( 145 - nsid.to_string(), 146 - CachedLexicon { 147 - expanded_scope: expanded.clone(), 148 - cached_at: std::time::Instant::now(), 149 - }, 150 - ); 256 + fn extract_namespace_authority(nsid: &str) -> String { 257 + let parts: Vec<&str> = nsid.split('.').collect(); 258 + if parts.len() >= 2 { 259 + parts[..parts.len() - 1].join(".") 260 + } else { 261 + nsid.to_string() 151 262 } 263 + } 152 264 153 - debug!(nsid, expanded = %expanded, "Successfully expanded permission set"); 154 - Ok(expanded) 265 + fn is_under_authority(target_nsid: &str, authority: &str) -> bool { 266 + target_nsid.starts_with(authority) 267 + && target_nsid 268 + .chars() 269 + .nth(authority.len()) 270 + .is_some_and(|c| c == '.') 271 + } 272 + 273 + const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"]; 274 + 275 + fn build_expanded_scopes( 276 + permissions: &[PermissionEntry], 277 + default_aud: Option<&str>, 278 + namespace_authority: &str, 279 + ) -> String { 280 + let mut scopes: Vec<String> = Vec::new(); 281 + 282 + permissions.iter().for_each(|perm| match perm.resource.as_str() { 283 + "repo" => { 284 + if let Some(collections) = &perm.collection { 285 + let actions: Vec<&str> = perm 286 + .action 287 + .as_ref() 288 + .map(|a| a.iter().map(String::as_str).collect()) 289 + .unwrap_or_else(|| DEFAULT_ACTIONS.to_vec()); 290 + 291 + collections 292 + .iter() 293 + .filter(|coll| is_under_authority(coll, namespace_authority)) 294 + .for_each(|coll| { 295 + actions.iter().for_each(|action| { 296 + scopes.push(format!("repo:{}?action={}", coll, action)); 297 + }); 298 + }); 299 + } 300 + } 301 + "rpc" => { 302 + if let Some(lxms) = &perm.lxm { 303 + let perm_aud = perm.aud.as_deref().or(default_aud); 304 + 305 + lxms.iter().for_each(|lxm| { 306 + let scope = match perm_aud { 307 + Some(aud) => format!("rpc:{}?aud={}", lxm, aud), 308 + None => format!("rpc:{}", lxm), 309 + }; 310 + scopes.push(scope); 311 + }); 312 + } 313 + } 314 + _ => {} 315 + }); 316 + 317 + scopes.join(" ") 155 318 } 156 319 157 320 #[cfg(test)] 158 321 mod tests { 322 + use super::*; 323 + 324 + #[test] 325 + fn test_parse_include_scope() { 326 + let (nsid, aud) = parse_include_scope("io.atcr.authFullApp"); 327 + assert_eq!(nsid, "io.atcr.authFullApp"); 328 + assert_eq!(aud, None); 329 + 330 + let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?aud=did:web:api.bsky.app"); 331 + assert_eq!(nsid, "io.atcr.authFullApp"); 332 + assert_eq!(aud, Some("did:web:api.bsky.app")); 333 + } 334 + 335 + #[test] 336 + fn test_parse_include_scope_with_multiple_params() { 337 + let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?foo=bar&aud=did:web:example.com&baz=qux"); 338 + assert_eq!(nsid, "io.atcr.authFullApp"); 339 + assert_eq!(aud, Some("did:web:example.com")); 340 + } 341 + 342 + #[test] 343 + fn test_extract_namespace_authority() { 344 + assert_eq!( 345 + extract_namespace_authority("io.atcr.authFullApp"), 346 + "io.atcr" 347 + ); 348 + assert_eq!( 349 + extract_namespace_authority("app.bsky.authFullApp"), 350 + "app.bsky" 351 + ); 352 + } 353 + 354 + #[test] 355 + fn test_extract_namespace_authority_deep_nesting() { 356 + assert_eq!( 357 + extract_namespace_authority("io.atcr.sailor.star.collection"), 358 + "io.atcr.sailor.star" 359 + ); 360 + } 361 + 362 + #[test] 363 + fn test_extract_namespace_authority_single_segment() { 364 + assert_eq!(extract_namespace_authority("single"), "single"); 365 + } 366 + 367 + #[test] 368 + fn test_is_under_authority() { 369 + assert!(is_under_authority("io.atcr.manifest", "io.atcr")); 370 + assert!(is_under_authority("io.atcr.sailor.star", "io.atcr")); 371 + assert!(!is_under_authority("app.bsky.feed.post", "io.atcr")); 372 + assert!(!is_under_authority("io.atcr", "io.atcr")); 373 + } 374 + 375 + #[test] 376 + fn test_is_under_authority_prefix_collision() { 377 + assert!(!is_under_authority("io.atcritical.something", "io.atcr")); 378 + assert!(is_under_authority("io.atcr.something", "io.atcr")); 379 + } 380 + 381 + #[test] 382 + fn test_build_expanded_scopes_repo() { 383 + let permissions = vec![PermissionEntry { 384 + resource: "repo".to_string(), 385 + action: Some(vec!["create".to_string(), "delete".to_string()]), 386 + collection: Some(vec![ 387 + "io.atcr.manifest".to_string(), 388 + "io.atcr.sailor.star".to_string(), 389 + "app.bsky.feed.post".to_string(), 390 + ]), 391 + lxm: None, 392 + aud: None, 393 + }]; 394 + 395 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 396 + assert!(expanded.contains("repo:io.atcr.manifest?action=create")); 397 + assert!(expanded.contains("repo:io.atcr.manifest?action=delete")); 398 + assert!(expanded.contains("repo:io.atcr.sailor.star?action=create")); 399 + assert!(!expanded.contains("app.bsky.feed.post")); 400 + } 401 + 402 + #[test] 403 + fn test_build_expanded_scopes_repo_default_actions() { 404 + let permissions = vec![PermissionEntry { 405 + resource: "repo".to_string(), 406 + action: None, 407 + collection: Some(vec!["io.atcr.manifest".to_string()]), 408 + lxm: None, 409 + aud: None, 410 + }]; 411 + 412 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 413 + assert!(expanded.contains("repo:io.atcr.manifest?action=create")); 414 + assert!(expanded.contains("repo:io.atcr.manifest?action=update")); 415 + assert!(expanded.contains("repo:io.atcr.manifest?action=delete")); 416 + } 417 + 418 + #[test] 419 + fn test_build_expanded_scopes_rpc() { 420 + let permissions = vec![PermissionEntry { 421 + resource: "rpc".to_string(), 422 + action: None, 423 + collection: None, 424 + lxm: Some(vec![ 425 + "io.atcr.getManifest".to_string(), 426 + "com.atproto.repo.getRecord".to_string(), 427 + ]), 428 + aud: Some("*".to_string()), 429 + }]; 430 + 431 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 432 + assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*")); 433 + assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*")); 434 + } 435 + 436 + #[test] 437 + fn test_build_expanded_scopes_rpc_with_default_aud() { 438 + let permissions = vec![PermissionEntry { 439 + resource: "rpc".to_string(), 440 + action: None, 441 + collection: None, 442 + lxm: Some(vec!["io.atcr.getManifest".to_string()]), 443 + aud: None, 444 + }]; 445 + 446 + let expanded = build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr"); 447 + assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com")); 448 + } 449 + 450 + #[test] 451 + fn test_build_expanded_scopes_rpc_no_aud() { 452 + let permissions = vec![PermissionEntry { 453 + resource: "rpc".to_string(), 454 + action: None, 455 + collection: None, 456 + lxm: Some(vec!["io.atcr.getManifest".to_string()]), 457 + aud: None, 458 + }]; 459 + 460 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 461 + assert_eq!(expanded, "rpc:io.atcr.getManifest"); 462 + } 463 + 464 + #[test] 465 + fn test_build_expanded_scopes_mixed_permissions() { 466 + let permissions = vec![ 467 + PermissionEntry { 468 + resource: "repo".to_string(), 469 + action: Some(vec!["create".to_string()]), 470 + collection: Some(vec!["io.atcr.manifest".to_string()]), 471 + lxm: None, 472 + aud: None, 473 + }, 474 + PermissionEntry { 475 + resource: "rpc".to_string(), 476 + action: None, 477 + collection: None, 478 + lxm: Some(vec!["com.atproto.repo.getRecord".to_string()]), 479 + aud: Some("*".to_string()), 480 + }, 481 + ]; 482 + 483 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 484 + assert!(expanded.contains("repo:io.atcr.manifest?action=create")); 485 + assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*")); 486 + } 487 + 488 + #[test] 489 + fn test_build_expanded_scopes_unknown_resource_ignored() { 490 + let permissions = vec![PermissionEntry { 491 + resource: "unknown".to_string(), 492 + action: None, 493 + collection: Some(vec!["io.atcr.manifest".to_string()]), 494 + lxm: None, 495 + aud: None, 496 + }]; 497 + 498 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 499 + assert!(expanded.is_empty()); 500 + } 501 + 502 + #[test] 503 + fn test_build_expanded_scopes_empty_permissions() { 504 + let permissions: Vec<PermissionEntry> = vec![]; 505 + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); 506 + assert!(expanded.is_empty()); 507 + } 508 + 509 + #[tokio::test] 510 + async fn test_expand_include_scopes_passthrough_non_include() { 511 + let result = expand_include_scopes("atproto transition:generic").await; 512 + assert_eq!(result, "atproto transition:generic"); 513 + } 514 + 515 + #[tokio::test] 516 + async fn test_expand_include_scopes_mixed_with_regular() { 517 + let result = expand_include_scopes("atproto repo:app.bsky.feed.post?action=create").await; 518 + assert!(result.contains("atproto")); 519 + assert!(result.contains("repo:app.bsky.feed.post?action=create")); 520 + } 521 + 522 + #[tokio::test] 523 + async fn test_cache_population_and_retrieval() { 524 + let cache_key = "test.cached.scope"; 525 + let cached_value = "repo:test.cached.collection?action=create"; 526 + 527 + { 528 + let mut cache = LEXICON_CACHE.write().await; 529 + cache.insert( 530 + cache_key.to_string(), 531 + CachedLexicon { 532 + expanded_scope: cached_value.to_string(), 533 + cached_at: std::time::Instant::now(), 534 + }, 535 + ); 536 + } 537 + 538 + let result = expand_permission_set(cache_key, None).await; 539 + assert!(result.is_ok()); 540 + assert_eq!(result.unwrap(), cached_value); 541 + 542 + { 543 + let mut cache = LEXICON_CACHE.write().await; 544 + cache.remove(cache_key); 545 + } 546 + } 547 + 548 + #[tokio::test] 549 + async fn test_cache_with_aud_parameter() { 550 + let nsid = "test.aud.scope"; 551 + let aud = "did:web:example.com"; 552 + let cache_key = format!("{}?aud={}", nsid, aud); 553 + let cached_value = "rpc:test.aud.method?aud=did:web:example.com"; 554 + 555 + { 556 + let mut cache = LEXICON_CACHE.write().await; 557 + cache.insert( 558 + cache_key.clone(), 559 + CachedLexicon { 560 + expanded_scope: cached_value.to_string(), 561 + cached_at: std::time::Instant::now(), 562 + }, 563 + ); 564 + } 565 + 566 + let result = expand_permission_set(nsid, Some(aud)).await; 567 + assert!(result.is_ok()); 568 + assert_eq!(result.unwrap(), cached_value); 569 + 570 + { 571 + let mut cache = LEXICON_CACHE.write().await; 572 + cache.remove(&cache_key); 573 + } 574 + } 575 + 576 + #[tokio::test] 577 + async fn test_expired_cache_triggers_refresh() { 578 + let cache_key = "test.expired.scope"; 579 + 580 + { 581 + let mut cache = LEXICON_CACHE.write().await; 582 + cache.insert( 583 + cache_key.to_string(), 584 + CachedLexicon { 585 + expanded_scope: "old_value".to_string(), 586 + cached_at: std::time::Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1), 587 + }, 588 + ); 589 + } 590 + 591 + let result = expand_permission_set(cache_key, None).await; 592 + assert!(result.is_err()); 593 + 594 + { 595 + let mut cache = LEXICON_CACHE.write().await; 596 + cache.remove(cache_key); 597 + } 598 + } 599 + 159 600 #[test] 160 - fn test_nsid_to_url() { 601 + fn test_nsid_authority_extraction_for_dns() { 161 602 let nsid = "io.atcr.authFullApp"; 162 603 let parts: Vec<&str> = nsid.split('.').collect(); 163 - let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); 164 - let domain = domain_parts.join("."); 165 - let path = parts[2..].join("/"); 604 + let authority = parts[..2].iter().rev().cloned().collect::<Vec<_>>().join("."); 605 + assert_eq!(authority, "atcr.io"); 166 606 167 - assert_eq!(domain, "atcr.io"); 168 - assert_eq!(path, "authFullApp"); 607 + let nsid2 = "app.bsky.feed.post"; 608 + let parts2: Vec<&str> = nsid2.split('.').collect(); 609 + let authority2 = parts2[..2].iter().rev().cloned().collect::<Vec<_>>().join("."); 610 + assert_eq!(authority2, "bsky.app"); 169 611 } 170 612 }
+39 -3
crates/tranquil-scopes/src/permissions.rs
··· 126 126 return Ok(()); 127 127 } 128 128 129 - let has_permission = self.find_repo_scopes().any(|repo_scope| { 129 + let has_repo_permission = self.find_repo_scopes().any(|repo_scope| { 130 130 repo_scope.actions.contains(&action) 131 131 && match &repo_scope.collection { 132 132 None => true, ··· 140 140 } 141 141 }); 142 142 143 - if has_permission { 143 + if has_repo_permission { 144 144 Ok(()) 145 145 } else { 146 146 Err(ScopeError::InsufficientScope { ··· 181 181 return Ok(()); 182 182 } 183 183 184 + let aud_base = aud.split('#').next().unwrap_or(aud); 185 + 184 186 let has_permission = self.find_rpc_scopes().any(|rpc_scope| { 185 187 let lxm_matches = match &rpc_scope.lxm { 186 188 None => true, ··· 195 197 let aud_matches = match &rpc_scope.aud { 196 198 None => true, 197 199 Some(scope_aud) if scope_aud == "*" => true, 198 - Some(scope_aud) => scope_aud == aud, 200 + Some(scope_aud) => { 201 + let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud); 202 + scope_aud_base == aud_base 203 + } 199 204 }; 200 205 201 206 lxm_matches && aud_matches ··· 521 526 assert!(perms.allows_blob("image/png")); 522 527 assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 523 528 } 529 + 530 + #[test] 531 + fn test_rpc_scope_with_did_fragment() { 532 + let perms = ScopePermissions::from_scope_string(Some( 533 + "rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview", 534 + )); 535 + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); 536 + assert!(perms.allows_rpc( 537 + "did:web:api.bsky.app#bsky_appview", 538 + "app.bsky.feed.getAuthorFeed" 539 + )); 540 + assert!(perms.allows_rpc( 541 + "did:web:api.bsky.app#other_service", 542 + "app.bsky.feed.getAuthorFeed" 543 + )); 544 + assert!(!perms.allows_rpc("did:web:other.app", "app.bsky.feed.getAuthorFeed")); 545 + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); 546 + } 547 + 548 + #[test] 549 + fn test_rpc_scope_without_fragment_matches_with_fragment() { 550 + let perms = ScopePermissions::from_scope_string(Some( 551 + "rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app", 552 + )); 553 + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); 554 + assert!(perms.allows_rpc( 555 + "did:web:api.bsky.app#bsky_appview", 556 + "app.bsky.feed.getAuthorFeed" 557 + )); 558 + } 559 + 524 560 }
+24 -16
crates/tranquil-storage/src/lib.rs
··· 22 22 const CID_SHARD_PREFIX_LEN: usize = 9; 23 23 24 24 fn split_cid_path(key: &str) -> Option<(&str, &str)> { 25 - let is_cid = key.get(..3).map_or(false, |p| p.eq_ignore_ascii_case("baf")); 26 - (key.len() > CID_SHARD_PREFIX_LEN && is_cid) 27 - .then(|| key.split_at(CID_SHARD_PREFIX_LEN)) 25 + let is_cid = key.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("baf")); 26 + (key.len() > CID_SHARD_PREFIX_LEN && is_cid).then(|| key.split_at(CID_SHARD_PREFIX_LEN)) 28 27 } 29 28 30 29 fn validate_key(key: &str) -> Result<(), StorageError> { ··· 771 770 let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 772 771 assert_eq!( 773 772 split_cid_path(cid), 774 - Some(("bafkreihd", "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")) 773 + Some(( 774 + "bafkreihd", 775 + "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 776 + )) 775 777 ); 776 778 } 777 779 ··· 780 782 let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"; 781 783 assert_eq!( 782 784 split_cid_path(cid), 783 - Some(("bafyreigd", "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje")) 785 + Some(( 786 + "bafyreigd", 787 + "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje" 788 + )) 784 789 ); 785 790 } 786 791 ··· 810 815 let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"; 811 816 assert_eq!( 812 817 split_cid_path(upper), 813 - Some(("BAFKREIHD", "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU")) 818 + Some(( 819 + "BAFKREIHD", 820 + "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU" 821 + )) 814 822 ); 815 823 assert_eq!( 816 824 split_cid_path(mixed), 817 - Some(("BaFkReIhD", "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu")) 825 + Some(( 826 + "BaFkReIhD", 827 + "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu" 828 + )) 818 829 ); 819 830 } 820 831 ··· 829 840 let base = PathBuf::from("/blobs"); 830 841 let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 831 842 832 - let expected = PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"); 833 - let result = split_cid_path(cid).map_or_else( 834 - || base.join(cid), 835 - |(dir, file)| base.join(dir).join(file), 836 - ); 843 + let expected = 844 + PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"); 845 + let result = split_cid_path(cid) 846 + .map_or_else(|| base.join(cid), |(dir, file)| base.join(dir).join(file)); 837 847 assert_eq!(result, expected); 838 848 } 839 849 ··· 843 853 let key = "temp/abc123"; 844 854 845 855 let expected = PathBuf::from("/blobs/temp/abc123"); 846 - let result = split_cid_path(key).map_or_else( 847 - || base.join(key), 848 - |(dir, file)| base.join(dir).join(file), 849 - ); 856 + let result = split_cid_path(key) 857 + .map_or_else(|| base.join(key), |(dir, file)| base.join(dir).join(file)); 850 858 assert_eq!(result, expected); 851 859 } 852 860 }
+100 -35
frontend/src/lib/api.ts
··· 16 16 unsafeAsISODate, 17 17 unsafeAsRefreshToken, 18 18 } from "./types/branded.ts"; 19 + import { 20 + createDPoPProofForRequest, 21 + getDPoPNonce, 22 + setDPoPNonce, 23 + } from "./oauth.ts"; 19 24 import type { 20 25 AccountInfo, 21 26 ApiErrorCode, ··· 91 96 } 92 97 } 93 98 94 - let tokenRefreshCallback: (() => Promise<string | null>) | null = null; 99 + let tokenRefreshCallback: (() => Promise<AccessToken | null>) | null = null; 95 100 96 101 export function setTokenRefreshCallback( 97 - callback: () => Promise<string | null>, 102 + callback: () => Promise<AccessToken | null>, 98 103 ) { 99 104 tokenRefreshCallback = callback; 100 105 } 101 106 107 + interface AuthenticatedFetchOptions { 108 + method?: "GET" | "POST"; 109 + token: AccessToken | RefreshToken; 110 + headers?: Record<string, string>; 111 + body?: BodyInit; 112 + } 113 + 114 + async function authenticatedFetch( 115 + url: string, 116 + options: AuthenticatedFetchOptions, 117 + ): Promise<Response> { 118 + const { method = "GET", token, headers = {}, body } = options; 119 + const fullUrl = url.startsWith("http") 120 + ? url 121 + : `${globalThis.location.origin}${url}`; 122 + const dpopProof = await createDPoPProofForRequest(method, fullUrl, token); 123 + const res = await fetch(url, { 124 + method, 125 + headers: { 126 + ...headers, 127 + Authorization: `DPoP ${token}`, 128 + DPoP: dpopProof, 129 + }, 130 + body, 131 + }); 132 + const dpopNonce = res.headers.get("DPoP-Nonce"); 133 + if (dpopNonce) { 134 + setDPoPNonce(dpopNonce); 135 + } 136 + return res; 137 + } 138 + 102 139 interface XrpcOptions { 103 140 method?: "GET" | "POST"; 104 141 params?: Record<string, string>; 105 142 body?: unknown; 106 - token?: string; 143 + token?: AccessToken | RefreshToken; 107 144 skipRetry?: boolean; 145 + skipDpopRetry?: boolean; 108 146 } 109 147 110 148 async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> { 111 - const { method: httpMethod = "GET", params, body, token, skipRetry } = 112 - options ?? {}; 149 + const { 150 + method: httpMethod = "GET", 151 + params, 152 + body, 153 + token, 154 + skipRetry, 155 + skipDpopRetry, 156 + } = options ?? {}; 113 157 let url = `${API_BASE}/${method}`; 114 158 if (params) { 115 159 const searchParams = new URLSearchParams(params); 116 160 url += `?${searchParams}`; 117 161 } 118 162 const headers: Record<string, string> = {}; 119 - if (token) { 120 - headers["Authorization"] = `Bearer ${token}`; 121 - } 122 163 if (body) { 123 164 headers["Content-Type"] = "application/json"; 124 165 } 125 - const res = await fetch(url, { 126 - method: httpMethod, 127 - headers, 128 - body: body ? JSON.stringify(body) : undefined, 129 - }); 166 + const res = token 167 + ? await authenticatedFetch(url, { 168 + method: httpMethod, 169 + token, 170 + headers, 171 + body: body ? JSON.stringify(body) : undefined, 172 + }) 173 + : await fetch(url, { 174 + method: httpMethod, 175 + headers, 176 + body: body ? JSON.stringify(body) : undefined, 177 + }); 130 178 if (!res.ok) { 131 179 const errData = await res.json().catch(() => ({ 132 180 error: "Unknown", 133 181 message: res.statusText, 134 182 })); 183 + if ( 184 + res.status === 401 && 185 + errData.error === "use_dpop_nonce" && 186 + token && 187 + !skipDpopRetry && 188 + getDPoPNonce() 189 + ) { 190 + return xrpc(method, { ...options, skipDpopRetry: true }); 191 + } 135 192 if ( 136 193 res.status === 401 && 137 194 (errData.error === "AuthenticationFailed" || 138 - errData.error === "ExpiredToken") && 139 - token && tokenRefreshCallback && !skipRetry 195 + errData.error === "ExpiredToken" || 196 + errData.error === "OAuthExpiredToken") && 197 + token && 198 + tokenRefreshCallback && 199 + !skipRetry 140 200 ) { 141 201 const newToken = await tokenRefreshCallback(); 142 202 if (newToken && newToken !== token) { ··· 536 596 token: AccessToken, 537 597 file: File, 538 598 ): Promise<UploadBlobResponse> { 539 - const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", { 599 + const res = await authenticatedFetch("/xrpc/com.atproto.repo.uploadBlob", { 540 600 method: "POST", 541 - headers: { 542 - "Authorization": `Bearer ${token}`, 543 - "Content-Type": file.type, 544 - }, 601 + token, 602 + headers: { "Content-Type": file.type }, 545 603 body: file, 546 604 }); 547 605 if (!res.ok) { ··· 1084 1142 }, 1085 1143 1086 1144 async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> { 1087 - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ 1088 - encodeURIComponent(did) 1089 - }`; 1090 - const res = await fetch(url, { 1091 - headers: { Authorization: `Bearer ${token}` }, 1092 - }); 1145 + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; 1146 + const res = await authenticatedFetch(url, { token }); 1093 1147 if (!res.ok) { 1094 1148 const errData = await res.json().catch(() => ({ 1095 1149 error: "Unknown", ··· 1106 1160 1107 1161 async getBackup(token: AccessToken, id: string): Promise<Blob> { 1108 1162 const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; 1109 - const res = await fetch(url, { 1110 - headers: { Authorization: `Bearer ${token}` }, 1111 - }); 1163 + const res = await authenticatedFetch(url, { token }); 1112 1164 if (!res.ok) { 1113 1165 const errData = await res.json().catch(() => ({ 1114 1166 error: "Unknown", ··· 1146 1198 }, 1147 1199 1148 1200 async importRepo(token: AccessToken, car: Uint8Array): Promise<void> { 1149 - const url = `${API_BASE}/com.atproto.repo.importRepo`; 1150 - const res = await fetch(url, { 1201 + const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, { 1151 1202 method: "POST", 1152 - headers: { 1153 - Authorization: `Bearer ${token}`, 1154 - "Content-Type": "application/vnd.ipld.car", 1155 - }, 1203 + token, 1204 + headers: { "Content-Type": "application/vnd.ipld.car" }, 1156 1205 body: car as unknown as BodyInit, 1157 1206 }); 1158 1207 if (!res.ok) { ··· 1163 1212 throw new ApiError(res.status, errData.error, errData.message); 1164 1213 } 1165 1214 }, 1215 + 1216 + async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> { 1217 + const res = await authenticatedFetch("/oauth/establish-session", { 1218 + method: "POST", 1219 + token, 1220 + headers: { "Content-Type": "application/json" }, 1221 + }); 1222 + if (!res.ok) { 1223 + const errData = await res.json().catch(() => ({ 1224 + error: "Unknown", 1225 + message: res.statusText, 1226 + })); 1227 + throw new ApiError(res.status, errData.error, errData.message); 1228 + } 1229 + return res.json(); 1230 + }, 1166 1231 }; 1167 1232 1168 1233 export const typedApi = {
+1 -1
frontend/src/lib/auth.svelte.ts
··· 281 281 } 282 282 } 283 283 284 - async function tryRefreshToken(): Promise<string | null> { 284 + async function tryRefreshToken(): Promise<AccessToken | null> { 285 285 if (state.current.kind !== "authenticated") return null; 286 286 const currentSession = state.current.session; 287 287 try {
+18 -1
frontend/src/lib/migration/atproto-client.ts
··· 240 240 }&cid=${encodeURIComponent(cid)}`; 241 241 const headers: Record<string, string> = {}; 242 242 if (this.accessToken) { 243 - headers["Authorization"] = `Bearer ${this.accessToken}`; 243 + if (this.dpopKeyPair) { 244 + headers["Authorization"] = `DPoP ${this.accessToken}`; 245 + const tokenHash = await computeAccessTokenHash(this.accessToken); 246 + const dpopProof = await createDPoPProof( 247 + this.dpopKeyPair, 248 + "GET", 249 + url.split("?")[0], 250 + this.dpopNonce ?? undefined, 251 + tokenHash, 252 + ); 253 + headers["DPoP"] = dpopProof; 254 + } else { 255 + headers["Authorization"] = `Bearer ${this.accessToken}`; 256 + } 244 257 } 245 258 const res = await fetch(url, { headers }); 259 + const newNonce = res.headers.get("DPoP-Nonce"); 260 + if (newNonce) { 261 + this.dpopNonce = newNonce; 262 + } 246 263 if (!res.ok) { 247 264 const err = await res.json().catch(() => ({ 248 265 error: "Unknown",
+3 -1
frontend/src/lib/migration/flow.svelte.ts
··· 88 88 89 89 function setStep(step: InboundStep) { 90 90 state.step = step; 91 - state.error = null; 91 + if (step !== "error") { 92 + state.error = null; 93 + } 92 94 if (step !== "success") { 93 95 saveMigrationState(state); 94 96 updateStep(step);
+3 -1
frontend/src/lib/migration/offline-flow.svelte.ts
··· 177 177 178 178 function setStep(step: OfflineInboundStep) { 179 179 state.step = step; 180 - state.error = null; 180 + if (step !== "error") { 181 + state.error = null; 182 + } 181 183 if (step !== "success") { 182 184 saveOfflineState(state); 183 185 }
+3 -3
frontend/src/lib/oauth.ts
··· 246 246 return base64UrlEncode(hash); 247 247 } 248 248 249 - function getDPoPNonce(): string | null { 249 + export function getDPoPNonce(): string | null { 250 250 return sessionStorage.getItem(DPOP_NONCE_KEY); 251 251 } 252 252 253 - function setDPoPNonce(nonce: string): void { 253 + export function setDPoPNonce(nonce: string): void { 254 254 sessionStorage.setItem(DPOP_NONCE_KEY, nonce); 255 255 } 256 256 257 - function extractDPoPNonceFromResponse(response: Response): void { 257 + export function extractDPoPNonceFromResponse(response: Response): void { 258 258 const nonce = response.headers.get("DPoP-Nonce"); 259 259 if (nonce) { 260 260 setDPoPNonce(nonce);
+5
frontend/src/locales/en.json
··· 779 779 "name": "Manage Account", 780 780 "description": "Manage account settings and preferences" 781 781 } 782 + }, 783 + "unexpectedState": { 784 + "title": "Unexpected State", 785 + "description": "The consent page is in an unexpected state. Please check the browser console for errors.", 786 + "reload": "Reload Page" 782 787 } 783 788 }, 784 789 "accounts": {
+5
frontend/src/locales/fi.json
··· 785 785 "name": "Hallitse tiliรค", 786 786 "description": "Hallitse tilin asetuksia ja asetuksia" 787 787 } 788 + }, 789 + "unexpectedState": { 790 + "title": "Odottamaton tila", 791 + "description": "Suostumussivulla on odottamaton tila. Tarkista selaimen konsoli virheiden varalta.", 792 + "reload": "Lataa sivu uudelleen" 788 793 } 789 794 }, 790 795 "accounts": {
+5
frontend/src/locales/ja.json
··· 778 778 "name": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ็ฎก็†", 779 779 "description": "ใ‚ขใ‚ซใ‚ฆใƒณใƒˆ่จญๅฎšใจ่จญๅฎšใ‚’็ฎก็†" 780 780 } 781 + }, 782 + "unexpectedState": { 783 + "title": "ไบˆๆœŸใ—ใชใ„็Šถๆ…‹", 784 + "description": "ๅŒๆ„ใƒšใƒผใ‚ธใŒไบˆๆœŸใ—ใชใ„็Šถๆ…‹ใงใ™ใ€‚ใƒ–ใƒฉใ‚ฆใ‚ถใฎใ‚ณใƒณใ‚ฝใƒผใƒซใงใ‚จใƒฉใƒผใ‚’็ขบ่ชใ—ใฆใใ ใ•ใ„ใ€‚", 785 + "reload": "ใƒšใƒผใ‚ธใ‚’ๅ†่ชญใฟ่พผใฟ" 781 786 } 782 787 }, 783 788 "accounts": {
+5
frontend/src/locales/ko.json
··· 778 778 "name": "๊ณ„์ • ๊ด€๋ฆฌ", 779 779 "description": "๊ณ„์ • ์„ค์ • ๋ฐ ํ™˜๊ฒฝ์„ค์ • ๊ด€๋ฆฌ" 780 780 } 781 + }, 782 + "unexpectedState": { 783 + "title": "์˜ˆ๊ธฐ์น˜ ์•Š์€ ์ƒํƒœ", 784 + "description": "๋™์˜ ํŽ˜์ด์ง€๊ฐ€ ์˜ˆ๊ธฐ์น˜ ์•Š์€ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. ๋ธŒ๋ผ์šฐ์ € ์ฝ˜์†”์—์„œ ์˜ค๋ฅ˜๋ฅผ ํ™•์ธํ•˜์„ธ์š”.", 785 + "reload": "ํŽ˜์ด์ง€ ์ƒˆ๋กœ๊ณ ์นจ" 781 786 } 782 787 }, 783 788 "accounts": {
+5
frontend/src/locales/sv.json
··· 778 778 "name": "Hantera konto", 779 779 "description": "Hantera kontoinstรคllningar och preferenser" 780 780 } 781 + }, 782 + "unexpectedState": { 783 + "title": "Ovรคntat tillstรฅnd", 784 + "description": "Samtyckes-sidan รคr i ett ovรคntat tillstรฅnd. Kontrollera webblรคsarens konsol fรถr fel.", 785 + "reload": "Ladda om sidan" 781 786 } 782 787 }, 783 788 "accounts": {
+5
frontend/src/locales/zh.json
··· 778 778 "name": "็ฎก็†่ดฆๆˆท", 779 779 "description": "็ฎก็†่ดฆๆˆท่ฎพ็ฝฎๅ’Œๅๅฅฝ" 780 780 } 781 + }, 782 + "unexpectedState": { 783 + "title": "ๆ„ๅค–็Šถๆ€", 784 + "description": "ๅŒๆ„้กต้ขๅค„ไบŽๆ„ๅค–็Šถๆ€ใ€‚่ฏทๆฃ€ๆŸฅๆต่งˆๅ™จๆŽงๅˆถๅฐไปฅๆŸฅ็œ‹้”™่ฏฏใ€‚", 785 + "reload": "้‡ๆ–ฐๅŠ ่ฝฝ้กต้ข" 781 786 } 782 787 }, 783 788 "accounts": {
+37 -16
frontend/src/routes/Migration.svelte
··· 2 2 import { setSession } from '../lib/auth.svelte' 3 3 import { navigate, routes } from '../lib/router.svelte' 4 4 import { _ } from '../lib/i18n' 5 + import { api } from '../lib/api' 6 + import { startOAuthLogin } from '../lib/oauth' 7 + import { unsafeAsAccessToken } from '../lib/types/branded' 5 8 import { 6 9 createInboundMigrationFlow, 7 10 createOfflineInboundMigrationFlow, ··· 143 146 direction = 'select' 144 147 } 145 148 146 - function handleInboundComplete() { 149 + async function handleInboundComplete() { 147 150 const session = inboundFlow?.getLocalSession() 148 151 if (session) { 149 - setSession({ 150 - did: session.did, 151 - handle: session.handle, 152 - accessJwt: session.accessJwt, 153 - refreshJwt: '', 154 - }) 152 + try { 153 + await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt)) 154 + clearMigrationState() 155 + await startOAuthLogin(session.handle) 156 + } catch (e) { 157 + console.error('Failed to establish OAuth session, falling back to direct login:', e) 158 + setSession({ 159 + did: session.did, 160 + handle: session.handle, 161 + accessJwt: session.accessJwt, 162 + refreshJwt: '', 163 + }) 164 + navigate(routes.dashboard) 165 + } 166 + } else { 167 + navigate(routes.dashboard) 155 168 } 156 - navigate(routes.dashboard) 157 169 } 158 170 159 - function handleOfflineComplete() { 171 + async function handleOfflineComplete() { 160 172 const session = offlineFlow?.getLocalSession() 161 173 if (session) { 162 - setSession({ 163 - did: session.did, 164 - handle: session.handle, 165 - accessJwt: session.accessJwt, 166 - refreshJwt: '', 167 - }) 174 + try { 175 + await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt)) 176 + clearOfflineState() 177 + await startOAuthLogin(session.handle) 178 + } catch (e) { 179 + console.error('Failed to establish OAuth session, falling back to direct login:', e) 180 + setSession({ 181 + did: session.did, 182 + handle: session.handle, 183 + accessJwt: session.accessJwt, 184 + refreshJwt: '', 185 + }) 186 + navigate(routes.dashboard) 187 + } 188 + } else { 189 + navigate(routes.dashboard) 168 190 } 169 - navigate(routes.dashboard) 170 191 } 171 192 </script> 172 193
+5 -31
frontend/src/routes/OAuthAccounts.svelte
··· 196 196 display: flex; 197 197 align-items: center; 198 198 padding: var(--space-4); 199 - background: var(--bg-card); 199 + background: var(--bg-secondary); 200 200 border: 1px solid var(--border-color); 201 201 border-radius: var(--radius-xl); 202 202 cursor: pointer; 203 203 text-align: left; 204 204 width: 100%; 205 - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); 205 + transition: border-color var(--transition-fast), background var(--transition-fast); 206 206 } 207 207 208 208 .account-item:hover:not(.disabled) { 209 209 border-color: var(--accent); 210 - box-shadow: var(--shadow-sm); 210 + background: var(--bg-tertiary); 211 211 } 212 212 213 213 .account-item.disabled { ··· 231 231 color: var(--text-secondary); 232 232 } 233 233 234 - button { 235 - padding: var(--space-3); 236 - background: var(--accent); 237 - color: var(--text-inverse); 238 - border: none; 239 - border-radius: var(--radius-md); 240 - font-size: var(--text-base); 241 - cursor: pointer; 242 - } 243 - 244 - button:hover:not(:disabled) { 245 - background: var(--accent-hover); 246 - } 247 - 248 - button:disabled { 249 - opacity: 0.6; 250 - cursor: not-allowed; 251 - } 252 - 253 - button.secondary { 254 - background: transparent; 255 - color: var(--accent); 256 - border: 1px solid var(--accent); 234 + .different-account { 235 + margin-top: var(--space-4); 257 236 width: 100%; 258 237 } 259 238 260 - button.secondary:hover:not(:disabled) { 261 - background: var(--accent); 262 - color: var(--text-inverse); 263 - } 264 - 265 239 .different-account { 266 240 margin-top: var(--space-4); 267 241 }
+38 -3
frontend/src/routes/OAuthConsent.svelte
··· 65 65 async function fetchConsentData() { 66 66 const requestUri = getRequestUri() 67 67 if (!requestUri) { 68 + console.error('[OAuthConsent] No request_uri in URL') 68 69 error = $_('oauth.error.genericError') 69 70 loading = false 70 71 return ··· 74 75 const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) 75 76 if (!response.ok) { 76 77 const data = await response.json() 78 + console.error('[OAuthConsent] Consent fetch failed:', data) 77 79 error = data.error_description || data.error || $_('oauth.error.genericError') 78 80 loading = false 79 81 return 80 82 } 81 83 const data: ConsentData = await response.json() 84 + 85 + if (!data.scopes || !Array.isArray(data.scopes)) { 86 + console.error('[OAuthConsent] Invalid scopes data:', data.scopes) 87 + error = 'Invalid consent data received' 88 + loading = false 89 + return 90 + } 91 + 82 92 consentData = data 83 93 84 94 scopeSelections = Object.fromEntries( ··· 91 101 if (!data.show_consent) { 92 102 await submitConsent() 93 103 } 94 - } catch { 104 + } catch (e) { 105 + console.error('[OAuthConsent] Error during consent fetch:', e) 95 106 error = $_('oauth.error.genericError') 96 107 } finally { 97 108 loading = false ··· 104 115 } 105 116 106 117 async function submitConsent() { 107 - if (!consentData) return 118 + if (!consentData) { 119 + console.error('[OAuthConsent] submitConsent called but no consentData') 120 + return 121 + } 108 122 109 123 submitting = true 110 124 let approvedScopes = Object.entries(scopeSelections) ··· 128 142 129 143 if (!response.ok) { 130 144 const data = await response.json() 145 + console.error('[OAuthConsent] Submit failed:', data) 131 146 error = data.error_description || data.error || $_('oauth.error.genericError') 132 147 submitting = false 133 148 return ··· 136 151 const data = await response.json() 137 152 if (data.redirect_uri) { 138 153 window.location.href = data.redirect_uri 154 + } else { 155 + console.error('[OAuthConsent] No redirect_uri in response') 156 + error = 'Authorization failed - no redirect received' 157 + submitting = false 139 158 } 140 - } catch { 159 + } catch (e) { 160 + console.error('[OAuthConsent] Submit error:', e) 141 161 error = $_('oauth.error.genericError') 142 162 submitting = false 143 163 } ··· 249 269 <div class="spinner"></div> 250 270 <p>{$_('common.loading')}</p> 251 271 </div> 272 + {:else} 273 + <p style="color: var(--text-muted); font-size: 0.875rem;">Loading consent data...</p> 252 274 {/if} 253 275 </div> 254 276 {:else if error} ··· 372 394 {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')} 373 395 </button> 374 396 </div> 397 + {:else} 398 + <div class="error-container"> 399 + <h1>{$_('oauth.consent.unexpectedState.title')}</h1> 400 + <p style="color: var(--text-secondary);"> 401 + {$_('oauth.consent.unexpectedState.description')} 402 + </p> 403 + <p style="color: var(--text-muted); font-size: 0.75rem; font-family: monospace;"> 404 + loading={loading}, error={error ? 'set' : 'null'}, consentData={consentData ? 'set' : 'null'}, submitting={submitting} 405 + </p> 406 + <button type="button" onclick={() => window.location.reload()}> 407 + {$_('oauth.consent.unexpectedState.reload')} 408 + </button> 409 + </div> 375 410 {/if} 376 411 </div> 377 412

History

6 rounds 1 comment
sign up or login to add to the discussion
3 commits
expand
fix: oauth consolidation, include-scope improvements
fix: consolidate auth extractors & standardize usage
fix: match ref pds permission-levels for some endpoints
expand 0 comments
pull request successfully merged
3 commits
expand
fix: oauth consolidation, include-scope improvements
fix: consolidate auth extractors & standardize usage
fix: match ref pds permission-levels for some endpoints
expand 0 comments
2 commits
expand
fix: oauth consolidation, include-scope improvements
fix: consolidate auth extractors & standardize usage
expand 0 comments
2 commits
expand
fix: oauth consolidation, include-scope improvements
fix: consolidate auth extractors & standardize usage
expand 1 comment

so three things:

crates/tranquil-pds/src/auth/auth_extractor.rs does not seem to be used at all anywhere?

crates/tranquil-pds/src/api/temp.rs feels like it just ... shouldnt excist based on the name

and i still dont really like these extractors. the separation of inter service auth is weird to me. inter-service auth is a form of user auth. it shouldnt be separated out from the other types. the AuthExtractor should just be AuthExtractor(pub AuthenticatedUser).

i also discovered https://docs.rs/axum/0.8.8/axum/extract/trait.OptionalFromRequestParts.html which we should be able to reduce optional vs not optional with just an AuthExtractor vs Option.

principly id want whether or not its required that the account is active or not and whether its an admin account or not to both also be type safe configurations on the extractor. probably with generics of some sort. but i cant think of a specific design i like right now so. if you come up with one feel free to do it. otherwise we can do it later

1 commit
expand
fix: oauth consolidation, include-scope improvements
expand 0 comments
lewis.moe submitted #0
1 commit
expand
fix: oauth consolidation, include-scope improvements
expand 0 comments