rustauth-core 0.2.0

Core types and primitives for RustAuth.
Documentation
use http::{header, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::{Duration, OffsetDateTime};

use super::shared::{
    additional_session_create_values, auth_session_cookies, current_session, error_response,
    json_response, percent_encode, query_param, record_new_session, request_dont_remember,
    status_openapi_response,
};
use crate::api::http_json::user_to_http_value;
use crate::api::response_helpers::redirect_response;
use crate::api::{
    create_auth_endpoint, parse_request_body, request_base_url, AsyncAuthEndpoint,
    AuthEndpointOptions, BodyField, BodySchema, JsonSchemaType, OpenApiOperation,
};
use crate::auth::trusted_origins::OriginMatchSettings;
use crate::cookies::Cookie;
use crate::crypto::jwt::{sign_jwt, verify_jwt};
use crate::db::User;
use crate::options::{EmailVerificationCallbackPayload, VerificationEmail};
use crate::outbound::dispatch_outbound;
use crate::session::CreateSessionInput;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendVerificationEmailBody {
    email: String,
    #[serde(default, alias = "callbackURL")]
    callback_url: Option<String>,
}

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

#[derive(Debug, Serialize)]
struct StatusBody {
    status: bool,
}

#[derive(Debug, Serialize)]
struct VerifyEmailResponse {
    status: bool,
    user: Option<serde_json::Value>,
}

pub(super) fn send_verification_email_endpoint() -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/send-verification-email",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("sendVerificationEmail")
            .body_schema(send_verification_email_body_schema())
            .openapi(
                OpenApiOperation::new("sendVerificationEmail")
                    .description("Send a verification email to the user")
                    .response("200", status_openapi_response("Success")),
            ),
        move |context, request| async move {
            let Some(sender) = context
                .options
                .email_verification
                .send_verification_email
                .clone()
            else {
                return error_response(
                    StatusCode::BAD_REQUEST,
                    "VERIFICATION_EMAIL_NOT_ENABLED",
                    "Verification email isn't enabled",
                );
            };
            let body: SendVerificationEmailBody = parse_request_body(&request)?;
            let normalized_email = body.email.to_lowercase();
            let users = context.users()?;
            let session = current_session(&context, &request).await?;

            let user = if let Some((_, session_user, _)) = session {
                if session_user.email != normalized_email {
                    return error_response(
                        StatusCode::BAD_REQUEST,
                        "EMAIL_MISMATCH",
                        "Email mismatch",
                    );
                }
                if session_user.email_verified {
                    return error_response(
                        StatusCode::BAD_REQUEST,
                        "EMAIL_ALREADY_VERIFIED",
                        "Email already verified",
                    );
                }
                Some(session_user)
            } else {
                users.find_user_by_email(&normalized_email).await?
            };

            let Some(user) = user else {
                simulate_verification_token(&context, &normalized_email)?;
                return json_response(StatusCode::OK, &StatusBody { status: true }, Vec::new());
            };
            if user.email_verified {
                simulate_verification_token(&context, &normalized_email)?;
                return json_response(StatusCode::OK, &StatusBody { status: true }, Vec::new());
            }

            let token = create_email_verification_token(&context, &user.email, None, None)?;
            let callback_url = body.callback_url.unwrap_or_else(|| "/".to_owned());
            let url = format!(
                "{}/verify-email?token={token}&callbackURL={}",
                request_base_url(&context, Some(&request)),
                percent_encode(&callback_url)
            );
            let send = sender
                .send_verification_email(VerificationEmail { user, url, token }, Some(&request));
            dispatch_outbound(&context, send);

            json_response(StatusCode::OK, &StatusBody { status: true }, Vec::new())
        },
    )
}

