cedros-login-server 0.0.43

Authentication server for cedros-login with email/password, Google OAuth, and Solana wallet sign-in
Documentation
//! Solana wallet authentication handlers

use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json};
use chrono::{Duration, Utc};
use std::sync::Arc;

use crate::callback::{AuthCallback, AuthCallbackPayload};
use crate::errors::AppError;
use crate::handlers::auth::{
    call_authenticated_callback_with_timeout, call_registered_callback_with_timeout,
};
use crate::models::{AuthMethod, AuthResponse, SolanaAuthRequest, SolanaChallengeRequest};
use crate::repositories::{
    generate_api_key, ApiKeyEntity, AuditEventType, MembershipEntity, NonceEntity, SessionEntity,
    UserEntity,
};
use crate::services::{EmailService, SolanaService};
use crate::utils::{
    build_json_response_with_cookies, compute_post_login, extract_client_ip_with_fallback,
    get_default_org_context, hash_refresh_token, resolve_org_assignment, user_entity_to_auth_user,
    PeerIp,
};
use crate::AppState;

/// Check whether Solana auth is enabled via runtime setting or static config.
async fn check_solana_enabled<C: AuthCallback, E: EmailService>(
    state: &Arc<AppState<C, E>>,
) -> Result<(), AppError> {
    let enabled = state
        .settings_service
        .get_bool("auth_solana_enabled")
        .await
        .ok()
        .flatten()
        .unwrap_or(state.config.solana.enabled);
    if !enabled {
        return Err(AppError::NotFound("Solana auth disabled".into()));
    }
    Ok(())
}

/// POST /auth/solana/challenge - Generate a challenge for Solana wallet sign-in
pub async fn solana_challenge<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    Json(req): Json<SolanaChallengeRequest>,
) -> Result<impl IntoResponse, AppError> {
    check_solana_enabled(&state).await?;

    // Validate public key by attempting to decode it
    // A valid Solana public key is exactly 32 bytes when decoded from base58
    let public_key_bytes = bs58::decode(&req.public_key)
        .into_vec()
        .map_err(|_| AppError::Validation("Invalid public key format".into()))?;

    if public_key_bytes.len() != 32 {
        return Err(AppError::Validation("Invalid public key length".into()));
    }

    // Resolve challenge expiry from runtime settings, falling back to static config
    let challenge_expiry = state
        .settings_service
        .get_u64("auth_solana_challenge_expiry")
        .await
        .ok()
        .flatten()
        .unwrap_or(state.config.solana.challenge_expiry_seconds);

    // Generate challenge with domain binding to prevent cross-site phishing
    let domain = state.config.server.frontend_url.as_deref();
    let challenge =
        state
            .solana_service
            .generate_challenge(&req.public_key, challenge_expiry, domain)?;

    // Store nonce for replay protection
    let nonce_entity = NonceEntity::new(
        challenge.nonce.clone(),
        req.public_key.clone(),
        challenge.message.clone(),
        challenge.expires_at,
    );
    state.nonce_repo.create(nonce_entity).await?;

    Ok(Json(challenge))
}

