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 super::email_verification::create_email_verification_token;
use super::shared::{auth_session_cookies, current_session, error_response, json_response};
use crate::api::{
    create_auth_endpoint, parse_request_body, AsyncAuthEndpoint, AuthEndpointOptions, BodyField,
    BodySchema, JsonSchemaType, OpenApiOperation,
};
use crate::db::{DbAdapter, User};
use crate::options::VerificationEmail;
use crate::user::DbUserStore;

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

#[derive(Debug, Serialize)]
struct ChangeEmailResponse {
    status: bool,
    message: &'static str,
    user: Option<User>,
}

pub(super) fn change_email_endpoint(adapter: Arc<dyn DbAdapter>) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/change-email",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("changeEmail")
            .body_schema(change_email_body_schema())
            .openapi(
                OpenApiOperation::new("changeEmail")
                    .description("Change the current user's email")
                    .response(
                        "200",
                        super::shared::json_openapi_response(
                            "Email change request processed successfully",
                            json!({
                                "type": "object",
                                "properties": {
                                    "status": { "type": "boolean" },
                                    "message": { "type": "string", "nullable": true },
                                    "user": {
                                        "oneOf": [
                                            { "$ref": "#/components/schemas/User" },
                                            { "type": "null" }
                                        ],
                                    },
                                },
                                "required": ["status"],
                            }),
                        ),
                    ),
            ),
        move |context, request| {
            let adapter = Arc::clone(&adapter);
            Box::pin(async move {
                if !context.options.user.change_email.enabled {
                    return error_response(
                        StatusCode::BAD_REQUEST,
                        "CHANGE_EMAIL_DISABLED",
                        "Change email is disabled",
                    );
                }
                let Some((session, user, _cookies)) =
                    current_session(adapter.as_ref(), context, &request).await?
                else {
                    return super::shared::unauthorized();
                };
                let body: ChangeEmailBody = parse_request_body(&request)?;
                let new_email = body.new_email.to_lowercase();
                if new_email == user.email {
                    return error_response(
                        StatusCode::BAD_REQUEST,
                        "EMAIL_IS_SAME",
                        "Email is the same",
                    );
                }

                let users = DbUserStore::new(adapter.as_ref());
                if users.find_user_by_email(&new_email).await?.is_some() {
                    create_email_verification_token(context, &user.email, Some(&new_email), None)?;
                    return json_response(
                        StatusCode::OK,
                        &ChangeEmailResponse {
                            status: true,
                            message: "Verification email sent",
                            user: None,
                        },
                        Vec::new(),
                    );
                }

                if !user.email_verified
                    && context
                        .options
                        .user
                        .change_email
                        .update_email_without_verification
                {
                    let updated = users
                        .update_user_email(&user.id, &new_email, false)
                        .await?
                        .unwrap_or(user);
                    let cookies = auth_session_cookies(context, &session, &updated, false)?;
                    return json_response(
                        StatusCode::OK,
                        &ChangeEmailResponse {
                            status: true,
                            message: "Email updated",
                            user: Some(updated),
                        },
                        cookies,
                    );
                }

                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 token = create_email_verification_token(
                    context,
                    &user.email,
                    Some(&new_email),
                    Some("change-email-verification"),
                )?;
                let callback_url = body.callback_url.unwrap_or_else(|| "/".to_owned());
                let url = format!(
                    "{}/verify-email?token={token}&callbackURL={}",
                    context.base_url,
                    super::shared::percent_encode(&callback_url)
                );
                sender.send_verification_email(
                    VerificationEmail {
                        user: User {
                            email: new_email,
                            ..user
                        },
                        url,
                        token,
                    },
                    Some(&request),
                )?;
                json_response(
                    StatusCode::OK,
                    &ChangeEmailResponse {
                        status: true,
                        message: "Verification email sent",
                        user: None,
                    },
                    Vec::new(),
                )
            })
        },
    )
}

fn change_email_body_schema() -> BodySchema {
    BodySchema::object([
        BodyField::new("newEmail", JsonSchemaType::String)
            .description("The new email address to set"),
        BodyField::optional("callbackURL", JsonSchemaType::String)
            .description("The URL to redirect to after email verification"),
    ])
}