pub(super) fn verify_email_endpoint() -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/verify-email",
        Method::GET,
        AuthEndpointOptions::new()
            .operation_id("verifyEmail")
            .openapi(
                OpenApiOperation::new("verifyEmail")
                    .description("Verify the email of the user")
                    .parameter(serde_json::json!({
                        "name": "callbackURL",
                        "in": "query",
                        "required": false,
                        "description": "The URL to redirect to after email verification",
                        "schema": { "type": "string" },
                    }))
                    .response(
                        "200",
                        super::shared::json_openapi_response(
                            "Success",
                            json!({
                                "type": "object",
                                "properties": {
                                    "status": { "type": "boolean" },
                                    "user": {
                                        "oneOf": [
                                            { "$ref": "#/components/schemas/User" },
                                            { "type": "null" }
                                        ],
                                    },
                                },
                                "required": ["status", "user"],
                            }),
                        ),
                    )
                    .response("302", super::shared::message_openapi_response("Redirect")),
            ),
        move |context, request| async move {
            let callback_url = query_param(&request, "callbackURL");
            let origin_settings = Some(OriginMatchSettings {
                allow_relative_paths: true,
            });
            if let Some(ref url) = callback_url {
                if !context.is_trusted_origin_for_request(url, origin_settings, Some(&request))? {
                    return redirect_with_error("/error", "INVALID_TOKEN");
                }
            }

            let Some(token) = query_param(&request, "token") else {
                return verification_error_response(
                    callback_url.as_deref(),
                    "INVALID_TOKEN",
                    "Invalid token",
                );
            };
            let Some(claims) = verify_jwt::<EmailVerificationClaims>(&token, &context.secret)?
            else {
                return verification_error_response(
                    callback_url.as_deref(),
                    "INVALID_TOKEN",
                    "Invalid token",
                );
            };
            let users = context.users()?;
            let Some(user) = users.find_user_by_email(&claims.email).await? else {
                return verification_error_response(
                    callback_url.as_deref(),
                    "USER_NOT_FOUND",
                    "User not found",
                );
            };

            if let Some(update_to) = claims.update_to {
                if let Some(callback) =
                    &context.options.email_verification.before_email_verification
                {
                    callback.before_email_verification(
                        EmailVerificationCallbackPayload { user: user.clone() },
                        Some(&request),
                    )?;
                }
                let updated = users
                    .update_user_email(
                        &user.id,
                        &update_to,
                        claims.request_type.as_deref() == Some("change-email-verification"),
                    )
                    .await?
                    .unwrap_or(user);
                if let Some(callback) = &context.options.email_verification.after_email_verification
                {
                    callback.after_email_verification(
                        EmailVerificationCallbackPayload {
                            user: updated.clone(),
                        },
                        Some(&request),
                    )?;
                }
                return verify_success_response(
                    callback_url.as_deref(),
                    &VerifyEmailResponse {
                        status: true,
                        user: Some(user_to_http_value(&updated)?),
                    },
                    Vec::new(),
                );
            }

            if let Some(callback) = &context.options.email_verification.before_email_verification {
                callback.before_email_verification(
                    EmailVerificationCallbackPayload { user: user.clone() },
                    Some(&request),
                )?;
            }
            let was_unverified = !user.email_verified;
            let updated = if was_unverified {
                users
                    .update_user_email_verified(&user.id, true)
                    .await?
                    .unwrap_or(user)
            } else {
                user
            };
            if let Some(callback) = &context.options.email_verification.after_email_verification {
                callback.after_email_verification(
                    EmailVerificationCallbackPayload {
                        user: updated.clone(),
                    },
                    Some(&request),
                )?;
            }
            let cookies = if context
                .options
                .email_verification
                .auto_sign_in_after_verification
                && was_unverified
            {
                auto_sign_in_after_verification_cookies(&context, &request, &updated, &claims.email)
                    .await?
            } else {
                Vec::new()
            };
            verify_success_response(
                callback_url.as_deref(),
                &VerifyEmailResponse {
                    status: true,
                    user: None,
                },
                cookies,
            )
        },
    )
}

pub(in crate::api) fn create_email_verification_token(
    context: &crate::context::AuthContext,
    email: &str,
    update_to: Option<&str>,
    request_type: Option<&str>,
) -> Result<String, crate::error::RustAuthError> {
    let expires_in = context
        .options
        .email_verification
        .expires_in
        .unwrap_or(time::Duration::hours(1));
    sign_jwt(
        &EmailVerificationClaims {
            email: email.to_lowercase(),
            update_to: update_to.map(str::to_owned),
            request_type: request_type.map(str::to_owned),
        },
        &context.secret,
        expires_in.whole_seconds(),
    )
}

fn simulate_verification_token(
    context: &crate::context::AuthContext,
    email: &str,
) -> Result<(), crate::error::RustAuthError> {
    create_email_verification_token(context, email, None, None).map(|_| ())
}

async fn auto_sign_in_after_verification_cookies(
    context: &crate::context::AuthContext,
    request: &crate::api::ApiRequest,
    user: &User,
    verified_email: &str,
) -> Result<Vec<Cookie>, crate::error::RustAuthError> {
    if let Some((session, session_user, _)) = current_session(context, request).await? {
        if session_user.email == verified_email {
            let dont_remember = request_dont_remember(context, request)?;
            return auth_session_cookies(context, &session, user, dont_remember);
        }
    }

    let expires_at = OffsetDateTime::now_utc()
        + Duration::seconds(context.session_config.expires_in.whole_seconds());
    let mut input = CreateSessionInput::new(&user.id, expires_at)
        .additional_fields(additional_session_create_values(context));
    if let Some(user_agent) = request
        .headers()
        .get(header::USER_AGENT)
        .and_then(|value| value.to_str().ok())
    {
        input = input.user_agent(user_agent);
    }
    let session = context.sessions()?.create_session(input).await?;
    record_new_session(&session, user)?;
    auth_session_cookies(context, &session, user, false)
}

fn verify_success_response(
    callback_url: Option<&str>,
    body: &VerifyEmailResponse,
    cookies: Vec<Cookie>,
) -> Result<crate::api::ApiResponse, crate::error::RustAuthError> {
    if let Some(url) = callback_url {
        return redirect_response(url, cookies);
    }
    json_response(StatusCode::OK, body, cookies)
}

fn verification_error_response(
    callback_url: Option<&str>,
    code: &str,
    message: &str,
) -> Result<crate::api::ApiResponse, crate::error::RustAuthError> {
    if let Some(url) = callback_url {
        return redirect_with_error(url, code);
    }
    error_response(StatusCode::UNAUTHORIZED, code, message)
}

fn redirect_with_error(
    location: &str,
    code: &str,
) -> Result<crate::api::ApiResponse, crate::error::RustAuthError> {
    let separator = if location.contains('?') { '&' } else { '?' };
    redirect(&format!(
        "{location}{separator}error={}",
        percent_encode(code)
    ))
}

fn redirect(location: &str) -> Result<crate::api::ApiResponse, crate::error::RustAuthError> {
    http::Response::builder()
        .status(StatusCode::FOUND)
        .header(header::LOCATION, location)
        .body(Vec::new())
        .map_err(|error| crate::error::RustAuthError::Serialization {
            context: "building email verification redirect response",
            message: error.to_string(),
        })
}

fn send_verification_email_body_schema() -> BodySchema {
    BodySchema::object([
        BodyField::new("email", JsonSchemaType::String)
            .description("The email to send the verification email to"),
        BodyField::optional("callbackURL", JsonSchemaType::String)
            .description("The URL to use for email verification callback"),
    ])
}