#![allow(
clippy::expect_used,
reason = "Fixture setup and response assertions require successful fallible construction."
)]
use super::{Authenticator, CREDENTIAL_ID, ORIGIN, RP_ID, STANDARD, TRANSPORTS, TestResult};
use crate::plugins::{
passkey::{PasskeyPlugin, tests::assert_verification_error},
test_helpers,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use better_auth_core::{
AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateUser, FieldMap,
HttpMethod, Passkey, PasskeyStorage,
};
use better_auth_seaorm::{
Database, DatabaseConnection, SeaOrmStore,
sea_orm::{ConnectionTrait, Schema},
store::{
__private_test_support::{bundled_schema::BundledSchema, migrator},
entities::{api_key, device_code},
},
};
use serde_json::{Value, json};
use std::{path::Path, sync::Arc};
#[path = "descriptor_authentication_tests.rs"]
mod descriptor_authentication_tests;
#[path = "native_boundary_tests.rs"]
mod native_boundary_tests;
#[path = "admission_authentication_tests.rs"]
mod admission_authentication_tests;
#[path = "user_id_tests.rs"]
mod user_id_tests;
mod native {
use better_auth_seaorm::{
AuthEntity,
sea_orm::{self, entity::prelude::*},
store::entities::user,
};
#[derive(Clone, Debug, DeriveEntityModel, AuthEntity)]
#[auth(role = "passkey", native_passkey)]
#[sea_orm(table_name = "native_passkey_reopen")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: Option<String>,
#[sea_orm(column_name = "publicKey")]
pub public_key: String,
#[sea_orm(column_name = "userId")]
pub user_id: String,
#[sea_orm(column_name = "credentialID")]
pub credential_id: String,
pub counter: i64,
#[sea_orm(column_name = "deviceType")]
pub device_type: String,
#[sea_orm(column_name = "backedUp")]
pub backed_up: bool,
pub transports: Option<String>,
#[sea_orm(column_name = "createdAt")]
pub created_at: Option<DateTimeUtc>,
pub aaguid: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "user::Entity",
from = "Column::UserId",
to = "user::Column::Id",
on_delete = "Cascade"
)]
User,
}
impl ActiveModelBehavior for ActiveModel {}
}
type Models = better_auth_seaorm::PluginModels<api_key::Model, device_code::Model, native::Model>;
struct Fixture {
ctx: AuthContext<BundledSchema>,
database: DatabaseConnection,
}
impl Fixture {
async fn open(path: &Path) -> TestResult<Self> {
let database = Database::connect(format!("sqlite://{}?mode=rw", path.display())).await?;
let mut config = test_helpers::create_test_config().base_url(ORIGIN);
config.telemetry.enabled = false;
let config = Arc::new(config);
let store = SeaOrmStore::<BundledSchema>::new(config.clone(), database.clone())
.with_plugin_schema::<Models>();
let ctx = AuthContext::new(config, Arc::new(store)).expect("fixture context initializes");
assert_eq!(ctx.database.passkey_storage(), PasskeyStorage::Native);
Ok(Self { ctx, database })
}
async fn create(path: &Path) -> TestResult<Self> {
let fixture = Self::open(path).await?;
migrator::run_migrations(&fixture.database).await?;
fixture
.database
.execute_unprepared("DROP TABLE passkeys")
.await?;
fixture
.database
.execute(
&Schema::new(fixture.database.get_database_backend())
.create_table_from_entity(native::Entity),
)
.await?;
Ok(fixture)
}
async fn close(self) -> TestResult {
let Self { ctx, database } = self;
drop(ctx);
database.close().await?;
Ok(())
}
async fn route(&self, request: &AuthRequest) -> AuthResult<AuthResponse> {
route(&self.ctx, request).await
}
async fn stored(&self) -> TestResult<Passkey> {
Ok(self
.ctx
.database
.get_passkey_by_credential_id(&URL_SAFE_NO_PAD.encode(CREDENTIAL_ID))
.await?
.ok_or("Persisted Native credential is missing")?)
}
}
async fn route(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
request: &AuthRequest,
) -> AuthResult<AuthResponse> {
let ctx = ctx.initialize_request_context().await?;
let plugin = PasskeyPlugin::new()
.rp_id(RP_ID)
.rp_name("Passkey reopen")
.origin(ORIGIN);
let response = match request.path.as_str() {
"/passkey/generate-register-options" => {
plugin
.handle_generate_register_options(request, &ctx)
.await?
}
"/passkey/verify-registration" => plugin.handle_verify_registration(request, &ctx).await?,
"/passkey/generate-authenticate-options" => {
plugin
.handle_generate_authenticate_options(request, &ctx)
.await?
}
"/passkey/verify-authentication" => {
plugin.handle_verify_authentication(request, &ctx).await?
}
"/passkey/list-user-passkeys" => plugin.handle_list_user_passkeys(request, &ctx).await?,
"/passkey/update-passkey" => plugin.handle_update_passkey(request, &ctx).await?,
"/passkey/delete-passkey" => plugin.handle_delete_passkey(request, &ctx).await?,
_ => return Err(AuthError::internal("Unknown Passkey fixture route")),
};
Ok(test_helpers::finalize_response(&ctx, request, response))
}
fn request(
path: &str,
token: Option<&str>,
body: Option<Value>,
cookie: Option<&str>,
) -> AuthRequest {
let method = if body.is_some() {
HttpMethod::Post
} else {
HttpMethod::Get
};
let mut request = test_helpers::create_auth_json_request_no_query(method, path, token, body);
let _ = request.headers.insert("origin".into(), ORIGIN.into());
if let Some(cookie) = cookie {
let _ = request.headers.insert("cookie".into(), cookie.into());
}
request
}
fn options(response: AuthResponse) -> TestResult<(Value, String)> {
assert_eq!(response.status, 200);
let cookie = response
.headers
.get("Set-Cookie")
.and_then(|header| header.split(';').next())
.ok_or("Passkey options must set the challenge cookie")?
.to_owned();
Ok((serde_json::from_slice(&response.body.bytes()?)?, cookie))
}
fn challenge(options: &Value) -> TestResult<Vec<u8>> {
Ok(URL_SAFE_NO_PAD.decode(
options
.get("challenge")
.and_then(Value::as_str)
.ok_or("Missing challenge")?,
)?)
}
fn assert_challenge_error(result: AuthResult<AuthResponse>) -> TestResult {
assert_verification_error(
result,
"BAD_REQUEST",
400,
FieldMap::from([
("message".into(), "Challenge not found".into()),
("code".into(), "CHALLENGE_NOT_FOUND".into()),
]),
)?;
Ok(())
}
fn assert_authentication_error(result: AuthResult<AuthResponse>) -> TestResult {
assert_verification_error(
result,
"BAD_REQUEST",
400,
FieldMap::from([
("message".into(), "Authentication failed".into()),
("code".into(), "AUTHENTICATION_FAILED".into()),
]),
)?;
Ok(())
}
fn assert_login(response: AuthResponse, owner: &str) -> TestResult {
assert_eq!(response.status, 200);
let body: Value = serde_json::from_slice(&response.body.bytes()?)?;
assert_eq!(body["user"]["id"], owner);
assert_eq!(body["session"]["userId"], owner);
Ok(())
}
#[tokio::test]
async fn native_sqlite_credential_authenticates_after_reopen() -> TestResult {
let path = std::env::temp_dir().join(format!(
"better-auth-passkey-reopen-{}.sqlite",
uuid::Uuid::new_v4()
));
drop(std::fs::File::create_new(&path)?);
let fixture = Fixture::create(&path).await?;
let (owner, session) = test_helpers::create_user_and_session(
&fixture.ctx,
CreateUser::new()
.with_name("Owner")
.with_email("owner@passkey.example"),
fixture.ctx.config.session.expires_in(),
)
.await;
let (_, other_session) = test_helpers::create_user_and_session(
&fixture.ctx,
CreateUser::new()
.with_name("Other")
.with_email("other@passkey.example"),
fixture.ctx.config.session.expires_in(),
)
.await;
let authenticator = Authenticator::new()?;
let (registration, cookie) = options(
fixture
.route(&request(
"/passkey/generate-register-options",
Some(session.token.typed().unwrap()),
None,
None,
))
.await?,
)?;
let user_handle = URL_SAFE_NO_PAD.decode(
registration["user"]["id"]
.as_str()
.ok_or("Missing registration user handle")?,
)?;
let response = authenticator.register(&challenge(®istration)?, Some(TRANSPORTS))?;
let registered = fixture
.route(&request(
"/passkey/verify-registration",
Some(session.token.typed().unwrap()),
Some(json!({"response":response,"name":"Persisted authenticator"})),
Some(&cookie),
))
.await?;
assert_eq!(registered.status, 200);
let initial = fixture.stored().await?;
assert_eq!(initial.user_id, *owner.id.typed()?);
assert_eq!(initial.counter, 0);
assert_eq!(
STANDARD.decode(initial.public_key.typed()?)?,
authenticator.cose
);
assert!(initial.credential.is_undefined());
assert!(initial.updated_at.is_undefined());
let (pending, cookie) = options(
fixture
.route(&request(
"/passkey/generate-authenticate-options",
None,
None,
None,
))
.await?,
)?;
fixture.close().await?;
let fixture = Fixture::open(&path).await?;
assert_eq!(fixture.stored().await?, initial);
let signed = authenticator.authenticate(&challenge(&pending)?, 1, &user_handle)?;
let signed_body = json!({"response":signed});
assert_login(
fixture
.route(&request(
"/passkey/verify-authentication",
None,
Some(signed_body.clone()),
Some(&cookie),
))
.await?,
owner.id.typed()?,
)?;
let mut advanced = initial;
advanced.counter = 1.into();
assert_eq!(fixture.stored().await?, advanced);
for (path, body, expected) in [
(
"/passkey/update-passkey",
json!({"id":advanced.id,"name":"Hijacked"}),
Some(json!({
"code": "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY",
"message": "You are not allowed to register this passkey"
})),
),
("/passkey/delete-passkey", json!({"id":advanced.id}), None),
] {
let result = fixture
.route(&request(
path,
Some(other_session.token.typed().unwrap()),
Some(body),
None,
))
.await;
let error = match result {
Err(error) => error,
Ok(_) => return Err("A different owner changed the persisted credential".into()),
};
assert_eq!(error.status_code().unwrap(), 401);
let body = better_auth_core::ResponseBody::Native(error.body_value()?)
.bytes()?
.into_owned();
if let Some(expected) = expected {
assert_eq!(serde_json::from_slice::<Value>(&body)?, expected);
} else {
assert!(body.is_empty());
}
assert_eq!(fixture.stored().await?, advanced);
}
fixture.close().await?;
let fixture = Fixture::open(&path).await?;
assert_eq!(fixture.stored().await?, advanced);
assert_challenge_error(
fixture
.route(&request(
"/passkey/verify-authentication",
None,
Some(signed_body),
Some(&cookie),
))
.await,
)?;
for counter in [1, 2] {
let (options, cookie) = options(
fixture
.route(&request(
"/passkey/generate-authenticate-options",
None,
None,
None,
))
.await?,
)?;
let response = authenticator.authenticate(&challenge(&options)?, counter, &user_handle)?;
let login = fixture
.route(&request(
"/passkey/verify-authentication",
None,
Some(json!({"response":response})),
Some(&cookie),
))
.await;
if counter == 1 {
assert_authentication_error(login)?;
} else {
assert_login(login?, owner.id.typed()?)?;
advanced.counter = 2.into();
}
assert_eq!(fixture.stored().await?, advanced);
}
fixture.close().await?;
let fixture = Fixture::open(&path).await?;
assert_eq!(fixture.stored().await?, advanced);
fixture.close().await?;
std::fs::remove_file(path)?;
Ok(())
}
#[tokio::test]
async fn native_authentication_uses_projected_counter_relational_semantics() -> TestResult {
use better_auth_core::{
CreatePasskey, FieldValue, PasskeyCredentialState, UpdatePasskeyAuthentication,
store::schema::EntityRole,
user_fields::{FieldTransforms, UserConfig, UserFieldConfig, UserFieldTransform},
};
let path = std::env::temp_dir().join(format!(
"better-auth-passkey-counter-{}.sqlite",
uuid::Uuid::new_v4()
));
drop(std::fs::File::create_new(&path)?);
let mut fixture = Fixture::create(&path).await?;
let raw = fixture.ctx.database.clone();
let owner = raw
.create_user(
CreateUser::new()
.with_name("Counter owner")
.with_email("counter@passkey.example"),
)
.await?;
let authenticator = Authenticator::new()?;
let passkey = raw
.create_passkey(CreatePasskey {
additional_fields: Default::default(),
user_id: owner.id.clone(),
name: None.into(),
public_key: STANDARD.encode(&authenticator.cose),
credential_id: URL_SAFE_NO_PAD.encode(CREDENTIAL_ID),
counter: 5,
device_type: "singleDevice".into(),
backed_up: false,
transports: Some("internal,hybrid".into()),
credential: PasskeyCredentialState::Native,
aaguid: None.into(),
})
.await?;
for (stored_counter, counter, allowed) in [
(FieldValue::from("5"), 5, false),
("5".into(), 6, true),
(FieldValue::Null, 0, true),
(FieldValue::Undefined, 0, true),
(false.into(), 0, true),
(true.into(), 0, false),
((-1.0).into(), 0, true),
(f64::NAN.into(), 0, true),
] {
raw.update_passkey_authentication(
&passkey.id,
UpdatePasskeyAuthentication::Native { counter: 5 },
)
.await?;
let mut model_fields = better_auth_core::plugin_runtime::ModelFields::default();
model_fields.register_model_fields(
EntityRole::Passkey,
UserConfig {
additional_fields: Some(
[(
"counter".into(),
UserFieldConfig {
required: Some(false),
transform: Some(FieldTransforms {
input: None,
output: Some(UserFieldTransform::new(move |_| {
Ok(stored_counter.clone())
})),
}),
..Default::default()
},
)]
.into(),
),
},
)?;
fixture.ctx.database =
raw.with_runtime(fixture.ctx.config.clone(), Vec::new(), model_fields)?;
let (options, cookie) = options(
fixture
.route(&request(
"/passkey/generate-authenticate-options",
None,
None,
None,
))
.await?,
)?;
let response = authenticator.authenticate(
&challenge(&options)?,
counter,
owner.id.typed()?.as_bytes(),
)?;
let response = fixture
.route(&request(
"/passkey/verify-authentication",
None,
Some(json!({"response":response})),
Some(&cookie),
))
.await;
if allowed {
assert_login(response?, owner.id.typed()?)?;
} else {
assert_authentication_error(response)?;
}
let stored = raw
.get_passkey_by_id(passkey.id.typed()?)
.await?
.ok_or("Passkey disappeared")?;
assert_eq!(stored.counter, if allowed { u64::from(counter) } else { 5 });
}
drop(raw);
fixture.close().await?;
std::fs::remove_file(path)?;
Ok(())
}