feat(auth): add WebAuthn passkey support with migrations, routes, and tests

This commit is contained in:
2025-10-29 17:50:14 +01:00
parent ec6e7f04f2
commit ca30b20873
23 changed files with 1511 additions and 83 deletions
+232 -3
View File
@@ -3,14 +3,23 @@ mod common;
use anyhow::{anyhow, Context, Result};
use axum::body::Body;
use axum::http::{header::SET_COOKIE, StatusCode};
use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use backend::jobs::JOB_PROVISION_TENANT;
use backend::models::{Job, NewUserMembership, TenantStatus};
use backend::models::{Job, NewUserMembership, TenantStatus, UserPasskey};
use backend::openapi::schemas::PasskeySummary;
use backend::schema::{jobs, tenants, user_memberships, users};
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use webauthn_rs_core::proto::{
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
RegisterPublicKeyCredential,
};
#[derive(Deserialize)]
struct AuthenticatedUser {
@@ -101,7 +110,7 @@ async fn signup_creates_user_tenant_and_membership() -> Result<()> {
let me = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(me.status(), StatusCode::OK);
app.with_conn(|conn| {
app.with_conn(move |conn| {
let user: backend::models::User = users::table
.filter(users::username.eq("signup-user"))
.first(conn)?;
@@ -153,6 +162,227 @@ async fn signup_creates_user_tenant_and_membership() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn passkey_register_start_creates_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-user", password, "admin").await?;
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
let response = app
.post_json(
"/api/auth/passkeys/register/start",
&json!({}),
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
assert_ne!(challenge.challenge_id, Uuid::nil());
let challenge_id = challenge.challenge_id;
app.with_conn(move |conn| {
use backend::schema::webauthn_challenges::dsl;
use diesel::dsl::{exists, select};
let exists: bool = select(exists(
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
))
.get_result(conn)?;
assert!(exists, "challenge not persisted");
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-register", password, "admin")
.await?;
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
let payload = PasskeyRegistrationFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_register_credential(),
nickname: None,
};
let response = app
.post_json(
"/api/auth/passkeys/register/finish",
&payload,
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_requires_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-login", password, "admin").await?;
let payload = PasskeyLoginStartPayload {
username: "passkey-login".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginStartPayload {
username: "nobody".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_authentication_credential(),
};
let response = app
.post_json("/api/auth/passkeys/login/finish", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn list_passkeys_returns_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
app.insert_passkey(user_id, Some("Laptop")).await?;
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
let response = app
.get("/api/profile/passkeys", Some(&session.access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
assert!(summaries[0].revoked_at.is_none());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_passkey_soft_revokes() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
app.with_conn(move |conn| {
use backend::schema::user_passkeys::dsl as passkey_dsl;
let record = passkey_dsl::user_passkeys
.find(passkey_id)
.first::<UserPasskey>(conn)?;
assert!(record.revoked_at.is_some());
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
fn fake_register_credential() -> RegisterPublicKeyCredential {
RegisterPublicKeyCredential {
id: "fake-passkey".to_string(),
raw_id: vec![1, 2, 3, 4].into(),
response: AuthenticatorAttestationResponseRaw {
attestation_object: vec![5, 6, 7, 8].into(),
client_data_json: vec![9, 10, 11, 12].into(),
transports: None,
},
type_: "public-key".to_string(),
extensions: Default::default(),
}
}
fn fake_authentication_credential() -> PublicKeyCredential {
PublicKeyCredential {
id: "fake-auth".to_string(),
raw_id: vec![1, 2, 3].into(),
response: AuthenticatorAssertionResponseRaw {
authenticator_data: vec![4, 5, 6].into(),
client_data_json: vec![7, 8, 9].into(),
signature: vec![10, 11, 12].into(),
user_handle: None,
},
extensions: Default::default(),
type_: "public-key".to_string(),
}
}
#[tokio::test]
async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await;
@@ -278,7 +508,6 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
id: Uuid::new_v4(),
user_id,
tenant_id: secondary_id,
role: "admin".to_string(),
};
diesel::insert_into(user_memberships::table)
+32 -5
View File
@@ -11,7 +11,7 @@ use axum::Router;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db::{self, PgPool};
use backend::models::{Job, NewUser, NewUserMembership, Tenant, TenantStatus};
use backend::models::{Job, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus};
use backend::routes;
use backend::state::AppState;
use backend::storage::ObjectStorage;
@@ -24,7 +24,7 @@ use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use serde_json;
use serde_json::{self, json};
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use uuid::Uuid;
@@ -142,6 +142,9 @@ impl TestApp {
quickwit_index: None,
default_tenant_slug: "admin".to_string(),
worker_max_document_bytes: 200 * 1024 * 1024,
webauthn_rp_id: Some("localhost".to_string()),
webauthn_origin: Some("http://localhost".to_string()),
webauthn_rp_name: "Papercrate".to_string(),
};
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
@@ -199,10 +202,9 @@ impl TestApp {
Ok(format!("{}{}", root, key))
}
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
pub async fn insert_user(&self, username: &str, password: &str, _role: &str) -> Result<Uuid> {
let username = username.to_string();
let password = password.to_string();
let role = role.to_string();
let tenant_id = self
.state
.tenants
@@ -224,7 +226,6 @@ impl TestApp {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
role,
};
diesel::insert_into(backend::schema::user_memberships::table)
@@ -236,6 +237,32 @@ impl TestApp {
.await
}
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
let passkey_id = Uuid::new_v4();
let nickname = nickname.map(|value| value.to_string());
self.with_conn(move |conn| {
let passkey = NewUserPasskey {
id: passkey_id,
user_id,
credential_id: vec![1, 2, 3],
public_key: vec![4, 5, 6],
credential: json!({ "dummy": true }),
sign_count: 0,
transports: vec![Some("usb".to_string())],
aaguid: None,
nickname,
};
diesel::insert_into(backend::schema::user_passkeys::table)
.values(&passkey)
.execute(conn)
.context("failed to insert passkey")?;
Ok(passkey_id)
})
.await
}
async fn ensure_default_tenant(&self) -> Result<Uuid> {
let slug_value = self.state.config.default_tenant_slug.clone();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
-1
View File
@@ -231,7 +231,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
id: Uuid::new_v4(),
user_id: user_b_id,
tenant_id: tenant_b_id,
role: "admin".to_string(),
};
diesel::insert_into(memberships_dsl::user_memberships)
.values(&membership)