use crate::config::AuthConfig;
use crate::error::{AuthError, AuthResult};
use crate::hooks::{RequestHookContext, current_request_hook_context};
use crate::types::CreateUser;
use async_trait::async_trait;
use chrono::Utc;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum UserValidationAction {
#[default]
CreateUser,
LinkAccount,
SignIn,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserValidationProvider {
pub provider_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<serde_json::Value>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserValidationSource {
#[serde(default)]
pub method: String,
#[serde(default)]
pub action: UserValidationAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub oauth: Option<UserValidationProvider>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sso: Option<UserValidationProvider>,
}
impl UserValidationSource {
#[must_use]
pub fn creation(method: impl Into<String>) -> Self {
Self {
method: method.into(),
action: UserValidationAction::CreateUser,
oauth: None,
sso: None,
}
}
#[must_use]
pub fn oauth(
provider_id: impl Into<String>,
profile: &serde_json::Value,
action: UserValidationAction,
) -> Self {
Self {
method: "oauth".into(),
action,
oauth: Some(UserValidationProvider {
provider_id: provider_id.into(),
profile: profile.is_object().then(|| profile.clone()),
}),
sso: None,
}
}
}
#[derive(Debug, Clone)]
pub struct UserValidationData {
pub user: CreateUser,
pub source: UserValidationSource,
}
#[derive(Debug, Clone)]
pub struct UserValidationRejection {
pub error: String,
pub error_description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct UserValidationContext {
pub request: Option<RequestHookContext>,
pub endpoint: Option<crate::endpoint::EndpointCall>,
}
#[async_trait]
pub trait UserInfoValidator: Send + Sync {
async fn validate(
&self,
data: &mut UserValidationData,
context: &UserValidationContext,
) -> AuthResult<Option<UserValidationRejection>>;
}
fn denial(code: &str, message: &str) -> AuthError {
AuthError::Api {
status: 403,
code: Some(code.into()),
message: message.into(),
}
}
fn assert_source(source: Option<&UserValidationSource>) -> AuthResult<()> {
let Some(source) = source.filter(|source| !source.method.is_empty()) else {
return Err(denial(
"validation_source_missing",
"User validation source is required",
));
};
if source.method == "oauth"
&& source
.oauth
.as_ref()
.is_none_or(|provider| provider.provider_id.is_empty())
{
return Err(denial(
"validation_source_missing",
"OAuth user validation source requires oauth.providerId",
));
}
if matches!(source.method.as_str(), "sso-oidc" | "sso-saml")
&& source
.sso
.as_ref()
.is_none_or(|provider| provider.provider_id.is_empty())
{
return Err(denial(
"validation_source_missing",
"SSO user validation source requires sso.providerId",
));
}
Ok(())
}
pub async fn validate_user_info(
config: &AuthConfig,
data: &mut UserValidationData,
) -> AuthResult<()> {
let Some(validator) = &config.user_validation else {
return Ok(());
};
assert_source(Some(&data.source))?;
let context = UserValidationContext {
request: current_request_hook_context(),
endpoint: crate::endpoint::current_endpoint_call_context(),
};
if context.request.is_none() && context.endpoint.is_none() {
return Err(denial(
"validation_context_missing",
"User validation requires an endpoint context",
));
}
match validator.validate(data, &context).await {
Ok(Some(rejection)) if !rejection.error.is_empty() => {
let message = rejection
.error_description
.as_deref()
.filter(|value| !value.is_empty())
.unwrap_or(&rejection.error);
Err(denial(&rejection.error, message))
}
Ok(_) => Ok(()),
Err(_) => Err(denial("validation_failed", "User validation failed")),
}
}
pub struct PreparedUserCreation {
data: CreateUser,
defaults: crate::store::UserCreationDefaults,
}
impl PreparedUserCreation {
#[must_use]
pub fn into_data(self) -> CreateUser {
self.data
}
#[must_use]
pub fn into_parts(self) -> (CreateUser, crate::store::UserCreationDefaults) {
(self.data, self.defaults)
}
pub(crate) fn from_data(data: CreateUser) -> Self {
Self {
data,
defaults: crate::store::UserCreationDefaults::default(),
}
}
pub(crate) fn with_defaults(mut self, defaults: crate::store::UserCreationDefaults) -> Self {
self.defaults = defaults;
self
}
}
pub(crate) async fn prepare_creation(
config: &AuthConfig,
mut user: CreateUser,
source: Option<UserValidationSource>,
) -> AuthResult<PreparedUserCreation> {
assert_source(source.as_ref())?;
let mut source = source.ok_or_else(|| {
denial(
"validation_source_missing",
"User validation source is required",
)
})?;
source.action = UserValidationAction::CreateUser;
let now = Utc::now();
_ = user.created_at.get_or_insert(now);
_ = user.updated_at.get_or_insert(now);
user.email = user
.email
.map(|email| crate::utils::normalize_user_email(&email));
let mut data = UserValidationData { user, source };
validate_user_info(config, &mut data).await?;
if data.user.created_at.is_none() || data.user.updated_at.is_none() {
return Err(AuthError::NotImplemented(
"Identity policy removal of creation timestamps is not supported".into(),
));
}
Ok(PreparedUserCreation::from_data(data.user))
}