rustauth-plugins 0.2.0

Official RustAuth plugin modules.
Documentation
use std::sync::Arc;

use http::{header, Method, StatusCode};
use rustauth_core::api::output::user_output_value;
use rustauth_core::api::{
    create_auth_endpoint, parse_request_body, AsyncAuthEndpoint, AuthEndpointOptions, BodyField,
    BodySchema, JsonSchemaType, OpenApiOperation,
};
use rustauth_core::context::AuthContext;
use rustauth_core::cookies::{set_session_cookie, Cookie, CookieOptions, SessionCookieOptions};
use rustauth_core::crypto::jwt::sign_jwt;
use rustauth_core::db::User;
use rustauth_core::error::RustAuthError;
use rustauth_core::options::VerificationEmail;
use rustauth_core::outbound::dispatch_outbound;
use rustauth_core::session::CreateSessionInput;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;

use super::errors;
use super::hooks::validation_error;
use super::options::{UsernameOptions, ValidationPhase};

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SignInUsernameBody {
    username: String,
    password: String,
    #[serde(default)]
    remember_me: Option<bool>,
    #[serde(default, rename = "callbackURL", alias = "callbackUrl")]
    callback_url: Option<String>,
}

#[derive(Debug, Deserialize)]
struct IsUsernameAvailableBody {
    username: String,
}

#[derive(Debug, Serialize)]
struct AuthTokenUserBody {
    token: String,
    user: Value,
}

#[derive(Debug, Serialize)]
struct AvailabilityBody {
    available: bool,
}

pub fn sign_in_username_endpoint(options: Arc<UsernameOptions>) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/sign-in/username",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("signInUsername")
            .allowed_media_types(["application/x-www-form-urlencoded", "application/json"])
            .body_schema(sign_in_username_body_schema())
            .openapi(
                OpenApiOperation::new("signInUsername")
                    .description("Sign in with username")
                    .response("200", json_openapi_response("Success")),
            ),
        move |context, request| {
            let options = options.clone();
            async move {
                let body: SignInUsernameBody = parse_request_body(&request)?;
                let username_for_validation = options.username_for_validation(&body.username);
                if let Err(error) =
                    options.validate_username(&username_for_validation, ValidationPhase::Endpoint)
                {
                    return validation_error(error, StatusCode::UNPROCESSABLE_ENTITY);
                }
                let normalized_username = options.normalize_username(&body.username);
                let users = context.users()?;
                let Some(user_with_accounts) = users
                    .find_user_by_username_with_accounts(&normalized_username)
                    .await?
                else {
                    let _ = (context.password.hash)(&body.password);
                    return invalid_username_or_password();
                };
                let Some(account) = user_with_accounts
                    .accounts
                    .iter()
                    .find(|account| account.provider_id == "credential")
                else {
                    let _ = (context.password.hash)(&body.password);
                    return invalid_username_or_password();
                };
                let Some(password_hash) = account.password.as_deref() else {
                    let _ = (context.password.hash)(&body.password);
                    return invalid_username_or_password();
                };
                if !(context.password.verify)(password_hash, &body.password)? {
                    return invalid_username_or_password();
                }
                if context.options.email_password.require_email_verification
                    && !user_with_accounts.user.email_verified
                {
                    maybe_send_verification_email(
                        &context,
                        &request,
                        &user_with_accounts.user,
                        body.callback_url.as_deref(),
                    )?;
                    return email_not_verified();
                }

                let remember_me = body.remember_me.unwrap_or(true);
                let session =
                    create_session(&context, &user_with_accounts.user, remember_me).await?;
                let cookies = session_cookies(&context, &session.token, !remember_me)?;
                let user =
                    user_output_value(context.adapter_ref()?, &context, &user_with_accounts.user)
                        .await?;
                json_response(
                    StatusCode::OK,
                    &AuthTokenUserBody {
                        token: session.token,
                        user,
                    },
                    cookies,
                )
            }
        },
    )
}

pub fn is_username_available_endpoint(options: Arc<UsernameOptions>) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/is-username-available",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("isUsernameAvailable")
            .allowed_media_types(["application/x-www-form-urlencoded", "application/json"])
            .body_schema(is_username_available_body_schema()),
        move |context, request| {
            let options = options.clone();
            async move {
                let body: IsUsernameAvailableBody = parse_request_body(&request)?;
                let username_for_validation = options.username_for_validation(&body.username);
                if let Err(error) =
                    options.validate_username(&username_for_validation, ValidationPhase::Endpoint)
                {
                    return validation_error(error, StatusCode::UNPROCESSABLE_ENTITY);
                }
                let normalized_username = options.normalize_username(&body.username);
                let available = context
                    .users()?
                    .find_user_by_username(&normalized_username)
                    .await?
                    .is_none();
                json_response(StatusCode::OK, &AvailabilityBody { available }, Vec::new())
            }
        },
    )
}

fn sign_in_username_body_schema() -> BodySchema {
    BodySchema::object([
        BodyField::new("username", JsonSchemaType::String).description("The username of the user"),
        BodyField::new("password", JsonSchemaType::String).description("The password of the user"),
        BodyField::optional("rememberMe", JsonSchemaType::Boolean)
            .description("Remember the user session"),
        BodyField::optional("callbackURL", JsonSchemaType::String)
            .description("The URL to redirect to after email verification"),
    ])
}

