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

fix: oauth consolidation, include-scope improvements

+2646 -1000
-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) 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; 75 63 } 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(); 128 - } 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 };
+12 -123
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; 640 529 641 - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &did).await { 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( 643 532 &*state.user_repo, 644 533 &*state.session_repo, 645 - &did, 534 + did, 646 535 ) 647 536 .await; 648 537 } 649 538 650 - let user_id = match state.user_repo.get_id_by_did(&did).await { 539 + let user_id = match state.user_repo.get_id_by_did(did).await { 651 540 Ok(Some(id)) => id, 652 541 _ => { 653 542 return ApiError::InternalError(None).into_response(); ··· 657 546 let expires_at = Utc::now() + Duration::minutes(15); 658 547 if let Err(e) = state 659 548 .infra_repo 660 - .create_deletion_request(&confirmation_token, &did, expires_at) 549 + .create_deletion_request(&confirmation_token, did, expires_at) 661 550 .await 662 551 { 663 552 error!("DB error creating deletion token: {:?}", e);
+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 + }
+437 -143
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 user_info = state 164 + .user_repo 165 + .get_user_info_by_did(&result.did) 166 + .await 167 + .ok() 168 + .flatten() 169 + .ok_or(AuthError::AuthenticationFailed)?; 170 + let status = AccountStatus::from_db_fields( 171 + user_info.takedown_ref.as_deref(), 172 + user_info.deactivated_at, 173 + ); 174 + if !flags.allow_deactivated && status.is_deactivated() { 175 + return Err(AuthError::AccountDeactivated); 176 + } 177 + if !flags.allow_takendown && status.is_takendown() { 178 + return Err(AuthError::AccountTakedown); 179 + } 180 + Ok(AuthenticatedUser { 181 + did: result.did, 182 + key_bytes: user_info.key_bytes.and_then(|kb| { 183 + crate::config::decrypt_key(&kb, user_info.encryption_version).ok() 184 + }), 185 + is_oauth: true, 186 + is_admin: user_info.is_admin, 187 + status, 188 + scope: result.scope, 189 + controller_did: None, 190 + }) 191 + } 192 + Err(crate::oauth::OAuthError::ExpiredToken(msg)) => Err(AuthError::OAuthExpiredToken(msg)), 193 + Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => Err(AuthError::UseDpopNonce(nonce)), 194 + Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => { 195 + Err(AuthError::InvalidDpopProof(msg)) 196 + } 197 + Err(_) => Err(AuthError::AuthenticationFailed), 198 + } 199 + } 200 + 110 201 impl FromRequestParts<AppState> for BearerAuth { 111 202 type Rejection = AuthError; 112 203 ··· 124 215 let extracted = 125 216 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 126 217 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()); 218 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 219 + let method = parts.method.as_str(); 220 + let uri = build_full_url(&parts.uri.to_string()); 131 221 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 + match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await { 223 + Ok(user) if !user.is_oauth => { 224 + return if user.status.is_deactivated() { 225 + Err(AuthError::AccountDeactivated) 226 + } else if user.status.is_takendown() { 227 + Err(AuthError::AccountTakedown) 228 + } else { 229 + Ok(BearerAuth(user)) 230 + }; 150 231 } 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), 232 + Ok(_) => {} 233 + Err(super::TokenValidationError::AccountDeactivated) => { 234 + return Err(AuthError::AccountDeactivated); 235 + } 236 + Err(super::TokenValidationError::AccountTakedown) => { 237 + return Err(AuthError::AccountTakedown); 238 + } 239 + Err(super::TokenValidationError::TokenExpired) => { 240 + info!("JWT access token expired in BearerAuth, returning ExpiredToken"); 241 + return Err(AuthError::TokenExpired); 164 242 } 243 + Err(_) => {} 165 244 } 245 + 246 + verify_oauth_token_and_build_user( 247 + state, 248 + &extracted.token, 249 + dpop_proof, 250 + method, 251 + &uri, 252 + StatusCheckFlags::default(), 253 + ) 254 + .await 255 + .map(BearerAuth) 166 256 } 167 257 } 168 258 ··· 185 275 let extracted = 186 276 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 187 277 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()); 278 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 279 + let method = parts.method.as_str(); 280 + let uri = build_full_url(&parts.uri.to_string()); 192 281 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 - ) 282 + match validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token) 204 283 .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), 284 + { 285 + Ok(user) if !user.is_oauth => { 286 + return if user.status.is_takendown() { 287 + Err(AuthError::AccountTakedown) 288 + } else { 289 + Ok(BearerAuthAllowDeactivated(user)) 290 + }; 210 291 } 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), 292 + Ok(_) => {} 293 + Err(super::TokenValidationError::AccountTakedown) => { 294 + return Err(AuthError::AccountTakedown); 295 + } 296 + Err(super::TokenValidationError::TokenExpired) => { 297 + return Err(AuthError::TokenExpired); 223 298 } 299 + Err(_) => {} 224 300 } 301 + 302 + verify_oauth_token_and_build_user( 303 + state, 304 + &extracted.token, 305 + dpop_proof, 306 + method, 307 + &uri, 308 + StatusCheckFlags { 309 + allow_deactivated: true, 310 + allow_takendown: false, 311 + }, 312 + ) 313 + .await 314 + .map(BearerAuthAllowDeactivated) 225 315 } 226 316 } 227 317 ··· 244 334 let extracted = 245 335 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 246 336 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()); 337 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 338 + let method = parts.method.as_str(); 339 + let uri = build_full_url(&parts.uri.to_string()); 251 340 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 - ) 341 + match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token) 263 342 .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), 343 + { 344 + Ok(user) if !user.is_oauth => { 345 + return if user.status.is_deactivated() { 346 + Err(AuthError::AccountDeactivated) 347 + } else { 348 + Ok(BearerAuthAllowTakendown(user)) 349 + }; 269 350 } 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), 351 + Ok(_) => {} 352 + Err(super::TokenValidationError::AccountDeactivated) => { 353 + return Err(AuthError::AccountDeactivated); 354 + } 355 + Err(super::TokenValidationError::TokenExpired) => { 356 + return Err(AuthError::TokenExpired); 278 357 } 358 + Err(_) => {} 279 359 } 360 + 361 + verify_oauth_token_and_build_user( 362 + state, 363 + &extracted.token, 364 + dpop_proof, 365 + method, 366 + &uri, 367 + StatusCheckFlags { 368 + allow_deactivated: false, 369 + allow_takendown: true, 370 + }, 371 + ) 372 + .await 373 + .map(BearerAuthAllowTakendown) 280 374 } 281 375 } 282 376 ··· 299 393 let extracted = 300 394 extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 301 395 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()); 396 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 397 + let method = parts.method.as_str(); 398 + let uri = build_full_url(&parts.uri.to_string()); 306 399 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) => { 400 + match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await { 401 + Ok(user) if !user.is_oauth => { 402 + if user.status.is_deactivated() { 322 403 return Err(AuthError::AccountDeactivated); 323 404 } 324 - Err(TokenValidationError::AccountTakedown) => { 405 + if user.status.is_takendown() { 325 406 return Err(AuthError::AccountTakedown); 326 407 } 327 - Err(TokenValidationError::TokenExpired) => { 328 - return Err(AuthError::TokenExpired); 408 + if !user.is_admin { 409 + return Err(AuthError::AdminRequired); 329 410 } 330 - Err(_) => return Err(AuthError::AuthenticationFailed), 411 + return Ok(BearerAuthAdmin(user)); 412 + } 413 + Ok(_) => {} 414 + Err(super::TokenValidationError::AccountDeactivated) => { 415 + return Err(AuthError::AccountDeactivated); 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 + Err(super::TokenValidationError::AccountTakedown) => { 418 + return Err(AuthError::AccountTakedown); 351 419 } 352 - }; 420 + Err(super::TokenValidationError::TokenExpired) => { 421 + return Err(AuthError::TokenExpired); 422 + } 423 + Err(_) => {} 424 + } 425 + 426 + let user = verify_oauth_token_and_build_user( 427 + state, 428 + &extracted.token, 429 + dpop_proof, 430 + method, 431 + &uri, 432 + StatusCheckFlags::default(), 433 + ) 434 + .await?; 353 435 354 436 if !user.is_admin { 355 437 return Err(AuthError::AdminRequired); 356 438 } 357 439 Ok(BearerAuthAdmin(user)) 440 + } 441 + } 442 + 443 + pub struct OptionalBearerAuth(pub Option<AuthenticatedUser>); 444 + 445 + impl FromRequestParts<AppState> for OptionalBearerAuth { 446 + type Rejection = AuthError; 447 + 448 + async fn from_request_parts( 449 + parts: &mut Parts, 450 + state: &AppState, 451 + ) -> Result<Self, Self::Rejection> { 452 + let auth_header = match parts.headers.get(AUTHORIZATION) { 453 + Some(h) => match h.to_str() { 454 + Ok(s) => s, 455 + Err(_) => return Ok(OptionalBearerAuth(None)), 456 + }, 457 + None => return Ok(OptionalBearerAuth(None)), 458 + }; 459 + 460 + let extracted = match extract_auth_token_from_header(Some(auth_header)) { 461 + Some(e) => e, 462 + None => return Ok(OptionalBearerAuth(None)), 463 + }; 464 + 465 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 466 + let method = parts.method.as_str(); 467 + let uri = build_full_url(&parts.uri.to_string()); 468 + 469 + if let Ok(user) = validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await 470 + && !user.is_oauth 471 + { 472 + return if user.status.is_deactivated() || user.status.is_takendown() { 473 + Ok(OptionalBearerAuth(None)) 474 + } else { 475 + Ok(OptionalBearerAuth(Some(user))) 476 + }; 477 + } 478 + 479 + Ok(OptionalBearerAuth( 480 + verify_oauth_token_and_build_user( 481 + state, 482 + &extracted.token, 483 + dpop_proof, 484 + method, 485 + &uri, 486 + StatusCheckFlags::default(), 487 + ) 488 + .await 489 + .ok(), 490 + )) 491 + } 492 + } 493 + 494 + pub struct ServiceAuth { 495 + pub claims: ServiceTokenClaims, 496 + pub did: Did, 497 + } 498 + 499 + impl FromRequestParts<AppState> for ServiceAuth { 500 + type Rejection = AuthError; 501 + 502 + async fn from_request_parts( 503 + parts: &mut Parts, 504 + _state: &AppState, 505 + ) -> Result<Self, Self::Rejection> { 506 + let auth_header = parts 507 + .headers 508 + .get(AUTHORIZATION) 509 + .ok_or(AuthError::MissingToken)? 510 + .to_str() 511 + .map_err(|_| AuthError::InvalidFormat)?; 512 + 513 + let extracted = 514 + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 515 + 516 + if !is_service_token(&extracted.token) { 517 + return Err(AuthError::InvalidFormat); 518 + } 519 + 520 + let verifier = ServiceTokenVerifier::new(); 521 + let claims = verifier 522 + .verify_service_token(&extracted.token, None) 523 + .await 524 + .map_err(|e| { 525 + error!("Service token verification failed: {:?}", e); 526 + AuthError::AuthenticationFailed 527 + })?; 528 + 529 + let did: Did = claims 530 + .iss 531 + .parse() 532 + .map_err(|_| AuthError::AuthenticationFailed)?; 533 + 534 + debug!("Service token verified for DID: {}", did); 535 + 536 + Ok(ServiceAuth { claims, did }) 537 + } 538 + } 539 + 540 + pub struct OptionalServiceAuth(pub Option<ServiceTokenClaims>); 541 + 542 + impl FromRequestParts<AppState> for OptionalServiceAuth { 543 + type Rejection = std::convert::Infallible; 544 + 545 + async fn from_request_parts( 546 + parts: &mut Parts, 547 + _state: &AppState, 548 + ) -> Result<Self, Self::Rejection> { 549 + let auth_header = match parts.headers.get(AUTHORIZATION) { 550 + Some(h) => match h.to_str() { 551 + Ok(s) => s, 552 + Err(_) => return Ok(OptionalServiceAuth(None)), 553 + }, 554 + None => return Ok(OptionalServiceAuth(None)), 555 + }; 556 + 557 + let extracted = match extract_auth_token_from_header(Some(auth_header)) { 558 + Some(e) => e, 559 + None => return Ok(OptionalServiceAuth(None)), 560 + }; 561 + 562 + if !is_service_token(&extracted.token) { 563 + return Ok(OptionalServiceAuth(None)); 564 + } 565 + 566 + let verifier = ServiceTokenVerifier::new(); 567 + match verifier.verify_service_token(&extracted.token, None).await { 568 + Ok(claims) => { 569 + debug!("Service token verified for DID: {}", claims.iss); 570 + Ok(OptionalServiceAuth(Some(claims))) 571 + } 572 + Err(e) => { 573 + debug!("Service token verification failed (optional): {:?}", e); 574 + Ok(OptionalServiceAuth(None)) 575 + } 576 + } 577 + } 578 + } 579 + 580 + pub enum BlobAuthResult { 581 + Service { did: Did }, 582 + User(AuthenticatedUser), 583 + } 584 + 585 + pub struct BlobAuth(pub BlobAuthResult); 586 + 587 + impl FromRequestParts<AppState> for BlobAuth { 588 + type Rejection = AuthError; 589 + 590 + async fn from_request_parts( 591 + parts: &mut Parts, 592 + state: &AppState, 593 + ) -> Result<Self, Self::Rejection> { 594 + let auth_header = parts 595 + .headers 596 + .get(AUTHORIZATION) 597 + .ok_or(AuthError::MissingToken)? 598 + .to_str() 599 + .map_err(|_| AuthError::InvalidFormat)?; 600 + 601 + let extracted = 602 + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; 603 + 604 + if is_service_token(&extracted.token) { 605 + debug!("Verifying service token for blob upload"); 606 + let verifier = ServiceTokenVerifier::new(); 607 + let claims = verifier 608 + .verify_service_token(&extracted.token, Some("com.atproto.repo.uploadBlob")) 609 + .await 610 + .map_err(|e| { 611 + error!("Service token verification failed: {:?}", e); 612 + AuthError::AuthenticationFailed 613 + })?; 614 + 615 + let did: Did = claims 616 + .iss 617 + .parse() 618 + .map_err(|_| AuthError::AuthenticationFailed)?; 619 + 620 + debug!("Service token verified for DID: {}", did); 621 + return Ok(BlobAuth(BlobAuthResult::Service { did })); 622 + } 623 + 624 + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); 625 + let uri = build_full_url("/xrpc/com.atproto.repo.uploadBlob"); 626 + 627 + if let Ok(user) = 628 + validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token) 629 + .await 630 + && !user.is_oauth 631 + { 632 + return if user.status.is_takendown() { 633 + Err(AuthError::AccountTakedown) 634 + } else { 635 + Ok(BlobAuth(BlobAuthResult::User(user))) 636 + }; 637 + } 638 + 639 + verify_oauth_token_and_build_user( 640 + state, 641 + &extracted.token, 642 + dpop_proof, 643 + "POST", 644 + &uri, 645 + StatusCheckFlags { 646 + allow_deactivated: true, 647 + allow_takendown: false, 648 + }, 649 + ) 650 + .await 651 + .map(|user| BlobAuth(BlobAuthResult::User(user))) 358 652 } 359 653 } 360 654
+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))
+140 -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 needs_consent = should_show_consent( 1122 + state.oauth_repo.as_ref(), 1123 + &did, 1124 + &client_id_typed, 1125 + &requested_scopes, 1126 + ) 1127 + .await 1128 + .unwrap_or(true); 1129 + 1130 + if needs_consent { 1131 + if state 1132 + .oauth_repo 1133 + .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) 1134 + .await 1135 + .is_err() 1136 + { 1137 + return json_error( 1138 + StatusCode::INTERNAL_SERVER_ERROR, 1139 + "server_error", 1140 + "An error occurred. Please try again.", 1141 + ); 1142 + } 1143 + let consent_url = format!( 1144 + "/app/oauth/consent?request_uri={}", 1145 + url_encode(&form.request_uri) 1146 + ); 1147 + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); 1148 + } 1149 + 1109 1150 let code = Code::generate(); 1110 1151 let select_code = AuthorizationCode::from(code.0.clone()); 1111 1152 if state ··· 1475 1516 requested_scope_str.to_string() 1476 1517 }; 1477 1518 1478 - let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); 1519 + let expanded_scope_str = expand_include_scopes(&effective_scope_str).await; 1520 + let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); 1479 1521 let consent_client_id = ClientId::from(request_data.parameters.client_id.clone()); 1480 1522 let preferences = state 1481 1523 .oauth_repo ··· 2407 2449 } 2408 2450 2409 2451 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 - } 2452 + Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() { 2453 + Ok(delegated_did) if delegated_did != user.did => { 2454 + match state 2455 + .delegation_repo 2456 + .get_delegation(&delegated_did, &user.did) 2457 + .await 2458 + { 2459 + Ok(Some(_)) => Some(delegated_did), 2460 + Ok(None) => None, 2461 + Err(e) => { 2462 + tracing::warn!( 2463 + error = %e, 2464 + delegated_did = %delegated_did, 2465 + controller_did = %user.did, 2466 + "Failed to verify delegation relationship" 2467 + ); 2468 + None 2429 2469 } 2430 2470 } 2431 - _ => None, 2432 2471 } 2433 - } 2472 + _ => None, 2473 + }, 2434 2474 None => None, 2435 2475 }; 2436 2476 2437 2477 let is_delegation_flow = delegation_from_param.is_some() 2438 - || request_data.did.as_ref().map_or(false, |existing_did| { 2478 + || request_data.did.as_ref().is_some_and(|existing_did| { 2439 2479 existing_did 2440 2480 .parse::<tranquil_types::Did>() 2441 2481 .ok() 2442 - .map_or(false, |parsed| parsed != user.did) 2482 + .is_some_and(|parsed| parsed != user.did) 2443 2483 }); 2444 2484 2445 2485 if let Some(delegated_did) = delegation_from_param { ··· 3601 3641 ); 3602 3642 Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() 3603 3643 } 3644 + 3645 + pub async fn establish_session( 3646 + State(state): State<AppState>, 3647 + headers: HeaderMap, 3648 + auth: crate::auth::BearerAuth, 3649 + ) -> Response { 3650 + let did = &auth.0.did; 3651 + 3652 + let existing_device = extract_device_cookie(&headers); 3653 + 3654 + let (device_id, new_cookie) = match existing_device { 3655 + Some(id) => { 3656 + let device_typed = DeviceIdType::from(id.clone()); 3657 + let _ = state 3658 + .oauth_repo 3659 + .upsert_account_device(did, &device_typed) 3660 + .await; 3661 + (id, None) 3662 + } 3663 + None => { 3664 + let new_id = DeviceId::generate(); 3665 + let device_data = DeviceData { 3666 + session_id: SessionId::generate().0, 3667 + user_agent: extract_user_agent(&headers), 3668 + ip_address: extract_client_ip(&headers), 3669 + last_seen_at: Utc::now(), 3670 + }; 3671 + let device_typed = DeviceIdType::from(new_id.0.clone()); 3672 + 3673 + if let Err(e) = state.oauth_repo.create_device(&device_typed, &device_data).await { 3674 + tracing::error!(error = ?e, "Failed to create device"); 3675 + return ( 3676 + StatusCode::INTERNAL_SERVER_ERROR, 3677 + Json(serde_json::json!({ 3678 + "error": "server_error", 3679 + "error_description": "Failed to establish session" 3680 + })), 3681 + ) 3682 + .into_response(); 3683 + } 3684 + 3685 + if let Err(e) = state.oauth_repo.upsert_account_device(did, &device_typed).await { 3686 + tracing::error!(error = ?e, "Failed to link device to account"); 3687 + return ( 3688 + StatusCode::INTERNAL_SERVER_ERROR, 3689 + Json(serde_json::json!({ 3690 + "error": "server_error", 3691 + "error_description": "Failed to establish session" 3692 + })), 3693 + ) 3694 + .into_response(); 3695 + } 3696 + 3697 + (new_id.0.clone(), Some(make_device_cookie(&new_id.0))) 3698 + } 3699 + }; 3700 + 3701 + tracing::info!(did = %did, device_id = %device_id, "Device session established"); 3702 + 3703 + match new_cookie { 3704 + Some(cookie) => ( 3705 + StatusCode::OK, 3706 + [(SET_COOKIE, cookie)], 3707 + Json(serde_json::json!({ 3708 + "success": true, 3709 + "device_id": device_id 3710 + })), 3711 + ) 3712 + .into_response(), 3713 + None => Json(serde_json::json!({ 3714 + "success": true, 3715 + "device_id": device_id 3716 + })) 3717 + .into_response(), 3718 + } 3719 + }
+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,
+31 -13
crates/tranquil-pds/src/oauth/verify.rs
··· 10 10 use sha2::Sha256; 11 11 use subtle::ConstantTimeEq; 12 12 use tranquil_db_traits::{OAuthRepository, UserRepository}; 13 - use tranquil_types::TokenId; 13 + use tranquil_types::{ClientId, TokenId}; 14 + 15 + use crate::types::Did; 14 16 15 17 use super::scopes::ScopePermissions; 16 18 use super::{DPoPVerifier, OAuthError}; ··· 27 29 } 28 30 29 31 pub struct VerifyResult { 30 - pub did: String, 31 - pub token_id: String, 32 - pub client_id: String, 32 + pub did: Did, 33 + pub token_id: TokenId, 34 + pub client_id: ClientId, 33 35 pub scope: Option<String>, 34 36 } 35 37 ··· 91 93 )); 92 94 } 93 95 } 96 + let did: Did = token_data 97 + .did 98 + .parse() 99 + .map_err(|_| OAuthError::InvalidToken("Invalid DID in token".to_string()))?; 94 100 Ok(VerifyResult { 95 - did: token_data.did, 96 - token_id: token_id.to_string(), 97 - client_id: token_data.client_id, 101 + did, 102 + token_id, 103 + client_id: ClientId::from(token_data.client_id), 98 104 scope: token_data.scope, 99 105 }) 100 106 } ··· 202 208 } 203 209 204 210 pub struct OAuthUser { 205 - pub did: String, 206 - pub client_id: Option<String>, 211 + pub did: Did, 212 + pub client_id: Option<ClientId>, 207 213 pub scope: Option<String>, 208 214 pub is_oauth: bool, 209 215 pub permissions: ScopePermissions, ··· 382 388 } 383 389 384 390 struct LegacyAuthResult { 385 - did: String, 391 + did: Did, 386 392 } 387 393 388 394 async fn try_legacy_auth( ··· 390 396 token: &str, 391 397 ) -> Result<LegacyAuthResult, ()> { 392 398 match crate::auth::validate_bearer_token(user_repo, token).await { 393 - Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { 394 - did: user.did.to_string(), 395 - }), 399 + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), 396 400 _ => Err(()), 397 401 } 398 402 } 403 + 404 + pub async fn dpop_nonce_middleware( 405 + req: axum::http::Request<axum::body::Body>, 406 + next: axum::middleware::Next, 407 + ) -> Response { 408 + let mut response = next.run(req).await; 409 + let config = AuthConfig::get(); 410 + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); 411 + let nonce = verifier.generate_nonce(); 412 + if let Ok(nonce_val) = nonce.parse() { 413 + response.headers_mut().insert("DPoP-Nonce", nonce_val); 414 + } 415 + response 416 + }
+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; ··· 15 16 } 16 17 17 18 const CACHE_TTL_SECS: u64 = 3600; 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 + } 18 36 19 37 #[derive(Debug, Deserialize)] 20 38 struct LexiconDoc { ··· 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))?; 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"); 84 173 85 174 let response = client 86 175 .get(&url) ··· 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))?; 192 + 193 + Ok(record.value) 194 + } 195 + 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))?; 199 + 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))?; 207 + 208 + txt_records 209 + .iter() 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 + }; 103 232 104 - let main_def = lexicon 105 - .defs 106 - .get("main") 107 - .ok_or("Missing 'main' definition in lexicon")?; 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))?; 108 239 109 - if main_def.def_type != "permission-set" { 110 - return Err(format!( 111 - "Expected permission-set type, got: {}", 112 - main_def.def_type 113 - )); 240 + if !response.status().is_success() { 241 + return Err(format!("Failed to resolve DID: HTTP {}", response.status())); 114 242 } 115 243 116 - let permissions = main_def 117 - .permissions 118 - .as_ref() 119 - .ok_or("Missing permissions in permission-set")?; 244 + let doc: PlcDocument = response 245 + .json() 246 + .await 247 + .map_err(|e| format!("Failed to parse DID document: {}", e))?; 120 248 121 - let mut collections: Vec<String> = permissions 249 + doc.service 122 250 .iter() 123 - .filter(|perm| perm.resource == "repo") 124 - .filter_map(|perm| perm.collection.as_ref()) 125 - .flatten() 126 - .cloned() 127 - .collect(); 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 + } 128 255 129 - if collections.is_empty() { 130 - return Err("No repo collections found in permission-set".to_string()); 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() 131 262 } 263 + } 132 264 133 - collections.sort(); 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 + } 134 272 135 - let collection_params: Vec<String> = collections 136 - .iter() 137 - .map(|c| format!("collection={}", c)) 138 - .collect(); 273 + const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"]; 139 274 140 - let expanded = format!("repo?{}", collection_params.join("&")); 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(); 141 281 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 - ); 151 - } 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()); 152 290 153 - debug!(nsid, expanded = %expanded, "Successfully expanded permission set"); 154 - Ok(expanded) 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 + 159 324 #[test] 160 - fn test_nsid_to_url() { 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 + 600 + #[test] 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", ··· 134 182 })); 135 183 if ( 136 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 + } 192 + if ( 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) { ··· 1162 1211 })); 1163 1212 throw new ApiError(res.status, errData.error, errData.message); 1164 1213 } 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(); 1165 1230 }, 1166 1231 }; 1167 1232
+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 - } 259 - 260 - button.secondary:hover:not(:disabled) { 261 - background: var(--accent); 262 - color: var(--text-inverse); 263 237 } 264 238 265 239 .different-account {
+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} ··· 370 392 </button> 371 393 <button type="button" class="approve-btn" onclick={submitConsent} disabled={submitting}> 372 394 {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')} 395 + </button> 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')} 373 408 </button> 374 409 </div> 375 410 {/if}