From aa8091f6000b682827fc089ec959b1a304132d46 Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Sat, 17 Oct 2026 09:28:36 +0000 Subject: [PATCH 1/4] fix(api): avoid duplicate signed swap routes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/main.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 053e7b74..68a5fca0 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -338,12 +338,6 @@ async fn main() { .route("/swap/marketplace/seller/{seller}", get(handlers::marketplace_by_seller)) .route("/swap/marketplace/buyer/{buyer}", get(handlers::marketplace_by_buyer)) .route("/swap/marketplace/ip/{ip_id}", get(handlers::marketplace_by_ip)) - .route("/swap/initiate", post(handlers::initiate_swap).layer(signed.clone())) - .route("/swap/batch-initiate", post(handlers::batch_initiate_swap)) - .route("/swap/{swap_id}/accept", post(handlers::accept_swap).layer(signed.clone())) - .route("/swap/{swap_id}/reveal", post(handlers::reveal_key).layer(signed.clone())) - .route("/swap/{swap_id}/cancel", post(handlers::cancel_swap).layer(signed.clone())) - .route("/swap/{swap_id}/cancel-expired", post(handlers::cancel_expired_swap)) .route("/swap/{swap_id}", get(handlers::get_swap)) .route("/openapi.json", get(openapi_handler)) .with_state(state) From 45d18c24ac3ca8327da8de8eedc55d3c843fb95c Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Sat, 17 Oct 2026 09:28:47 +0000 Subject: [PATCH 2/4] fix(session): periodically remove expired sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/handlers.rs | 16 ++++++++++++++++ api-server/src/main.rs | 1 + api-server/src/session.rs | 16 ++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/api-server/src/handlers.rs b/api-server/src/handlers.rs index 69bbf272..86a5c2c0 100644 --- a/api-server/src/handlers.rs +++ b/api-server/src/handlers.rs @@ -1801,6 +1801,22 @@ pub async fn use_backup_code( static SESSION_STORE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| crate::session::SessionStore::new(crate::session::SessionConfig::default())); +pub fn spawn_session_cleanup_task() { + tokio::spawn(async { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + match SESSION_STORE.cleanup_expired_sessions() { + Ok(removed) if removed > 0 => { + tracing::debug!(removed, "cleaned up expired sessions"); + } + Ok(_) => {} + Err(error) => tracing::error!(%error, "failed to clean up expired sessions"), + } + } + }); +} + /// #985: Check current session status /// Returns session status including timeout warnings and grace period info #[utoipa::path( diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 68a5fca0..20e85868 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -258,6 +258,7 @@ async fn main() { metrics::init(); // #1064/#1065: periodic lifecycle anomaly detection + alert escalation. commitment_monitoring::spawn_background_evaluator(); + handlers::spawn_session_cleanup_task(); let subscription_broadcaster = init_subscription_broadcaster().await; let rpc_client: Arc = Arc::new(graphql::MockSorobanRpcClient::default()); diff --git a/api-server/src/session.rs b/api-server/src/session.rs index 5118095e..55a02fc4 100644 --- a/api-server/src/session.rs +++ b/api-server/src/session.rs @@ -288,4 +288,20 @@ mod tests { assert!(new_expiry > original_expiry); } + + #[test] + fn test_cleanup_expired_sessions_removes_expired_sessions() { + let mut config = SessionConfig::default(); + config.idle_timeout_minutes = 0; + config.grace_period_minutes = 0; + let store = SessionStore::new(config); + store + .create_session("user123", "token123") + .expect("Failed to create session"); + + std::thread::sleep(std::time::Duration::from_secs(1)); + + assert_eq!(store.cleanup_expired_sessions().unwrap(), 1); + assert!(store.get_session("token123").unwrap().is_none()); + } } From 04ebc825e9eae7a33a588b7f7f06e74b66c7c58c Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Sat, 17 Oct 2026 09:29:35 +0000 Subject: [PATCH 3/4] feat(auth): issue sessions after 2fa verification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/auth_2fa.rs | 2 ++ api-server/src/handlers.rs | 20 ++++++++++-- api-server/src/main.rs | 66 ++++++++++++++++++++++++++++++++++++++ api-server/src/schemas.rs | 2 ++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/api-server/src/auth_2fa.rs b/api-server/src/auth_2fa.rs index 836665ee..62c5ffeb 100644 --- a/api-server/src/auth_2fa.rs +++ b/api-server/src/auth_2fa.rs @@ -72,6 +72,8 @@ pub struct UseBackupCodeRequest { pub struct Verify2faResponse { pub success: bool, pub message: String, + /// Opaque token for the session created after successful verification. + pub session_token: String, } /// In-process 2FA store (for demo/testing) diff --git a/api-server/src/handlers.rs b/api-server/src/handlers.rs index 86a5c2c0..42abd8c9 100644 --- a/api-server/src/handlers.rs +++ b/api-server/src/handlers.rs @@ -1643,14 +1643,14 @@ pub async fn enable_2fa( } /// #984: Verify 2FA code during login -/// Returns success if TOTP code is valid and marks 2FA as verified +/// Returns success if TOTP code is valid, marks 2FA as verified, and creates a session. #[utoipa::path( post, path = "/auth/2fa/verify", tag = "Authentication", request_body = Verify2faRequest, responses( - (status = 200, description = "2FA code verified successfully", body = Verify2faResponse), + (status = 200, description = "2FA code verified and session created", body = Verify2faResponse), (status = 401, description = "Invalid 2FA code", body = ErrorResponse), (status = 400, description = "Invalid request", body = ErrorResponse), (status = 404, description = "2FA not configured for user", body = ErrorResponse), @@ -1727,10 +1727,12 @@ pub async fn verify_2fa( Json(ErrorResponse { error: err }), ) })?; + let session_token = issue_session_token(&body.user_id)?; Ok(Json(Verify2faResponse { success: true, message: "2FA verified successfully".to_string(), + session_token, })) } Ok(false) => Err(( @@ -1754,7 +1756,7 @@ pub async fn verify_2fa( tag = "Authentication", request_body = UseBackupCodeRequest, responses( - (status = 200, description = "Backup code accepted, account recovered", body = Verify2faResponse), + (status = 200, description = "Backup code accepted, account recovered, and session created", body = Verify2faResponse), (status = 401, description = "Invalid or already used backup code", body = ErrorResponse), (status = 404, description = "User not found", body = ErrorResponse), ) @@ -1790,9 +1792,11 @@ pub async fn use_backup_code( )); } + let session_token = issue_session_token(&body.user_id)?; Ok(Json(Verify2faResponse { success: true, message: "Account recovered with backup code. Please update your 2FA settings.".to_string(), + session_token, })) } @@ -1801,6 +1805,16 @@ pub async fn use_backup_code( static SESSION_STORE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| crate::session::SessionStore::new(crate::session::SessionConfig::default())); +fn issue_session_token( + user_id: &str, +) -> Result)> { + let token = uuid::Uuid::new_v4().to_string(); + SESSION_STORE + .create_session(user_id, &token) + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error })))?; + Ok(token) +} + pub fn spawn_session_cleanup_task() { tokio::spawn(async { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 20e85868..09788762 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -323,6 +323,11 @@ async fn main() { .route("/v1/auth/recovery/verify-token", post(account_recovery::verify_recovery_token)) .route("/v1/auth/recovery/questions", get(account_recovery::get_security_questions)) .route("/v1/auth/recovery/verify-question", post(account_recovery::verify_security_question)) + .route("/auth/2fa/enable", post(handlers::enable_2fa)) + .route("/auth/2fa/verify", post(handlers::verify_2fa)) + .route("/auth/2fa/backup-code", post(handlers::use_backup_code)) + .route("/auth/session/status", post(handlers::check_session)) + .route("/auth/extend-session", post(handlers::extend_session)) .route("/ip/{ip_id}", get(handlers::get_ip)) .route("/ip/verify", post(handlers::verify_commitment)) .route("/ip/reveal-batch", post(handlers::batch_reveal_commitments)) @@ -593,6 +598,67 @@ mod tests { assert_ne!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); } + #[tokio::test] + async fn successful_2fa_verification_creates_checkable_session() { + let app = build_app(); + let user_id = uuid::Uuid::new_v4().to_string(); + + let enable_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/2fa/enable") + .header("content-type", "application/json") + .body(Body::from(format!(r#"{{"user_id":"{user_id}"}}"#))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(enable_response.status(), StatusCode::OK); + + let verify_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/2fa/verify") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"user_id":"{user_id}","totp_code":"123456"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(verify_response.status(), StatusCode::OK); + let verify_body = axum::body::to_bytes(verify_response.into_body(), usize::MAX) + .await + .unwrap(); + let verify_json: serde_json::Value = serde_json::from_slice(&verify_body).unwrap(); + let session_token = verify_json["session_token"].as_str().unwrap(); + + let status_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/session/status") + .header("content-type", "application/json") + .body(Body::from(format!( + r#"{{"token":"{session_token}"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(status_response.status(), StatusCode::OK); + let status_body = axum::body::to_bytes(status_response.into_body(), usize::MAX) + .await + .unwrap(); + let status_json: serde_json::Value = serde_json::from_slice(&status_body).unwrap(); + assert_eq!(status_json["active"], true); + } + #[tokio::test] async fn test_openapi_json_endpoint_returns_valid_spec() { let app = Router::new() diff --git a/api-server/src/schemas.rs b/api-server/src/schemas.rs index 734d7503..92d54bd1 100644 --- a/api-server/src/schemas.rs +++ b/api-server/src/schemas.rs @@ -502,6 +502,8 @@ pub struct Verify2faRequest { pub struct Verify2faResponse { pub success: bool, pub message: String, + /// Opaque token used with the session status and extension endpoints. + pub session_token: String, } /// #984: Request to use backup code for account recovery From a5fd264db47d91033f26295f71f54c99c8207903 Mon Sep 17 00:00:00 2001 From: augustinemartins Date: Sat, 17 Oct 2026 09:29:53 +0000 Subject: [PATCH 4/4] fix(api): remove duplicate test router routes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api-server/src/main.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/api-server/src/main.rs b/api-server/src/main.rs index 09788762..dbc0feeb 100644 --- a/api-server/src/main.rs +++ b/api-server/src/main.rs @@ -470,7 +470,6 @@ fn build_app() -> Router { .route("/v1/swap/marketplace/seller/{seller}", get(handlers::marketplace_by_seller)) .route("/v1/swap/marketplace/buyer/{buyer}", get(handlers::marketplace_by_buyer)) .route("/v1/swap/marketplace/ip/{ip_id}", get(handlers::marketplace_by_ip)) - .route("/v1/ip/owner/{owner}/cursor", get(handlers::list_ip_by_owner_cursor)) .route("/v1/swap/initiate", post(handlers::initiate_swap)) .route("/v1/swap/batch-initiate", post(handlers::batch_initiate_swap)) .route("/v1/swap/bulk/initiate", post(handlers::batch_initiate_swap)) @@ -483,8 +482,6 @@ fn build_app() -> Router { .route("/v1/swap/{swap_id}/history", get(handlers::get_swap_history)) .route("/v1/swap/{swap_id}/disputes", get(handlers::get_swap_disputes)) .route("/v1/swaps", get(handlers::list_swaps)) - .route("/v1/webhooks", post(handlers::register_webhook)) - .route("/v1/webhooks/{id}", axum::routing::delete(handlers::unregister_webhook)) .route("/v1/bulk/commit-ip", post(handlers::bulk_commit_ip)) .route("/v1/bulk/initiate-swap", post(handlers::bulk_initiate_swap)) .route("/v1/swaps/execute-batch", post(handlers::execute_batch_swaps))