fn is_username_available_body_schema() -> BodySchema {
    BodySchema::object([
        BodyField::new("username", JsonSchemaType::String).description("The username to check")
    ])
}

async fn create_session(
    context: &AuthContext,
    user: &User,
    remember_me: bool,
) -> Result<rustauth_core::db::Session, RustAuthError> {
    let expires_in = if remember_me {
        context.session_config.expires_in
    } else {
        time::Duration::days(1)
    };
    context
        .sessions()?
        .create_session(CreateSessionInput::new(
            &user.id,
            OffsetDateTime::now_utc() + expires_in,
        ))
        .await
}

fn invalid_username_or_password() -> Result<rustauth_core::api::ApiResponse, RustAuthError> {
    errors::error_response(
        StatusCode::UNAUTHORIZED,
        errors::INVALID_USERNAME_OR_PASSWORD,
        "Invalid username or password",
    )
}

fn email_not_verified() -> Result<rustauth_core::api::ApiResponse, RustAuthError> {
    errors::error_response(
        StatusCode::FORBIDDEN,
        errors::EMAIL_NOT_VERIFIED,
        "Email not verified",
    )
}

fn maybe_send_verification_email(
    context: &AuthContext,
    request: &rustauth_core::api::ApiRequest,
    user: &User,
    callback_url: Option<&str>,
) -> Result<(), RustAuthError> {
    if !context.options.email_verification.send_on_sign_in {
        return Ok(());
    }
    let Some(sender) = context
        .options
        .email_verification
        .send_verification_email
        .clone()
    else {
        return Ok(());
    };
    let expires_in = context
        .options
        .email_verification
        .expires_in
        .unwrap_or(time::Duration::hours(1))
        .whole_seconds();
    let token = sign_jwt(
        &EmailVerificationClaims {
            email: user.email.to_lowercase(),
            update_to: None,
            request_type: None,
        },
        &context.secret,
        expires_in,
    )?;
    let callback_url = callback_url.unwrap_or("/");
    let url = format!(
        "{}/verify-email?token={token}&callbackURL={}",
        context.base_url,
        percent_encode(callback_url)
    );
    let send = sender.send_verification_email(
        VerificationEmail {
            user: user.clone(),
            url,
            token,
        },
        Some(request),
    );
    dispatch_outbound(context, send);
    Ok(())
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct EmailVerificationClaims {
    email: String,
    update_to: Option<String>,
    request_type: Option<String>,
}

fn percent_encode(value: &str) -> String {
    url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}

fn session_cookies(
    context: &AuthContext,
    token: &str,
    dont_remember: bool,
) -> Result<Vec<Cookie>, RustAuthError> {
    set_session_cookie(
        &context.auth_cookies,
        &context.secret,
        token,
        SessionCookieOptions {
            dont_remember,
            overrides: CookieOptions::default(),
        },
    )
}

fn json_response<T>(
    status: StatusCode,
    body: &T,
    cookies: Vec<Cookie>,
) -> Result<rustauth_core::api::ApiResponse, RustAuthError>
where
    T: Serialize,
{
    let body = serde_json::to_vec(body).map_err(|error| RustAuthError::Api(error.to_string()))?;
    let mut response = http::Response::builder()
        .status(status)
        .header(header::CONTENT_TYPE, "application/json")
        .body(body)
        .map_err(|error| RustAuthError::Api(error.to_string()))?;
    for cookie in cookies {
        response.headers_mut().append(
            header::SET_COOKIE,
            http::HeaderValue::from_str(&serialize_cookie(&cookie))
                .map_err(|error| RustAuthError::Cookie(error.to_string()))?,
        );
    }
    Ok(response)
}

fn serialize_cookie(cookie: &Cookie) -> String {
    let mut value = format!("{}={}", cookie.name, cookie.value);
    push_attr(
        &mut value,
        "Max-Age",
        cookie.attributes.max_age.map(|v| v.to_string()),
    );
    push_attr(&mut value, "Expires", cookie.attributes.expires.clone());
    push_attr(&mut value, "Domain", cookie.attributes.domain.clone());
    push_attr(&mut value, "Path", cookie.attributes.path.clone());
    push_flag(&mut value, "Secure", cookie.attributes.secure);
    push_flag(&mut value, "HttpOnly", cookie.attributes.http_only);
    push_attr(&mut value, "SameSite", cookie.attributes.same_site.clone());
    push_flag(&mut value, "Partitioned", cookie.attributes.partitioned);
    value
}

fn push_attr(cookie: &mut String, name: &str, value: Option<String>) {
    if let Some(value) = value {
        cookie.push_str("; ");
        cookie.push_str(name);
        cookie.push('=');
        cookie.push_str(&value);
    }
}

fn push_flag(cookie: &mut String, name: &str, enabled: Option<bool>) {
    if enabled == Some(true) {
        cookie.push_str("; ");
        cookie.push_str(name);
    }
}

fn json_openapi_response(description: &str) -> serde_json::Value {
    serde_json::json!({
        "description": description,
        "content": {
            "application/json": {
                "schema": {
                    "type": "object"
                }
            }
        }
    })
}