use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use better_auth_core::{
ApiErrorClass, AuthContext, AuthError, AuthRecordFields, AuthRequest, AuthResult, AuthSchema,
CreatePasskey, CreateSession, FieldMap, FieldValue, session::NativeSessionData,
store::AuthTransaction, wire::PasskeyView,
};
use chrono::Utc;
use serde_json::{Map, Value};
use super::{
PasskeyConfig, PasskeyCredential, PasskeyEndpoint, PasskeyExtensions, PasskeyRegistrationInfo,
PasskeyRegistrationUser, PasskeyRegistrationVerification, callbacks::Users,
credential::WebAuthnCredential, types::VerifyRegistrationRequest, webauthn::*,
};
pub(super) async fn optional_session<S: AuthSchema>(
ctx: &AuthContext<S>,
req: &AuthRequest,
) -> AuthResult<Option<NativeSessionData>> {
ctx.native_session(req, better_auth_core::session::SessionRead::Cached)
.await
}
pub(super) async fn registration_session<S: AuthSchema>(
ctx: &AuthContext<S>,
req: &AuthRequest,
config: &PasskeyConfig,
) -> AuthResult<Option<NativeSessionData>> {
if !config.registration.require_session {
return optional_session(ctx, req).await;
}
let data = ctx.require_native_session(req).await?;
if !crate::plugins::helpers::session_is_fresh(&data, &ctx.config)? {
return Err(AuthError::from_code(
"FORBIDDEN",
"SESSION_NOT_FRESH",
"Session is not fresh",
));
}
Ok(Some(data))
}
pub(super) async fn resolve_user<S: AuthSchema>(
ctx: &AuthContext<S>,
req: &AuthRequest,
config: &PasskeyConfig,
) -> AuthResult<PasskeyRegistrationUser> {
if let Some(data) = registration_session(ctx, req, config).await?
&& data.user_field("id")?.is_truthy()
{
let name = data.user_field("email")?;
let name = if name.is_truthy() {
name
} else {
data.user_field("id")?
};
return Ok(PasskeyRegistrationUser {
id: better_auth_core::SchemaValue::from_field(data.user_field("id")?),
display_name: better_auth_core::SchemaValue::from_field(name.clone()),
name: better_auth_core::SchemaValue::from_field(name),
});
}
if config.registration.require_session {
return Err(AuthError::from_code(
"UNAUTHORIZED",
"SESSION_REQUIRED",
"Passkey registration requires an authenticated session",
));
}
let resolver = config.registration.resolve_user.as_ref().ok_or_else(|| AuthError::from_code("BAD_REQUEST", "RESOLVE_USER_REQUIRED", "Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false"))?;
let users = Users {
ctx,
transaction: None,
};
let user = resolver
.resolve(
PasskeyEndpoint::new(ctx, req, &Value::Null, &users),
req.query_string("context")?.as_deref(),
)
.await?;
if !user.id.is_truthy()? || !user.name.is_truthy()? {
return Err(invalid_user());
}
Ok(user)
}
pub(super) async fn resolve_extensions(
config: &PasskeyExtensions,
ctx: PasskeyEndpoint<'_>,
) -> AuthResult<Option<Map<String, Value>>> {
match config {
PasskeyExtensions::None => Ok(None),
PasskeyExtensions::Static(value) => Ok(Some(value.clone())),
PasskeyExtensions::Dynamic(resolver) => resolver.extensions(ctx).await,
}
}
fn invalid_user() -> AuthError {
AuthError::from_code(
"BAD_REQUEST",
"RESOLVED_USER_INVALID",
"Resolved user is invalid",
)
}
fn forbidden_user() -> AuthError {
AuthError::from_code(
"UNAUTHORIZED",
"YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY",
"You are not allowed to register this passkey",
)
}
pub(super) fn verification_error(error: AuthError, registration: bool) -> AuthError {
if !error.is_api_error_instance(ApiErrorClass::BetterAuth) {
if let Err(error) = better_auth_core::observability::logger::current().error(
if registration {
"Failed to verify registration"
} else {
"Failed to verify authentication"
},
&[better_auth_core::observability::LogArgument::Error(&error)],
) {
return error;
}
if registration {
AuthError::from_code(
"INTERNAL_SERVER_ERROR",
"FAILED_TO_VERIFY_REGISTRATION",
"Failed to verify registration",
)
} else {
AuthError::from_code(
"BAD_REQUEST",
"AUTHENTICATION_FAILED",
"Authentication failed",
)
}
} else {
error
}
}
pub(super) async fn verify_registration_core<S: AuthSchema>(
body: VerifyRegistrationRequest,
req: &AuthRequest,
session_user: Option<NativeSessionData>,
config: &PasskeyConfig,
ctx: &AuthContext<S>,
) -> AuthResult<FieldValue> {
let Some(origins) = resolve_origins(config, req) else {
return Err(AuthError::from_code(
"BAD_REQUEST",
"FAILED_TO_VERIFY_REGISTRATION",
"Failed to verify registration",
));
};
let state =
super::challenge::consume(ctx, req, config, super::challenge::Ceremony::Registration)
.await?;
let user = state.model_property("userData")?;
let session_user = if config.registration.require_session {
session_user
} else {
optional_session(ctx, req).await?
};
if let Some(id) = session_user
.as_ref()
.map(|user| user.user_field("id"))
.transpose()?
&& id.is_truthy()
&& !id.strict_equals(&user.model_property("id")?)
{
return Err(forbidden_user());
}
let result = async {
let expected_challenge = state.model_property("expectedChallenge")?;
let registration = super::verification::registration_response(
&body.response,
&expected_challenge,
&origins,
)?;
let webauthn = build_webauthn(config, &ctx.config, &origins)?;
let verification_state =
super::challenge::registration_state(&webauthn, &expected_challenge)?;
let credential = webauthn
.register_credential(®istration, &verification_state, None)
.map_err(|error| {
AuthError::internal(format!("WebAuthn registration failed: {error}"))
})?;
let mut stored = WebAuthnCredential::registered(credential, ctx.database.passkey_storage());
let metadata = extract_registration_metadata(®istration)?;
let transports = body
.response
.model_property("response")?
.model_property("transports")?;
let transports = match transports {
FieldValue::Null | FieldValue::Undefined => None,
value => Some(value.decode::<Vec<String>>()?),
};
stored.cred.transports = transports
.as_ref()
.map(|values| {
values
.iter()
.map(|value| serde_json::from_value(Value::String(value.clone())))
.collect::<Result<_, _>>()
})
.transpose()?;
let credential_id = URL_SAFE_NO_PAD.encode(stored.cred.cred_id.as_ref());
let verification = PasskeyRegistrationVerification {
verified: true,
registration_info: PasskeyRegistrationInfo {
fmt: metadata.fmt,
aaguid: metadata.aaguid.clone().unwrap_or_default(),
credential: PasskeyCredential {
id: credential_id.clone(),
public_key: base64::engine::general_purpose::STANDARD
.decode(&metadata.public_key)
.map_err(|error| AuthError::internal(error.to_string()))?,
counter: u64::from(stored.cred.counter),
transports: transports.clone(),
},
credential_type: "public-key",
attestation_object: registration.response.attestation_object.as_ref().to_vec(),
user_verified: stored.cred.user_verified,
credential_device_type: stored.device_type(),
credential_backed_up: stored.cred.backup_state,
origin: client_origin(registration.response.client_data_json.as_ref())?,
rp_id: rp_id(config, &ctx.config)?,
authenticator_extension_results: metadata.extensions,
},
};
let input = CreatePasskey {
additional_fields: Default::default(),
user_id: better_auth_core::SchemaValue::Undefined,
name: body
.name
.clone()
.filter(|name| !name.is_empty())
.map(|name| Some(name).into())
.unwrap_or_default(),
credential_id,
public_key: metadata.public_key,
counter: u64::from(stored.cred.counter),
device_type: stored.device_type().into(),
backed_up: stored.cred.backup_state,
transports: Some(transports.unwrap_or_default().join(",")),
credential: stored.create_state()?,
aaguid: metadata
.aaguid
.map(|aaguid| Some(aaguid).into())
.unwrap_or_default(),
};
let registration = Registration {
ctx: clone_context(ctx),
config: config.clone(),
request: req.clone(),
state,
verification,
input,
session_user,
body,
};
let create_session = registration.body.create_session == Some(true);
let (passkey, session) = if create_session {
let database = ctx.database.clone();
better_auth_core::store::transaction(database.as_ref(), move |tx| {
Box::pin(async move { registration.persist(Some(tx)).await })
})
.await?
} else {
registration.persist(None).await?
};
let mut result = passkey
.as_ref()
.map(|passkey| PasskeyView::from(passkey).field_values())
.transpose()?;
if let Some((user, session)) = session {
let manager = ctx.session_manager();
manager
.set_session_cookie(req, manager.internal_data(&user, &session).await?, None)
.await?;
let result = result.get_or_insert_with(FieldMap::new);
let _ = result.insert(
"session".into(),
FieldMap::from(ctx.session_view(&session).await?).into(),
);
let _ = result.insert(
"user".into(),
FieldMap::from(ctx.user_view(&user).await?).into(),
);
}
Ok(result.map(FieldValue::from).unwrap_or(FieldValue::Null))
}
.await
.map_err(|error| verification_error(error, true))?;
Ok(result)
}
struct Registration<S: AuthSchema> {
ctx: AuthContext<S>,
config: PasskeyConfig,
request: AuthRequest,
state: FieldValue,
verification: PasskeyRegistrationVerification,
input: CreatePasskey,
session_user: Option<NativeSessionData>,
body: VerifyRegistrationRequest,
}
impl<S: AuthSchema> Registration<S> {
async fn persist(
mut self,
transaction: Option<&dyn AuthTransaction<S>>,
) -> AuthResult<(
Option<better_auth_core::Passkey>,
Option<(
better_auth_core::wire::UserView,
better_auth_core::wire::SessionView,
)>,
)> {
let mut state = RegistrationUserState::from_native(&self.state)?;
if !state.user.name.is_truthy()? {
state.user.name = state.user.id.clone();
}
self.input.user_id = state.user.id.clone();
if let Some(hook) = &self.config.registration.after_verification {
let users = Users {
ctx: &self.ctx,
transaction,
};
let body = self.request.input_body()?.unwrap_or(Value::Null);
let client_data = body.get("response").unwrap_or(&Value::Null);
let context = match &state.context {
FieldValue::Undefined | FieldValue::Null => None,
value => Some(value.decode::<String>()?),
};
let result = hook
.after_verification(
PasskeyEndpoint::new(&self.ctx, &self.request, &body, &users),
&self.verification,
&state.user,
client_data,
context.as_deref(),
)
.await?;
if let Some(user_id) = result.user_id.filter(|id| !id.is_empty()) {
if let Some(id) = self
.session_user
.as_ref()
.map(|user| user.user_field("id"))
.transpose()?
&& id.is_truthy()
&& !id.strict_equals(&FieldValue::from(user_id.as_str()))
{
return Err(forbidden_user());
}
self.input.user_id = user_id.into();
}
if self.input.name.is_absent() {
self.input.name = result
.name
.map(|name| super::types::trim_name(&name).to_owned())
.filter(|name| !name.is_empty())
.map(|name| Some(name).into())
.unwrap_or_default();
}
}
if !self.input.user_id.is_truthy()? {
return Err(invalid_user());
}
let user = if self.body.create_session == Some(true) {
Some(
match transaction {
Some(tx) => {
tx.get_user_by_id_value(&self.input.user_id.field_value())
.await?
}
None => {
self.ctx
.database
.get_user_by_id_value(&self.input.user_id.field_value())
.await?
}
}
.ok_or_else(|| {
AuthError::from_code(
"INTERNAL_SERVER_ERROR",
"USER_NOT_FOUND",
"User not found",
)
})?,
)
} else {
None
};
let user_id = self.input.user_id.clone();
let passkey = match transaction {
Some(tx) => tx.create_passkey_optional(self.input).await?,
None => {
self.ctx
.database
.create_passkey_optional(self.input)
.await?
}
};
let session = if let Some(user) = user {
crate::plugins::helpers::admit_session_for_id(&self.ctx, &user_id, transaction)
.await
.map_err(crate::plugins::helpers::SessionIssueError::into_auth_error)?;
let input = CreateSession {
inherited_fields: Default::default(),
additional_fields: Default::default(),
user_id,
expires_at: (Utc::now() + self.ctx.config.session.expires_in()).into(),
ip_address: self.ctx.config.advanced.ip_address.resolve(&self.request),
user_agent: self.request.headers.get("user-agent").cloned(),
impersonated_by: None,
active_organization_id: None,
};
let session = match transaction {
Some(tx) => {
tx.create_session_with_deferred_secondary_optional(input)
.await?
}
None => self.ctx.database.create_session_optional(input).await?,
}
.ok_or_else(|| {
AuthError::from_code(
"INTERNAL_SERVER_ERROR",
"UNABLE_TO_CREATE_SESSION",
"Unable to create session",
)
})?;
Some((user, session))
} else {
None
};
Ok((passkey, session))
}
}
fn clone_context<S: AuthSchema>(ctx: &AuthContext<S>) -> AuthContext<S> {
ctx.clone()
}