From f18da5f615974dff43b67d7ac28f054ce08bcb4d Mon Sep 17 00:00:00 2001 From: Emmyt24 Date: Fri, 7 Aug 2026 11:29:47 +0100 Subject: [PATCH] feat: email OTP for signup verification and withdrawal confirmation Adds a new octo-email crate (Resend HTTP client, OTP generation/hashing, branded HTML templates) and wires it through signup/login (OTP-gated email verification before a session token is issued) and a new withdraw/request-otp + withdraw/confirm pair that binds a one-time code to the exact signed transaction hash before it's relayed to Horizon. --- .env.example | 6 + Cargo.lock | 17 ++ Cargo.toml | 2 + bin/server/Cargo.toml | 1 + bin/server/src/main.rs | 12 + crates/api/Cargo.toml | 3 + crates/api/src/auth.rs | 181 +++++++++++- crates/api/src/lib.rs | 10 + crates/api/src/routes/submit.rs | 274 ++++++++++++++++-- crates/api/src/state.rs | 13 + crates/api/tests/api_tests.rs | 276 +++++++----------- crates/api/tests/auth_tests.rs | 175 +++++++++--- crates/api/tests/authz_matrix_tests.rs | 34 +-- crates/api/tests/common/mod.rs | 66 +++++ crates/api/tests/drift_tests.rs | 32 +-- crates/api/tests/horizon_live_tests.rs | 57 ++-- crates/api/tests/malformed_body_tests.rs | 28 +- crates/api/tests/session_revocation_tests.rs | 45 +-- crates/api/tests/sponsor_e2e_tests.rs | 64 ++--- crates/api/tests/sponsor_webhook_tests.rs | 26 +- crates/api/tests/withdraw_otp_tests.rs | 283 +++++++++++++++++++ crates/email/Cargo.toml | 23 ++ crates/email/src/error.rs | 10 + crates/email/src/lib.rs | 123 ++++++++ crates/email/src/templates.rs | 200 +++++++++++++ crates/store/migrations/0019_email_otp.sql | 17 ++ crates/store/src/error.rs | 4 + crates/store/src/lib.rs | 85 +++++- crates/store/src/models.rs | 17 ++ crates/store/tests/store_tests.rs | 15 +- 30 files changed, 1641 insertions(+), 458 deletions(-) create mode 100644 crates/api/tests/withdraw_otp_tests.rs create mode 100644 crates/email/Cargo.toml create mode 100644 crates/email/src/error.rs create mode 100644 crates/email/src/lib.rs create mode 100644 crates/email/src/templates.rs create mode 100644 crates/store/migrations/0019_email_otp.sql diff --git a/.env.example b/.env.example index 13d4c6d..1d7367c 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,12 @@ RUST_LOG=info,octo=debug # links (e.g. https://app.octo.dev/pay/). No trailing slash. PUBLIC_APP_URL=http://localhost:3000 +# --- Email (Resend) --- +# API key from https://resend.com — required for OTP/welcome/withdrawal emails. +RESEND_API_KEY= +# Verified sender address, e.g. "Octo ". +EMAIL_FROM_ADDRESS= + # --- Ingest worker --- # How often the deposit ingest supervisor polls Horizon for all wallets, and the page size. INGEST_INTERVAL_SECS=5 diff --git a/Cargo.lock b/Cargo.lock index 19abdde..b088290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1728,6 +1728,7 @@ dependencies = [ "http-body-util", "jsonschema", "octo-crypto", + "octo-email", "octo-ingest", "octo-resilience", "octo-store", @@ -1765,6 +1766,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "octo-email" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "hex", + "rand 0.8.6", + "reqwest", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "octo-ingest" version = "0.1.0" @@ -1818,6 +1834,7 @@ dependencies = [ "axum", "dotenvy", "octo-api", + "octo-email", "octo-ingest", "octo-resilience", "octo-store", diff --git a/Cargo.toml b/Cargo.toml index 73c98ae..f8fb362 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/wallet-core", "crates/store", "crates/webhooks", + "crates/email", "crates/ingest", "crates/api", "crates/resilience", @@ -68,6 +69,7 @@ octo-crypto = { path = "crates/crypto" } octo-wallet-core = { path = "crates/wallet-core" } octo-store = { path = "crates/store" } octo-webhooks = { path = "crates/webhooks" } +octo-email = { path = "crates/email" } octo-ingest = { path = "crates/ingest" } octo-api = { path = "crates/api" } octo-resilience = { path = "crates/resilience" } diff --git a/bin/server/Cargo.toml b/bin/server/Cargo.toml index cde4f9c..8b1d88c 100644 --- a/bin/server/Cargo.toml +++ b/bin/server/Cargo.toml @@ -18,6 +18,7 @@ octo-api.workspace = true octo-ingest.workspace = true octo-store.workspace = true octo-webhooks.workspace = true +octo-email.workspace = true octo-wallet-core.workspace = true octo-resilience.workspace = true tokio.workspace = true diff --git a/bin/server/src/main.rs b/bin/server/src/main.rs index 366bebf..ac5a8bc 100644 --- a/bin/server/src/main.rs +++ b/bin/server/src/main.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result}; use octo_api::{build_router, AppState}; +use octo_email::EmailSender; use octo_ingest::Supervisor; use octo_resilience::ResilienceConfig; use octo_store::Store; @@ -42,6 +43,7 @@ async fn main() -> Result<()> { ); // Shared state (includes the API's Horizon client wired with resilience). + let email = EmailSender::new(cfg.resend_api_key.clone(), cfg.email_from_address.clone()); let mut state = AppState::new_with_resilience( store.clone(), cfg.master_key, @@ -49,6 +51,7 @@ async fn main() -> Result<()> { cfg.horizon_url.clone(), cfg.friendbot_url.clone(), cfg.public_app_url.clone(), + email, resilience.retry_policy(), resilience.circuit_breaker(), ) @@ -119,6 +122,8 @@ struct Config { /// Base URL of the hosted checkout frontend (e.g. `https://app.octo.dev`), used to build the /// `url` field on payment-link responses. Defaults to the local frontend dev server. public_app_url: String, + resend_api_key: String, + email_from_address: String, master_key: [u8; 32], /// Optional next master key for zero-downtime rotation. Present only during the rotation /// window while `octo-migrate-keys` is backfilling. When set, the server uses this key as @@ -159,6 +164,11 @@ impl Config { .trim_end_matches('/') .to_string(); + let resend_api_key = + std::env::var("RESEND_API_KEY").context("RESEND_API_KEY is required")?; + let email_from_address = + std::env::var("EMAIL_FROM_ADDRESS").context("EMAIL_FROM_ADDRESS is required")?; + let master_key_b64 = std::env::var("MASTER_KEY").context("MASTER_KEY is required")?; let master_key = AppState::decode_master_key(&master_key_b64) .map_err(|_| anyhow::anyhow!("MASTER_KEY must be base64-encoded 32 bytes"))?; @@ -199,6 +209,8 @@ impl Config { horizon_url, friendbot_url, public_app_url, + resend_api_key, + email_from_address, master_key, master_key_next, jwt_secret, diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index ba255db..2f37fd0 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -13,6 +13,7 @@ octo-crypto.workspace = true octo-wallet-core.workspace = true octo-store.workspace = true octo-webhooks.workspace = true +octo-email.workspace = true octo-resilience.workspace = true axum.workspace = true tower.workspace = true @@ -53,3 +54,5 @@ serde_yaml = "0.9" jsonschema = "0.26" # Signing fixtures for validation tests only — never enabled in production builds. octo-wallet-core = { workspace = true, features = ["test-fixtures"] } +# Captures OTP codes in memory instead of emailing them — never enabled in production builds. +octo-email = { workspace = true, features = ["test-fixtures"] } diff --git a/crates/api/src/auth.rs b/crates/api/src/auth.rs index abcf105..56705c4 100644 --- a/crates/api/src/auth.rs +++ b/crates/api/src/auth.rs @@ -67,6 +67,28 @@ pub struct AuthResponse { pub user: UserView, } +/// Returned by signup, and by login when the account isn't yet email-verified — tells the +/// client to show the OTP-entry step instead of a token. +#[derive(Debug, Serialize)] +pub struct VerificationRequiredResponse { + pub user_id: Uuid, + pub email_verification_required: bool, +} + +#[derive(Debug, Deserialize, Default)] +pub struct VerifyEmailRequest { + pub user_id: Option, + pub code: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct ResendOtpRequest { + pub user_id: Option, +} + +/// Signup-OTP TTL. Matches the wallet-ownership challenge's 10-minute window. +const OTP_TTL_MINUTES: i64 = 10; + #[derive(Debug, Serialize)] pub struct UserView { pub id: Uuid, @@ -113,13 +135,37 @@ fn check_auth_rate_limit( } } +/// Generate, store, and email a signup-verification OTP. Shared by `signup`, `resend_otp`, and +/// `login` when the account isn't yet verified. +async fn issue_signup_otp(state: &AppState, user_id: Uuid, email: &str) -> Result<(), ApiError> { + let code = octo_email::generate_otp(); + let code_hash = octo_email::hash_otp(&code); + state + .store() + .create_otp( + user_id, + "signup", + &code_hash, + None, + chrono::Duration::minutes(OTP_TTL_MINUTES), + ) + .await + .map_err(|_| ApiError::Internal)?; + state + .email() + .send_otp(email, "signup", &code) + .await + .map_err(|_| ApiError::Internal)?; + Ok(()) +} + /// `POST /v1/auth/signup` pub async fn signup( State(state): State, peer: Option>, headers: HeaderMap, body: Bytes, -) -> ApiResult<(StatusCode, Json>)> { +) -> ApiResult<(StatusCode, Json>)> { check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?; let creds: Credentials = parse_optional(&body)?; let (email, password) = validate(creds)?; @@ -146,15 +192,110 @@ pub async fn signup( ) .await; + issue_signup_otp(&state, user.id, &user.email).await?; + + let (code, json) = Envelope::created(VerificationRequiredResponse { + user_id: user.id, + email_verification_required: true, + }); + Ok((code, json)) +} + +/// `POST /v1/auth/verify-email` — consume a signup OTP and issue the first session token. +pub async fn verify_email( + State(state): State, + body: Bytes, +) -> ApiResult>> { + let req: VerifyEmailRequest = parse_optional(&body)?; + let user_id = req + .user_id + .ok_or_else(|| ApiError::BadRequest("user_id is required".into()))?; + let code = req + .code + .filter(|c| !c.is_empty()) + .ok_or_else(|| ApiError::BadRequest("code is required".into()))?; + + let code_hash = octo_email::hash_otp(&code); + state + .store() + .verify_and_consume_otp(user_id, "signup", &code_hash, None) + .await + .map_err(|_| ApiError::BadRequest("invalid or expired code".into()))?; + + let user = state + .store() + .get_user(user_id) + .await + .map_err(|_| ApiError::Internal)? + .ok_or(ApiError::NotFound)?; + + state + .store() + .mark_email_verified(user_id) + .await + .map_err(|_| ApiError::Internal)?; + + let welcome_html = octo_email::templates::welcome_email(&user.email); + let _ = state + .email() + .send(&user.email, "Welcome to Octo", &welcome_html) + .await; + let token = issue_token(state.jwt_secret(), user.id)?; - let (code, json) = Envelope::created(AuthResponse { + Ok(Envelope::ok(AuthResponse { token, user: UserView { id: user.id, email: user.email, }, - }); - Ok((code, json)) + })) +} + +/// `POST /v1/auth/resend-otp` — re-send a signup verification code. +pub async fn resend_otp( + State(state): State, + peer: Option>, + headers: HeaderMap, + body: Bytes, +) -> ApiResult>> { + check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?; + let req: ResendOtpRequest = parse_optional(&body)?; + let user_id = req + .user_id + .ok_or_else(|| ApiError::BadRequest("user_id is required".into()))?; + + let ip = crate::rate_limit::client_ip(&headers, peer.map(|c| c.0)); + if !state.rate_limiter().check( + &format!("otp:{user_id}"), + "otp_resend", + 3, + std::time::Duration::from_secs(60 * 60), + ) { + return Err(ApiError::TooManyRequests( + "too many resend attempts — wait a while and try again".into(), + )); + } + // Belt and suspenders: also cap per-IP, so one IP can't hammer many user_ids. + if !state.rate_limiter().check( + &ip, + "otp_resend_ip", + 10, + std::time::Duration::from_secs(60 * 60), + ) { + return Err(ApiError::TooManyRequests( + "too many resend attempts — wait a while and try again".into(), + )); + } + + let user = state + .store() + .get_user(user_id) + .await + .map_err(|_| ApiError::Internal)? + .ok_or(ApiError::NotFound)?; + + issue_signup_otp(&state, user.id, &user.email).await?; + Ok(Envelope::ok(serde_json::json!({ "sent": true }))) } /// `POST /v1/auth/login` @@ -163,7 +304,7 @@ pub async fn login( peer: Option>, headers: HeaderMap, body: Bytes, -) -> ApiResult>> { +) -> ApiResult>> { check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?; let creds: Credentials = parse_optional(&body)?; let (email, password) = validate(creds)?; @@ -178,6 +319,19 @@ pub async fn login( verify_password(&password, &user.password_hash) .map_err(|_| ApiError::BadRequest("invalid email or password".into()))?; + // A correct password on an unverified account (pre-existing user, or one who abandoned + // signup before verifying) re-triggers the same OTP gate signup uses, instead of a token. + if user.email_verified_at.is_none() { + issue_signup_otp(&state, user.id, &user.email).await?; + return Ok(Envelope::ok( + serde_json::to_value(VerificationRequiredResponse { + user_id: user.id, + email_verification_required: true, + }) + .map_err(|_| ApiError::Internal)?, + )); + } + crate::audit::record( &state, user.id, @@ -189,13 +343,16 @@ pub async fn login( .await; let token = issue_token(state.jwt_secret(), user.id)?; - Ok(Envelope::ok(AuthResponse { - token, - user: UserView { - id: user.id, - email: user.email, - }, - })) + Ok(Envelope::ok( + serde_json::to_value(AuthResponse { + token, + user: UserView { + id: user.id, + email: user.email, + }, + }) + .map_err(|_| ApiError::Internal)?, + )) } /// `POST /v1/auth/refresh` — exchange a valid, unexpired token for a freshly-signed one. diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index e54e022..dc6f5fb 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -43,6 +43,8 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/health", get(health)) .route("/v1/auth/signup", post(auth::signup)) + .route("/v1/auth/verify-email", post(auth::verify_email)) + .route("/v1/auth/resend-otp", post(auth::resend_otp)) .route("/v1/auth/login", post(auth::login)) .route("/v1/auth/refresh", post(auth::refresh)) .route("/v1/auth/me", get(auth::me)) @@ -108,6 +110,14 @@ pub fn build_router(state: AppState) -> Router { "/v1/wallets/:id/submit-signed", post(routes::submit::submit_signed), ) + .route( + "/v1/wallets/:id/withdraw/request-otp", + post(routes::submit::withdraw_request_otp), + ) + .route( + "/v1/wallets/:id/withdraw/confirm", + post(routes::submit::withdraw_confirm), + ) .route( "/v1/wallets/:id/signing-info", get(routes::submit::signing_info), diff --git a/crates/api/src/routes/submit.rs b/crates/api/src/routes/submit.rs index aef0027..69d5adf 100644 --- a/crates/api/src/routes/submit.rs +++ b/crates/api/src/routes/submit.rs @@ -5,7 +5,7 @@ //! user's own ed25519 signature (checked by the network); the API credential only authorizes use //! of Octo's relay + bookkeeping. -use crate::auth::authorize_wallet; +use crate::auth::{authorize_wallet, require_login}; use crate::error::{ApiError, ApiResult, Envelope}; use crate::json::parse_optional; use crate::state::AppState; @@ -18,6 +18,15 @@ use octo_wallet_core::compute_inner_tx_hash; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// Withdrawal-OTP TTL. Matches the signup OTP's 10-minute window. +const WITHDRAW_OTP_TTL_MINUTES: i64 = 10; + +/// Stroops (1e-7) as a trimmed decimal string, matching Horizon's own asset precision. +fn format_amount(stroops: i64) -> String { + let s = format!("{:.7}", stroops as f64 / 10_000_000.0); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + #[derive(Debug, Default, Deserialize)] pub struct SubmitSignedRequest { /// Base64 XDR of the client-signed v1 `TransactionEnvelope`. @@ -59,26 +68,25 @@ fn explain_code(code: &str) -> String { } } -/// `POST /v1/wallets/:id/submit-signed` -pub async fn submit_signed( - State(state): State, - Path(wallet_id): Path, - headers: HeaderMap, - body: Bytes, -) -> ApiResult<(StatusCode, Json>)> { - authorize_wallet(&headers, &state, wallet_id).await?; - // For the audit log only: present when the caller used a login JWT (None for API keys). - let audit_user = crate::auth::authenticate(&headers, &state).await.ok(); - let wallet = state.store().get_wallet(wallet_id).await?; - - let req: SubmitSignedRequest = parse_optional(&body)?; - let signed_xdr = req - .transaction_xdr - .filter(|x| !x.is_empty()) - .ok_or_else(|| ApiError::BadRequest("transaction_xdr is required".into()))?; +/// Result of `relay_signed_transaction`: the response plus the payment destination (for audit +/// logging / withdrawal emails), when the transaction was a Payment/PathPayment. +pub struct RelayOutcome { + pub response: SubmitSignedResponse, + pub destination: Option, + pub amount_stroops: Option, + pub asset_code: Option, +} +/// Core relay logic shared by `submit_signed` and the withdrawal OTP `confirm` endpoint: validate, +/// allowlist-check, submit to Horizon, and record history. Callers handle auth and OTP gating. +pub async fn relay_signed_transaction( + state: &AppState, + wallet_id: Uuid, + wallet: &octo_store::Wallet, + signed_xdr: &str, +) -> ApiResult { // Pure validation: v1 envelope, ≥1 signature, source == this wallet, op allowlist. - let payment = validate_signed_xdr(&signed_xdr, &wallet.stellar_account_g)?; + let payment = validate_signed_xdr(signed_xdr, &wallet.stellar_account_g)?; // Withdrawal allowlist: if the wallet has opted in, a Payment/PathPayment destination must be // pre-approved. Checked BEFORE anything touches Horizon, so a blocked send never reaches the @@ -110,11 +118,11 @@ pub async fn submit_signed( // Compute the canonical tx hash up-front so the response/record always reference it, even // when Horizon submission errors out at the transport layer. let precomputed_hash = hex::encode( - compute_inner_tx_hash(&signed_xdr, state.network()) + compute_inner_tx_hash(signed_xdr, state.network()) .map_err(|_| ApiError::BadRequest("transaction_xdr could not be hashed".into()))?, ); - let submit = state.horizon().submit_transaction(&signed_xdr).await; + let submit = state.horizon().submit_transaction(signed_xdr).await; let (status, hash, detail) = match submit { Ok(r) if r.successful => ("confirmed", Some(r.hash), None), @@ -152,24 +160,232 @@ pub async fn submit_signed( .await; } + Ok(RelayOutcome { + response: SubmitSignedResponse { + status: status.to_string(), + stellar_tx_hash: hash, + detail, + }, + destination: payment.as_ref().map(|p| p.destination.clone()), + amount_stroops: payment.as_ref().map(|p| p.amount_stroops), + asset_code: payment.as_ref().map(|p| p.asset_code.clone()), + }) +} + +/// `POST /v1/wallets/:id/submit-signed` +pub async fn submit_signed( + State(state): State, + Path(wallet_id): Path, + headers: HeaderMap, + body: Bytes, +) -> ApiResult<(StatusCode, Json>)> { + authorize_wallet(&headers, &state, wallet_id).await?; + // For the audit log only: present when the caller used a login JWT (None for API keys). + let audit_user = crate::auth::authenticate(&headers, &state).await.ok(); + let wallet = state.store().get_wallet(wallet_id).await?; + + let req: SubmitSignedRequest = parse_optional(&body)?; + let signed_xdr = req + .transaction_xdr + .filter(|x| !x.is_empty()) + .ok_or_else(|| ApiError::BadRequest("transaction_xdr is required".into()))?; + + let outcome = relay_signed_transaction(&state, wallet_id, &wallet, &signed_xdr).await?; + if let Some(user_id) = audit_user { crate::audit::record( &state, user_id, - &format!("submitted a signed transaction ({status})"), + &format!( + "submitted a signed transaction ({})", + outcome.response.status + ), crate::audit::category::WALLET, - payment.as_ref().map(|p| p.destination.as_str()), + outcome.destination.as_deref(), &headers, ) .await; } - let resp = SubmitSignedResponse { - status: status.to_string(), - stellar_tx_hash: hash, - detail, - }; - let (code, json) = Envelope::created(resp); + let (code, json) = Envelope::created(outcome.response); + Ok((code, json)) +} + +#[derive(Debug, Default, Deserialize)] +pub struct WithdrawOtpRequest { + pub transaction_xdr: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct WithdrawConfirmRequest { + pub transaction_xdr: Option, + pub code: Option, +} + +#[derive(Debug, Serialize)] +pub struct WithdrawOtpResponse { + pub sent: bool, +} + +fn require_xdr(x: Option) -> ApiResult { + x.filter(|x| !x.is_empty()) + .ok_or_else(|| ApiError::BadRequest("transaction_xdr is required".into())) +} + +/// `POST /v1/wallets/:id/withdraw/request-otp` — email a code bound to this exact, already-signed +/// transaction. Requires a dashboard login (not an API key) since this gates a sensitive action. +pub async fn withdraw_request_otp( + State(state): State, + Path(wallet_id): Path, + headers: HeaderMap, + body: Bytes, +) -> ApiResult>> { + let user_id = require_login(&headers, &state).await?; + let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.user_id != Some(user_id) { + return Err(ApiError::NotFound); + } + let user = state + .store() + .get_user(user_id) + .await + .map_err(|_| ApiError::Internal)? + .ok_or(ApiError::NotFound)?; + + let req: WithdrawOtpRequest = parse_optional(&body)?; + let signed_xdr = require_xdr(req.transaction_xdr)?; + + // Validate up front so a garbage transaction never gets an OTP issued for it. + validate_signed_xdr(&signed_xdr, &wallet.stellar_account_g)?; + let tx_hash = hex::encode( + compute_inner_tx_hash(&signed_xdr, state.network()) + .map_err(|_| ApiError::BadRequest("transaction_xdr could not be hashed".into()))?, + ); + + let code = octo_email::generate_otp(); + let code_hash = octo_email::hash_otp(&code); + state + .store() + .create_otp( + user_id, + "withdrawal", + &code_hash, + Some(&tx_hash), + chrono::Duration::minutes(WITHDRAW_OTP_TTL_MINUTES), + ) + .await + .map_err(|_| ApiError::Internal)?; + state + .email() + .send_otp(&user.email, "withdrawal", &code) + .await + .map_err(|_| ApiError::Internal)?; + + Ok(Envelope::ok(WithdrawOtpResponse { sent: true })) +} + +/// `POST /v1/wallets/:id/withdraw/confirm` — verify the OTP (bound to this exact transaction) and, +/// only if it's correct, relay to Horizon. A wrong/expired/reused code never reaches Horizon. +pub async fn withdraw_confirm( + State(state): State, + Path(wallet_id): Path, + headers: HeaderMap, + body: Bytes, +) -> ApiResult<(StatusCode, Json>)> { + let user_id = require_login(&headers, &state).await?; + let wallet = state.store().get_wallet(wallet_id).await?; + if wallet.user_id != Some(user_id) { + return Err(ApiError::NotFound); + } + let user = state + .store() + .get_user(user_id) + .await + .map_err(|_| ApiError::Internal)? + .ok_or(ApiError::NotFound)?; + + let req: WithdrawConfirmRequest = parse_optional(&body)?; + let signed_xdr = require_xdr(req.transaction_xdr)?; + let code = req + .code + .filter(|c| !c.is_empty()) + .ok_or_else(|| ApiError::BadRequest("code is required".into()))?; + + let tx_hash = hex::encode( + compute_inner_tx_hash(&signed_xdr, state.network()) + .map_err(|_| ApiError::BadRequest("transaction_xdr could not be hashed".into()))?, + ); + + let code_hash = octo_email::hash_otp(&code); + let payment = validate_signed_xdr(&signed_xdr, &wallet.stellar_account_g)?; + if state + .store() + .verify_and_consume_otp(user_id, "withdrawal", &code_hash, Some(&tx_hash)) + .await + .is_err() + { + // Wrong/expired/reused code: never touches Horizon. Tell the user in case it wasn't them. + if let Some(p) = &payment { + let html = octo_email::templates::withdrawal_failed_email( + &format_amount(p.amount_stroops), + &p.asset_code, + &p.destination, + "an incorrect verification code was entered", + ); + let _ = state + .email() + .send(&user.email, "Withdrawal attempt failed", &html) + .await; + } + return Err(ApiError::BadRequest("invalid or expired code".into())); + } + + let outcome = relay_signed_transaction(&state, wallet_id, &wallet, &signed_xdr).await?; + + crate::audit::record( + &state, + user_id, + &format!("confirmed a withdrawal ({})", outcome.response.status), + crate::audit::category::WALLET, + outcome.destination.as_deref(), + &headers, + ) + .await; + + let (amount, asset, destination) = ( + outcome.amount_stroops.map(format_amount), + outcome.asset_code.clone(), + outcome.destination.clone(), + ); + if let (Some(amount), Some(asset), Some(destination)) = (amount, asset, destination) { + let html = if outcome.response.status == "confirmed" { + octo_email::templates::withdrawal_success_email( + &amount, + &asset, + &destination, + outcome.response.stellar_tx_hash.as_deref().unwrap_or(""), + ) + } else { + octo_email::templates::withdrawal_failed_email( + &amount, + &asset, + &destination, + outcome + .response + .detail + .as_deref() + .unwrap_or("the transaction was rejected"), + ) + }; + let subject = if outcome.response.status == "confirmed" { + "Withdrawal successful" + } else { + "Withdrawal attempt failed" + }; + let _ = state.email().send(&user.email, subject, &html).await; + } + + let (code, json) = Envelope::created(outcome.response); Ok((code, json)) } diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index e723d51..05c9f67 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -4,6 +4,7 @@ use crate::error::ApiError; use crate::horizon::Horizon; use base64::Engine; use octo_crypto::{master_key_from_slice, MASTER_KEY_LEN}; +use octo_email::EmailSender; use octo_resilience::{CircuitBreaker, RetryPolicy}; use octo_store::Store; use octo_wallet_core::StellarNetwork; @@ -37,6 +38,8 @@ struct Inner { jwt_secret: Vec, /// Fires signed webhooks (e.g. `transaction.sponsored`) to registered endpoints. webhooks: WebhookSender, + /// Sends OTP/welcome/withdrawal emails via Resend. + email: EmailSender, /// Per-IP rate limiting for auth + public endpoints. rate_limiter: crate::rate_limit::RateLimiter, /// Cloudinary credentials for signed image uploads; `None` when unconfigured, in which case @@ -79,6 +82,7 @@ impl AppState { network: StellarNetwork, horizon_url: String, friendbot_url: Option, + email: EmailSender, ) -> Self { let mut secret = vec![0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut secret); @@ -90,6 +94,7 @@ impl AppState { horizon_url, friendbot_url, "http://localhost:3000".to_string(), + email, secret, octo_resilience::RetryPolicy::default(), octo_resilience::CircuitBreaker::new(5, std::time::Duration::from_secs(30)), @@ -105,6 +110,7 @@ impl AppState { horizon_url: String, friendbot_url: Option, public_app_url: String, + email: EmailSender, retry: octo_resilience::RetryPolicy, circuit: octo_resilience::CircuitBreaker, ) -> Self { @@ -118,6 +124,7 @@ impl AppState { horizon_url, friendbot_url, public_app_url, + email, secret, retry, circuit, @@ -150,6 +157,7 @@ impl AppState { horizon_url: String, friendbot_url: Option, public_app_url: String, + email: EmailSender, jwt_secret: Vec, retry: RetryPolicy, circuit: CircuitBreaker, @@ -170,6 +178,7 @@ impl AppState { public_app_url, jwt_secret, webhooks, + email, }), } } @@ -236,6 +245,10 @@ impl AppState { &self.inner.webhooks } + pub fn email(&self) -> &EmailSender { + &self.inner.email + } + pub fn horizon_url(&self) -> &str { &self.inner.horizon_url } diff --git a/crates/api/tests/api_tests.rs b/crates/api/tests/api_tests.rs index 6397d8c..445d88d 100644 --- a/crates/api/tests/api_tests.rs +++ b/crates/api/tests/api_tests.rs @@ -38,6 +38,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), None, + octo_email::EmailSender::new_captured(), )) } @@ -96,26 +97,9 @@ async fn create_wallet_req(app: &axum::Router, token: &str) -> Request { } /// Sign up a fresh user via the router and return its bearer token. -async fn auth_token(app: &axum::Router) -> String { +async fn auth_token(app: &axum::Router, state: &AppState) -> String { let email = format!("u-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() + common::signup_and_verify(app, state, &email).await } async fn body_limit_handler(_: Bytes) -> Result { @@ -153,7 +137,7 @@ async fn test_oversized_body_returns_413() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // إرسال طلب كبير جداً (أكبر من الحد المسموح به عادة) let resp = app @@ -184,7 +168,7 @@ async fn create_wallet_is_non_custodial_and_stores_no_seed() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; // The client generates the keypair, proves ownership, and sends only public material. let kp = stellar_base::crypto::DalekKeyPair::random().unwrap(); @@ -241,8 +225,8 @@ async fn create_wallet_rejects_bad_public_key() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Missing public_key → 400. let resp = app @@ -269,8 +253,8 @@ async fn addresses_return_both_forms_and_share_base() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Create a wallet (empty body is allowed). let resp = app @@ -314,8 +298,8 @@ async fn transactions_endpoint_returns_list() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -346,8 +330,8 @@ async fn get_unknown_wallet_is_404() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let uri = format!("/v1/wallets/{}", uuid::Uuid::new_v4()); let resp = app.oneshot(get_auth(&uri, &token)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -358,8 +342,8 @@ async fn balances_requires_auth_and_a_real_wallet() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/balances"); @@ -369,7 +353,7 @@ async fn balances_requires_auth_and_a_real_wallet() { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // Another user must not learn whether this wallet exists. - let other = auth_token(&app).await; + let other = auth_token(&app, &state).await; let resp = app.clone().oneshot(get_auth(&uri, &other)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -418,8 +402,8 @@ async fn signing_info_requires_auth_and_a_real_wallet() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/signing-info"); @@ -430,7 +414,7 @@ async fn signing_info_requires_auth_and_a_real_wallet() { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // Another user must not learn whether this wallet exists. - let other = auth_token(&app).await; + let other = auth_token(&app, &state).await; let resp = app.clone().oneshot(get_auth(&uri, &other)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -445,7 +429,7 @@ async fn health_is_public_and_ok() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // The liveness probe must not require auth — a load balancer has no token. let resp = app.oneshot(get("/health")).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); @@ -456,8 +440,8 @@ async fn backup_round_trips_the_opaque_blob_verbatim() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // The blob is ciphertext the CLIENT produced; the server must store and return it byte-for // byte without interpreting it. @@ -496,8 +480,8 @@ async fn backup_is_null_when_the_client_stored_none() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // encrypted_backup is optional — a user may decline server-side backup entirely. let wallet_id = create_wallet_for(&app, &token).await; @@ -512,8 +496,8 @@ async fn backup_rejects_api_key_auth_and_other_users() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/backup"); @@ -528,7 +512,7 @@ async fn backup_rejects_api_key_auth_and_other_users() { ); // Another logged-in user gets 404 (not 403) so wallet existence isn't leaked. - let other = auth_token(&app).await; + let other = auth_token(&app, &state).await; let resp = app.oneshot(get_auth(&uri, &other)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } @@ -538,7 +522,7 @@ async fn unauthenticated_request_is_401() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // No token at all → 401 (auth required on wallet endpoints). let uri = format!("/v1/wallets/{}", uuid::Uuid::new_v4()); let resp = app.oneshot(get(&uri)).await.unwrap(); @@ -550,8 +534,8 @@ async fn addresses_on_unknown_wallet_is_404() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let uri = format!("/v1/wallets/{}/addresses", uuid::Uuid::new_v4()); let resp = app.oneshot(post_auth(&uri, &token)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); @@ -601,8 +585,8 @@ async fn custodial_withdraw_is_gone() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() .oneshot(create_wallet_req(&app, &token).await) @@ -632,8 +616,8 @@ async fn submit_signed_requires_transaction_xdr() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() .oneshot(create_wallet_req(&app, &token).await) @@ -670,8 +654,8 @@ async fn custodial_trustline_is_gone() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() .oneshot(create_wallet_req(&app, &token).await) @@ -708,8 +692,8 @@ async fn api_key_generate_and_get() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Create a wallet owned by this user. let resp = app @@ -778,10 +762,10 @@ async fn api_key_requires_ownership() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // User A creates a wallet. - let token_a = auth_token(&app).await; + let token_a = auth_token(&app, &state).await; let resp = app .clone() .oneshot(create_wallet_req(&app, &token_a).await) @@ -793,7 +777,7 @@ async fn api_key_requires_ownership() { .to_string(); // User B cannot generate a key for A's wallet → 404 (not revealed). - let token_b = auth_token(&app).await; + let token_b = auth_token(&app, &state).await; let resp = app .oneshot(post_auth( &format!("/v1/wallets/{wallet_id}/api-key"), @@ -823,8 +807,8 @@ async fn regenerating_api_key_invalidates_the_previous_one() { // is implemented in `Store::upsert_api_key`; the only way to confirm it *replaces* rather // than *appends* a row is to check the hash lookup, not just the HTTP responses). let store = state.store().clone(); - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -959,8 +943,8 @@ async fn api_key_bearer_calling_generate_key_behavior_is_documented() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -1009,8 +993,8 @@ async fn api_key_can_create_address_on_its_wallet() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Create a wallet + its API key. let resp = app @@ -1050,8 +1034,8 @@ async fn api_key_cannot_touch_another_wallet() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Two wallets owned by the same user; key for wallet A. let a = body_json( @@ -1089,8 +1073,8 @@ async fn delete_api_key_revokes_it_and_subsequent_calls_using_it_are_401() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Create a wallet and generate an API key. let resp = app @@ -1157,10 +1141,10 @@ async fn delete_api_key_requires_wallet_ownership() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // User A creates a wallet with an API key. - let token_a = auth_token(&app).await; + let token_a = auth_token(&app, &state).await; let resp = app .clone() .oneshot(create_wallet_req(&app, &token_a).await) @@ -1173,7 +1157,7 @@ async fn delete_api_key_requires_wallet_ownership() { api_key_for(&app, &token_a, &wallet_id).await; // User B cannot revoke A's key → 404 (not revealed). - let token_b = auth_token(&app).await; + let token_b = auth_token(&app, &state).await; let resp = app .clone() .oneshot(delete_auth( @@ -1190,8 +1174,8 @@ async fn delete_api_key_on_a_wallet_with_no_key_is_ok() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Create a wallet without generating a key. let resp = app @@ -1221,8 +1205,8 @@ async fn delete_api_key_rejects_api_key_auth() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -1252,8 +1236,8 @@ async fn api_key_cannot_provision_gas_tank() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = body_json( app.clone() @@ -1289,22 +1273,11 @@ async fn audit_logs_record_and_list() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); - // Signup records "created an account"; capture the token. + // Signup + verify records "created an account"; capture the token. let email = format!("audit-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - let token = body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string(); + let token = common::signup_and_verify(&app, &state, &email).await; // Create a wallet → records "created master wallet". app.clone() @@ -1346,21 +1319,11 @@ async fn audit_logs_are_strictly_scoped_to_the_authenticated_user() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // User A signs up and performs an auditable action with a distinctive marker. let email_a = format!("audit-a-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email_a}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - let data_a = body_json(resp).await; - let token_a = data_a["data"]["token"].as_str().unwrap().to_string(); - let user_id_a = data_a["data"]["user"]["id"].as_str().unwrap().to_string(); + let (token_a, user_id_a) = common::signup_and_verify_full(&app, &state, &email_a).await; let kp_a = stellar_base::crypto::DalekKeyPair::random().unwrap(); let account_a = kp_a.public_key().account_id(); @@ -1378,17 +1341,7 @@ async fn audit_logs_are_strictly_scoped_to_the_authenticated_user() { // User B signs up and performs its own auditable action with a different marker. let email_b = format!("audit-b-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email_b}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - let data_b = body_json(resp).await; - let token_b = data_b["data"]["token"].as_str().unwrap().to_string(); - let user_id_b = data_b["data"]["user"]["id"].as_str().unwrap().to_string(); + let (token_b, user_id_b) = common::signup_and_verify_full(&app, &state, &email_b).await; let kp_b = stellar_base::crypto::DalekKeyPair::random().unwrap(); let account_b = kp_b.public_key().account_id(); @@ -1464,22 +1417,11 @@ async fn audit_logs_category_all_behaves_like_no_filter() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); - // Signup records "created an account"; capture the token. + // Signup + verify records "created an account"; capture the token. let email = format!("audit-all-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - let token = body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string(); + let token = common::signup_and_verify(&app, &state, &email).await; // Create a wallet → records "created master wallet", so there's more than one row/category. app.clone() @@ -1521,7 +1463,7 @@ async fn audit_logs_without_token_is_401() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); // No Authorization header at all → 401 (audit-logs requires `authenticate`). let resp = app.oneshot(get("/v1/audit-logs")).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); @@ -1557,8 +1499,8 @@ async fn list_sponsored_transactions_returns_empty_for_new_wallet() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -1584,7 +1526,7 @@ async fn list_sponsored_transactions_pagination() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -1646,7 +1588,7 @@ async fn list_sponsored_transactions_status_filter() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let resp = app .clone() @@ -1682,7 +1624,7 @@ async fn list_sponsored_transactions_requires_auth() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); let uri = format!( "/v1/wallets/{}/sponsored-transactions", uuid::Uuid::new_v4() @@ -1714,8 +1656,8 @@ async fn list_wallets_pagination_returns_a_next_cursor_and_respects_limit() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Create 5 wallets for this user. for _ in 0..5 { @@ -1767,8 +1709,8 @@ async fn list_addresses_pagination_returns_a_next_cursor_and_respects_limit() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; // Create 5 addresses. @@ -1811,7 +1753,7 @@ async fn list_transactions_pagination_returns_a_next_cursor_and_respects_limit() return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; // Insert 5 synthetic deposit transactions directly via the store. @@ -1886,8 +1828,8 @@ async fn pagination_limit_boundaries_are_validated_consistently_with_sponsored_t eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; // limit=0 → 400 on all three endpoints. @@ -1943,7 +1885,7 @@ async fn payment_link_public_routes_require_no_auth_and_404_unknown_slugs() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); // No Authorization header at all — must not be treated as unauthenticated-401, just 404. let resp = app @@ -1979,9 +1921,9 @@ async fn payment_link_management_requires_wallet_ownership() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let owner = auth_token(&app).await; - let other = auth_token(&app).await; + let app = build_router(state.clone()); + let owner = auth_token(&app, &state).await; + let other = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &owner).await; let uri = format!("/v1/wallets/{wallet_id}/payment-links"); @@ -2059,8 +2001,8 @@ async fn payment_link_response_includes_checkout_url_and_redirect_url() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/payment-links"); @@ -2115,8 +2057,8 @@ async fn payment_link_intent_rejects_flexible_amount_without_one() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let resp = app @@ -2164,8 +2106,8 @@ async fn create_wallet_without_challenge_is_rejected() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // A valid public key but no ownership proof — must be rejected, or anyone could register a // stranger's account and watch its deposit history. @@ -2190,8 +2132,8 @@ async fn create_wallet_rejects_signature_from_a_different_key() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // The challenge is signed by key B, but the registration claims key A's account. let kp_a = stellar_base::crypto::DalekKeyPair::random().unwrap(); @@ -2221,9 +2163,9 @@ async fn create_wallet_rejects_another_users_challenge() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let user_a = auth_token(&app).await; - let user_b = auth_token(&app).await; + let app = build_router(state.clone()); + let user_a = auth_token(&app, &state).await; + let user_b = auth_token(&app, &state).await; // Challenge issued to user A, redeemed by user B: the HMAC user-binding must reject it, // otherwise a captured (challenge, signature) pair could be replayed cross-account. @@ -2266,7 +2208,7 @@ async fn signup_is_rate_limited_per_ip() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); let ip = format!("203.0.113.{}", rand_octet()); // The limit is 10/min/IP; the 11th attempt from the same IP must be refused. @@ -2312,8 +2254,8 @@ async fn payment_intent_creation_is_rate_limited_per_ip() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let resp = app @@ -2361,8 +2303,8 @@ async fn concurrent_payment_intents_get_distinct_deposit_addresses() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let resp = app @@ -2446,9 +2388,9 @@ async fn payment_link_payments_list_requires_ownership_and_returns_payers() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let owner = auth_token(&app).await; - let other = auth_token(&app).await; + let app = build_router(state.clone()); + let owner = auth_token(&app, &state).await; + let other = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &owner).await; let resp = app @@ -2513,7 +2455,7 @@ async fn upload_signature_requires_auth() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); // No credential: must be 401 rather than handing out signed upload params. let resp = app @@ -2525,7 +2467,7 @@ async fn upload_signature_requires_auth() { // Authenticated: 200 with params when Cloudinary is configured, or a clear 400 when it // isn't. Either way it must not be a 401/500 — the test env usually has no credentials. - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let resp = app .oneshot(get_auth("/v1/uploads/signature", &token)) .await @@ -2543,8 +2485,8 @@ async fn submit_payment_validates_against_the_intents_own_address() { eprintln!("SKIPPED: set DATABASE_URL to run integration tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let wallet_id = create_wallet_for(&app, &token).await; let resp = app diff --git a/crates/api/tests/auth_tests.rs b/crates/api/tests/auth_tests.rs index f311b6b..87834b6 100644 --- a/crates/api/tests/auth_tests.rs +++ b/crates/api/tests/auth_tests.rs @@ -1,5 +1,7 @@ //! Integration tests for dashboard auth (signup / login / me). Require Postgres via DATABASE_URL. +mod common; + use axum::body::Body; use axum::http::{Request, StatusCode}; use octo_api::{build_router, AppState}; @@ -28,6 +30,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), None, + octo_email::EmailSender::new_captured(), ) .with_jwt_secret(b"test-jwt-secret-at-least-16-bytes".to_vec()), ) @@ -53,22 +56,11 @@ fn unique_email() -> String { format!("user-{}@octo.test", uuid::Uuid::new_v4().simple()) } -/// Sign up a fresh user and return `(token, user_id)`. -async fn signup(app: &axum::Router, email: &str) -> (String, String) { - let resp = app - .clone() - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - let j = body_json(resp).await; - ( - j["data"]["token"].as_str().unwrap().to_string(), - j["data"]["user"]["id"].as_str().unwrap().to_string(), - ) +/// Sign up a fresh user, verify via the captured OTP, and return `(token, user_id)`. +async fn signup(app: &axum::Router, state: &AppState, email: &str) -> (String, String) { + let token = common::signup_and_verify(app, state, email).await; + let claims = octo_api::auth::verify_token(state.jwt_secret(), &token).unwrap(); + (token, claims.sub) } fn post_refresh(token: Option<&str>) -> Request { @@ -110,9 +102,9 @@ async fn refresh_issues_a_new_token_with_an_extended_expiry_for_the_same_user() eprintln!("SKIPPED: set DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - let (token, user_id) = signup(&app, &email).await; + let (token, user_id) = signup(&app, &state, &email).await; let old_claims = jwt_claims(&token); // Ensure the wall clock advances so the new exp is strictly later. @@ -154,8 +146,8 @@ async fn refresh_rejects_an_expired_token() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); - let (_token, user_id) = signup(&app, &unique_email()).await; + let app = build_router(state.clone()); + let (_token, user_id) = signup(&app, &state, &unique_email()).await; // Correctly signed, but expired a minute ago. let expired = forge_token(&user_id, chrono::Utc::now().timestamp() - 60); @@ -203,10 +195,10 @@ async fn signup_login_me_flow() { eprintln!("SKIPPED: set DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - // Signup → 201 + token. + // Signup → 201, but no token yet — an OTP is emailed and must be verified first. let resp = app .clone() .oneshot(post_json( @@ -217,6 +209,21 @@ async fn signup_login_me_flow() { .unwrap(); assert_eq!(resp.status(), StatusCode::CREATED); let j = body_json(resp).await; + assert_eq!(j["data"]["email_verification_required"], true); + let user_id = j["data"]["user_id"].as_str().unwrap().to_string(); + + // Verify with the captured OTP → token. + let code = state.email().last_otp_for(&email).unwrap(); + let resp = app + .clone() + .oneshot(post_json( + "/v1/auth/verify-email", + &format!(r#"{{"user_id":"{user_id}","code":"{code}"}}"#), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let j = body_json(resp).await; let token = j["data"]["token"].as_str().unwrap().to_string(); assert_eq!(j["data"]["user"]["email"], email); assert!(!token.is_empty()); @@ -236,7 +243,7 @@ async fn signup_login_me_flow() { assert_eq!(resp.status(), StatusCode::OK); assert_eq!(body_json(resp).await["data"]["email"], email); - // Login with the right password → token. + // Login with the right password, now that the account is verified → a real token directly. let resp = app .oneshot(post_json( "/v1/auth/login", @@ -251,6 +258,113 @@ async fn signup_login_me_flow() { .is_empty()); } +#[tokio::test] +async fn login_before_verification_resends_otp_instead_of_a_token() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: set DATABASE_URL"); + return; + }; + let app = build_router(state.clone()); + let email = unique_email(); + + app.clone() + .oneshot(post_json( + "/v1/auth/signup", + &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), + )) + .await + .unwrap(); + + // Correct password, but the account was never verified — login must gate on OTP too. + let resp = app + .oneshot(post_json( + "/v1/auth/login", + &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let j = body_json(resp).await; + assert_eq!(j["data"]["email_verification_required"], true); + assert!(j["data"]["token"].is_null()); +} + +#[tokio::test] +async fn verify_email_rejects_a_wrong_code() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: set DATABASE_URL"); + return; + }; + let app = build_router(state.clone()); + let email = unique_email(); + + let resp = app + .clone() + .oneshot(post_json( + "/v1/auth/signup", + &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), + )) + .await + .unwrap(); + let user_id = body_json(resp).await["data"]["user_id"] + .as_str() + .unwrap() + .to_string(); + + let resp = app + .oneshot(post_json( + "/v1/auth/verify-email", + &format!(r#"{{"user_id":"{user_id}","code":"000000"}}"#), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn resend_otp_issues_a_working_code() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: set DATABASE_URL"); + return; + }; + let app = build_router(state.clone()); + let email = unique_email(); + + let resp = app + .clone() + .oneshot(post_json( + "/v1/auth/signup", + &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), + )) + .await + .unwrap(); + let user_id = body_json(resp).await["data"]["user_id"] + .as_str() + .unwrap() + .to_string(); + + let resp = app + .clone() + .oneshot(post_json( + "/v1/auth/resend-otp", + &format!(r#"{{"user_id":"{user_id}"}}"#), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // The resend replaces the code — the newest capture must verify. + let code = state.email().last_otp_for(&email).unwrap(); + let resp = app + .oneshot(post_json( + "/v1/auth/verify-email", + &format!(r#"{{"user_id":"{user_id}","code":"{code}"}}"#), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + #[tokio::test] async fn duplicate_email_is_rejected() { let Some(state) = test_state().await else { @@ -338,20 +452,9 @@ async fn short_password_rejected() { /// Helper: sign up a fresh user and return (cloneable app, token). async fn signup_and_get_token(state: AppState) -> (axum::Router, String) { - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - let resp = app - .clone() - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - let token = body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string(); + let token = common::signup_and_verify(&app, &state, &email).await; (app, token) } diff --git a/crates/api/tests/authz_matrix_tests.rs b/crates/api/tests/authz_matrix_tests.rs index 6f860c4..e91e3e9 100644 --- a/crates/api/tests/authz_matrix_tests.rs +++ b/crates/api/tests/authz_matrix_tests.rs @@ -46,6 +46,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), None, + octo_email::EmailSender::new_captured(), )) } @@ -74,26 +75,9 @@ fn post_auth(uri: &str, token: &str) -> Request { } /// Sign up a fresh user via the router and return its bearer token. -async fn auth_token(app: &axum::Router) -> String { +async fn auth_token(app: &axum::Router, state: &AppState) -> String { let email = format!("u-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() + common::signup_and_verify(app, state, &email).await } /// Create a wallet for `token`'s user and return its id. Non-custodial: the caller generates the @@ -228,12 +212,12 @@ async fn jwt_owner_of_wallet_a_is_404_on_every_guarded_route_for_wallet_b() { eprintln!("SKIPPED: set DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); - let token_a = auth_token(&app).await; + let token_a = auth_token(&app, &state).await; let wallet_a = create_wallet(&app, &token_a).await; - let token_b = auth_token(&app).await; + let token_b = auth_token(&app, &state).await; let wallet_b = create_wallet(&app, &token_b).await; for route in guarded_routes() { @@ -277,13 +261,13 @@ async fn api_key_for_wallet_a_is_404_on_every_guarded_route_for_wallet_b() { eprintln!("SKIPPED: set DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); - let token_a = auth_token(&app).await; + let token_a = auth_token(&app, &state).await; let wallet_a = create_wallet(&app, &token_a).await; let key_a = api_key_for(&app, &token_a, &wallet_a).await; - let token_b = auth_token(&app).await; + let token_b = auth_token(&app, &state).await; let wallet_b = create_wallet(&app, &token_b).await; for route in guarded_routes() { diff --git a/crates/api/tests/common/mod.rs b/crates/api/tests/common/mod.rs index ef949e2..258a3bb 100644 --- a/crates/api/tests/common/mod.rs +++ b/crates/api/tests/common/mod.rs @@ -3,9 +3,75 @@ use axum::body::Body; use axum::http::Request; +use octo_api::AppState; use stellar_base::crypto::DalekKeyPair; use tower::ServiceExt; +/// Sign up a fresh user, verify via the captured OTP (`state`'s `EmailSender` must be +/// `new_captured()`), and return the bearer token. `email` should be unique per call. +pub async fn signup_and_verify(app: &axum::Router, state: &AppState, email: &str) -> String { + signup_and_verify_full(app, state, email).await.0 +} + +/// Same as `signup_and_verify` but also returns the verified user's id. +pub async fn signup_and_verify_full( + app: &axum::Router, + state: &AppState, + email: &str, +) -> (String, String) { + let body = serde_json::json!({ "email": email, "password": "supersecret123" }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/signup") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let user_id = json["data"]["user_id"] + .as_str() + .expect("signup must return user_id"); + let code = state + .email() + .last_otp_for(email) + .expect("otp must be captured"); + + let verify_body = serde_json::json!({ "user_id": user_id, "code": code }); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/auth/verify-email") + .header("content-type", "application/json") + .body(Body::from(verify_body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let token = json["data"]["token"] + .as_str() + .expect("verify-email must return a token") + .to_string(); + let user_id = json["data"]["user"]["id"] + .as_str() + .expect("verify-email must return the user") + .to_string(); + (token, user_id) +} + /// Fetch an ownership challenge for the authenticated user and sign it with `kp`. /// Returns `(challenge, signature_b64)` ready for `POST /v1/wallets`. pub async fn signed_challenge( diff --git a/crates/api/tests/drift_tests.rs b/crates/api/tests/drift_tests.rs index b5e5934..e96f6ed 100644 --- a/crates/api/tests/drift_tests.rs +++ b/crates/api/tests/drift_tests.rs @@ -30,6 +30,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, String::from("https://horizon-testnet.stellar.org"), None, + octo_email::EmailSender::new_captured(), )) } @@ -67,31 +68,10 @@ fn validate_response(spec: &Value, path: &str, method: &str, status: &str, respo } } -/// Sign up a fresh user and return its bearer token. Every /v1/wallets route is authenticated, -/// so the drift test needs a real token or it only ever exercises the 401 path. -async fn auth_token(app: &axum::Router) -> String { +/// Sign up a fresh user, verify via the captured OTP, and return its bearer token. +async fn auth_token(app: &axum::Router, state: &AppState) -> String { let email = format!("drift-{}@octo.test", uuid::Uuid::new_v4().simple()); - let body = serde_json::json!({ "email": email, "password": "correct horse battery" }); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap(), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(resp.into_body(), 1024 * 1024) - .await - .unwrap(); - let json: Value = serde_json::from_slice(&bytes).unwrap(); - json["data"]["token"] - .as_str() - .expect("signup must return a token") - .to_string() + common::signup_and_verify(app, state, &email).await } #[tokio::test] @@ -100,9 +80,9 @@ async fn live_wallet_creation_response_matches_the_openapi_schema() { Some(s) => s, None => return, }; - let app = build_router(state); + let app = build_router(state.clone()); let spec = load_openapi_spec(); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; // 1. Success case: Create wallet. // diff --git a/crates/api/tests/horizon_live_tests.rs b/crates/api/tests/horizon_live_tests.rs index dd5318d..0185a78 100644 --- a/crates/api/tests/horizon_live_tests.rs +++ b/crates/api/tests/horizon_live_tests.rs @@ -42,6 +42,7 @@ async fn live_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), Some("https://friendbot.stellar.org".into()), + octo_email::EmailSender::new_captured(), )) } @@ -72,26 +73,9 @@ async fn body_json(resp: axum::response::Response) -> serde_json::Value { } /// Sign up a fresh user and return its bearer token (wallet creation requires auth). -async fn auth_token(app: &axum::Router) -> String { +async fn auth_token(app: &axum::Router, state: &AppState) -> String { let email = format!("live-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() + common::signup_and_verify(app, state, &email).await } #[tokio::test] @@ -100,8 +84,8 @@ async fn create_wallet_funds_and_has_balance() { eprintln!("SKIPPED: set OCTO_LIVE_TESTS=1 and DATABASE_URL to run live testnet tests"); return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Client generates the key; server friendbot-funds the supplied account on testnet. let kp = DalekKeyPair::random().unwrap(); @@ -154,8 +138,11 @@ async fn create_wallet_funds_and_has_balance() { /// Create a non-custodial wallet from a caller-generated keypair, friendbot-funded on testnet. /// Returns `(wallet_id, account_g, keypair, owner_token)` so the test can sign + relay locally. -async fn create_funded_wallet(app: &axum::Router) -> (String, String, DalekKeyPair, String) { - let token = auth_token(app).await; +async fn create_funded_wallet( + app: &axum::Router, + state: &AppState, +) -> (String, String, DalekKeyPair, String) { + let token = auth_token(app, state).await; let kp = DalekKeyPair::random().unwrap(); let body = common::wallet_body(app, &token, &kp).await; let resp = app @@ -186,12 +173,12 @@ async fn submit_signed_sends_xlm_on_chain() { eprintln!("SKIPPED: set OCTO_LIVE_TESTS=1 and DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); // Two funded wallets: A pays B. A signs the payment CLIENT-SIDE (the server never holds A's // key) and relays it through submit-signed. - let (wallet_a, addr_a, kp_a, token_a) = create_funded_wallet(&app).await; - let (_wallet_b, addr_b, _kp_b, _token_b) = create_funded_wallet(&app).await; + let (wallet_a, addr_a, kp_a, token_a) = create_funded_wallet(&app, &state).await; + let (_wallet_b, addr_b, _kp_b, _token_b) = create_funded_wallet(&app, &state).await; // Build + sign a 1 XLM payment locally, using signing-info for the sequence number. let seq = sequence_with_retry("https://horizon-testnet.stellar.org", &addr_a).await; @@ -309,13 +296,13 @@ async fn submit_signed_enforces_withdrawal_allowlist() { eprintln!("SKIPPED: set OCTO_LIVE_TESTS=1 and DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); // A pays B, but B is never whitelisted — a third wallet C seeds the allowlist so enabling it // doesn't require B to already be on the list (that would defeat the point of the test). - let (wallet_a, addr_a, kp_a, token_a) = create_funded_wallet(&app).await; - let (_wallet_b, addr_b, _kp_b, _token_b) = create_funded_wallet(&app).await; - let (_wallet_c, addr_c, _kp_c, _token_c) = create_funded_wallet(&app).await; + let (wallet_a, addr_a, kp_a, token_a) = create_funded_wallet(&app, &state).await; + let (_wallet_b, addr_b, _kp_b, _token_b) = create_funded_wallet(&app, &state).await; + let (_wallet_c, addr_c, _kp_c, _token_c) = create_funded_wallet(&app, &state).await; enable_allowlist(&app, &wallet_a, &token_a, &addr_c).await; @@ -458,7 +445,7 @@ async fn sponsored_webhook_fires_on_confirmation() { let app = build_router(state.clone()); // One user owns both the wallet and its webhook registration throughout. - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let kp = DalekKeyPair::random().unwrap(); let wallet_reg_body = common::wallet_body(&app, &token, &kp).await; let resp = app @@ -574,7 +561,7 @@ async fn payment_link_wrong_asset_deposit_is_recorded_but_not_confirmed() { }; let app = build_router(state.clone()); - let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app).await; + let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app, &state).await; // Friendbot's HTTP call returning success doesn't guarantee Horizon has indexed the new // account yet for lookups from other calls — settle before anyone tries to pay it. sequence_with_retry("https://horizon-testnet.stellar.org", &merchant_g).await; @@ -727,7 +714,7 @@ async fn payment_link_public_submit_rejects_wrong_asset() { }; let app = build_router(state.clone()); - let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app).await; + let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app, &state).await; sequence_with_retry("https://horizon-testnet.stellar.org", &merchant_g).await; let resp = app .clone() @@ -817,7 +804,7 @@ async fn payment_link_public_submit_rejects_wrong_destination() { }; let app = build_router(state.clone()); - let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app).await; + let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app, &state).await; sequence_with_retry("https://horizon-testnet.stellar.org", &merchant_g).await; let resp = app .clone() @@ -885,7 +872,7 @@ async fn payment_link_signing_info_returns_the_requested_payer_account_sequence( }; let app = build_router(state.clone()); - let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app).await; + let (wallet_id, merchant_g, _kp, token) = create_funded_wallet(&app, &state).await; sequence_with_retry("https://horizon-testnet.stellar.org", &merchant_g).await; let resp = app .clone() diff --git a/crates/api/tests/malformed_body_tests.rs b/crates/api/tests/malformed_body_tests.rs index bcde33a..d6705cd 100644 --- a/crates/api/tests/malformed_body_tests.rs +++ b/crates/api/tests/malformed_body_tests.rs @@ -39,6 +39,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), None, + octo_email::EmailSender::new_captured(), )) } @@ -49,29 +50,10 @@ async fn body_json(resp: axum::response::Response) -> serde_json::Value { serde_json::from_slice(&bytes).expect("json") } -/// POST with no body but an Authorization bearer token — used only to provision the wallet the -/// malformed-body cases target; not itself part of the matrix. /// Sign up a fresh user via the router and return its bearer token. -async fn auth_token(app: &axum::Router) -> String { +async fn auth_token(app: &axum::Router, state: &AppState) -> String { let email = format!("u-{}@octo.test", uuid::Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() + common::signup_and_verify(app, state, &email).await } /// Build a request carrying a raw (possibly malformed) JSON body, with an optional bearer token. @@ -176,8 +158,8 @@ struct Fixture { async fn fixture() -> Option { let state = test_state().await?; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; // Non-custodial contract: `public_key` plus a signed ownership challenge is required, so an // empty body now 400s. let kp = stellar_base::crypto::DalekKeyPair::random().unwrap(); diff --git a/crates/api/tests/session_revocation_tests.rs b/crates/api/tests/session_revocation_tests.rs index 1e2c105..05592ca 100644 --- a/crates/api/tests/session_revocation_tests.rs +++ b/crates/api/tests/session_revocation_tests.rs @@ -7,6 +7,8 @@ //! - logout revokes the current token //! - a request in-flight at the moment of refresh: documents the accepted race window +mod common; + use axum::body::Body; use axum::http::{Request, StatusCode}; use octo_api::{build_router, AppState}; @@ -35,6 +37,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), None, + octo_email::EmailSender::new_captured(), ) .with_jwt_secret(b"test-jwt-secret-at-least-16-bytes".to_vec()), ) @@ -47,15 +50,6 @@ async fn body_json(resp: axum::response::Response) -> serde_json::Value { serde_json::from_slice(&b).unwrap() } -fn post_json(uri: &str, body: &str) -> Request { - Request::builder() - .method("POST") - .uri(uri) - .header("content-type", "application/json") - .body(Body::from(body.to_string())) - .unwrap() -} - fn authed(method: &str, uri: &str, token: &str) -> Request { Request::builder() .method(method) @@ -70,21 +64,10 @@ fn unique_email() -> String { } // --------------------------------------------------------------------------- -// Helper: signup and return the initial token +// Helper: signup, verify the OTP, and return the resulting token // --------------------------------------------------------------------------- -async fn signup_and_get_token(app: axum::Router, email: &str) -> String { - let resp = app - .oneshot(post_json( - "/v1/auth/signup", - &format!(r#"{{"email":"{email}","password":"supersecret"}}"#), - )) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::CREATED); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() +async fn signup_and_get_token(app: axum::Router, state: &AppState, email: &str) -> String { + common::signup_and_verify(&app, state, email).await } // --------------------------------------------------------------------------- @@ -96,9 +79,9 @@ async fn refresh_revokes_the_previous_token_so_it_can_no_longer_authenticate() { eprintln!("SKIPPED: set DATABASE_URL"); return; }; - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - let token1 = signup_and_get_token(app.clone(), &email).await; + let token1 = signup_and_get_token(app.clone(), &state, &email).await; // Refresh using token1 — should succeed and return token2. let resp = app @@ -142,9 +125,9 @@ async fn logout_revokes_the_current_token() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - let token = signup_and_get_token(app.clone(), &email).await; + let token = signup_and_get_token(app.clone(), &state, &email).await; // Logout. let resp = app @@ -185,9 +168,9 @@ async fn in_flight_request_behaviour_around_refresh_is_as_documented() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - let token1 = signup_and_get_token(app.clone(), &email).await; + let token1 = signup_and_get_token(app.clone(), &state, &email).await; // Part a: old token is valid BEFORE refresh. let resp = app @@ -232,9 +215,9 @@ async fn double_logout_is_harmless() { let Some(state) = test_state().await else { return; }; - let app = build_router(state); + let app = build_router(state.clone()); let email = unique_email(); - let token = signup_and_get_token(app.clone(), &email).await; + let token = signup_and_get_token(app.clone(), &state, &email).await; app.clone() .oneshot(authed("POST", "/v1/auth/logout", &token)) diff --git a/crates/api/tests/sponsor_e2e_tests.rs b/crates/api/tests/sponsor_e2e_tests.rs index 128f864..e266606 100644 --- a/crates/api/tests/sponsor_e2e_tests.rs +++ b/crates/api/tests/sponsor_e2e_tests.rs @@ -45,6 +45,7 @@ async fn test_state(horizon_url: String) -> Option { StellarNetwork::Testnet, horizon_url, None, + octo_email::EmailSender::new_captured(), )) } @@ -92,26 +93,9 @@ fn get_auth(uri: &str, token: &str) -> Request { .unwrap() } -async fn auth_token(app: &Router) -> String { +async fn auth_token(app: &Router, state: &AppState) -> String { let email = format!("sponsor-e2e-{}@octo.test", Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() + common::signup_and_verify(app, state, &email).await } /// Create a non-custodial wallet (client-generated key) and provision its gas tank so the @@ -293,7 +277,7 @@ async fn e2e_sponsor_full_flow() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; let (hook_url, captured) = start_webhook_sink().await; @@ -359,8 +343,8 @@ async fn e2e_sponsor_rejected_when_disabled() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/sponsorship"); @@ -394,8 +378,8 @@ async fn e2e_sponsor_update_config_persists() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, _) = create_wallet(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/sponsorship"); @@ -437,7 +421,7 @@ async fn e2e_sponsorship_get_counts_only_confirmed_fees_spent_today() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let (wallet_id, _) = create_wallet(&app, &token).await; enable_sponsorship_via_api(&app, &token, &wallet_id).await; let wallet_id = Uuid::parse_str(&wallet_id).expect("wallet UUID"); @@ -480,8 +464,8 @@ async fn e2e_sponsor_rejects_account_merge_op() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, _) = create_wallet(&app, &token).await; enable_sponsorship_via_api(&app, &token, &wallet_id).await; @@ -510,8 +494,8 @@ async fn e2e_sponsor_rejects_self_sponsorship() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; enable_sponsorship_via_api(&app, &token, &wallet_id).await; @@ -554,8 +538,8 @@ async fn e2e_sponsor_duplicate_inner_tx_hash() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; enable_sponsorship_via_api(&app, &token, &wallet_id).await; @@ -583,8 +567,8 @@ async fn e2e_sponsor_budget_exceeded() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; let uri = format!("/v1/wallets/{wallet_id}/sponsorship"); @@ -629,8 +613,8 @@ async fn sponsor_at_exact_fee_cap_succeeds() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; let cap = 250_000_i64; @@ -669,8 +653,8 @@ async fn sponsor_one_stroop_over_fee_cap_is_rejected() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; let cap = 250_000_i64; @@ -712,8 +696,8 @@ async fn sponsor_uncapped_per_tx_with_daily_budget_set_succeeds_under_budget() { let Some(state) = test_state(horizon).await else { return; }; - let app = build_router(state); - let token = auth_token(&app).await; + let app = build_router(state.clone()); + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; // No per_tx_fee_cap_stroops in the request body -> stored as NULL (uncapped per-tx), @@ -763,7 +747,7 @@ async fn e2e_concurrent_sponsor_requests_respect_budget() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; let fee_per_tx = 200_i64; diff --git a/crates/api/tests/sponsor_webhook_tests.rs b/crates/api/tests/sponsor_webhook_tests.rs index 0feb81a..c8a5533 100644 --- a/crates/api/tests/sponsor_webhook_tests.rs +++ b/crates/api/tests/sponsor_webhook_tests.rs @@ -51,6 +51,7 @@ async fn test_state() -> Option { StellarNetwork::Testnet, "https://horizon-testnet.stellar.org".into(), None, + octo_email::EmailSender::new_captured(), )) } @@ -80,26 +81,9 @@ fn post_json_auth(uri: &str, body: &str, token: &str) -> Request { .unwrap() } -async fn auth_token(app: &Router) -> String { +async fn auth_token(app: &Router, state: &AppState) -> String { let email = format!("sponsor-{}@octo.test", Uuid::new_v4().simple()); - let resp = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/auth/signup") - .header("content-type", "application/json") - .body(Body::from(format!( - r#"{{"email":"{email}","password":"supersecret"}}"# - ))) - .unwrap(), - ) - .await - .unwrap(); - body_json(resp).await["data"]["token"] - .as_str() - .unwrap() - .to_string() + common::signup_and_verify(app, state, &email).await } /// Create a non-custodial wallet (client-generated key) via the API and provision its gas tank @@ -228,7 +212,7 @@ async fn sponsored_webhook_fires_on_failure() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; enable_sponsorship(&state, &wallet_id).await; @@ -290,7 +274,7 @@ async fn sponsored_webhook_skipped_when_no_endpoint() { return; }; let app = build_router(state.clone()); - let token = auth_token(&app).await; + let token = auth_token(&app, &state).await; let (wallet_id, master_g) = create_wallet(&app, &token).await; enable_sponsorship(&state, &wallet_id).await; // Deliberately no webhook endpoint registered for this wallet. diff --git a/crates/api/tests/withdraw_otp_tests.rs b/crates/api/tests/withdraw_otp_tests.rs new file mode 100644 index 0000000..38ed98d --- /dev/null +++ b/crates/api/tests/withdraw_otp_tests.rs @@ -0,0 +1,283 @@ +//! Tests for the withdrawal OTP gate: `/v1/wallets/:id/withdraw/request-otp` and `.../confirm`. +//! +//! Uses fresh, unfunded testnet wallets, so a *correctly*-confirmed withdrawal still fails at +//! Horizon (no account on-chain) — the point of these tests is the OTP gate itself (an incorrect +//! or missing code must never reach Horizon at all), not on-chain success. +//! +//! Requires Postgres via `DATABASE_URL` (skipped with a message otherwise, like `api_tests.rs`). + +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use octo_api::{build_router, AppState}; +use octo_store::Store; +use octo_wallet_core::StellarNetwork; +use std::sync::Once; +use stellar_base::crypto::DalekKeyPair; +use stellar_base::operations::Operation; +use stellar_base::transaction::{Transaction, MIN_BASE_FEE}; +use stellar_base::xdr::XDRSerialize; +use tower::ServiceExt; +use uuid::Uuid; + +static LOAD_ENV: Once = Once::new(); + +fn database_url() -> Option { + LOAD_ENV.call_once(|| { + let _ = dotenvy::dotenv(); + }); + std::env::var("DATABASE_URL").ok() +} + +async fn test_state() -> Option { + let url = database_url()?; + let store = Store::connect(&url).await.expect("connect"); + store.migrate().await.expect("migrate"); + Some(AppState::new( + store, + [42u8; 32], + StellarNetwork::Testnet, + "https://horizon-testnet.stellar.org".into(), + None, + octo_email::EmailSender::new_captured(), + )) +} + +async fn body_json(resp: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +fn post_json_auth(uri: &str, body: &str, token: &str) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap() +} + +/// Sign up, verify, and return `(token, email)` — the email is needed to pull the withdrawal +/// OTP back out of the captured-email test double. +async fn auth_token_and_email(app: &axum::Router, state: &AppState) -> (String, String) { + let email = format!("withdraw-otp-{}@octo.test", Uuid::new_v4().simple()); + let token = common::signup_and_verify(app, state, &email).await; + (token, email) +} + +/// Register a non-custodial wallet for `kp` and return its wallet id. +async fn create_wallet(app: &axum::Router, token: &str, kp: &DalekKeyPair) -> String { + let body = common::wallet_body(app, token, kp).await; + let resp = app + .clone() + .oneshot(post_json_auth("/v1/wallets", &body, token)) + .await + .unwrap(); + body_json(resp).await["data"]["id"] + .as_str() + .unwrap() + .to_string() +} + +/// A trivially-signed Payment inner transaction from `kp` (never funded on-chain — Horizon will +/// reject it on submission, which is fine: these tests only need a validly-*signed* envelope). +fn signed_payment_xdr(kp: &DalekKeyPair, destination_g: &str) -> String { + let dest = stellar_base::crypto::PublicKey::from_account_id(destination_g).unwrap(); + let op = Operation::new_payment() + .with_destination(dest) + .with_amount(stellar_base::amount::Stroops::new(100)) + .unwrap() + .with_asset(stellar_base::asset::Asset::new_native()) + .build() + .unwrap(); + let mut tx = Transaction::builder(kp.public_key(), 1, MIN_BASE_FEE) + .add_operation(op) + .into_transaction() + .unwrap(); + tx.sign(kp.as_ref(), &stellar_base::network::Network::new_test()) + .unwrap(); + tx.into_envelope().xdr_base64().unwrap() +} + +#[tokio::test] +async fn request_otp_then_confirm_with_correct_code_relays_to_horizon() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: DATABASE_URL is not set"); + return; + }; + let app = build_router(state.clone()); + let (token, email) = auth_token_and_email(&app, &state).await; + let kp = DalekKeyPair::random().unwrap(); + let wallet_id = create_wallet(&app, &token, &kp).await; + let dest = DalekKeyPair::random().unwrap().public_key().account_id(); + let xdr = signed_payment_xdr(&kp, &dest); + + let resp = app + .clone() + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/request-otp"), + &serde_json::json!({ "transaction_xdr": xdr }).to_string(), + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "request-otp must succeed"); + + let code = state + .email() + .last_otp_for(&email) + .expect("otp must be captured"); + + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/confirm"), + &serde_json::json!({ "transaction_xdr": xdr, "code": code }).to_string(), + &token, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::CREATED, + "confirm with the correct code must proceed to relay (Horizon may still reject an \ + unfunded account, but the OTP gate itself must pass)" + ); + let out = body_json(resp).await; + // Unfunded source account — Horizon rejects it, but that's past the OTP gate. + assert_eq!(out["data"]["status"], "failed"); +} + +#[tokio::test] +async fn confirm_with_wrong_code_never_relays() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: DATABASE_URL is not set"); + return; + }; + let app = build_router(state.clone()); + let (token, _email) = auth_token_and_email(&app, &state).await; + let kp = DalekKeyPair::random().unwrap(); + let wallet_id = create_wallet(&app, &token, &kp).await; + let dest = DalekKeyPair::random().unwrap().public_key().account_id(); + let xdr = signed_payment_xdr(&kp, &dest); + + app.clone() + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/request-otp"), + &serde_json::json!({ "transaction_xdr": xdr }).to_string(), + &token, + )) + .await + .unwrap(); + + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/confirm"), + &serde_json::json!({ "transaction_xdr": xdr, "code": "000000" }).to_string(), + &token, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "a wrong code must be rejected, never relayed" + ); +} + +#[tokio::test] +async fn confirm_rejects_a_code_issued_for_a_different_transaction() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: DATABASE_URL is not set"); + return; + }; + let app = build_router(state.clone()); + let (token, email) = auth_token_and_email(&app, &state).await; + let kp = DalekKeyPair::random().unwrap(); + let wallet_id = create_wallet(&app, &token, &kp).await; + let dest = DalekKeyPair::random().unwrap().public_key().account_id(); + let xdr_a = signed_payment_xdr(&kp, &dest); + + app.clone() + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/request-otp"), + &serde_json::json!({ "transaction_xdr": xdr_a }).to_string(), + &token, + )) + .await + .unwrap(); + let code = state + .email() + .last_otp_for(&email) + .expect("otp must be captured"); + + // A different destination produces a different tx hash — the code above must not bind to it. + let other_dest = DalekKeyPair::random().unwrap().public_key().account_id(); + let xdr_b = signed_payment_xdr(&kp, &other_dest); + + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/confirm"), + &serde_json::json!({ "transaction_xdr": xdr_b, "code": code }).to_string(), + &token, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "a code bound to one transaction must not confirm a different, swapped-in transaction" + ); +} + +#[tokio::test] +async fn confirm_without_a_prior_otp_request_is_rejected() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: DATABASE_URL is not set"); + return; + }; + let app = build_router(state.clone()); + let (token, _email) = auth_token_and_email(&app, &state).await; + let kp = DalekKeyPair::random().unwrap(); + let wallet_id = create_wallet(&app, &token, &kp).await; + let dest = DalekKeyPair::random().unwrap().public_key().account_id(); + let xdr = signed_payment_xdr(&kp, &dest); + + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/confirm"), + &serde_json::json!({ "transaction_xdr": xdr, "code": "123456" }).to_string(), + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn request_otp_rejects_a_wallet_the_caller_does_not_own() { + let Some(state) = test_state().await else { + eprintln!("SKIPPED: DATABASE_URL is not set"); + return; + }; + let app = build_router(state.clone()); + let (owner_token, _) = auth_token_and_email(&app, &state).await; + let (other_token, _) = auth_token_and_email(&app, &state).await; + let kp = DalekKeyPair::random().unwrap(); + let wallet_id = create_wallet(&app, &owner_token, &kp).await; + let dest = DalekKeyPair::random().unwrap().public_key().account_id(); + let xdr = signed_payment_xdr(&kp, &dest); + + let resp = app + .oneshot(post_json_auth( + &format!("/v1/wallets/{wallet_id}/withdraw/request-otp"), + &serde_json::json!({ "transaction_xdr": xdr }).to_string(), + &other_token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml new file mode 100644 index 0000000..42e1147 --- /dev/null +++ b/crates/email/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "octo-email" +description = "Transactional email via Resend, plus OTP generation and hashing." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[features] +test-fixtures = [] + +[dependencies] +base64.workspace = true +chrono.workspace = true +reqwest.workspace = true +sha2.workspace = true +hex.workspace = true +rand.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true diff --git a/crates/email/src/error.rs b/crates/email/src/error.rs new file mode 100644 index 0000000..45e10e3 --- /dev/null +++ b/crates/email/src/error.rs @@ -0,0 +1,10 @@ +use thiserror::Error; + +/// Errors from sending an email via Resend. +#[derive(Debug, Error)] +pub enum EmailError { + #[error("request to Resend failed")] + Request(#[from] reqwest::Error), + #[error("Resend rejected the request: {0}")] + Rejected(String), +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs new file mode 100644 index 0000000..be7a9a7 --- /dev/null +++ b/crates/email/src/lib.rs @@ -0,0 +1,123 @@ +//! Transactional email via Resend, plus OTP generation and hashing. +#![forbid(unsafe_code)] + +mod error; +pub mod templates; + +pub use error::EmailError; + +use rand::Rng; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +#[cfg(feature = "test-fixtures")] +use std::sync::{Arc, Mutex}; + +const RESEND_URL: &str = "https://api.resend.com/emails"; + +/// A captured OTP, recorded instead of actually emailed when using [`EmailSender::new_captured`]. +#[cfg(feature = "test-fixtures")] +#[derive(Debug, Clone)] +pub struct CapturedOtp { + pub to: String, + pub code: String, +} + +/// Sends transactional email via Resend's HTTP API. +#[derive(Clone)] +pub struct EmailSender { + api_key: String, + from_address: String, + http: reqwest::Client, + #[cfg(feature = "test-fixtures")] + captured: Option>>>, +} + +impl EmailSender { + pub fn new(api_key: String, from_address: String) -> Self { + Self { + api_key, + from_address, + http: reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap_or_default(), + #[cfg(feature = "test-fixtures")] + captured: None, + } + } + + /// A sender that records OTP codes in memory instead of calling Resend — lets integration + /// tests complete the signup/withdrawal OTP flow without a real inbox. + #[cfg(feature = "test-fixtures")] + pub fn new_captured() -> Self { + Self { + captured: Some(Arc::new(Mutex::new(Vec::new()))), + ..Self::new(String::new(), "test@octo.test".to_string()) + } + } + + /// The most recently captured OTP for `to`, if any. Test-fixtures only. + #[cfg(feature = "test-fixtures")] + pub fn last_otp_for(&self, to: &str) -> Option { + let captured = self.captured.as_ref()?.lock().ok()?; + captured + .iter() + .rev() + .find(|c| c.to == to) + .map(|c| c.code.clone()) + } + + /// Send an OTP email. In captured mode, records `code` instead of calling Resend. + pub async fn send_otp(&self, to: &str, purpose: &str, code: &str) -> Result<(), EmailError> { + #[cfg(feature = "test-fixtures")] + if let Some(captured) = &self.captured { + captured.lock().unwrap().push(CapturedOtp { + to: to.to_string(), + code: code.to_string(), + }); + return Ok(()); + } + let html = templates::otp_email(code, purpose); + self.send(to, "Your Octo verification code", &html).await + } + + /// Send one HTML email. Logs and propagates failure — callers on a critical path (e.g. an + /// OTP the user has no other way to receive) need to know sending failed, not just log it. + pub async fn send(&self, to: &str, subject: &str, html: &str) -> Result<(), EmailError> { + let body = serde_json::json!({ + "from": self.from_address, + "to": to, + "subject": subject, + "html": html, + }); + + let resp = self + .http + .post(RESEND_URL) + .bearer_auth(&self.api_key) + .json(&body) + .send() + .await?; + + if resp.status().is_success() { + return Ok(()); + } + let detail = resp.text().await.unwrap_or_default(); + tracing::warn!(to, subject, detail, "Resend rejected an email send"); + Err(EmailError::Rejected(detail)) + } +} + +/// A fresh 6-digit numeric OTP, zero-padded (e.g. "042817"). +pub fn generate_otp() -> String { + let n: u32 = rand::rngs::OsRng.gen_range(0..1_000_000); + format!("{n:06}") +} + +/// SHA-256 hex digest of an OTP — stored instead of the raw code, same principle as password +/// hashing elsewhere in this codebase. +pub fn hash_otp(code: &str) -> String { + let digest = Sha256::digest(code.as_bytes()); + hex::encode(digest) +} diff --git a/crates/email/src/templates.rs b/crates/email/src/templates.rs new file mode 100644 index 0000000..1e00e99 --- /dev/null +++ b/crates/email/src/templates.rs @@ -0,0 +1,200 @@ +//! Email HTML templates. Keep in sync with Octo-frontend's `src/emails/` — that's the +//! hand-maintained source; this is the copy actually sent. + +use base64::Engine; + +const BURGUNDY: &str = "#7b1733"; +const BURGUNDY_BRIGHT: &str = "#b81f4d"; +const INK: &str = "#0a0506"; + +/// Octo's octopus mark, resolved to static colors (no CSS vars — email clients won't resolve them). +fn logo_svg() -> String { + format!( + "\ +\ +\ +\ +\ +\ +\ +\ +\ +" + ) +} + +fn svg_data_uri(svg: &str) -> String { + format!( + "data:image/svg+xml;base64,{}", + base64::engine::general_purpose::STANDARD.encode(svg) + ) +} + +/// A social icon: white glyph on a filled burgundy circle, matching the button's brand color. +fn social_icon(path: &str, href: &str, label: &str) -> String { + let svg = format!( + "\ +\ +{path}\ +" + ); + let uri = svg_data_uri(&svg); + format!( + "\"{label}\"" + ) +} + +fn socials() -> String { + [ + social_icon( + "", + "https://x.com/Octo_Hq", + "X (Twitter)", + ), + social_icon( + "", + "https://instagram.com/OctoHQ", + "Instagram", + ), + social_icon( + "", + "https://linkedin.com/company/OctoHQ", + "LinkedIn", + ), + social_icon( + "", + "https://github.com/Octo-Protocol-org", + "GitHub", + ), + social_icon( + "", + "https://t.me/OctoHQ", + "Telegram", + ), + ] + .join("") +} + +/// A purpose icon shown above the body copy: a colored circle badge with a glyph. +enum Icon { + Key, + Wave, + Check, + Warn, +} + +fn icon_svg(icon: Icon) -> String { + let inner = match icon { + Icon::Key => format!( + "" + ), + Icon::Wave => format!( + "" + ), + Icon::Check => "".to_string(), + Icon::Warn => "".to_string(), + }; + svg_data_uri(&format!( + "{inner}" + )) +} + +fn shell(body: &str, icon: Icon) -> String { + let logo_uri = svg_data_uri(&logo_svg()); + let icon_uri = icon_svg(icon); + let socials = socials(); + let year = chrono::Utc::now().format("%Y"); + format!( + "
\ +
\ +
\ +\"Octo\"\ +
Octo
\ +
\ +
\ +
\ +
\"\"
\ +{body}\ +
\ +
\ +{socials}\ +

© {year} Octo · Stellar-native wallet infrastructure

\ +
\ +
\ +
" + ) +} + +/// One-time code email, shared by signup verification and withdrawal confirmation. +pub fn otp_email(code: &str, purpose: &str) -> String { + let action = match purpose { + "withdrawal" => "confirm a withdrawal", + _ => "verify your email", + }; + shell( + &format!( + "

Your verification code

\ +

Use this code to {action}.

\ +
\ +{code}\ +
\ +

This code expires in 10 minutes. If you didn't request it, you can ignore this email.

" + ), + Icon::Key, + ) +} + +/// Sent once, right after signup verification succeeds. +pub fn welcome_email(email: &str) -> String { + shell( + &format!( + "

Welcome to Octo 🎉

\ +

{email} is verified and ready to go.

\ +Go to dashboard" + ), + Icon::Wave, + ) +} + +/// Sent after a withdrawal successfully relays to Horizon. +pub fn withdrawal_success_email( + amount: &str, + asset: &str, + destination: &str, + tx_hash: &str, +) -> String { + shell( + &format!( + "

Withdrawal confirmed

\ +

Your withdrawal has been confirmed on-chain.

\ +
\ +

Amount: {amount} {asset}

\ +

Destination: {destination}

\ +

Transaction: {tx_hash}

\ +
" + ), + Icon::Check, + ) +} + +/// Sent when a withdrawal was attempted but did not complete (wrong OTP, or Horizon rejected it). +pub fn withdrawal_failed_email( + amount: &str, + asset: &str, + destination: &str, + reason: &str, +) -> String { + shell( + &format!( + "

Withdrawal attempt failed

\ +

A withdrawal attempt on your account did not complete.

\ +
\ +

Amount: {amount} {asset}

\ +

Destination: {destination}

\ +

Reason: {reason}

\ +
\ +

If this wasn't you, secure your account and contact support.

" + ), + Icon::Warn, + ) +} diff --git a/crates/store/migrations/0019_email_otp.sql b/crates/store/migrations/0019_email_otp.sql new file mode 100644 index 0000000..ee76779 --- /dev/null +++ b/crates/store/migrations/0019_email_otp.sql @@ -0,0 +1,17 @@ +-- Email OTP codes for signup verification and withdrawal confirmation. +CREATE TABLE email_otps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL CHECK (purpose IN ('signup', 'withdrawal')), + code_hash TEXT NOT NULL, -- SHA-256 hex of the 6-digit code, never the raw code + tx_hash_bound TEXT, -- binds a withdrawal OTP to one exact transaction; null for signup + attempts SMALLINT NOT NULL DEFAULT 0, + consumed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_email_otps_user_purpose ON email_otps(user_id, purpose, created_at DESC); + +-- Null means never verified; existing users are left null so their next login re-triggers OTP. +ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ; diff --git a/crates/store/src/error.rs b/crates/store/src/error.rs index 7c82210..ffba872 100644 --- a/crates/store/src/error.rs +++ b/crates/store/src/error.rs @@ -25,6 +25,10 @@ pub enum StoreError { /// The daily sponsorship budget would be exceeded by this request. #[error("daily sponsorship budget exceeded")] BudgetExceeded, + + /// An OTP was wrong, expired, already used, over the attempt limit, or tx-hash mismatched. + #[error("invalid or expired code")] + InvalidOtp, } impl StoreError { diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 3e6342b..e35b76e 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -17,9 +17,9 @@ mod models; pub use error::StoreError; pub use models::{ - Address, ApiKey, AuditLog, DenylistedToken, GasSponsorshipConfig, NewDeposit, NewPaymentLink, - NewSponsoredTx, PaymentLink, PaymentLinkPayment, SponsoredTransaction, Transaction, User, - Wallet, WebhookDelivery, WebhookEndpoint, WhitelistedAddress, Withdrawal, + Address, ApiKey, AuditLog, DenylistedToken, EmailOtp, GasSponsorshipConfig, NewDeposit, + NewPaymentLink, NewSponsoredTx, PaymentLink, PaymentLinkPayment, SponsoredTransaction, + Transaction, User, Wallet, WebhookDelivery, WebhookEndpoint, WhitelistedAddress, Withdrawal, WithdrawalAllowlistConfig, }; @@ -132,6 +132,85 @@ impl Store { Ok(row) } + /// Mark a user's email as verified. + pub async fn mark_email_verified(&self, user_id: Uuid) -> Result<(), StoreError> { + sqlx::query("UPDATE users SET email_verified_at = now() WHERE id = $1") + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + // --- email OTP ---------------------------------------------------------- + + /// Issue a fresh OTP row. Callers hash the code themselves before calling this. + pub async fn create_otp( + &self, + user_id: Uuid, + purpose: &str, + code_hash: &str, + tx_hash_bound: Option<&str>, + ttl: chrono::Duration, + ) -> Result { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO email_otps (user_id, purpose, code_hash, tx_hash_bound, expires_at) + VALUES ($1, $2, $3, $4, now() + $5) RETURNING id", + ) + .bind(user_id) + .bind(purpose) + .bind(code_hash) + .bind(tx_hash_bound) + .bind(ttl) + .fetch_one(&self.pool) + .await?; + Ok(id) + } + + /// Verify an already-hashed code against the most recent unconsumed OTP for + /// `(user_id, purpose)`. On a wrong code, increments `attempts` and returns `InvalidOtp` + /// rather than panicking — callers should surface a generic "invalid or expired code" either + /// way, so guessing can't distinguish "wrong code" from "no such code exists". + pub async fn verify_and_consume_otp( + &self, + user_id: Uuid, + purpose: &str, + code_hash: &str, + tx_hash_bound: Option<&str>, + ) -> Result<(), StoreError> { + const MAX_ATTEMPTS: i16 = 5; + + let otp = sqlx::query_as::<_, EmailOtp>( + "SELECT * FROM email_otps WHERE user_id = $1 AND purpose = $2 + ORDER BY created_at DESC LIMIT 1", + ) + .bind(user_id) + .bind(purpose) + .fetch_optional(&self.pool) + .await? + .ok_or(StoreError::InvalidOtp)?; + + if otp.consumed_at.is_some() + || otp.attempts >= MAX_ATTEMPTS + || otp.expires_at < chrono::Utc::now() + || otp.tx_hash_bound.as_deref() != tx_hash_bound + { + return Err(StoreError::InvalidOtp); + } + if otp.code_hash != code_hash { + sqlx::query("UPDATE email_otps SET attempts = attempts + 1 WHERE id = $1") + .bind(otp.id) + .execute(&self.pool) + .await?; + return Err(StoreError::InvalidOtp); + } + + sqlx::query("UPDATE email_otps SET consumed_at = now() WHERE id = $1") + .bind(otp.id) + .execute(&self.pool) + .await?; + Ok(()) + } + // --- audit logs ------------------------------------------------------- /// Append an audit-log entry. Best-effort: failures are surfaced to the caller, which logs and diff --git a/crates/store/src/models.rs b/crates/store/src/models.rs index d402964..bf4354c 100644 --- a/crates/store/src/models.rs +++ b/crates/store/src/models.rs @@ -107,6 +107,8 @@ pub struct User { pub email: String, /// argon2id PHC hash — never returned to clients. pub password_hash: String, + /// Null until the signup/login OTP is verified. + pub email_verified_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } @@ -294,3 +296,18 @@ pub struct PaymentLinkPayment { pub status: String, pub created_at: DateTime, } + +/// A one-time email code, for signup verification or withdrawal confirmation. +#[derive(Debug, Clone, FromRow)] +pub struct EmailOtp { + pub id: Uuid, + pub user_id: Uuid, + pub purpose: String, + pub code_hash: String, + /// Withdrawal OTPs bind to the exact transaction hash they gate; null for signup. + pub tx_hash_bound: Option, + pub attempts: i16, + pub consumed_at: Option>, + pub expires_at: DateTime, + pub created_at: DateTime, +} diff --git a/crates/store/tests/store_tests.rs b/crates/store/tests/store_tests.rs index 52fcb3c..e2f1be1 100644 --- a/crates/store/tests/store_tests.rs +++ b/crates/store/tests/store_tests.rs @@ -850,18 +850,13 @@ async fn migrate_applies_exactly_the_expected_version_set() { .expect("query _sqlx_migrations"); versions.sort_unstable(); - // One version per file under crates/store/migrations/, 0001_init.sql .. 0018. - // - // NOTE: this version number is a repeat offender — five migrations have now landed with a - // colliding 0008 at one point or another (scheme_version, token_denylist, - // sponsored_tx_status_index, sponsored_and_audit_indexing, client_custody). sqlx keys - // migrations by version, so only one of any colliding set could ever apply and the rest - // silently never ran. This assertion is what guards against that recurring, so it must list - // every version explicitly rather than just checking a count. + // One version per file under crates/store/migrations/, 0001_init.sql .. 0019. + // Guards against silent version collisions — sqlx keys migrations by version, so a repeated + // number means only one of the colliding pair actually ran. assert_eq!( versions, - vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], - "expected exactly the eighteen known migrations to be recorded as applied" + vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], + "expected exactly the nineteen known migrations to be recorded as applied" ); }