openauth-core 0.0.6

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::shared::{
    error_response, json_response, query_param, request_cookie_header, sensitive_session,
};
use crate::api::services::user as user_service;
use crate::api::services::user::{DeleteUserError, DeleteUserErrorOrOpenAuth};
use crate::api::{
    create_auth_endpoint, parse_request_body, AsyncAuthEndpoint, AuthEndpointOptions, BodyField,
    BodySchema, JsonSchemaType, OpenApiOperation,
};
use crate::cookies::delete_session_cookie;
use crate::db::DbAdapter;

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

#[derive(Debug, Serialize)]
struct DeleteUserResponse {
    success: bool,
    message: &'static str,
}

pub(super) fn delete_user_endpoint(adapter: Arc<dyn DbAdapter>) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/delete-user",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("deleteUser")
            .body_schema(delete_user_body_schema())
            .openapi(delete_user_openapi("deleteUser", "Delete the user")),
        move |context, request| {
            let adapter = Arc::clone(&adapter);
            Box::pin(async move {
                if !context.options.user.delete_user.enabled {
                    return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "Not found");
                }
                let body: DeleteUserBody = parse_request_body(&request)?;
                let _callback_url_seen = body.callback_url.as_deref();
                if let Some(token) = body.token.as_deref() {
                    return delete_user_by_token(adapter.as_ref(), context, &request, token).await;
                }
                let Some((session, user, _cookies)) =
                    sensitive_session(adapter.as_ref(), context, &request).await?
                else {
                    return super::shared::unauthorized();
                };
                if let Err(error) = user_service::delete_user_with_password_or_fresh_session(
                    adapter.as_ref(),
                    context,
                    &session,
                    &user,
                    body.password.as_deref(),
                )
                .await
                {
                    return delete_user_error_response(error);
                }
                let cookies = delete_session_cookie(
                    &context.auth_cookies,
                    &request_cookie_header(&request).unwrap_or_default(),
                    false,
                );
                json_response(
                    StatusCode::OK,
                    &DeleteUserResponse {
                        success: true,
                        message: "User deleted",
                    },
                    cookies,
                )
            })
        },
    )
}

pub(super) fn delete_user_callback_endpoint(adapter: Arc<dyn DbAdapter>) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/delete-user/callback",
        Method::GET,
        AuthEndpointOptions::new().openapi(delete_user_openapi(
            "deleteUserCallback",
            "Callback to complete user deletion with verification token",
        )),
        move |context, request| {
            let adapter = Arc::clone(&adapter);
            Box::pin(async move {
                if !context.options.user.delete_user.enabled {
                    return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "Not found");
                }
                let Some(token) = query_param(&request, "token") else {
                    return invalid_token();
                };
                delete_user_by_token(adapter.as_ref(), context, &request, &token).await
            })
        },
    )
}

async fn delete_user_by_token(
    adapter: &dyn DbAdapter,
    context: &crate::context::AuthContext,
    request: &crate::api::ApiRequest,
    token: &str,
) -> Result<crate::api::ApiResponse, crate::error::OpenAuthError> {
    let Some((_, user, _)) = sensitive_session(adapter, context, request).await? else {
        return super::shared::unauthorized();
    };
    if let Err(error) = user_service::delete_user_with_token(adapter, context, &user, token).await {
        return delete_user_error_response(error);
    }
    let cookies = delete_session_cookie(
        &context.auth_cookies,
        &request_cookie_header(request).unwrap_or_default(),
        false,
    );
    json_response(
        StatusCode::OK,
        &DeleteUserResponse {
            success: true,
            message: "User deleted",
        },
        cookies,
    )
}

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

fn delete_user_error_response(
    error: DeleteUserErrorOrOpenAuth,
) -> Result<crate::api::ApiResponse, crate::error::OpenAuthError> {
    match error {
        DeleteUserErrorOrOpenAuth::OpenAuth(error) => Err(error),
        DeleteUserErrorOrOpenAuth::Service(error) => match error {
            DeleteUserError::InvalidPassword => error_response(
                StatusCode::BAD_REQUEST,
                "INVALID_PASSWORD",
                "Invalid password",
            ),
            DeleteUserError::InvalidToken => invalid_token(),
            DeleteUserError::SessionExpired => error_response(
                StatusCode::BAD_REQUEST,
                "SESSION_EXPIRED",
                "Session expired",
            ),
        },
    }
}

fn delete_user_body_schema() -> BodySchema {
    BodySchema::object([
        BodyField::optional("callbackURL", JsonSchemaType::String)
            .description("The callback URL to redirect to after the user is deleted"),
        BodyField::optional("password", JsonSchemaType::String)
            .description("The user's password. Required if session is not fresh"),
        BodyField::optional("token", JsonSchemaType::String)
            .description("The deletion verification token"),
    ])
}

fn delete_user_openapi(operation_id: &str, description: &str) -> OpenApiOperation {
    OpenApiOperation::new(operation_id)
        .description(description)
        .response(
            "200",
            super::shared::json_openapi_response(
                "User deletion processed successfully",
                json!({
                    "type": "object",
                    "properties": {
                        "success": { "type": "boolean" },
                        "message": { "type": "string" },
                    },
                    "required": ["success", "message"],
                }),
            ),
        )
}