From 8af0cfe0aff5757520d7a36708e0411dd7f031fb Mon Sep 17 00:00:00 2001 From: lewis Date: Fri, 23 Jan 2026 10:22:04 +0200 Subject: [PATCH] fix: oauth consolidation, include-scope improvements --- ...236391290c2e63d37d2bb1cd89ea822950a82.json | 77 --- ...ff9cbba4c48dd8a7546882ab5647114ffab4a.json | 77 --- ...cc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json | 22 - ...b610f7b122b485ab0fd0d0646d68ae8e64fe6.json | 31 - ...0bc88f5535dedae244f7b6e4afa95769b8f1a.json | 32 - Cargo.lock | 2 + crates/tranquil-pds/src/api/error.rs | 7 + .../tranquil-pds/src/api/identity/account.rs | 8 +- crates/tranquil-pds/src/api/proxy.rs | 15 +- crates/tranquil-pds/src/api/repo/blob.rs | 97 +-- .../src/api/repo/record/delete.rs | 16 +- .../tranquil-pds/src/api/repo/record/write.rs | 54 +- .../src/api/server/account_status.rs | 135 +--- .../tranquil-pds/src/api/server/migration.rs | 64 +- crates/tranquil-pds/src/api/temp.rs | 27 +- .../tranquil-pds/src/auth/auth_extractor.rs | 547 ++++++++++++++++ crates/tranquil-pds/src/auth/extractor.rs | 580 ++++++++++++----- crates/tranquil-pds/src/auth/mod.rs | 3 +- crates/tranquil-pds/src/lib.rs | 13 +- .../src/oauth/endpoints/authorize.rs | 164 ++++- .../src/oauth/endpoints/delegation.rs | 56 +- crates/tranquil-pds/src/oauth/verify.rs | 44 +- crates/tranquil-pds/tests/auth_extractor.rs | 583 +++++++++++++++++ crates/tranquil-pds/tests/common/mod.rs | 6 +- crates/tranquil-pds/tests/oauth_security.rs | 10 +- crates/tranquil-scopes/Cargo.toml | 2 + crates/tranquil-scopes/src/permission_set.rs | 588 +++++++++++++++--- crates/tranquil-scopes/src/permissions.rs | 42 +- crates/tranquil-storage/src/lib.rs | 40 +- frontend/src/lib/api.ts | 135 ++-- frontend/src/lib/auth.svelte.ts | 2 +- frontend/src/lib/migration/atproto-client.ts | 19 +- frontend/src/lib/migration/flow.svelte.ts | 4 +- .../src/lib/migration/offline-flow.svelte.ts | 4 +- frontend/src/lib/oauth.ts | 6 +- frontend/src/locales/en.json | 5 + frontend/src/locales/fi.json | 5 + frontend/src/locales/ja.json | 5 + frontend/src/locales/ko.json | 5 + frontend/src/locales/sv.json | 5 + frontend/src/locales/zh.json | 5 + frontend/src/routes/Migration.svelte | 53 +- frontend/src/routes/OAuthAccounts.svelte | 36 +- frontend/src/routes/OAuthConsent.svelte | 41 +- 44 files changed, 2659 insertions(+), 1013 deletions(-) delete mode 100644 .sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json delete mode 100644 .sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json delete mode 100644 .sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json delete mode 100644 .sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json delete mode 100644 .sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json create mode 100644 crates/tranquil-pds/src/auth/auth_extractor.rs create mode 100644 crates/tranquil-pds/tests/auth_extractor.rs diff --git a/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json b/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json deleted file mode 100644 index 6e51788..0000000 --- a/.sqlx/query-06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "db_name": "PostgreSQL", - "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 ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "request_uri", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "provider: SsoProviderType", - "type_info": { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "provider_user_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "provider_username", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "provider_email", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - false - ] - }, - "hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82" -} diff --git a/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json b/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json deleted file mode 100644 index fa4098d..0000000 --- a/.sqlx/query-5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "db_name": "PostgreSQL", - "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 ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "request_uri", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "provider: SsoProviderType", - "type_info": { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - } - }, - { - "ordinal": 3, - "name": "provider_user_id", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "provider_username", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "provider_email", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - true, - false, - false - ] - }, - "hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a" -} diff --git a/.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json b/.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json deleted file mode 100644 index 9b813df..0000000 --- a/.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email_verified", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" -} diff --git a/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json b/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json deleted file mode 100644 index a7fb42d..0000000 --- a/.sqlx/query-a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6" -} diff --git a/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json b/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json deleted file mode 100644 index fa8b7de..0000000 --- a/.sqlx/query-dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "PostgreSQL", - "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 ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - { - "Custom": { - "name": "sso_provider_type", - "kind": { - "Enum": [ - "github", - "discord", - "google", - "gitlab", - "oidc" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a" -} diff --git a/Cargo.lock b/Cargo.lock index bbcd39a..976d754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6156,11 +6156,13 @@ version = "0.1.0" dependencies = [ "axum", "futures", + "hickory-resolver", "reqwest", "serde", "serde_json", "tokio", "tracing", + "urlencoding", ] [[package]] diff --git a/crates/tranquil-pds/src/api/error.rs b/crates/tranquil-pds/src/api/error.rs index 0af81d4..82cf3ec 100644 --- a/crates/tranquil-pds/src/api/error.rs +++ b/crates/tranquil-pds/src/api/error.rs @@ -543,6 +543,13 @@ impl From for ApiError { crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated, crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown, crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired, + crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => { + Self::OAuthExpiredToken(Some(msg)) + } + crate::auth::extractor::AuthError::UseDpopNonce(_) + | crate::auth::extractor::AuthError::InvalidDpopProof(_) => { + Self::AuthenticationFailed(None) + } } } } diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index df81e44..e7ce67e 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -1,7 +1,7 @@ use super::did::verify_did_web; use crate::api::error::ApiError; use crate::api::repo::record::utils::create_signed_commit; -use crate::auth::{ServiceTokenVerifier, is_service_token}; +use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token}; use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; use crate::state::{AppState, RateLimitKind}; use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey}; @@ -96,9 +96,9 @@ pub async fn create_account( .into_response(); } - let migration_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { + let migration_auth = if let Some(extracted) = + extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) + { let token = extracted.token; if is_service_token(&token) { let verifier = ServiceTokenVerifier::new(); diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index b3b3905..e5dba33 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -267,9 +267,18 @@ async fn proxy_handler( } } Err(e) => { - warn!("Token validation failed: {:?}", e); - if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) { - return ApiError::from(e).into_response(); + info!(error = ?e, "Proxy token validation failed, returning error to client"); + if matches!( + e, + crate::auth::TokenValidationError::OAuthTokenExpired + | crate::auth::TokenValidationError::TokenExpired + ) { + let mut response = ApiError::from(e).into_response(); + let nonce = crate::oauth::verify::generate_dpop_nonce(); + if let Ok(nonce_val) = nonce.parse() { + response.headers_mut().insert("DPoP-Nonce", nonce_val); + } + return response; } } } diff --git a/crates/tranquil-pds/src/api/repo/blob.rs b/crates/tranquil-pds/src/api/repo/blob.rs index 66c9a17..a9c2ac3 100644 --- a/crates/tranquil-pds/src/api/repo/blob.rs +++ b/crates/tranquil-pds/src/api/repo/blob.rs @@ -1,5 +1,5 @@ use crate::api::error::ApiError; -use crate::auth::{BearerAuthAllowDeactivated, ServiceTokenVerifier, is_service_token}; +use crate::auth::{BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult}; use crate::delegation::DelegationActionType; use crate::state::AppState; use crate::types::{CidLink, Did}; @@ -44,88 +44,25 @@ fn detect_mime_type(data: &[u8], client_hint: &str) -> String { pub async fn upload_blob( State(state): State, headers: axum::http::HeaderMap, + auth: BlobAuth, body: Body, ) -> Response { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), - }; - let token = extracted.token; - - let is_service_auth = is_service_token(&token); - - let (did, _is_migration, controller_did): (Did, bool, Option) = if is_service_auth { - debug!("Verifying service token for blob upload"); - let verifier = ServiceTokenVerifier::new(); - match verifier - .verify_service_token(&token, Some("com.atproto.repo.uploadBlob")) - .await - { - Ok(claims) => { - debug!("Service token verified for DID: {}", claims.iss); - let did: Did = match claims.iss.parse() { - Ok(d) => d, - Err(_) => { - return ApiError::InvalidDid("Invalid DID format".into()).into_response(); - } - }; - (did, false, None) - } - Err(e) => { - error!("Service token verification failed: {:?}", e); - return ApiError::AuthenticationFailed(Some(format!( - "Service token verification failed: {}", - e - ))) - .into_response(); - } - } - } else { - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/com.atproto.repo.uploadBlob", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &token, - extracted.is_dpop, - dpop_proof, - "POST", - &http_uri, - true, - false, - ) - .await - { - Ok(user) => { - let mime_type_for_check = headers - .get("content-type") - .and_then(|h| h.to_str().ok()) - .unwrap_or("application/octet-stream"); - if let Err(e) = crate::auth::scope_check::check_blob_scope( - user.is_oauth, - user.scope.as_deref(), - mime_type_for_check, - ) { - return e; - } - let deactivated = state - .user_repo - .get_status_by_did(&user.did) - .await - .ok() - .flatten() - .and_then(|s| s.deactivated_at); - let ctrl_did = user.controller_did.clone(); - (user.did, deactivated.is_some(), ctrl_did) - } - Err(_) => { - return ApiError::AuthenticationFailed(None).into_response(); + let (did, controller_did): (Did, Option) = match auth.0 { + BlobAuthResult::Service { did } => (did, None), + BlobAuthResult::User(auth_user) => { + let mime_type_for_check = headers + .get("content-type") + .and_then(|h| h.to_str().ok()) + .unwrap_or("application/octet-stream"); + if let Err(e) = crate::auth::scope_check::check_blob_scope( + auth_user.is_oauth, + auth_user.scope.as_deref(), + mime_type_for_check, + ) { + return e; } + let ctrl_did = auth_user.controller_did.clone(); + (auth_user.did, ctrl_did) } }; diff --git a/crates/tranquil-pds/src/api/repo/record/delete.rs b/crates/tranquil-pds/src/api/repo/record/delete.rs index b417738..1ba38b3 100644 --- a/crates/tranquil-pds/src/api/repo/record/delete.rs +++ b/crates/tranquil-pds/src/api/repo/record/delete.rs @@ -1,6 +1,7 @@ use crate::api::error::ApiError; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; +use crate::auth::BearerAuth; use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -8,7 +9,7 @@ use crate::types::{AtIdentifier, AtUri, Nsid, Rkey}; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{IntoResponse, Response}, }; use cid::Cid; @@ -39,19 +40,10 @@ pub struct DeleteRecordOutput { pub async fn delete_record( State(state): State, - headers: HeaderMap, - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, + auth: BearerAuth, Json(input): Json, ) -> Response { - let auth = match prepare_repo_write( - &state, - &headers, - &input.repo, - "POST", - &crate::util::build_full_url(&uri.to_string()), - ) - .await - { + let auth = match prepare_repo_write(&state, auth.0, &input.repo).await { Ok(res) => res, Err(err_res) => return err_res, }; diff --git a/crates/tranquil-pds/src/api/repo/record/write.rs b/crates/tranquil-pds/src/api/repo/record/write.rs index c8743db..8580721 100644 --- a/crates/tranquil-pds/src/api/repo/record/write.rs +++ b/crates/tranquil-pds/src/api/repo/record/write.rs @@ -3,6 +3,7 @@ use crate::api::error::ApiError; use crate::api::repo::record::utils::{ CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids, }; +use crate::auth::{AuthenticatedUser, BearerAuth}; use crate::delegation::DelegationActionType; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; @@ -10,7 +11,7 @@ use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey}; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::StatusCode, response::{IntoResponse, Response}, }; use cid::Cid; @@ -33,32 +34,9 @@ pub struct RepoWriteAuth { pub async fn prepare_repo_write( state: &AppState, - headers: &HeaderMap, + auth_user: AuthenticatedUser, repo: &AtIdentifier, - http_method: &str, - http_uri: &str, ) -> Result { - let extracted = crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) - .ok_or_else(|| ApiError::AuthenticationRequired.into_response())?; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let auth_user = crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - http_method, - http_uri, - false, - false, - ) - .await - .map_err(|e| { - tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); - ApiError::from(e).into_response() - })?; if repo.as_str() != auth_user.did.as_str() { return Err( ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), @@ -146,19 +124,10 @@ pub struct CreateRecordOutput { } pub async fn create_record( State(state): State, - headers: HeaderMap, - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, + auth: BearerAuth, Json(input): Json, ) -> Response { - let auth = match prepare_repo_write( - &state, - &headers, - &input.repo, - "POST", - &crate::util::build_full_url(&uri.to_string()), - ) - .await - { + let auth = match prepare_repo_write(&state, auth.0, &input.repo).await { Ok(res) => res, Err(err_res) => return err_res, }; @@ -445,19 +414,10 @@ pub struct PutRecordOutput { } pub async fn put_record( State(state): State, - headers: HeaderMap, - axum::extract::OriginalUri(uri): axum::extract::OriginalUri, + auth: BearerAuth, Json(input): Json, ) -> Response { - let auth = match prepare_repo_write( - &state, - &headers, - &input.repo, - "POST", - &crate::util::build_full_url(&uri.to_string()), - ) - .await - { + let auth = match prepare_repo_write(&state, auth.0, &input.repo).await { Ok(res) => res, Err(err_res) => return err_res, }; diff --git a/crates/tranquil-pds/src/api/server/account_status.rs b/crates/tranquil-pds/src/api/server/account_status.rs index 7c8b6b1..3b158ec 100644 --- a/crates/tranquil-pds/src/api/server/account_status.rs +++ b/crates/tranquil-pds/src/api/server/account_status.rs @@ -40,35 +40,9 @@ pub struct CheckAccountStatusOutput { pub async fn check_account_status( State(state): State, - headers: axum::http::HeaderMap, + auth: crate::auth::BearerAuthAllowDeactivated, ) -> Response { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), - }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/com.atproto.server.checkAccountStatus", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - let did = match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "GET", - &http_uri, - true, - false, - ) - .await - { - Ok(user) => user.did, - Err(e) => return ApiError::from(e).into_response(), - }; + let did = auth.0.did; let user_id = match state.user_repo.get_id_by_did(&did).await { Ok(Some(id)) => id, _ => { @@ -331,42 +305,10 @@ async fn assert_valid_did_document_for_service( pub async fn activate_account( State(state): State, - headers: axum::http::HeaderMap, + auth: crate::auth::BearerAuthAllowDeactivated, ) -> Response { info!("[MIGRATION] activateAccount called"); - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - info!("[MIGRATION] activateAccount: No auth token"); - return ApiError::AuthenticationRequired.into_response(); - } - }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/com.atproto.server.activateAccount", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - let auth_user = match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "POST", - &http_uri, - true, - false, - ) - .await - { - Ok(user) => user, - Err(e) => { - info!("[MIGRATION] activateAccount: Auth failed: {:?}", e); - return ApiError::from(e).into_response(); - } - }; + let auth_user = auth.0; info!( "[MIGRATION] activateAccount: Authenticated user did={}", auth_user.did @@ -528,36 +470,10 @@ pub struct DeactivateAccountInput { pub async fn deactivate_account( State(state): State, - headers: axum::http::HeaderMap, + auth: crate::auth::BearerAuth, Json(input): Json, ) -> Response { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), - }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/com.atproto.server.deactivateAccount", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - let auth_user = match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "POST", - &http_uri, - false, - false, - ) - .await - { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = auth.0; if let Err(e) = crate::auth::scope_check::check_account_scope( auth_user.is_oauth, @@ -607,47 +523,20 @@ pub async fn deactivate_account( pub async fn request_account_delete( State(state): State, - headers: axum::http::HeaderMap, + auth: crate::auth::BearerAuthAllowDeactivated, ) -> Response { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), - }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/com.atproto.server.requestAccountDelete", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - let validated = match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "POST", - &http_uri, - true, - false, - ) - .await - { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; - let did = validated.did.clone(); + let did = &auth.0.did; - if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &did).await { + if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await { return crate::api::server::reauth::legacy_mfa_required_response( &*state.user_repo, &*state.session_repo, - &did, + did, ) .await; } - let user_id = match state.user_repo.get_id_by_did(&did).await { + let user_id = match state.user_repo.get_id_by_did(did).await { Ok(Some(id)) => id, _ => { return ApiError::InternalError(None).into_response(); @@ -657,7 +546,7 @@ pub async fn request_account_delete( let expires_at = Utc::now() + Duration::minutes(15); if let Err(e) = state .infra_repo - .create_deletion_request(&confirmation_token, &did, expires_at) + .create_deletion_request(&confirmation_token, did, expires_at) .await { error!("DB error creating deletion token: {:?}", e); diff --git a/crates/tranquil-pds/src/api/server/migration.rs b/crates/tranquil-pds/src/api/server/migration.rs index 7e491e7..f69c8b4 100644 --- a/crates/tranquil-pds/src/api/server/migration.rs +++ b/crates/tranquil-pds/src/api/server/migration.rs @@ -1,4 +1,5 @@ use crate::api::ApiError; +use crate::auth::BearerAuth; use crate::state::AppState; use axum::{ Json, @@ -35,36 +36,10 @@ pub struct UpdateDidDocumentOutput { pub async fn update_did_document( State(state): State, - headers: axum::http::HeaderMap, + auth: BearerAuth, Json(input): Json, ) -> Response { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), - }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/_account.updateDidDocument", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - let auth_user = match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "POST", - &http_uri, - true, - false, - ) - .await - { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; + let auth_user = auth.0; if !auth_user.did.starts_with("did:web:") { return ApiError::InvalidRequest( @@ -166,37 +141,8 @@ pub async fn update_did_document( .into_response() } -pub async fn get_did_document( - State(state): State, - headers: axum::http::HeaderMap, -) -> Response { - let extracted = match crate::auth::extract_auth_token_from_header( - headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => return ApiError::AuthenticationRequired.into_response(), - }; - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = format!( - "https://{}/xrpc/_account.getDidDocument", - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) - ); - let auth_user = match crate::auth::validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "GET", - &http_uri, - true, - false, - ) - .await - { - Ok(user) => user, - Err(e) => return ApiError::from(e).into_response(), - }; +pub async fn get_did_document(State(state): State, auth: BearerAuth) -> Response { + let auth_user = auth.0; if !auth_user.did.starts_with("did:web:") { return ApiError::InvalidRequest( diff --git a/crates/tranquil-pds/src/api/temp.rs b/crates/tranquil-pds/src/api/temp.rs index a3e4a55..e10f09f 100644 --- a/crates/tranquil-pds/src/api/temp.rs +++ b/crates/tranquil-pds/src/api/temp.rs @@ -1,10 +1,9 @@ use crate::api::error::ApiError; -use crate::auth::{BearerAuth, extract_auth_token_from_header, validate_token_with_dpop}; +use crate::auth::{BearerAuth, OptionalBearerAuth}; use crate::state::AppState; use axum::{ Json, extract::State, - http::HeaderMap, response::{IntoResponse, Response}, }; use cid::Cid; @@ -22,27 +21,11 @@ pub struct CheckSignupQueueOutput { pub estimated_time_ms: Option, } -pub async fn check_signup_queue(State(state): State, headers: HeaderMap) -> Response { - if let Some(extracted) = - extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) +pub async fn check_signup_queue(auth: OptionalBearerAuth) -> Response { + if let Some(user) = auth.0 + && user.is_oauth { - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - if let Ok(user) = validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "GET", - "/", - false, - false, - ) - .await - && user.is_oauth - { - return ApiError::Forbidden.into_response(); - } + return ApiError::Forbidden.into_response(); } Json(CheckSignupQueueOutput { activated: true, diff --git a/crates/tranquil-pds/src/auth/auth_extractor.rs b/crates/tranquil-pds/src/auth/auth_extractor.rs new file mode 100644 index 0000000..c7988ec --- /dev/null +++ b/crates/tranquil-pds/src/auth/auth_extractor.rs @@ -0,0 +1,547 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client, create_account_and_login, pds_endpoint}; +use helpers::verify_new_account; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn generate_pkce() -> (String, String) { + let verifier_bytes: [u8; 32] = rand::random(); + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); + (code_verifier, code_challenge) +} + +async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer { + let mock_server = MockServer::start().await; + let metadata = json!({ + "client_id": mock_server.uri(), + "client_name": "Auth Extractor Test Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": dpop_bound + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + mock_server +} + +async fn get_oauth_session( + http_client: &reqwest::Client, + url: &str, + dpop_bound: bool, +) -> (String, String, String, String) { + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("ae{}", suffix); + let password = "AuthExtract123!"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap().to_string(); + verify_new_account(http_client, &did).await; + + let redirect_uri = "https://example.com/auth-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_body: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_body: Value = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + ( + token_body["access_token"].as_str().unwrap().to_string(), + token_body["refresh_token"].as_str().unwrap().to_string(), + client_id, + did, + ) +} + +#[tokio::test] +async fn test_oauth_token_works_with_bearer_auth() { + let url = base_url().await; + let http_client = client(); + let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await; + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&access_token) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with BearerAuth extractor"); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["did"].as_str().unwrap(), did); +} + +#[tokio::test] +async fn test_session_token_still_works() { + let url = base_url().await; + let http_client = client(); + let (jwt, did) = create_account_and_login(&http_client).await; + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&jwt) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK, "Session token should still work"); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["did"].as_str().unwrap(), did); +} + + +#[tokio::test] +async fn test_oauth_admin_extractor_allows_oauth_tokens() { + let url = base_url().await; + let http_client = client(); + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("adm{}", suffix); + let password = "AdminOAuth123!"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap().to_string(); + verify_new_account(&http_client, &did).await; + + let pool = common::get_test_db_pool().await; + sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) + .execute(pool) + .await + .expect("Failed to mark user as admin"); + + let redirect_uri = "https://example.com/admin-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri, false).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_body: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + let token_body: Value = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let access_token = token_body["access_token"].as_str().unwrap(); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did)) + .bearer_auth(access_token) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::OK, + "OAuth token for admin user should work with admin endpoint" + ); +} + +#[tokio::test] +async fn test_expired_oauth_token_returns_proper_error() { + let url = base_url().await; + let http_client = client(); + + let now = Utc::now().timestamp(); + let header = json!({"alg": "HS256", "typ": "at+jwt"}); + let payload = json!({ + "iss": url, + "sub": "did:plc:test123", + "aud": url, + "iat": now - 7200, + "exp": now - 3600, + "jti": "expired-token", + "sid": "expired-session", + "scope": "atproto", + "client_id": "https://example.com" + }); + let fake_token = format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), + URL_SAFE_NO_PAD.encode([1u8; 32]) + ); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&fake_token) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Expired token should be rejected" + ); +} + +#[tokio::test] +async fn test_dpop_nonce_error_has_proper_headers() { + let url = base_url().await; + let pds_url = pds_endpoint(); + let http_client = client(); + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("dpop{}", suffix); + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}@test.com", handle), + "password": "DpopTest123!" + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap(); + verify_new_account(&http_client, did).await; + + let redirect_uri = "https://example.com/dpop-callback"; + let mock_server = MockServer::start().await; + let client_id = mock_server.uri(); + let metadata = json!({ + "client_id": &client_id, + "client_name": "DPoP Test Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + + let (code_verifier, code_challenge) = generate_pkce(); + let par_body: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let request_uri = par_body["request_uri"].as_str().unwrap(); + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": "DpopTest123!", + "remember_device": false + })) + .send() + .await + .unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + + let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap(); + + let token_endpoint = format!("{}/oauth/token", pds_url); + let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None); + + let token_res = http_client + .post(format!("{}/oauth/token", url)) + .header("DPoP", &dpop_proof) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + let token_status = token_res.status(); + let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string()); + let token_body: Value = token_res.json().await.unwrap(); + + let access_token = if token_status == StatusCode::OK { + token_body["access_token"].as_str().unwrap().to_string() + } else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") { + let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error"); + let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce)); + + let retry_res = http_client + .post(format!("{}/oauth/token", url)) + .header("DPoP", &dpop_proof_with_nonce) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + let retry_body: Value = retry_res.json().await.unwrap(); + retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string() + } else { + panic!("Token exchange failed unexpectedly: {:?}", token_body); + }; + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("DPoP {}", access_token)) + .send() + .await + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail"); + + let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap()); + assert!(www_auth.is_some(), "Should have WWW-Authenticate header"); + assert!( + www_auth.unwrap().contains("use_dpop_nonce"), + "WWW-Authenticate should indicate dpop nonce required" + ); + + let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap()); + assert!(nonce.is_some(), "Should return DPoP-Nonce header"); + + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce"); +} + +fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) { + use p256::ecdsa::{SigningKey, signature::Signer}; + use p256::elliptic_curve::rand_core::OsRng; + + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let jwk = json!({ + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + }); + + let header = { + let h = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": jwk.clone() + }); + h + }; + + let mut payload = json!({ + "jti": uuid::Uuid::new_v4().to_string(), + "htm": method, + "htu": uri, + "iat": Utc::now().timestamp() + }); + if let Some(n) = nonce { + payload["nonce"] = json!(n); + } + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); + let signing_input = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let proof = format!("{}.{}", signing_input, sig_b64); + (jwk, proof) +} diff --git a/crates/tranquil-pds/src/auth/extractor.rs b/crates/tranquil-pds/src/auth/extractor.rs index 8cbd195..09745cd 100644 --- a/crates/tranquil-pds/src/auth/extractor.rs +++ b/crates/tranquil-pds/src/auth/extractor.rs @@ -1,16 +1,18 @@ use axum::{ extract::FromRequestParts, - http::{header::AUTHORIZATION, request::Parts}, + http::{StatusCode, header::AUTHORIZATION, request::Parts}, response::{IntoResponse, Response}, }; +use tracing::{debug, error, info}; use super::{ - AuthenticatedUser, TokenValidationError, validate_bearer_token_allow_takendown, - validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated, - validate_token_with_dpop, + AccountStatus, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier, is_service_token, + validate_bearer_token, validate_bearer_token_allow_deactivated, + validate_bearer_token_allow_takendown, }; use crate::api::error::ApiError; use crate::state::AppState; +use crate::types::Did; use crate::util::build_full_url; pub struct BearerAuth(pub AuthenticatedUser); @@ -24,11 +26,38 @@ pub enum AuthError { AccountDeactivated, AccountTakedown, AdminRequired, + OAuthExpiredToken(String), + UseDpopNonce(String), + InvalidDpopProof(String), } impl IntoResponse for AuthError { fn into_response(self) -> Response { - ApiError::from(self).into_response() + match self { + Self::UseDpopNonce(nonce) => ( + StatusCode::UNAUTHORIZED, + [ + ("DPoP-Nonce", nonce.as_str()), + ("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""), + ], + axum::Json(serde_json::json!({ + "error": "use_dpop_nonce", + "message": "DPoP nonce required" + })), + ) + .into_response(), + Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(), + Self::InvalidDpopProof(msg) => ( + StatusCode::UNAUTHORIZED, + [("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")], + axum::Json(serde_json::json!({ + "error": "invalid_dpop_proof", + "message": msg + })), + ) + .into_response(), + other => ApiError::from(other).into_response(), + } } } @@ -107,6 +136,68 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option, + method: &str, + uri: &str, + flags: StatusCheckFlags, +) -> Result { + match crate::oauth::verify::verify_oauth_access_token( + state.oauth_repo.as_ref(), + token, + dpop_proof, + method, + uri, + ) + .await + { + Ok(result) => { + let user_info = state + .user_repo + .get_user_info_by_did(&result.did) + .await + .ok() + .flatten() + .ok_or(AuthError::AuthenticationFailed)?; + let status = AccountStatus::from_db_fields( + user_info.takedown_ref.as_deref(), + user_info.deactivated_at, + ); + if !flags.allow_deactivated && status.is_deactivated() { + return Err(AuthError::AccountDeactivated); + } + if !flags.allow_takendown && status.is_takendown() { + return Err(AuthError::AccountTakedown); + } + Ok(AuthenticatedUser { + did: result.did, + key_bytes: user_info.key_bytes.and_then(|kb| { + crate::config::decrypt_key(&kb, user_info.encryption_version).ok() + }), + is_oauth: true, + is_admin: user_info.is_admin, + status, + scope: result.scope, + controller_did: None, + }) + } + Err(crate::oauth::OAuthError::ExpiredToken(msg)) => Err(AuthError::OAuthExpiredToken(msg)), + Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => Err(AuthError::UseDpopNonce(nonce)), + Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => { + Err(AuthError::InvalidDpopProof(msg)) + } + Err(_) => Err(AuthError::AuthenticationFailed), + } +} + impl FromRequestParts for BearerAuth { type Rejection = AuthError; @@ -124,45 +215,44 @@ impl FromRequestParts for BearerAuth { let extracted = extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - if extracted.is_dpop { - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); - let method = parts.method.as_str(); - let uri = build_full_url(&parts.uri.to_string()); + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = build_full_url(&parts.uri.to_string()); - match validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - true, - dpop_proof, - method, - &uri, - false, - false, - ) - .await - { - Ok(user) => Ok(BearerAuth(user)), - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), - Err(_) => Err(AuthError::AuthenticationFailed), + match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await { + Ok(user) if !user.is_oauth => { + return if user.status.is_deactivated() { + Err(AuthError::AccountDeactivated) + } else if user.status.is_takendown() { + Err(AuthError::AccountTakedown) + } else { + Ok(BearerAuth(user)) + }; } - } else { - match validate_bearer_token_cached( - state.user_repo.as_ref(), - state.cache.as_ref(), - &extracted.token, - ) - .await - { - Ok(user) => Ok(BearerAuth(user)), - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), - Err(_) => Err(AuthError::AuthenticationFailed), + Ok(_) => {} + Err(super::TokenValidationError::AccountDeactivated) => { + return Err(AuthError::AccountDeactivated); } + Err(super::TokenValidationError::AccountTakedown) => { + return Err(AuthError::AccountTakedown); + } + Err(super::TokenValidationError::TokenExpired) => { + info!("JWT access token expired in BearerAuth, returning ExpiredToken"); + return Err(AuthError::TokenExpired); + } + Err(_) => {} } + + verify_oauth_token_and_build_user( + state, + &extracted.token, + dpop_proof, + method, + &uri, + StatusCheckFlags::default(), + ) + .await + .map(BearerAuth) } } @@ -185,43 +275,43 @@ impl FromRequestParts for BearerAuthAllowDeactivated { let extracted = extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - if extracted.is_dpop { - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); - let method = parts.method.as_str(); - let uri = build_full_url(&parts.uri.to_string()); + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = build_full_url(&parts.uri.to_string()); - match validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - true, - dpop_proof, - method, - &uri, - true, - false, - ) + match validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token) .await - { - Ok(user) => Ok(BearerAuthAllowDeactivated(user)), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), - Err(_) => Err(AuthError::AuthenticationFailed), + { + Ok(user) if !user.is_oauth => { + return if user.status.is_takendown() { + Err(AuthError::AccountTakedown) + } else { + Ok(BearerAuthAllowDeactivated(user)) + }; } - } else { - match validate_bearer_token_cached_allow_deactivated( - state.user_repo.as_ref(), - state.cache.as_ref(), - &extracted.token, - ) - .await - { - Ok(user) => Ok(BearerAuthAllowDeactivated(user)), - Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), - Err(_) => Err(AuthError::AuthenticationFailed), + Ok(_) => {} + Err(super::TokenValidationError::AccountTakedown) => { + return Err(AuthError::AccountTakedown); } + Err(super::TokenValidationError::TokenExpired) => { + return Err(AuthError::TokenExpired); + } + Err(_) => {} } + + verify_oauth_token_and_build_user( + state, + &extracted.token, + dpop_proof, + method, + &uri, + StatusCheckFlags { + allow_deactivated: true, + allow_takendown: false, + }, + ) + .await + .map(BearerAuthAllowDeactivated) } } @@ -244,39 +334,43 @@ impl FromRequestParts for BearerAuthAllowTakendown { let extracted = extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - if extracted.is_dpop { - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); - let method = parts.method.as_str(); - let uri = build_full_url(&parts.uri.to_string()); + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = build_full_url(&parts.uri.to_string()); - match validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - true, - dpop_proof, - method, - &uri, - false, - true, - ) + match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token) .await - { - Ok(user) => Ok(BearerAuthAllowTakendown(user)), - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), - Err(_) => Err(AuthError::AuthenticationFailed), + { + Ok(user) if !user.is_oauth => { + return if user.status.is_deactivated() { + Err(AuthError::AccountDeactivated) + } else { + Ok(BearerAuthAllowTakendown(user)) + }; } - } else { - match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token) - .await - { - Ok(user) => Ok(BearerAuthAllowTakendown(user)), - Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), - Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired), - Err(_) => Err(AuthError::AuthenticationFailed), + Ok(_) => {} + Err(super::TokenValidationError::AccountDeactivated) => { + return Err(AuthError::AccountDeactivated); } + Err(super::TokenValidationError::TokenExpired) => { + return Err(AuthError::TokenExpired); + } + Err(_) => {} } + + verify_oauth_token_and_build_user( + state, + &extracted.token, + dpop_proof, + method, + &uri, + StatusCheckFlags { + allow_deactivated: false, + allow_takendown: true, + }, + ) + .await + .map(BearerAuthAllowTakendown) } } @@ -299,57 +393,45 @@ impl FromRequestParts for BearerAuthAdmin { let extracted = extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; - let user = if extracted.is_dpop { - let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok()); - let method = parts.method.as_str(); - let uri = build_full_url(&parts.uri.to_string()); + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = build_full_url(&parts.uri.to_string()); - match validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - true, - dpop_proof, - method, - &uri, - false, - false, - ) - .await - { - Ok(user) => user, - Err(TokenValidationError::AccountDeactivated) => { + match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await { + Ok(user) if !user.is_oauth => { + if user.status.is_deactivated() { return Err(AuthError::AccountDeactivated); } - Err(TokenValidationError::AccountTakedown) => { + if user.status.is_takendown() { return Err(AuthError::AccountTakedown); } - Err(TokenValidationError::TokenExpired) => { - return Err(AuthError::TokenExpired); + if !user.is_admin { + return Err(AuthError::AdminRequired); } - Err(_) => return Err(AuthError::AuthenticationFailed), + return Ok(BearerAuthAdmin(user)); } - } else { - match validate_bearer_token_cached( - state.user_repo.as_ref(), - state.cache.as_ref(), - &extracted.token, - ) - .await - { - Ok(user) => user, - Err(TokenValidationError::AccountDeactivated) => { - return Err(AuthError::AccountDeactivated); - } - Err(TokenValidationError::AccountTakedown) => { - return Err(AuthError::AccountTakedown); - } - Err(TokenValidationError::TokenExpired) => { - return Err(AuthError::TokenExpired); - } - Err(_) => return Err(AuthError::AuthenticationFailed), + Ok(_) => {} + Err(super::TokenValidationError::AccountDeactivated) => { + return Err(AuthError::AccountDeactivated); } - }; + Err(super::TokenValidationError::AccountTakedown) => { + return Err(AuthError::AccountTakedown); + } + Err(super::TokenValidationError::TokenExpired) => { + return Err(AuthError::TokenExpired); + } + Err(_) => {} + } + + let user = verify_oauth_token_and_build_user( + state, + &extracted.token, + dpop_proof, + method, + &uri, + StatusCheckFlags::default(), + ) + .await?; if !user.is_admin { return Err(AuthError::AdminRequired); @@ -358,6 +440,218 @@ impl FromRequestParts for BearerAuthAdmin { } } +pub struct OptionalBearerAuth(pub Option); + +impl FromRequestParts for OptionalBearerAuth { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_header = match parts.headers.get(AUTHORIZATION) { + Some(h) => match h.to_str() { + Ok(s) => s, + Err(_) => return Ok(OptionalBearerAuth(None)), + }, + None => return Ok(OptionalBearerAuth(None)), + }; + + let extracted = match extract_auth_token_from_header(Some(auth_header)) { + Some(e) => e, + None => return Ok(OptionalBearerAuth(None)), + }; + + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let method = parts.method.as_str(); + let uri = build_full_url(&parts.uri.to_string()); + + if let Ok(user) = validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await + && !user.is_oauth + { + return if user.status.is_deactivated() || user.status.is_takendown() { + Ok(OptionalBearerAuth(None)) + } else { + Ok(OptionalBearerAuth(Some(user))) + }; + } + + Ok(OptionalBearerAuth( + verify_oauth_token_and_build_user( + state, + &extracted.token, + dpop_proof, + method, + &uri, + StatusCheckFlags::default(), + ) + .await + .ok(), + )) + } +} + +pub struct ServiceAuth { + pub claims: ServiceTokenClaims, + pub did: Did, +} + +impl FromRequestParts for ServiceAuth { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + _state: &AppState, + ) -> Result { + let auth_header = parts + .headers + .get(AUTHORIZATION) + .ok_or(AuthError::MissingToken)? + .to_str() + .map_err(|_| AuthError::InvalidFormat)?; + + let extracted = + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; + + if !is_service_token(&extracted.token) { + return Err(AuthError::InvalidFormat); + } + + let verifier = ServiceTokenVerifier::new(); + let claims = verifier + .verify_service_token(&extracted.token, None) + .await + .map_err(|e| { + error!("Service token verification failed: {:?}", e); + AuthError::AuthenticationFailed + })?; + + let did: Did = claims + .iss + .parse() + .map_err(|_| AuthError::AuthenticationFailed)?; + + debug!("Service token verified for DID: {}", did); + + Ok(ServiceAuth { claims, did }) + } +} + +pub struct OptionalServiceAuth(pub Option); + +impl FromRequestParts for OptionalServiceAuth { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + _state: &AppState, + ) -> Result { + let auth_header = match parts.headers.get(AUTHORIZATION) { + Some(h) => match h.to_str() { + Ok(s) => s, + Err(_) => return Ok(OptionalServiceAuth(None)), + }, + None => return Ok(OptionalServiceAuth(None)), + }; + + let extracted = match extract_auth_token_from_header(Some(auth_header)) { + Some(e) => e, + None => return Ok(OptionalServiceAuth(None)), + }; + + if !is_service_token(&extracted.token) { + return Ok(OptionalServiceAuth(None)); + } + + let verifier = ServiceTokenVerifier::new(); + match verifier.verify_service_token(&extracted.token, None).await { + Ok(claims) => { + debug!("Service token verified for DID: {}", claims.iss); + Ok(OptionalServiceAuth(Some(claims))) + } + Err(e) => { + debug!("Service token verification failed (optional): {:?}", e); + Ok(OptionalServiceAuth(None)) + } + } + } +} + +pub enum BlobAuthResult { + Service { did: Did }, + User(AuthenticatedUser), +} + +pub struct BlobAuth(pub BlobAuthResult); + +impl FromRequestParts for BlobAuth { + type Rejection = AuthError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_header = parts + .headers + .get(AUTHORIZATION) + .ok_or(AuthError::MissingToken)? + .to_str() + .map_err(|_| AuthError::InvalidFormat)?; + + let extracted = + extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?; + + if is_service_token(&extracted.token) { + debug!("Verifying service token for blob upload"); + let verifier = ServiceTokenVerifier::new(); + let claims = verifier + .verify_service_token(&extracted.token, Some("com.atproto.repo.uploadBlob")) + .await + .map_err(|e| { + error!("Service token verification failed: {:?}", e); + AuthError::AuthenticationFailed + })?; + + let did: Did = claims + .iss + .parse() + .map_err(|_| AuthError::AuthenticationFailed)?; + + debug!("Service token verified for DID: {}", did); + return Ok(BlobAuth(BlobAuthResult::Service { did })); + } + + let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok()); + let uri = build_full_url("/xrpc/com.atproto.repo.uploadBlob"); + + if let Ok(user) = + validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token) + .await + && !user.is_oauth + { + return if user.status.is_takendown() { + Err(AuthError::AccountTakedown) + } else { + Ok(BlobAuth(BlobAuthResult::User(user))) + }; + } + + verify_oauth_token_and_build_user( + state, + &extracted.token, + dpop_proof, + "POST", + &uri, + StatusCheckFlags { + allow_deactivated: true, + allow_takendown: false, + }, + ) + .await + .map(|user| BlobAuth(BlobAuthResult::User(user))) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index e27c437..6f6348c 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -16,7 +16,8 @@ pub mod verification_token; pub mod webauthn; pub use extractor::{ - AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken, + AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult, + ExtractedToken, OptionalBearerAuth, OptionalServiceAuth, ServiceAuth, extract_auth_token_from_header, extract_bearer_token_from_header, }; pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token}; diff --git a/crates/tranquil-pds/src/lib.rs b/crates/tranquil-pds/src/lib.rs index 1f023b5..1453e2e 100644 --- a/crates/tranquil-pds/src/lib.rs +++ b/crates/tranquil-pds/src/lib.rs @@ -528,7 +528,11 @@ pub fn app(state: AppState) -> Router { )); let xrpc_service = ServiceBuilder::new() .layer(XrpcProxyLayer::new(state.clone())) - .service(xrpc_router.with_state(state.clone())); + .service( + xrpc_router + .layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware)) + .with_state(state.clone()), + ); let oauth_router = Router::new() .route("/jwks", get(oauth::endpoints::oauth_jwks)) @@ -568,6 +572,10 @@ pub fn app(state: AppState) -> Router { "/register/complete", post(oauth::endpoints::register_complete), ) + .route( + "/establish-session", + post(oauth::endpoints::establish_session), + ) .route("/authorize/consent", get(oauth::endpoints::consent_get)) .route("/authorize/consent", post(oauth::endpoints::consent_post)) .route( @@ -605,7 +613,8 @@ pub fn app(state: AppState) -> Router { .route( "/sso/check-handle-available", get(sso::endpoints::check_handle_available), - ); + ) + .layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware)); let well_known_router = Router::new() .route("/did.json", get(api::identity::well_known_did)) diff --git a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs index e3f97b6..c6bfe3b 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/authorize.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/authorize.rs @@ -2,6 +2,7 @@ use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code}; use crate::oauth::{ AuthFlowState, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, SessionId, db::should_show_consent, + scopes::expand_include_scopes, }; use crate::state::{AppState, RateLimitKind}; use crate::types::{Did, Handle, PlainPassword}; @@ -1106,6 +1107,46 @@ pub async fn authorize_select( .oauth_repo .upsert_account_device(&did, &select_device_typed) .await; + + let requested_scope_str = request_data + .parameters + .scope + .as_deref() + .unwrap_or("atproto"); + let requested_scopes: Vec = requested_scope_str + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + let client_id_typed = ClientId::from(request_data.parameters.client_id.clone()); + let needs_consent = should_show_consent( + state.oauth_repo.as_ref(), + &did, + &client_id_typed, + &requested_scopes, + ) + .await + .unwrap_or(true); + + if needs_consent { + if state + .oauth_repo + .set_authorization_did(&select_request_id, &did, Some(&select_device_typed)) + .await + .is_err() + { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "An error occurred. Please try again.", + ); + } + let consent_url = format!( + "/app/oauth/consent?request_uri={}", + url_encode(&form.request_uri) + ); + return Json(serde_json::json!({"redirect_uri": consent_url})).into_response(); + } + let code = Code::generate(); let select_code = AuthorizationCode::from(code.0.clone()); if state @@ -1475,7 +1516,8 @@ pub async fn consent_get( requested_scope_str.to_string() }; - let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); + let expanded_scope_str = expand_include_scopes(&effective_scope_str).await; + let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect(); let consent_client_id = ClientId::from(request_data.parameters.client_id.clone()); let preferences = state .oauth_repo @@ -2407,39 +2449,37 @@ pub async fn passkey_start( } let delegation_from_param = match &form.delegated_did { - Some(delegated_did_str) => { - match delegated_did_str.parse::() { - Ok(delegated_did) if delegated_did != user.did => { - match state - .delegation_repo - .get_delegation(&delegated_did, &user.did) - .await - { - Ok(Some(_)) => Some(delegated_did), - Ok(None) => None, - Err(e) => { - tracing::warn!( - error = %e, - delegated_did = %delegated_did, - controller_did = %user.did, - "Failed to verify delegation relationship" - ); - None - } + Some(delegated_did_str) => match delegated_did_str.parse::() { + Ok(delegated_did) if delegated_did != user.did => { + match state + .delegation_repo + .get_delegation(&delegated_did, &user.did) + .await + { + Ok(Some(_)) => Some(delegated_did), + Ok(None) => None, + Err(e) => { + tracing::warn!( + error = %e, + delegated_did = %delegated_did, + controller_did = %user.did, + "Failed to verify delegation relationship" + ); + None } } - _ => None, } - } + _ => None, + }, None => None, }; let is_delegation_flow = delegation_from_param.is_some() - || request_data.did.as_ref().map_or(false, |existing_did| { + || request_data.did.as_ref().is_some_and(|existing_did| { existing_did .parse::() .ok() - .map_or(false, |parsed| parsed != user.did) + .is_some_and(|parsed| parsed != user.did) }); if let Some(delegated_did) = delegation_from_param { @@ -3601,3 +3641,79 @@ pub async fn register_complete( ); Json(serde_json::json!({"redirect_uri": redirect_url})).into_response() } + +pub async fn establish_session( + State(state): State, + headers: HeaderMap, + auth: crate::auth::BearerAuth, +) -> Response { + let did = &auth.0.did; + + let existing_device = extract_device_cookie(&headers); + + let (device_id, new_cookie) = match existing_device { + Some(id) => { + let device_typed = DeviceIdType::from(id.clone()); + let _ = state + .oauth_repo + .upsert_account_device(did, &device_typed) + .await; + (id, None) + } + None => { + let new_id = DeviceId::generate(); + let device_data = DeviceData { + session_id: SessionId::generate().0, + user_agent: extract_user_agent(&headers), + ip_address: extract_client_ip(&headers), + last_seen_at: Utc::now(), + }; + let device_typed = DeviceIdType::from(new_id.0.clone()); + + if let Err(e) = state.oauth_repo.create_device(&device_typed, &device_data).await { + tracing::error!(error = ?e, "Failed to create device"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "Failed to establish session" + })), + ) + .into_response(); + } + + if let Err(e) = state.oauth_repo.upsert_account_device(did, &device_typed).await { + tracing::error!(error = ?e, "Failed to link device to account"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "server_error", + "error_description": "Failed to establish session" + })), + ) + .into_response(); + } + + (new_id.0.clone(), Some(make_device_cookie(&new_id.0))) + } + }; + + tracing::info!(did = %did, device_id = %device_id, "Device session established"); + + match new_cookie { + Some(cookie) => ( + StatusCode::OK, + [(SET_COOKIE, cookie)], + Json(serde_json::json!({ + "success": true, + "device_id": device_id + })), + ) + .into_response(), + None => Json(serde_json::json!({ + "success": true, + "device_id": device_id + })) + .into_response(), + } +} diff --git a/crates/tranquil-pds/src/oauth/endpoints/delegation.rs b/crates/tranquil-pds/src/oauth/endpoints/delegation.rs index 217ca1e..9df8afd 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/delegation.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/delegation.rs @@ -1,8 +1,8 @@ -use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop}; +use crate::auth::BearerAuth; use crate::delegation::DelegationActionType; use crate::state::{AppState, RateLimitKind}; use crate::types::PlainPassword; -use crate::util::{build_full_url, extract_client_ip}; +use crate::util::extract_client_ip; use axum::{ Json, extract::State, @@ -463,58 +463,10 @@ pub struct DelegationTokenAuthSubmit { pub async fn delegation_auth_token( State(state): State, headers: HeaderMap, + auth: BearerAuth, Json(form): Json, ) -> Response { - let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); - - let extracted = match extract_auth_token_from_header(auth_header) { - Some(e) => e, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(DelegationAuthResponse { - success: false, - needs_totp: None, - redirect_uri: None, - error: Some("Missing or invalid authorization header".to_string()), - }), - ) - .into_response(); - } - }; - - let dpop_proof = headers.get("dpop").and_then(|h| h.to_str().ok()); - let uri = build_full_url("/oauth/delegation/auth-token"); - - let auth_user = match validate_token_with_dpop( - state.user_repo.as_ref(), - state.oauth_repo.as_ref(), - &extracted.token, - extracted.is_dpop, - dpop_proof, - "POST", - &uri, - false, - false, - ) - .await - { - Ok(user) => user, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(DelegationAuthResponse { - success: false, - needs_totp: None, - redirect_uri: None, - error: Some("Invalid or expired access token".to_string()), - }), - ) - .into_response(); - } - }; - - let controller_did = auth_user.did; + let controller_did = auth.0.did; let delegated_did: Did = match form.delegated_did.parse() { Ok(d) => d, diff --git a/crates/tranquil-pds/src/oauth/verify.rs b/crates/tranquil-pds/src/oauth/verify.rs index 3a4d956..7a660d5 100644 --- a/crates/tranquil-pds/src/oauth/verify.rs +++ b/crates/tranquil-pds/src/oauth/verify.rs @@ -10,7 +10,9 @@ use serde_json::json; use sha2::Sha256; use subtle::ConstantTimeEq; use tranquil_db_traits::{OAuthRepository, UserRepository}; -use tranquil_types::TokenId; +use tranquil_types::{ClientId, TokenId}; + +use crate::types::Did; use super::scopes::ScopePermissions; use super::{DPoPVerifier, OAuthError}; @@ -27,9 +29,9 @@ pub struct OAuthTokenInfo { } pub struct VerifyResult { - pub did: String, - pub token_id: String, - pub client_id: String, + pub did: Did, + pub token_id: TokenId, + pub client_id: ClientId, pub scope: Option, } @@ -91,10 +93,14 @@ pub async fn verify_oauth_access_token( )); } } + let did: Did = token_data + .did + .parse() + .map_err(|_| OAuthError::InvalidToken("Invalid DID in token".to_string()))?; Ok(VerifyResult { - did: token_data.did, - token_id: token_id.to_string(), - client_id: token_data.client_id, + did, + token_id, + client_id: ClientId::from(token_data.client_id), scope: token_data.scope, }) } @@ -202,8 +208,8 @@ pub fn generate_dpop_nonce() -> String { } pub struct OAuthUser { - pub did: String, - pub client_id: Option, + pub did: Did, + pub client_id: Option, pub scope: Option, pub is_oauth: bool, pub permissions: ScopePermissions, @@ -382,7 +388,7 @@ impl FromRequestParts for OAuthUser { } struct LegacyAuthResult { - did: String, + did: Did, } async fn try_legacy_auth( @@ -390,9 +396,21 @@ async fn try_legacy_auth( token: &str, ) -> Result { match crate::auth::validate_bearer_token(user_repo, token).await { - Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { - did: user.did.to_string(), - }), + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), _ => Err(()), } } + +pub async fn dpop_nonce_middleware( + req: axum::http::Request, + next: axum::middleware::Next, +) -> Response { + let mut response = next.run(req).await; + let config = AuthConfig::get(); + let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); + let nonce = verifier.generate_nonce(); + if let Ok(nonce_val) = nonce.parse() { + response.headers_mut().insert("DPoP-Nonce", nonce_val); + } + response +} diff --git a/crates/tranquil-pds/tests/auth_extractor.rs b/crates/tranquil-pds/tests/auth_extractor.rs new file mode 100644 index 0000000..fae4214 --- /dev/null +++ b/crates/tranquil-pds/tests/auth_extractor.rs @@ -0,0 +1,583 @@ +mod common; +mod helpers; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::Utc; +use common::{base_url, client, create_account_and_login, pds_endpoint}; +use helpers::verify_new_account; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn generate_pkce() -> (String, String) { + let verifier_bytes: [u8; 32] = rand::random(); + let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); + (code_verifier, code_challenge) +} + +async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer { + let mock_server = MockServer::start().await; + let metadata = json!({ + "client_id": mock_server.uri(), + "client_name": "Auth Extractor Test Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": dpop_bound + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + mock_server +} + +async fn get_oauth_session( + http_client: &reqwest::Client, + url: &str, + dpop_bound: bool, +) -> (String, String, String, String) { + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("ae{}", suffix); + let password = "AuthExtract123!"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap().to_string(); + verify_new_account(http_client, &did).await; + + let redirect_uri = "https://example.com/auth-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_body: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_body: Value = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + ( + token_body["access_token"].as_str().unwrap().to_string(), + token_body["refresh_token"].as_str().unwrap().to_string(), + client_id, + did, + ) +} + +#[tokio::test] +async fn test_oauth_token_works_with_bearer_auth() { + let url = base_url().await; + let http_client = client(); + let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await; + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&access_token) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::OK, + "OAuth token should work with BearerAuth extractor" + ); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["did"].as_str().unwrap(), did); +} + +#[tokio::test] +async fn test_session_token_still_works() { + let url = base_url().await; + let http_client = client(); + let (jwt, did) = create_account_and_login(&http_client).await; + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&jwt) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::OK, + "Session token should still work" + ); + let body: Value = res.json().await.unwrap(); + assert_eq!(body["did"].as_str().unwrap(), did); +} + +#[tokio::test] +async fn test_oauth_admin_extractor_allows_oauth_tokens() { + let url = base_url().await; + let http_client = client(); + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("adm{}", suffix); + let password = "AdminOAuth123!"; + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": password + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap().to_string(); + verify_new_account(&http_client, &did).await; + + let pool = common::get_test_db_pool().await; + sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did) + .execute(pool) + .await + .expect("Failed to mark user as admin"); + + let redirect_uri = "https://example.com/admin-callback"; + let mock_client = setup_mock_client_metadata(redirect_uri, false).await; + let client_id = mock_client.uri(); + let (code_verifier, code_challenge) = generate_pkce(); + + let par_body: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let request_uri = par_body["request_uri"].as_str().unwrap(); + + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": password, + "remember_device": false + })) + .send() + .await + .unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + let token_body: Value = http_client + .post(format!("{}/oauth/token", url)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let access_token = token_body["access_token"].as_str().unwrap(); + + let res = http_client + .get(format!( + "{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", + url, did + )) + .bearer_auth(access_token) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::OK, + "OAuth token for admin user should work with admin endpoint" + ); +} + +#[tokio::test] +async fn test_expired_oauth_token_returns_proper_error() { + let url = base_url().await; + let http_client = client(); + + let now = Utc::now().timestamp(); + let header = json!({"alg": "HS256", "typ": "at+jwt"}); + let payload = json!({ + "iss": url, + "sub": "did:plc:test123", + "aud": url, + "iat": now - 7200, + "exp": now - 3600, + "jti": "expired-token", + "sid": "expired-session", + "scope": "atproto", + "client_id": "https://example.com" + }); + let fake_token = format!( + "{}.{}.{}", + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()), + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), + URL_SAFE_NO_PAD.encode([1u8; 32]) + ); + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .bearer_auth(&fake_token) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "Expired token should be rejected" + ); +} + +#[tokio::test] +async fn test_dpop_nonce_error_has_proper_headers() { + let url = base_url().await; + let pds_url = pds_endpoint(); + let http_client = client(); + + let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; + let handle = format!("dpop{}", suffix); + let create_res = http_client + .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) + .json(&json!({ + "handle": handle, + "email": format!("{}@test.com", handle), + "password": "DpopTest123!" + })) + .send() + .await + .unwrap(); + assert_eq!(create_res.status(), StatusCode::OK); + let account: Value = create_res.json().await.unwrap(); + let did = account["did"].as_str().unwrap(); + verify_new_account(&http_client, did).await; + + let redirect_uri = "https://example.com/dpop-callback"; + let mock_server = MockServer::start().await; + let client_id = mock_server.uri(); + let metadata = json!({ + "client_id": &client_id, + "client_name": "DPoP Test Client", + "redirect_uris": [redirect_uri], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true + }); + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&mock_server) + .await; + + let (code_verifier, code_challenge) = generate_pkce(); + let par_body: Value = http_client + .post(format!("{}/oauth/par", url)) + .form(&[ + ("response_type", "code"), + ("client_id", &client_id), + ("redirect_uri", redirect_uri), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + + let request_uri = par_body["request_uri"].as_str().unwrap(); + let auth_res = http_client + .post(format!("{}/oauth/authorize", url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(&json!({ + "request_uri": request_uri, + "username": &handle, + "password": "DpopTest123!", + "remember_device": false + })) + .send() + .await + .unwrap(); + let auth_body: Value = auth_res.json().await.unwrap(); + let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); + if location.contains("/oauth/consent") { + let consent_res = http_client + .post(format!("{}/oauth/authorize/consent", url)) + .header("Content-Type", "application/json") + .json(&json!({ + "request_uri": request_uri, + "approved_scopes": ["atproto"], + "remember": false + })) + .send() + .await + .unwrap(); + let consent_body: Value = consent_res.json().await.unwrap(); + location = consent_body["redirect_uri"].as_str().unwrap().to_string(); + } + + let code = location + .split("code=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap(); + + let token_endpoint = format!("{}/oauth/token", pds_url); + let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None); + + let token_res = http_client + .post(format!("{}/oauth/token", url)) + .header("DPoP", &dpop_proof) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + + let token_status = token_res.status(); + let token_nonce = token_res + .headers() + .get("dpop-nonce") + .map(|h| h.to_str().unwrap().to_string()); + let token_body: Value = token_res.json().await.unwrap(); + + let access_token = if token_status == StatusCode::OK { + token_body["access_token"].as_str().unwrap().to_string() + } else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") { + let nonce = + token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error"); + let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce)); + + let retry_res = http_client + .post(format!("{}/oauth/token", url)) + .header("DPoP", &dpop_proof_with_nonce) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("code_verifier", &code_verifier), + ("client_id", &client_id), + ]) + .send() + .await + .unwrap(); + let retry_body: Value = retry_res.json().await.unwrap(); + retry_body["access_token"] + .as_str() + .expect("Should get access_token after nonce retry") + .to_string() + } else { + panic!("Token exchange failed unexpectedly: {:?}", token_body); + }; + + let res = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("DPoP {}", access_token)) + .send() + .await + .unwrap(); + + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "DPoP token without proof should fail" + ); + + let www_auth = res + .headers() + .get("www-authenticate") + .map(|h| h.to_str().unwrap()); + assert!(www_auth.is_some(), "Should have WWW-Authenticate header"); + assert!( + www_auth.unwrap().contains("use_dpop_nonce"), + "WWW-Authenticate should indicate dpop nonce required" + ); + + let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap()); + assert!(nonce.is_some(), "Should return DPoP-Nonce header"); + + let body: Value = res.json().await.unwrap(); + assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce"); +} + +fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) { + use p256::ecdsa::{SigningKey, signature::Signer}; + use p256::elliptic_curve::rand_core::OsRng; + + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let point = verifying_key.to_encoded_point(false); + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); + + let jwk = json!({ + "kty": "EC", + "crv": "P-256", + "x": x, + "y": y + }); + + let header = { + let h = json!({ + "typ": "dpop+jwt", + "alg": "ES256", + "jwk": jwk.clone() + }); + h + }; + + let mut payload = json!({ + "jti": uuid::Uuid::new_v4().to_string(), + "htm": method, + "htu": uri, + "iat": Utc::now().timestamp() + }); + if let Some(n) = nonce { + payload["nonce"] = json!(n); + } + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); + let signing_input = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let proof = format!("{}.{}", signing_input, sig_b64); + (jwk, proof) +} diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index c0c394e..3be4c54 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -1,8 +1,8 @@ -#[cfg(feature = "s3-storage")] +#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))] use aws_config::BehaviorVersion; -#[cfg(feature = "s3-storage")] +#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))] use aws_sdk_s3::Client as S3Client; -#[cfg(feature = "s3-storage")] +#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))] use aws_sdk_s3::config::Credentials; use chrono::Utc; use reqwest::{Client, StatusCode, header}; diff --git a/crates/tranquil-pds/tests/oauth_security.rs b/crates/tranquil-pds/tests/oauth_security.rs index 7fd85d4..5ef4201 100644 --- a/crates/tranquil-pds/tests/oauth_security.rs +++ b/crates/tranquil-pds/tests/oauth_security.rs @@ -1373,10 +1373,16 @@ async fn test_delegation_oauth_token_sub_is_delegated_account() { .send() .await .unwrap(); - assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed"); + assert_eq!( + token_res.status(), + StatusCode::OK, + "Token exchange should succeed" + ); let tokens: Value = token_res.json().await.unwrap(); - let sub = tokens["sub"].as_str().expect("Token response should have sub claim"); + let sub = tokens["sub"] + .as_str() + .expect("Token response should have sub claim"); assert_eq!( sub, delegated_did, diff --git a/crates/tranquil-scopes/Cargo.toml b/crates/tranquil-scopes/Cargo.toml index 985d823..df6360d 100644 --- a/crates/tranquil-scopes/Cargo.toml +++ b/crates/tranquil-scopes/Cargo.toml @@ -7,8 +7,10 @@ license.workspace = true [dependencies] axum = { workspace = true } futures = { workspace = true } +hickory-resolver = { version = "0.24", features = ["tokio-runtime"] } reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } +urlencoding = "2" diff --git a/crates/tranquil-scopes/src/permission_set.rs b/crates/tranquil-scopes/src/permission_set.rs index b120de9..29cf6d2 100644 --- a/crates/tranquil-scopes/src/permission_set.rs +++ b/crates/tranquil-scopes/src/permission_set.rs @@ -1,3 +1,4 @@ +use hickory_resolver::TokioAsyncResolver; use reqwest::Client; use serde::Deserialize; use std::collections::HashMap; @@ -16,6 +17,23 @@ struct CachedLexicon { const CACHE_TTL_SECS: u64 = 3600; +#[derive(Debug, Deserialize)] +struct PlcDocument { + service: Vec, +} + +#[derive(Debug, Deserialize)] +struct PlcService { + id: String, + #[serde(rename = "serviceEndpoint")] + service_endpoint: String, +} + +#[derive(Debug, Deserialize)] +struct GetRecordResponse { + value: LexiconDoc, +} + #[derive(Debug, Deserialize)] struct LexiconDoc { defs: HashMap, @@ -31,7 +49,10 @@ struct LexiconDef { #[derive(Debug, Deserialize)] struct PermissionEntry { resource: String, + action: Option>, collection: Option>, + lxm: Option>, + aud: Option, } pub async fn expand_include_scopes(scope_string: &str) -> String { @@ -39,12 +60,14 @@ pub async fn expand_include_scopes(scope_string: &str) -> String { .split_whitespace() .map(|scope| async move { match scope.strip_prefix("include:") { - Some(nsid) => { - let nsid_base = nsid.split('?').next().unwrap_or(nsid); - expand_permission_set(nsid_base).await.unwrap_or_else(|e| { - warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original"); - scope.to_string() - }) + Some(rest) => { + let (nsid_base, aud) = parse_include_scope(rest); + expand_permission_set(nsid_base, aud) + .await + .unwrap_or_else(|e| { + warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original"); + scope.to_string() + }) } None => scope.to_string(), } @@ -54,10 +77,24 @@ pub async fn expand_include_scopes(scope_string: &str) -> String { futures::future::join_all(futures).await.join(" ") } -async fn expand_permission_set(nsid: &str) -> Result { +fn parse_include_scope(rest: &str) -> (&str, Option<&str>) { + rest.split_once('?') + .map(|(nsid, params)| { + let aud = params.split('&').find_map(|p| p.strip_prefix("aud=")); + (nsid, aud) + }) + .unwrap_or((rest, None)) +} + +async fn expand_permission_set(nsid: &str, aud: Option<&str>) -> Result { + let cache_key = match aud { + Some(a) => format!("{}?aud={}", nsid, a), + None => nsid.to_string(), + }; + { let cache = LEXICON_CACHE.read().await; - if let Some(cached) = cache.get(nsid) + if let Some(cached) = cache.get(&cache_key) && cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS { debug!(nsid, "Using cached permission set expansion"); @@ -65,41 +102,7 @@ async fn expand_permission_set(nsid: &str) -> Result { } } - let parts: Vec<&str> = nsid.split('.').collect(); - if parts.len() < 3 { - return Err(format!("Invalid NSID format: {}", nsid)); - } - - let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); - let domain = domain_parts.join("."); - let path = parts[2..].join("/"); - - let url = format!("https://{}/lexicons/{}.json", domain, path); - debug!(nsid, url = %url, "Fetching permission set lexicon"); - - let client = Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let response = client - .get(&url) - .header("Accept", "application/json") - .send() - .await - .map_err(|e| format!("Failed to fetch lexicon: {}", e))?; - - if !response.status().is_success() { - return Err(format!( - "Failed to fetch lexicon: HTTP {}", - response.status() - )); - } - - let lexicon: LexiconDoc = response - .json() - .await - .map_err(|e| format!("Failed to parse lexicon: {}", e))?; + let lexicon = fetch_lexicon_via_atproto(nsid).await?; let main_def = lexicon .defs @@ -118,31 +121,17 @@ async fn expand_permission_set(nsid: &str) -> Result { .as_ref() .ok_or("Missing permissions in permission-set")?; - let mut collections: Vec = permissions - .iter() - .filter(|perm| perm.resource == "repo") - .filter_map(|perm| perm.collection.as_ref()) - .flatten() - .cloned() - .collect(); + let namespace_authority = extract_namespace_authority(nsid); + let expanded = build_expanded_scopes(permissions, aud, &namespace_authority); - if collections.is_empty() { - return Err("No repo collections found in permission-set".to_string()); + if expanded.is_empty() { + return Err("No valid permissions found in permission-set".to_string()); } - collections.sort(); - - let collection_params: Vec = collections - .iter() - .map(|c| format!("collection={}", c)) - .collect(); - - let expanded = format!("repo?{}", collection_params.join("&")); - { let mut cache = LEXICON_CACHE.write().await; cache.insert( - nsid.to_string(), + cache_key, CachedLexicon { expanded_scope: expanded.clone(), cached_at: std::time::Instant::now(), @@ -154,17 +143,470 @@ async fn expand_permission_set(nsid: &str) -> Result { Ok(expanded) } -#[cfg(test)] -mod tests { - #[test] - fn test_nsid_to_url() { - let nsid = "io.atcr.authFullApp"; - let parts: Vec<&str> = nsid.split('.').collect(); - let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect(); - let domain = domain_parts.join("."); - let path = parts[2..].join("/"); +async fn fetch_lexicon_via_atproto(nsid: &str) -> Result { + let parts: Vec<&str> = nsid.split('.').collect(); + if parts.len() < 3 { + return Err(format!("Invalid NSID format: {}", nsid)); + } - assert_eq!(domain, "atcr.io"); - assert_eq!(path, "authFullApp"); + let authority = parts[..2].iter().rev().cloned().collect::>().join("."); + debug!(nsid, authority = %authority, "Resolving lexicon DID authority via DNS"); + + let did = resolve_lexicon_did_authority(&authority).await?; + debug!(nsid, did = %did, "Resolved lexicon DID authority"); + + let pds_endpoint = resolve_did_to_pds(&did).await?; + debug!(nsid, pds = %pds_endpoint, "Resolved DID to PDS endpoint"); + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let url = format!( + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}", + pds_endpoint, + urlencoding::encode(&did), + urlencoding::encode(nsid) + ); + debug!(nsid, url = %url, "Fetching lexicon from PDS"); + + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to fetch lexicon: {}", e))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to fetch lexicon: HTTP {}", + response.status() + )); + } + + let record: GetRecordResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse lexicon response: {}", e))?; + + Ok(record.value) +} + +async fn resolve_lexicon_did_authority(authority: &str) -> Result { + let resolver = TokioAsyncResolver::tokio_from_system_conf() + .map_err(|e| format!("Failed to create DNS resolver: {}", e))?; + + let dns_name = format!("_lexicon.{}", authority); + debug!(dns_name = %dns_name, "Looking up DNS TXT record"); + + let txt_records = resolver + .txt_lookup(&dns_name) + .await + .map_err(|e| format!("DNS lookup failed for {}: {}", dns_name, e))?; + + txt_records + .iter() + .flat_map(|record| record.iter()) + .find_map(|data| { + let txt = String::from_utf8_lossy(data); + txt.strip_prefix("did=").map(|did| did.to_string()) + }) + .ok_or_else(|| format!("No valid did= TXT record found at {}", dns_name)) +} + +async fn resolve_did_to_pds(did: &str) -> Result { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let url = if did.starts_with("did:plc:") { + format!("https://plc.directory/{}", did) + } else if did.starts_with("did:web:") { + let domain = did.strip_prefix("did:web:").unwrap(); + format!("https://{}/.well-known/did.json", domain) + } else { + return Err(format!("Unsupported DID method: {}", did)); + }; + + let response = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Failed to resolve DID: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Failed to resolve DID: HTTP {}", response.status())); + } + + let doc: PlcDocument = response + .json() + .await + .map_err(|e| format!("Failed to parse DID document: {}", e))?; + + doc.service + .iter() + .find(|s| s.id == "#atproto_pds") + .map(|s| s.service_endpoint.clone()) + .ok_or_else(|| "No #atproto_pds service found in DID document".to_string()) +} + +fn extract_namespace_authority(nsid: &str) -> String { + let parts: Vec<&str> = nsid.split('.').collect(); + if parts.len() >= 2 { + parts[..parts.len() - 1].join(".") + } else { + nsid.to_string() + } +} + +fn is_under_authority(target_nsid: &str, authority: &str) -> bool { + target_nsid.starts_with(authority) + && target_nsid + .chars() + .nth(authority.len()) + .is_some_and(|c| c == '.') +} + +const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"]; + +fn build_expanded_scopes( + permissions: &[PermissionEntry], + default_aud: Option<&str>, + namespace_authority: &str, +) -> String { + let mut scopes: Vec = Vec::new(); + + permissions.iter().for_each(|perm| match perm.resource.as_str() { + "repo" => { + if let Some(collections) = &perm.collection { + let actions: Vec<&str> = perm + .action + .as_ref() + .map(|a| a.iter().map(String::as_str).collect()) + .unwrap_or_else(|| DEFAULT_ACTIONS.to_vec()); + + collections + .iter() + .filter(|coll| is_under_authority(coll, namespace_authority)) + .for_each(|coll| { + actions.iter().for_each(|action| { + scopes.push(format!("repo:{}?action={}", coll, action)); + }); + }); + } + } + "rpc" => { + if let Some(lxms) = &perm.lxm { + let perm_aud = perm.aud.as_deref().or(default_aud); + + lxms.iter().for_each(|lxm| { + let scope = match perm_aud { + Some(aud) => format!("rpc:{}?aud={}", lxm, aud), + None => format!("rpc:{}", lxm), + }; + scopes.push(scope); + }); + } + } + _ => {} + }); + + scopes.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_include_scope() { + let (nsid, aud) = parse_include_scope("io.atcr.authFullApp"); + assert_eq!(nsid, "io.atcr.authFullApp"); + assert_eq!(aud, None); + + let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?aud=did:web:api.bsky.app"); + assert_eq!(nsid, "io.atcr.authFullApp"); + assert_eq!(aud, Some("did:web:api.bsky.app")); + } + + #[test] + fn test_parse_include_scope_with_multiple_params() { + let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?foo=bar&aud=did:web:example.com&baz=qux"); + assert_eq!(nsid, "io.atcr.authFullApp"); + assert_eq!(aud, Some("did:web:example.com")); + } + + #[test] + fn test_extract_namespace_authority() { + assert_eq!( + extract_namespace_authority("io.atcr.authFullApp"), + "io.atcr" + ); + assert_eq!( + extract_namespace_authority("app.bsky.authFullApp"), + "app.bsky" + ); + } + + #[test] + fn test_extract_namespace_authority_deep_nesting() { + assert_eq!( + extract_namespace_authority("io.atcr.sailor.star.collection"), + "io.atcr.sailor.star" + ); + } + + #[test] + fn test_extract_namespace_authority_single_segment() { + assert_eq!(extract_namespace_authority("single"), "single"); + } + + #[test] + fn test_is_under_authority() { + assert!(is_under_authority("io.atcr.manifest", "io.atcr")); + assert!(is_under_authority("io.atcr.sailor.star", "io.atcr")); + assert!(!is_under_authority("app.bsky.feed.post", "io.atcr")); + assert!(!is_under_authority("io.atcr", "io.atcr")); + } + + #[test] + fn test_is_under_authority_prefix_collision() { + assert!(!is_under_authority("io.atcritical.something", "io.atcr")); + assert!(is_under_authority("io.atcr.something", "io.atcr")); + } + + #[test] + fn test_build_expanded_scopes_repo() { + let permissions = vec![PermissionEntry { + resource: "repo".to_string(), + action: Some(vec!["create".to_string(), "delete".to_string()]), + collection: Some(vec![ + "io.atcr.manifest".to_string(), + "io.atcr.sailor.star".to_string(), + "app.bsky.feed.post".to_string(), + ]), + lxm: None, + aud: None, + }]; + + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert!(expanded.contains("repo:io.atcr.manifest?action=create")); + assert!(expanded.contains("repo:io.atcr.manifest?action=delete")); + assert!(expanded.contains("repo:io.atcr.sailor.star?action=create")); + assert!(!expanded.contains("app.bsky.feed.post")); + } + + #[test] + fn test_build_expanded_scopes_repo_default_actions() { + let permissions = vec![PermissionEntry { + resource: "repo".to_string(), + action: None, + collection: Some(vec!["io.atcr.manifest".to_string()]), + lxm: None, + aud: None, + }]; + + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert!(expanded.contains("repo:io.atcr.manifest?action=create")); + assert!(expanded.contains("repo:io.atcr.manifest?action=update")); + assert!(expanded.contains("repo:io.atcr.manifest?action=delete")); + } + + #[test] + fn test_build_expanded_scopes_rpc() { + let permissions = vec![PermissionEntry { + resource: "rpc".to_string(), + action: None, + collection: None, + lxm: Some(vec![ + "io.atcr.getManifest".to_string(), + "com.atproto.repo.getRecord".to_string(), + ]), + aud: Some("*".to_string()), + }]; + + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*")); + assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*")); + } + + #[test] + fn test_build_expanded_scopes_rpc_with_default_aud() { + let permissions = vec![PermissionEntry { + resource: "rpc".to_string(), + action: None, + collection: None, + lxm: Some(vec!["io.atcr.getManifest".to_string()]), + aud: None, + }]; + + let expanded = build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr"); + assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com")); + } + + #[test] + fn test_build_expanded_scopes_rpc_no_aud() { + let permissions = vec![PermissionEntry { + resource: "rpc".to_string(), + action: None, + collection: None, + lxm: Some(vec!["io.atcr.getManifest".to_string()]), + aud: None, + }]; + + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert_eq!(expanded, "rpc:io.atcr.getManifest"); + } + + #[test] + fn test_build_expanded_scopes_mixed_permissions() { + let permissions = vec![ + PermissionEntry { + resource: "repo".to_string(), + action: Some(vec!["create".to_string()]), + collection: Some(vec!["io.atcr.manifest".to_string()]), + lxm: None, + aud: None, + }, + PermissionEntry { + resource: "rpc".to_string(), + action: None, + collection: None, + lxm: Some(vec!["com.atproto.repo.getRecord".to_string()]), + aud: Some("*".to_string()), + }, + ]; + + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert!(expanded.contains("repo:io.atcr.manifest?action=create")); + assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*")); + } + + #[test] + fn test_build_expanded_scopes_unknown_resource_ignored() { + let permissions = vec![PermissionEntry { + resource: "unknown".to_string(), + action: None, + collection: Some(vec!["io.atcr.manifest".to_string()]), + lxm: None, + aud: None, + }]; + + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert!(expanded.is_empty()); + } + + #[test] + fn test_build_expanded_scopes_empty_permissions() { + let permissions: Vec = vec![]; + let expanded = build_expanded_scopes(&permissions, None, "io.atcr"); + assert!(expanded.is_empty()); + } + + #[tokio::test] + async fn test_expand_include_scopes_passthrough_non_include() { + let result = expand_include_scopes("atproto transition:generic").await; + assert_eq!(result, "atproto transition:generic"); + } + + #[tokio::test] + async fn test_expand_include_scopes_mixed_with_regular() { + let result = expand_include_scopes("atproto repo:app.bsky.feed.post?action=create").await; + assert!(result.contains("atproto")); + assert!(result.contains("repo:app.bsky.feed.post?action=create")); + } + + #[tokio::test] + async fn test_cache_population_and_retrieval() { + let cache_key = "test.cached.scope"; + let cached_value = "repo:test.cached.collection?action=create"; + + { + let mut cache = LEXICON_CACHE.write().await; + cache.insert( + cache_key.to_string(), + CachedLexicon { + expanded_scope: cached_value.to_string(), + cached_at: std::time::Instant::now(), + }, + ); + } + + let result = expand_permission_set(cache_key, None).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), cached_value); + + { + let mut cache = LEXICON_CACHE.write().await; + cache.remove(cache_key); + } + } + + #[tokio::test] + async fn test_cache_with_aud_parameter() { + let nsid = "test.aud.scope"; + let aud = "did:web:example.com"; + let cache_key = format!("{}?aud={}", nsid, aud); + let cached_value = "rpc:test.aud.method?aud=did:web:example.com"; + + { + let mut cache = LEXICON_CACHE.write().await; + cache.insert( + cache_key.clone(), + CachedLexicon { + expanded_scope: cached_value.to_string(), + cached_at: std::time::Instant::now(), + }, + ); + } + + let result = expand_permission_set(nsid, Some(aud)).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), cached_value); + + { + let mut cache = LEXICON_CACHE.write().await; + cache.remove(&cache_key); + } + } + + #[tokio::test] + async fn test_expired_cache_triggers_refresh() { + let cache_key = "test.expired.scope"; + + { + let mut cache = LEXICON_CACHE.write().await; + cache.insert( + cache_key.to_string(), + CachedLexicon { + expanded_scope: "old_value".to_string(), + cached_at: std::time::Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1), + }, + ); + } + + let result = expand_permission_set(cache_key, None).await; + assert!(result.is_err()); + + { + let mut cache = LEXICON_CACHE.write().await; + cache.remove(cache_key); + } + } + + #[test] + fn test_nsid_authority_extraction_for_dns() { + let nsid = "io.atcr.authFullApp"; + let parts: Vec<&str> = nsid.split('.').collect(); + let authority = parts[..2].iter().rev().cloned().collect::>().join("."); + assert_eq!(authority, "atcr.io"); + + let nsid2 = "app.bsky.feed.post"; + let parts2: Vec<&str> = nsid2.split('.').collect(); + let authority2 = parts2[..2].iter().rev().cloned().collect::>().join("."); + assert_eq!(authority2, "bsky.app"); } } diff --git a/crates/tranquil-scopes/src/permissions.rs b/crates/tranquil-scopes/src/permissions.rs index d30b686..de3bebc 100644 --- a/crates/tranquil-scopes/src/permissions.rs +++ b/crates/tranquil-scopes/src/permissions.rs @@ -126,7 +126,7 @@ impl ScopePermissions { return Ok(()); } - let has_permission = self.find_repo_scopes().any(|repo_scope| { + let has_repo_permission = self.find_repo_scopes().any(|repo_scope| { repo_scope.actions.contains(&action) && match &repo_scope.collection { None => true, @@ -140,7 +140,7 @@ impl ScopePermissions { } }); - if has_permission { + if has_repo_permission { Ok(()) } else { Err(ScopeError::InsufficientScope { @@ -181,6 +181,8 @@ impl ScopePermissions { return Ok(()); } + let aud_base = aud.split('#').next().unwrap_or(aud); + let has_permission = self.find_rpc_scopes().any(|rpc_scope| { let lxm_matches = match &rpc_scope.lxm { None => true, @@ -195,7 +197,10 @@ impl ScopePermissions { let aud_matches = match &rpc_scope.aud { None => true, Some(scope_aud) if scope_aud == "*" => true, - Some(scope_aud) => scope_aud == aud, + Some(scope_aud) => { + let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud); + scope_aud_base == aud_base + } }; lxm_matches && aud_matches @@ -521,4 +526,35 @@ mod tests { assert!(perms.allows_blob("image/png")); assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); } + + #[test] + fn test_rpc_scope_with_did_fragment() { + let perms = ScopePermissions::from_scope_string(Some( + "rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview", + )); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); + assert!(perms.allows_rpc( + "did:web:api.bsky.app#bsky_appview", + "app.bsky.feed.getAuthorFeed" + )); + assert!(perms.allows_rpc( + "did:web:api.bsky.app#other_service", + "app.bsky.feed.getAuthorFeed" + )); + assert!(!perms.allows_rpc("did:web:other.app", "app.bsky.feed.getAuthorFeed")); + assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline")); + } + + #[test] + fn test_rpc_scope_without_fragment_matches_with_fragment() { + let perms = ScopePermissions::from_scope_string(Some( + "rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app", + )); + assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed")); + assert!(perms.allows_rpc( + "did:web:api.bsky.app#bsky_appview", + "app.bsky.feed.getAuthorFeed" + )); + } + } diff --git a/crates/tranquil-storage/src/lib.rs b/crates/tranquil-storage/src/lib.rs index b72c93c..b683688 100644 --- a/crates/tranquil-storage/src/lib.rs +++ b/crates/tranquil-storage/src/lib.rs @@ -22,9 +22,8 @@ const EXDEV: i32 = 18; const CID_SHARD_PREFIX_LEN: usize = 9; fn split_cid_path(key: &str) -> Option<(&str, &str)> { - let is_cid = key.get(..3).map_or(false, |p| p.eq_ignore_ascii_case("baf")); - (key.len() > CID_SHARD_PREFIX_LEN && is_cid) - .then(|| key.split_at(CID_SHARD_PREFIX_LEN)) + let is_cid = key.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("baf")); + (key.len() > CID_SHARD_PREFIX_LEN && is_cid).then(|| key.split_at(CID_SHARD_PREFIX_LEN)) } fn validate_key(key: &str) -> Result<(), StorageError> { @@ -771,7 +770,10 @@ mod tests { let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; assert_eq!( split_cid_path(cid), - Some(("bafkreihd", "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")) + Some(( + "bafkreihd", + "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" + )) ); } @@ -780,7 +782,10 @@ mod tests { let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"; assert_eq!( split_cid_path(cid), - Some(("bafyreigd", "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje")) + Some(( + "bafyreigd", + "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje" + )) ); } @@ -810,11 +815,17 @@ mod tests { let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"; assert_eq!( split_cid_path(upper), - Some(("BAFKREIHD", "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU")) + Some(( + "BAFKREIHD", + "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU" + )) ); assert_eq!( split_cid_path(mixed), - Some(("BaFkReIhD", "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu")) + Some(( + "BaFkReIhD", + "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu" + )) ); } @@ -829,11 +840,10 @@ mod tests { let base = PathBuf::from("/blobs"); let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; - let expected = PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"); - let result = split_cid_path(cid).map_or_else( - || base.join(cid), - |(dir, file)| base.join(dir).join(file), - ); + let expected = + PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"); + let result = split_cid_path(cid) + .map_or_else(|| base.join(cid), |(dir, file)| base.join(dir).join(file)); assert_eq!(result, expected); } @@ -843,10 +853,8 @@ mod tests { let key = "temp/abc123"; let expected = PathBuf::from("/blobs/temp/abc123"); - let result = split_cid_path(key).map_or_else( - || base.join(key), - |(dir, file)| base.join(dir).join(file), - ); + let result = split_cid_path(key) + .map_or_else(|| base.join(key), |(dir, file)| base.join(dir).join(file)); assert_eq!(result, expected); } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c5b708c..18975c4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -16,6 +16,11 @@ import { unsafeAsISODate, unsafeAsRefreshToken, } from "./types/branded.ts"; +import { + createDPoPProofForRequest, + getDPoPNonce, + setDPoPNonce, +} from "./oauth.ts"; import type { AccountInfo, ApiErrorCode, @@ -91,52 +96,107 @@ export class ApiError extends Error { } } -let tokenRefreshCallback: (() => Promise) | null = null; +let tokenRefreshCallback: (() => Promise) | null = null; export function setTokenRefreshCallback( - callback: () => Promise, + callback: () => Promise, ) { tokenRefreshCallback = callback; } +interface AuthenticatedFetchOptions { + method?: "GET" | "POST"; + token: AccessToken | RefreshToken; + headers?: Record; + body?: BodyInit; +} + +async function authenticatedFetch( + url: string, + options: AuthenticatedFetchOptions, +): Promise { + const { method = "GET", token, headers = {}, body } = options; + const fullUrl = url.startsWith("http") + ? url + : `${globalThis.location.origin}${url}`; + const dpopProof = await createDPoPProofForRequest(method, fullUrl, token); + const res = await fetch(url, { + method, + headers: { + ...headers, + Authorization: `DPoP ${token}`, + DPoP: dpopProof, + }, + body, + }); + const dpopNonce = res.headers.get("DPoP-Nonce"); + if (dpopNonce) { + setDPoPNonce(dpopNonce); + } + return res; +} + interface XrpcOptions { method?: "GET" | "POST"; params?: Record; body?: unknown; - token?: string; + token?: AccessToken | RefreshToken; skipRetry?: boolean; + skipDpopRetry?: boolean; } async function xrpc(method: string, options?: XrpcOptions): Promise { - const { method: httpMethod = "GET", params, body, token, skipRetry } = - options ?? {}; + const { + method: httpMethod = "GET", + params, + body, + token, + skipRetry, + skipDpopRetry, + } = options ?? {}; let url = `${API_BASE}/${method}`; if (params) { const searchParams = new URLSearchParams(params); url += `?${searchParams}`; } const headers: Record = {}; - if (token) { - headers["Authorization"] = `Bearer ${token}`; - } if (body) { headers["Content-Type"] = "application/json"; } - const res = await fetch(url, { - method: httpMethod, - headers, - body: body ? JSON.stringify(body) : undefined, - }); + const res = token + ? await authenticatedFetch(url, { + method: httpMethod, + token, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + : await fetch(url, { + method: httpMethod, + headers, + body: body ? JSON.stringify(body) : undefined, + }); if (!res.ok) { const errData = await res.json().catch(() => ({ error: "Unknown", message: res.statusText, })); + if ( + res.status === 401 && + errData.error === "use_dpop_nonce" && + token && + !skipDpopRetry && + getDPoPNonce() + ) { + return xrpc(method, { ...options, skipDpopRetry: true }); + } if ( res.status === 401 && (errData.error === "AuthenticationFailed" || - errData.error === "ExpiredToken") && - token && tokenRefreshCallback && !skipRetry + errData.error === "ExpiredToken" || + errData.error === "OAuthExpiredToken") && + token && + tokenRefreshCallback && + !skipRetry ) { const newToken = await tokenRefreshCallback(); if (newToken && newToken !== token) { @@ -536,12 +596,10 @@ export const api = { token: AccessToken, file: File, ): Promise { - const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", { + const res = await authenticatedFetch("/xrpc/com.atproto.repo.uploadBlob", { method: "POST", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": file.type, - }, + token, + headers: { "Content-Type": file.type }, body: file, }); if (!res.ok) { @@ -1084,12 +1142,8 @@ export const api = { }, async getRepo(token: AccessToken, did: Did): Promise { - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ - encodeURIComponent(did) - }`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - }); + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; + const res = await authenticatedFetch(url, { token }); if (!res.ok) { const errData = await res.json().catch(() => ({ error: "Unknown", @@ -1106,9 +1160,7 @@ export const api = { async getBackup(token: AccessToken, id: string): Promise { const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${token}` }, - }); + const res = await authenticatedFetch(url, { token }); if (!res.ok) { const errData = await res.json().catch(() => ({ error: "Unknown", @@ -1146,13 +1198,10 @@ export const api = { }, async importRepo(token: AccessToken, car: Uint8Array): Promise { - const url = `${API_BASE}/com.atproto.repo.importRepo`; - const res = await fetch(url, { + const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, { method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/vnd.ipld.car", - }, + token, + headers: { "Content-Type": "application/vnd.ipld.car" }, body: car as unknown as BodyInit, }); if (!res.ok) { @@ -1163,6 +1212,22 @@ export const api = { throw new ApiError(res.status, errData.error, errData.message); } }, + + async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> { + const res = await authenticatedFetch("/oauth/establish-session", { + method: "POST", + token, + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + throw new ApiError(res.status, errData.error, errData.message); + } + return res.json(); + }, }; export const typedApi = { diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index 8113310..5beaa79 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -281,7 +281,7 @@ export function clearError(): void { } } -async function tryRefreshToken(): Promise { +async function tryRefreshToken(): Promise { if (state.current.kind !== "authenticated") return null; const currentSession = state.current.session; try { diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index dba99b6..274d8ef 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -240,9 +240,26 @@ export class AtprotoClient { }&cid=${encodeURIComponent(cid)}`; const headers: Record = {}; if (this.accessToken) { - headers["Authorization"] = `Bearer ${this.accessToken}`; + if (this.dpopKeyPair) { + headers["Authorization"] = `DPoP ${this.accessToken}`; + const tokenHash = await computeAccessTokenHash(this.accessToken); + const dpopProof = await createDPoPProof( + this.dpopKeyPair, + "GET", + url.split("?")[0], + this.dpopNonce ?? undefined, + tokenHash, + ); + headers["DPoP"] = dpopProof; + } else { + headers["Authorization"] = `Bearer ${this.accessToken}`; + } } const res = await fetch(url, { headers }); + const newNonce = res.headers.get("DPoP-Nonce"); + if (newNonce) { + this.dpopNonce = newNonce; + } if (!res.ok) { const err = await res.json().catch(() => ({ error: "Unknown", diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index c5d0cbb..87ce05a 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -88,7 +88,9 @@ export function createInboundMigrationFlow() { function setStep(step: InboundStep) { state.step = step; - state.error = null; + if (step !== "error") { + state.error = null; + } if (step !== "success") { saveMigrationState(state); updateStep(step); diff --git a/frontend/src/lib/migration/offline-flow.svelte.ts b/frontend/src/lib/migration/offline-flow.svelte.ts index 1578418..8913a9b 100644 --- a/frontend/src/lib/migration/offline-flow.svelte.ts +++ b/frontend/src/lib/migration/offline-flow.svelte.ts @@ -177,7 +177,9 @@ export function createOfflineInboundMigrationFlow() { function setStep(step: OfflineInboundStep) { state.step = step; - state.error = null; + if (step !== "error") { + state.error = null; + } if (step !== "success") { saveOfflineState(state); } diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index fa90472..79d88a8 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -246,15 +246,15 @@ async function computeJwkThumbprint(jwk: JsonWebKey): Promise { return base64UrlEncode(hash); } -function getDPoPNonce(): string | null { +export function getDPoPNonce(): string | null { return sessionStorage.getItem(DPOP_NONCE_KEY); } -function setDPoPNonce(nonce: string): void { +export function setDPoPNonce(nonce: string): void { sessionStorage.setItem(DPOP_NONCE_KEY, nonce); } -function extractDPoPNonceFromResponse(response: Response): void { +export function extractDPoPNonceFromResponse(response: Response): void { const nonce = response.headers.get("DPoP-Nonce"); if (nonce) { setDPoPNonce(nonce); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index cc283a8..82f37fd 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -779,6 +779,11 @@ "name": "Manage Account", "description": "Manage account settings and preferences" } + }, + "unexpectedState": { + "title": "Unexpected State", + "description": "The consent page is in an unexpected state. Please check the browser console for errors.", + "reload": "Reload Page" } }, "accounts": { diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index 235c683..b1f00dc 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -785,6 +785,11 @@ "name": "Hallitse tiliä", "description": "Hallitse tilin asetuksia ja asetuksia" } + }, + "unexpectedState": { + "title": "Odottamaton tila", + "description": "Suostumussivulla on odottamaton tila. Tarkista selaimen konsoli virheiden varalta.", + "reload": "Lataa sivu uudelleen" } }, "accounts": { diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 1cc57cb..5399028 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -778,6 +778,11 @@ "name": "アカウント管理", "description": "アカウント設定と設定を管理" } + }, + "unexpectedState": { + "title": "予期しない状態", + "description": "同意ページが予期しない状態です。ブラウザのコンソールでエラーを確認してください。", + "reload": "ページを再読み込み" } }, "accounts": { diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index c794e7e..351a34c 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -778,6 +778,11 @@ "name": "계정 관리", "description": "계정 설정 및 환경설정 관리" } + }, + "unexpectedState": { + "title": "예기치 않은 상태", + "description": "동의 페이지가 예기치 않은 상태입니다. 브라우저 콘솔에서 오류를 확인하세요.", + "reload": "페이지 새로고침" } }, "accounts": { diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 1043a02..916dc27 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -778,6 +778,11 @@ "name": "Hantera konto", "description": "Hantera kontoinställningar och preferenser" } + }, + "unexpectedState": { + "title": "Oväntat tillstånd", + "description": "Samtyckes-sidan är i ett oväntat tillstånd. Kontrollera webbläsarens konsol för fel.", + "reload": "Ladda om sidan" } }, "accounts": { diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 264714e..0844544 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -778,6 +778,11 @@ "name": "管理账户", "description": "管理账户设置和偏好" } + }, + "unexpectedState": { + "title": "意外状态", + "description": "同意页面处于意外状态。请检查浏览器控制台以查看错误。", + "reload": "重新加载页面" } }, "accounts": { diff --git a/frontend/src/routes/Migration.svelte b/frontend/src/routes/Migration.svelte index 40e9ba4..d3f4abd 100644 --- a/frontend/src/routes/Migration.svelte +++ b/frontend/src/routes/Migration.svelte @@ -2,6 +2,9 @@ import { setSession } from '../lib/auth.svelte' import { navigate, routes } from '../lib/router.svelte' import { _ } from '../lib/i18n' + import { api } from '../lib/api' + import { startOAuthLogin } from '../lib/oauth' + import { unsafeAsAccessToken } from '../lib/types/branded' import { createInboundMigrationFlow, createOfflineInboundMigrationFlow, @@ -143,30 +146,48 @@ direction = 'select' } - function handleInboundComplete() { + async function handleInboundComplete() { const session = inboundFlow?.getLocalSession() if (session) { - setSession({ - did: session.did, - handle: session.handle, - accessJwt: session.accessJwt, - refreshJwt: '', - }) + try { + await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt)) + clearMigrationState() + await startOAuthLogin(session.handle) + } catch (e) { + console.error('Failed to establish OAuth session, falling back to direct login:', e) + setSession({ + did: session.did, + handle: session.handle, + accessJwt: session.accessJwt, + refreshJwt: '', + }) + navigate(routes.dashboard) + } + } else { + navigate(routes.dashboard) } - navigate(routes.dashboard) } - function handleOfflineComplete() { + async function handleOfflineComplete() { const session = offlineFlow?.getLocalSession() if (session) { - setSession({ - did: session.did, - handle: session.handle, - accessJwt: session.accessJwt, - refreshJwt: '', - }) + try { + await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt)) + clearOfflineState() + await startOAuthLogin(session.handle) + } catch (e) { + console.error('Failed to establish OAuth session, falling back to direct login:', e) + setSession({ + did: session.did, + handle: session.handle, + accessJwt: session.accessJwt, + refreshJwt: '', + }) + navigate(routes.dashboard) + } + } else { + navigate(routes.dashboard) } - navigate(routes.dashboard) } diff --git a/frontend/src/routes/OAuthAccounts.svelte b/frontend/src/routes/OAuthAccounts.svelte index 013b3e9..28993f1 100644 --- a/frontend/src/routes/OAuthAccounts.svelte +++ b/frontend/src/routes/OAuthAccounts.svelte @@ -196,18 +196,18 @@ display: flex; align-items: center; padding: var(--space-4); - background: var(--bg-card); + background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: var(--radius-xl); cursor: pointer; text-align: left; width: 100%; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + transition: border-color var(--transition-fast), background var(--transition-fast); } .account-item:hover:not(.disabled) { border-color: var(--accent); - box-shadow: var(--shadow-sm); + background: var(--bg-tertiary); } .account-item.disabled { @@ -231,37 +231,11 @@ color: var(--text-secondary); } - button { - padding: var(--space-3); - background: var(--accent); - color: var(--text-inverse); - border: none; - border-radius: var(--radius-md); - font-size: var(--text-base); - cursor: pointer; - } - - button:hover:not(:disabled) { - background: var(--accent-hover); - } - - button:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - button.secondary { - background: transparent; - color: var(--accent); - border: 1px solid var(--accent); + .different-account { + margin-top: var(--space-4); width: 100%; } - button.secondary:hover:not(:disabled) { - background: var(--accent); - color: var(--text-inverse); - } - .different-account { margin-top: var(--space-4); } diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte index e8a17ea..2e1d45d 100644 --- a/frontend/src/routes/OAuthConsent.svelte +++ b/frontend/src/routes/OAuthConsent.svelte @@ -65,6 +65,7 @@ async function fetchConsentData() { const requestUri = getRequestUri() if (!requestUri) { + console.error('[OAuthConsent] No request_uri in URL') error = $_('oauth.error.genericError') loading = false return @@ -74,11 +75,20 @@ const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`) if (!response.ok) { const data = await response.json() + console.error('[OAuthConsent] Consent fetch failed:', data) error = data.error_description || data.error || $_('oauth.error.genericError') loading = false return } const data: ConsentData = await response.json() + + if (!data.scopes || !Array.isArray(data.scopes)) { + console.error('[OAuthConsent] Invalid scopes data:', data.scopes) + error = 'Invalid consent data received' + loading = false + return + } + consentData = data scopeSelections = Object.fromEntries( @@ -91,7 +101,8 @@ if (!data.show_consent) { await submitConsent() } - } catch { + } catch (e) { + console.error('[OAuthConsent] Error during consent fetch:', e) error = $_('oauth.error.genericError') } finally { loading = false @@ -104,7 +115,10 @@ } async function submitConsent() { - if (!consentData) return + if (!consentData) { + console.error('[OAuthConsent] submitConsent called but no consentData') + return + } submitting = true let approvedScopes = Object.entries(scopeSelections) @@ -128,6 +142,7 @@ if (!response.ok) { const data = await response.json() + console.error('[OAuthConsent] Submit failed:', data) error = data.error_description || data.error || $_('oauth.error.genericError') submitting = false return @@ -136,8 +151,13 @@ const data = await response.json() if (data.redirect_uri) { window.location.href = data.redirect_uri + } else { + console.error('[OAuthConsent] No redirect_uri in response') + error = 'Authorization failed - no redirect received' + submitting = false } - } catch { + } catch (e) { + console.error('[OAuthConsent] Submit error:', e) error = $_('oauth.error.genericError') submitting = false } @@ -249,6 +269,8 @@

{$_('common.loading')}

+ {:else} +

Loading consent data...

{/if} {:else if error} @@ -372,6 +394,19 @@ {submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')} + {:else} +
+

{$_('oauth.consent.unexpectedState.title')}

+

+ {$_('oauth.consent.unexpectedState.description')} +

+

+ loading={loading}, error={error ? 'set' : 'null'}, consentData={consentData ? 'set' : 'null'}, submitting={submitting} +

+ +
{/if}