/// POST /auth/solana - Verify signature and authenticate
pub async fn solana_auth<C: AuthCallback, E: EmailService>(
    State(state): State<Arc<AppState<C, E>>>,
    headers: HeaderMap,
    PeerIp(peer_ip): PeerIp,
    Json(req): Json<SolanaAuthRequest>,
) -> Result<impl IntoResponse, AppError> {
    check_solana_enabled(&state).await?;

    // Extract nonce from message
    let nonce = SolanaService::extract_nonce(&req.message)
        .ok_or(AppError::Validation("Invalid message format".into()))?;

    // S-10: Atomically consume nonce to prevent TOCTOU race conditions.
    // This combines find + validity check + mark_used into a single atomic operation.
    let nonce_entity = state
        .nonce_repo
        .consume_if_valid(&nonce)
        .await?
        .ok_or(AppError::ChallengeExpired)?;

    // Verify the public key matches the challenge
    if nonce_entity.public_key != req.public_key {
        return Err(AppError::InvalidSignature);
    }

    // Verify the message matches
    if nonce_entity.message != req.message {
        return Err(AppError::InvalidSignature);
    }

    // Verify signature
    if !state
        .solana_service
        .verify_signature(&req.public_key, &req.signature, &req.message)?
    {
        return Err(AppError::InvalidSignature);
    }

    // Sanctions check — runs after signature verification to avoid exposing the
    // check to unauthenticated callers while still blocking before user creation.
    state
        .sanctions_service
        .check_address(&req.public_key)
        .await?;

    // GeoIP country screening (fail-open: skipped when header not configured or absent)
    state
        .sanctions_service
        .check_country_from_request(&headers)
        .await?;

    // Check if user exists by wallet address
    let existing_user = state.user_repo.find_by_wallet(&req.public_key).await?;

    let (user, is_new_user, api_key) = if let Some(user) = existing_user {
        (user, false, None)
    } else {
        // Check if wallet already exists (shouldn't happen, but safety check)
        if state.user_repo.wallet_exists(&req.public_key).await? {
            return Err(AppError::WalletExists);
        }

        // Signup gating: enforce access codes and/or rate limits for new users
        let gate_result = state
            .signup_gating_service
            .check_signup(req.access_code.as_deref())
            .await?;

        // Create new user
        let now = Utc::now();
        let mut user = UserEntity {
            id: uuid::Uuid::new_v4(),
            email: None,
            email_verified: false,
            password_hash: None,
            name: None,
            username: None,
            picture: None,
            wallet_address: Some(req.public_key.clone()),
            google_id: None,
            apple_id: None,
            stripe_customer_id: None,
            auth_methods: vec![AuthMethod::Solana],
            is_system_admin: false,
            created_at: now,
            updated_at: now,
            last_login_at: Some(now),
            welcome_completed_at: None,
            referral_code: crate::repositories::generate_referral_code(),
            referred_by: None,
            payout_wallet_address: None,
            kyc_status: "none".to_string(),
            kyc_verified_at: None,
            kyc_expires_at: None,
            accreditation_status: "none".to_string(),
            accreditation_verified_at: None,
            accreditation_expires_at: None,
        };

        // Resolve referral: if feature enabled and referral code provided, link referrer
        let referrals_enabled = state
            .settings_service
            .get_bool("feature_referrals_enabled")
            .await
            .ok()
            .flatten()
            .unwrap_or(false);
        if referrals_enabled {
            if let Some(ref code) = req.referral {
                match state.user_repo.find_by_referral_code(code).await {
                    Ok(Some(referrer)) => {
                        user.referred_by = Some(referrer.id);
                    }
                    Ok(None) => {
                        tracing::debug!(referral_code = %code, "Referral code not found, ignoring");
                    }
                    Err(e) => {
                        tracing::warn!(error = %e, "Failed to look up referral code, ignoring");
                    }
                }
            }
        }

        // Persist user FIRST — org resolution may auto-create a "Default" org
        // whose owner_id FK references users.id, so the user row must exist.
        let user = state.user_repo.create(user).await?;

        // NOW resolve org assignment — user exists in DB, FK satisfied
        let org_assignment = resolve_org_assignment(&state, user.id).await?;
        let membership = MembershipEntity::new(user.id, org_assignment.org_id, org_assignment.role);
        let raw_api_key = generate_api_key();
        let api_key_entity = ApiKeyEntity::new(user.id, &raw_api_key, "default");

        state.membership_repo.create(membership).await?;
        state.api_key_repo.create(api_key_entity).await?;

        // Mark access code as used (non-fatal)
        if let Some(code_id) = gate_result.access_code_id {
            if let Err(e) = state.signup_gating_service.mark_code_used(code_id).await {
                tracing::warn!(
                    user_id = %user.id,
                    code_id = %code_id,
                    error = %e,
                    "Failed to mark access code as used"
                );
            }
        }

        (user, true, Some(raw_api_key))
    };

    // Issue referral signup reward for new users (non-fatal)
    if is_new_user {
        if let Some(referrer_id) = user.referred_by {
            if let Err(e) = crate::services::referral_reward_service::issue_signup_reward(
                &*state.user_repo,
                &*state.credit_repo,
                &*state.referral_payout_repo,
                &state.settings_service,
                &*state.callback,
                user.id,
                referrer_id,
                &state.config.privacy.company_currency,
            )
            .await
            {
                tracing::warn!(
                    user_id = %user.id,
                    referrer_id = %referrer_id,
                    error = %e,
                    "Failed to issue referral signup reward"
                );
            }
        }
    }

    // Get user's memberships to find default org context
    let memberships = state.membership_repo.find_by_user(user.id).await?;
    let token_context =
        get_default_org_context(&memberships, user.is_system_admin, user.email_verified);

    // Create session with org context
    let session_id = uuid::Uuid::new_v4();
    let token_pair =
        state
            .jwt_service
            .generate_token_pair_with_context(user.id, session_id, &token_context)?;
    let refresh_expiry =
        Utc::now() + Duration::seconds(state.jwt_service.refresh_expiry_secs() as i64);

    let ip_address =
        extract_client_ip_with_fallback(&headers, state.config.server.trust_proxy, peer_ip);
    let user_agent = headers
        .get(axum::http::header::USER_AGENT)
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());

    let mut session = SessionEntity::new_with_id(
        session_id,
        user.id,
        hash_refresh_token(&token_pair.refresh_token, &state.config.jwt.secret),
        refresh_expiry,
        ip_address.clone(),
        user_agent.clone(),
    );
    session.last_strong_auth_at = Some(Utc::now());
    state.session_repo.create(session).await?;

    // Fire callback
    let auth_user = user_entity_to_auth_user(&user);
    let payload = AuthCallbackPayload {
        user: auth_user.clone(),
        method: AuthMethod::Solana,
        is_new_user,
        session_id: session_id.to_string(),
        ip_address,
        user_agent,
        referral: req.referral.clone(),
    };

    let callback_data = if is_new_user {
        call_registered_callback_with_timeout(&state.callback, &payload).await
    } else {
        call_authenticated_callback_with_timeout(&state.callback, &payload).await
    };

    // Log audit event (fire-and-forget, don't fail auth on audit error)
    let audit_event = if is_new_user {
        AuditEventType::UserRegister
    } else {
        AuditEventType::UserLogin
    };
    let _ = state
        .audit_service
        .log_user_event(audit_event, user.id, Some(&headers))
        .await;

    let response_tokens = if state.config.cookie.enabled {
        None
    } else {
        Some(token_pair.clone())
    };

    let response = AuthResponse {
        user: auth_user,
        tokens: response_tokens,
        is_new_user,
        callback_data,
        api_key,
        email_queued: None,
        post_login: compute_post_login(
            &user,
            &state.settings_service,
            &*state.totp_repo,
            &*state.credential_repo,
            &*state.wallet_material_repo,
            &*state.storage.pending_wallet_recovery_repo,
        )
        .await,
    };

    Ok(build_json_response_with_cookies(
        &state.config.cookie,
        &token_pair,
        state.jwt_service.refresh_expiry_secs(),
        response,
    ))
}