openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
use http::{header, StatusCode};
use openauth_core::api::{ApiRequest, ApiResponse};
use openauth_core::auth::session::{GetSessionInput, SessionAuth};
use openauth_core::context::{AuthContext, SecretMaterial};
use openauth_core::db::{DbAdapter, DbValue, User, Verification};
use openauth_core::error::OpenAuthError;
use openauth_core::session::{CreateSessionInput, DbSessionStore};
use openauth_core::verification::{
    CreateVerificationInput, DbVerificationStore, UpdateVerificationInput,
};
use time::OffsetDateTime;

use super::otp;
use super::response;
use super::types::{EmailOtpOptions, EmailOtpPayload, EmailOtpType, ResendStrategy};

pub(super) async fn resolve_otp(
    adapter: &dyn DbAdapter,
    options: &EmailOtpOptions,
    secret: &SecretMaterial,
    email: &str,
    otp_type: EmailOtpType,
    identifier: &str,
) -> Result<String, OpenAuthError> {
    let store = DbVerificationStore::new(adapter);
    if options.resend_strategy == ResendStrategy::Reuse {
        if let Some(existing) = store.find_verification(identifier).await? {
            let parts = otp::split_value(&existing.value);
            if existing.expires_at > OffsetDateTime::now_utc()
                && parts.attempts < options.allowed_attempts
            {
                if let Some(plain) = otp::reusable_otp(options, secret, &parts)? {
                    store
                        .update_verification(
                            identifier,
                            UpdateVerificationInput::new().expires_at(expires_at(options)?),
                        )
                        .await?;
                    return Ok(plain);
                }
            } else {
                store.delete_verification(identifier).await?;
            }
        }
    }
    let plain = otp::generate(options, email, otp_type);
    let stored = otp::store(options, secret, &plain)?;
    let input = CreateVerificationInput::new(
        identifier,
        otp::encode_value(&stored, 0),
        expires_at(options)?,
    );
    if store.create_verification(input.clone()).await.is_err() {
        store.delete_verification(identifier).await?;
        store.create_verification(input).await?;
    }
    Ok(plain)
}

pub(super) async fn verify_otp(
    adapter: &dyn DbAdapter,
    options: &EmailOtpOptions,
    secret: &SecretMaterial,
    identifier: &str,
    provided: &str,
    consume: bool,
) -> Result<Option<ApiResponse>, OpenAuthError> {
    let store = DbVerificationStore::new(adapter);
    let Some(verification) = store.find_verification(identifier).await? else {
        return response::error(StatusCode::BAD_REQUEST, "INVALID_OTP", "Invalid OTP").map(Some);
    };
    if let Some(response) = reject_if_expired(&store, &verification).await? {
        return Ok(Some(response));
    }
    let parts = otp::split_value(&verification.value);
    if parts.attempts >= options.allowed_attempts {
        store.delete_verification(identifier).await?;
        return response::error(
            StatusCode::FORBIDDEN,
            "TOO_MANY_ATTEMPTS",
            "Too many attempts",
        )
        .map(Some);
    }
    if consume {
        store.delete_verification(identifier).await?;
    }
    if !otp::verify(options, secret, &parts.value, provided)? {
        let attempts = parts.attempts.saturating_add(1);
        if attempts >= options.allowed_attempts {
            store.delete_verification(identifier).await?;
        } else if consume {
            store
                .create_verification(CreateVerificationInput::new(
                    identifier,
                    otp::encode_value(&parts.value, attempts),
                    verification.expires_at,
                ))
                .await?;
        } else {
            store
                .update_verification(
                    identifier,
                    UpdateVerificationInput::new().value(otp::encode_value(&parts.value, attempts)),
                )
                .await?;
        }
        return response::error(StatusCode::BAD_REQUEST, "INVALID_OTP", "Invalid OTP").map(Some);
    }
    Ok(None)
}

pub(super) async fn authenticated_user(
    adapter: &dyn DbAdapter,
    context: &AuthContext,
    request: &ApiRequest,
) -> Result<Result<User, ApiResponse>, OpenAuthError> {
    let cookie_header = request
        .headers()
        .get(header::COOKIE)
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default();
    let Some(result) = SessionAuth::new(adapter, context)
        .get_session(GetSessionInput::new(cookie_header))
        .await?
    else {
        return response::error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized").map(Err);
    };
    match result.user {
        Some(user) => Ok(Ok(user)),
        None => response::error(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized").map(Err),
    }
}

pub(super) async fn create_session(
    adapter: &dyn DbAdapter,
    context: &AuthContext,
    user_id: &str,
    request: &ApiRequest,
) -> Result<openauth_core::db::Session, OpenAuthError> {
    let expires_at = OffsetDateTime::now_utc()
        + time::Duration::seconds(context.session_config.expires_in as i64);
    let mut input = CreateSessionInput::new(user_id, expires_at);
    let additional_session_fields = context
        .options
        .session
        .additional_fields
        .iter()
        .map(|(name, field)| {
            (
                name.clone(),
                field.default_value.clone().unwrap_or(DbValue::Null),
            )
        })
        .collect();
    input = input.additional_fields(additional_session_fields);
    if let Some(user_agent) = request
        .headers()
        .get(header::USER_AGENT)
        .and_then(|value| value.to_str().ok())
    {
        input = input.user_agent(user_agent);
    }
    DbSessionStore::new(adapter).create_session(input).await
}

pub(super) fn send_email(
    options: &EmailOtpOptions,
    email: &str,
    plain_otp: String,
    otp_type: EmailOtpType,
    request: Option<&ApiRequest>,
) -> Result<Option<ApiResponse>, OpenAuthError> {
    let Some(sender) = &options.sender else {
        return response::error(
            StatusCode::BAD_REQUEST,
            "SEND_VERIFICATION_OTP_NOT_CONFIGURED",
            "send email verification is not implemented",
        )
        .map(Some);
    };
    sender.send_email_otp(
        EmailOtpPayload {
            email: email.to_owned(),
            otp: plain_otp,
            otp_type,
        },
        request,
    )?;
    Ok(None)
}

pub(super) fn validated_email(email: &str) -> Result<Result<String, ApiResponse>, OpenAuthError> {
    let email = otp::normalize_email(email);
    if !otp::valid_email(&email) {
        return response::error(StatusCode::BAD_REQUEST, "INVALID_EMAIL", "Invalid email").map(Err);
    }
    Ok(Ok(email))
}

pub(super) fn parse_type(value: &str) -> Result<Result<EmailOtpType, ApiResponse>, OpenAuthError> {
    match EmailOtpType::try_from(value) {
        Ok(otp_type) => Ok(Ok(otp_type)),
        Err(()) => response::error(
            StatusCode::BAD_REQUEST,
            "INVALID_OTP_TYPE",
            "Invalid OTP type",
        )
        .map(Err),
    }
}

fn expires_at(options: &EmailOtpOptions) -> Result<OffsetDateTime, OpenAuthError> {
    Ok(OffsetDateTime::now_utc() + otp::seconds_to_duration(options.expires_in)?)
}

async fn reject_if_expired(
    store: &DbVerificationStore<'_>,
    verification: &Verification,
) -> Result<Option<ApiResponse>, OpenAuthError> {
    if verification.expires_at <= OffsetDateTime::now_utc() {
        store.delete_verification(&verification.identifier).await?;
        return response::error(StatusCode::BAD_REQUEST, "OTP_EXPIRED", "OTP expired").map(Some);
    }
    Ok(None)
}