alibi-plugins 0.8.0

Built-in authentication plugins and API metadata for Alibi
Documentation
use super::*;
use alibi_core::error::codes;
#[expect(
    clippy::as_conversions,
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::suboptimal_flops,
    reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
)]
pub(in crate::two_factor) async fn send_otp_core(
    req: &impl alibi_core::session::SessionRequest,
    config: &TwoFactorConfig,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> Result<StatusResponse, SendOtpError> {
    let sender = config
        .send_otp
        .as_ref()
        .ok_or_else(|| AuthError::bad_request("otp isn't configured"))?;
    let state = Box::pin(resolve_two_factor_state(req, ctx)).await?;

    let otp =
        generate_numeric_string(config.otp_digits, true).ok_or(SendOtpError::InvalidGeneration)?;
    let stored_otp = config.otp_storage.store(&otp, &ctx.config).await?;
    let identifier = otp_verification_identifier(state.key());
    let period = if config.otp_period_minutes == 0.0 || config.otp_period_minutes.is_nan() {
        3.0
    } else {
        config.otp_period_minutes
    };
    let milliseconds = Utc::now().timestamp_millis() as f64 + period * 60.0 * 1000.0;
    if !milliseconds.is_finite() || milliseconds.abs() > 8_640_000_000_000_000.0 {
        return Err(SendOtpError::InvalidGeneration);
    }
    let expires_at = chrono::DateTime::from_timestamp_millis(milliseconds.trunc() as i64)
        .ok_or(SendOtpError::InvalidGeneration)?;

    _ = ctx
        .verifications()
        .create(CreateVerification {
            identifier,
            value: format!("{stored_otp}:0"),
            expires_at,
        })
        .await?;

    otp::deliver(
        Arc::clone(sender),
        ctx.user_view(&state.user()),
        otp,
        ctx.config.background_tasks.clone(),
    )
    .await?;

    Ok(StatusResponse { status: true })
}

#[expect(
    clippy::too_many_lines,
    reason = "Keep OTP ownership, attempt limits, and successful factor consumption in protocol order"
)]
pub(in crate::two_factor) async fn verify_otp_core(
    req: &impl alibi_core::session::SessionRequest,
    body: &VerifyOtpRequest,
    config: &TwoFactorConfig,
    ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> Result<(SessionTokenResponse<UserView>, Vec<String>), ExistingSessionFactorError> {
    let state = Box::pin(resolve_two_factor_state(req, ctx)).await?;
    let factor = if matches!(state, ResolvedTwoFactorState::Pending(_)) {
        let factor = ctx
            .database
            .get_two_factor_by_user_id(state.user().id().as_ref())
            .await?;
        if let Some(factor) = &factor {
            assert_account_not_locked(config, factor, ctx).await?;
        }
        factor
    } else {
        None
    };
    let identifier = otp_verification_identifier(state.key());
    let Some(verification) = ctx.verifications().consume(&identifier).await? else {
        return Err(AuthError::bad_request("OTP has expired").into());
    };

    let mut parts = verification.value()?.split(':');
    let stored_otp = parts.next().unwrap_or_default();
    let counter = parts.next().unwrap_or_default();
    // parseInt(counter, 10) accepts a signed decimal prefix and ignores its suffix.
    let trimmed = counter.trim_start_matches(|c: char| {
        matches!(
            c,
            '\t' | '\n' | '\r' | '\u{b}' | '\u{c}' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}'
                ..='\u{200a}'
                    | '\u{2028}'
                    | '\u{2029}'
                    | '\u{202f}'
                    | '\u{205f}'
                    | '\u{3000}'
                    | '\u{feff}'
        )
    });
    let prefix_length = trimmed
        .char_indices()
        .take_while(|(index, c)| c.is_ascii_digit() || (*index == 0 && (*c == '+' || *c == '-')))
        .last()
        .map_or(0, |(index, c)| index + c.len_utf8());
    let attempts = trimmed
        .get(..prefix_length)
        .unwrap_or_default()
        .parse::<f64>()
        .unwrap_or(0.0);
    let allowed_attempts =
        if config.otp_allowed_attempts == 0.0 || config.otp_allowed_attempts.is_nan() {
            5.0
        } else {
            config.otp_allowed_attempts
        };
    if attempts >= allowed_attempts {
        return Err(AuthError::Upstream {
            status: 400,
            code: codes::TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE,
            message: "Too many attempts. Please request a new code.",
        }
        .into());
    }

    let is_valid = config
        .otp_storage
        .verify(stored_otp, &body.code, &ctx.config)
        .await?;

    if !is_valid {
        let next_count = attempts + 1.0;
        let next_counter = if next_count.is_infinite() {
            if next_count.is_sign_negative() {
                "-Infinity".into()
            } else {
                "Infinity".into()
            }
        } else {
            alibi_core::utils::json::number_to_string(
                &serde_json::Number::from_f64(next_count)
                    .ok_or_else(|| AuthError::internal("Invalid OTP counter"))?,
            )
            .map_err(AuthError::from)?
        };
        let next_value = format!("{stored_otp}:{next_counter}");
        let expires_at = verification.expires_at()?;
        let verification_identifier = otp_verification_identifier(state.key());
        _ = ctx
            .verifications()
            .create(CreateVerification {
                identifier: verification_identifier,
                value: next_value,
                expires_at,
            })
            .await?;
        if let Some(factor) = &factor {
            record_account_failure(config, factor, ctx).await?;
        }
        return Err(AuthError::authentication_failed("Invalid code").into());
    }

    if let Some(factor) = &factor {
        reset_account_failures(config, factor, ctx).await?;
    }

    match state {
        ResolvedTwoFactorState::Session { user, session, .. } => {
            Box::pin(verify_existing_session_factor(
                user, *session, true, true, ctx,
            ))
            .await
        }
        ResolvedTwoFactorState::Pending(pending) => Box::pin(finalize_pending_two_factor(
            pending,
            req,
            body.trust_device.unwrap_or(false),
            true,
            ctx,
        ))
        .await
        .map_err(ExistingSessionFactorError::Auth),
    }
}

pub(in crate::two_factor) fn otp_verification_identifier(key: &str) -> String {
    format!("2fa-otp-{key}")
}