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

fix: oauth consolidation, include-scope improvements #4

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

None yet.

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

History

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

so three things:

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

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

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

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

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

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