use super::{EmailOtpConfig, EmailOtpPlugin, EmailOtpType};
use crate::plugins::endpoint_context::EndpointContext;
use better_auth_core::{
AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthRoute, AuthSchema,
FieldDate, FieldMap, FieldValue, HttpMethod, NativeRequest, SchemaValue,
};
pub struct EmailOtpApi<'a, S: AuthSchema> {
plugin: EmailOtpPlugin,
context: &'a AuthContext<S>,
source: NativeRequest<'a>,
transaction: Option<&'a dyn better_auth_core::store::AuthTransaction<S>>,
}
impl<'a, S: AuthSchema> EmailOtpApi<'a, S> {
pub fn from_context(context: &'a AuthContext<S>) -> AuthResult<Self> {
let config = context
.extensions
.get::<EmailOtpConfig>()
.ok_or_else(|| AuthError::config("EmailOtpPlugin is not registered"))?;
Ok(Self {
plugin: EmailOtpPlugin::with_config(config.clone()),
context,
transaction: None,
source: NativeRequest::default(),
})
}
pub fn from_endpoint(endpoint: &EndpointContext<'a, S>) -> AuthResult<Self> {
let mut api = Self::from_context(endpoint.auth)?;
api.transaction = endpoint.transaction;
Ok(api)
}
pub fn with_request(mut self, source: NativeRequest<'a>) -> Self {
self.source = source;
self
}
pub async fn create(&self, email: &str, kind: EmailOtpType) -> AuthResult<String> {
let route = AuthRoute::server_only(HttpMethod::Post, "createEmailVerificationOTP")
.body_validator(validate_body);
let response = self
.context
.dispatch_native(
self.source,
route,
Some(serde_json::json!({"email":email,"type":kind})),
None,
|request, context| async move {
let body = request
.validated_body::<OtpInput>()
.ok_or_else(|| AuthError::internal("Missing validated OTP input"))?;
let mut endpoint = EndpointContext::native(
Some(&request),
request.original_request(),
request.input_field_value()?,
&context,
);
endpoint.transaction = self.transaction;
let email = body.email.to_lowercase();
let otp = self
.plugin
.create_otp(&endpoint, &email, body.kind, &body.kind.identifier(&email))
.await?;
AuthResponse::json(None, &otp)
},
)
.await?;
response.body.field_value()?.decode()
}
pub async fn get(&self, email: &str, kind: EmailOtpType) -> AuthResult<Option<String>> {
let otp = self.get_value(email, kind).await?;
if otp.is_null() || otp.is_undefined() {
Ok(None)
} else {
Ok(Some(
SchemaValue::<String>::from_field(otp).typed()?.clone(),
))
}
}
pub async fn get_value(&self, email: &str, kind: EmailOtpType) -> AuthResult<FieldValue> {
let route = AuthRoute::server_only(HttpMethod::Get, "getEmailVerificationOTP")
.query_validator(validate_query);
let response = self
.context
.dispatch_native(
self.source,
route,
None,
Some(serde_json::json!({"email":email,"type":kind})),
|request, context| async move {
let query: OtpInput =
serde_json::from_value(request.input_query()?.unwrap_or_default())?;
let identifier = query.kind.identifier(&query.email.to_lowercase());
let record = match self.transaction {
Some(transaction) => {
transaction.find_verification_value(&identifier).await?
}
None => {
context
.database
.find_verification_value(&identifier)
.await?
}
};
let otp = if let Some(record) = record
&& record.is_truthy()
&& !SchemaValue::<FieldDate>::from_field(
record.model_property("expiresAt")?,
)
.is_before(context.now())?
{
if matches!(
&self.plugin.config.storage,
super::EmailOtpStorage::Hashed | super::EmailOtpStorage::CustomHash(_)
) {
return Err(AuthError::from_status(
"BAD_REQUEST",
FieldMap::from([(
"message".into(),
"OTP is hashed, cannot return the plain text OTP".into(),
)])
.into(),
));
}
let (stored, _) = super::otp::split(&record.model_property("value")?)?;
self.plugin
.recover(&stored, context.config.encryption_secret())
.await?
} else {
FieldValue::Null
};
Ok(AuthResponse::json_value(
None,
FieldMap::from([("otp".into(), otp)]).into(),
))
},
)
.await?;
response.body.field_value()?.model_property("otp")
}
}
#[derive(serde::Deserialize)]
struct OtpInput {
email: String,
#[serde(rename = "type")]
kind: EmailOtpType,
}
fn validate_body(
request: &AuthRequest,
) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
let body = project(request.input_body()?, "body")?;
let input: OtpInput = serde_json::from_value(body.clone())?;
Ok(better_auth_core::endpoint_input::ValidatedBody::new(
Some(body),
input,
))
}
fn validate_query(query: Option<serde_json::Value>) -> AuthResult<Option<serde_json::Value>> {
project(query, "query").map(Some)
}
fn project(input: Option<serde_json::Value>, location: &str) -> AuthResult<serde_json::Value> {
use crate::plugins::json_body::{invalid_type, validation_error};
use serde_json::{Map, Value};
let body = input
.as_ref()
.and_then(Value::as_object)
.ok_or_else(|| validation_error(&invalid_type(location, "object", input.as_ref())))?;
let mut errors = Vec::new();
if !body.get("email").is_some_and(Value::is_string) {
errors.push(invalid_type(
&format!("{location}.email"),
"string",
body.get("email"),
));
}
if !matches!(
body.get("type").and_then(Value::as_str),
Some("email-verification" | "sign-in" | "forget-password" | "change-email")
) {
errors.push(format!("[{location}.type] Invalid option: expected one of \"email-verification\"|\"sign-in\"|\"forget-password\"|\"change-email\""));
}
if !errors.is_empty() {
return Err(validation_error(&errors.join("; ")));
}
let projected: Map<String, Value> = body
.iter()
.filter(|(key, _)| matches!(key.as_str(), "email" | "type"))
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
Ok(Value::Object(projected))
}