openauth-core 0.0.4

Core types and primitives for OpenAuth.
Documentation
use std::sync::Arc;

use http::{Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::Duration;

use super::shared::{
    current_session, error_response, json_response, percent_encode, query_param,
    status_openapi_response,
};
use crate::api::{
    create_auth_endpoint, parse_request_body, AsyncAuthEndpoint, AuthEndpointOptions, BodyField,
    BodySchema, JsonSchemaType, OpenApiOperation,
};
use crate::crypto::jwt::{sign_jwt, verify_jwt};
use crate::db::{DbAdapter, User};
use crate::options::{EmailVerificationCallbackPayload, VerificationEmail};
use crate::user::DbUserStore;

#[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<User>,
}

pub(super) fn send_verification_email_endpoint(adapter: Arc<dyn DbAdapter>) -> 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| {
            let adapter = Arc::clone(&adapter);
            Box::pin(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 = DbUserStore::new(adapter.as_ref());
                let session = current_session(adapter.as_ref(), 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={}",
                    context.base_url,
                    percent_encode(&callback_url)
                );
                sender.send_verification_email(
                    VerificationEmail { user, url, token },
                    Some(&request),
                )?;

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

pub(super) fn verify_email_endpoint(adapter: Arc<dyn DbAdapter>) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/verify-email",
        Method::GET,
        AuthEndpointOptions::new()
            .operation_id("verifyEmail")
            .openapi(
                OpenApiOperation::new("verifyEmail")
                    .description("Verify the email of the user")
                    .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"],
                            }),
                        ),
                    ),
            ),
        move |context, request| {
            let adapter = Arc::clone(&adapter);
            Box::pin(async move {
                let Some(token) = query_param(&request, "token") else {
                    return invalid_token();
                };
                let Some(claims) = verify_jwt::<EmailVerificationClaims>(&token, &context.secret)?
                else {
                    return invalid_token();
                };
                let users = DbUserStore::new(adapter.as_ref());
                let Some(user) = users.find_user_by_email(&claims.email).await? else {
                    return error_response(
                        StatusCode::UNAUTHORIZED,
                        "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 json_response(
                        StatusCode::OK,
                        &VerifyEmailResponse {
                            status: true,
                            user: Some(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 updated = if !user.email_verified {
                    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 },
                        Some(&request),
                    )?;
                }
                json_response(
                    StatusCode::OK,
                    &VerifyEmailResponse {
                        status: true,
                        user: None,
                    },
                    Vec::new(),
                )
            })
        },
    )
}

pub(super) fn create_email_verification_token(
    context: &crate::context::AuthContext,
    email: &str,
    update_to: Option<&str>,
    request_type: Option<&str>,
) -> Result<String, crate::error::OpenAuthError> {
    let expires_in = context
        .options
        .email_verification
        .expires_in
        .unwrap_or(60 * 60);
    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,
        Duration::seconds(expires_in as i64).whole_seconds(),
    )
}

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

fn invalid_token() -> Result<crate::api::ApiResponse, crate::error::OpenAuthError> {
    error_response(StatusCode::UNAUTHORIZED, "INVALID_TOKEN", "Invalid token")
}

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"),
    ])
}