diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs index ae6b18d..86812ae 100644 --- a/backend/src/auth/jwt.rs +++ b/backend/src/auth/jwt.rs @@ -17,6 +17,8 @@ pub struct JwtService { download_expiry: Duration, selector_audience: String, selector_expiry: Duration, + signup_audience: String, + signup_expiry: Duration, } impl JwtService { @@ -31,6 +33,8 @@ impl JwtService { download_expiry: Duration::minutes(config.download_token_expiry_minutes), selector_audience: format!("{}:tenant-selector", config.jwt_audience), selector_expiry: Duration::minutes(15), + signup_audience: format!("{}:signup", config.jwt_audience), + signup_expiry: Duration::minutes(15), }) } @@ -108,6 +112,35 @@ impl JwtService { let data = decode::(token, &self.decoding, &validation)?; Ok(data.claims) } + + pub fn generate_signup_token( + &self, + user_id: Uuid, + challenge_id: Uuid, + username: String, + ) -> Result { + let now = Utc::now(); + let exp = now + self.signup_expiry; + let claims = SignupClaims { + sub: user_id, + challenge_id, + username, + iss: self.issuer.clone(), + aud: self.signup_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_signup_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.signup_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -140,3 +173,14 @@ pub struct TenantSelectionClaims { pub iat: usize, pub exp: usize, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignupClaims { + pub sub: Uuid, + pub challenge_id: Uuid, + pub username: String, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs index 907b924..35d0548 100644 --- a/backend/src/auth/passkeys.rs +++ b/backend/src/auth/passkeys.rs @@ -25,6 +25,32 @@ pub struct PasskeyService { challenge_ttl: ChronoDuration, } +pub struct PreparedPasskey { + pub id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, +} + +impl PreparedPasskey { + pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option) -> NewUserPasskey { + NewUserPasskey { + id: self.id, + user_id, + credential_id: self.credential_id, + public_key: self.public_key, + credential: self.credential, + sign_count: self.sign_count, + transports: self.transports, + aaguid: self.aaguid, + nickname, + } + } +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RegistrationChallengeResponse { @@ -147,6 +173,48 @@ impl PasskeyService { }) } + pub fn start_signup_registration( + &self, + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + ) -> AppResult { + self.prune_expired(conn); + + let (challenge, state) = self + .webauthn + .start_passkey_registration(user_id, username, username, None) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey registration"); + AppError::internal("failed to start passkey registration") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode passkey registration state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: None, + purpose: PURPOSE_REGISTRATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(RegistrationChallengeResponse { + challenge_id, + challenge, + }) + } + pub fn finish_registration( &self, conn: &mut PgConnection, @@ -325,11 +393,7 @@ impl PasskeyService { Ok(passkeys.into_iter().map(PasskeySummary::from).collect()) } - pub fn active_passkey_count( - &self, - conn: &mut PgConnection, - user_id: Uuid, - ) -> AppResult { + pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult { let count: i64 = passkey_dsl::user_passkeys .filter(passkey_dsl::user_id.eq(user_id)) .filter(passkey_dsl::revoked_at.is_null()) @@ -338,6 +402,92 @@ impl PasskeyService { Ok(count) } + pub fn consume_signup_challenge( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: &RegisterPublicKeyCredential, + ) -> AppResult { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_REGISTRATION { + return Err(AppError::bad_request("challenge is not for registration")); + } + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyRegistration = serde_json::from_slice(&record.state) + .context("failed to decode registration state") + .map_err(AppError::internal)?; + + let passkey = self + .webauthn + .finish_passkey_registration(credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey registration validation failed"); + AppError::bad_request("invalid passkey attestation") + })?; + + let credential_struct: Credential = passkey.clone().into(); + let credential_id_vec: Vec = credential_struct.cred_id.clone().into(); + + let duplicate = passkey_dsl::user_passkeys + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .first::(conn) + .optional()?; + if duplicate.is_some() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::conflict("credential already registered")); + } + + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let aaguid = match credential_struct.attestation.metadata { + AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => { + Some(aaguid) + } + _ => None, + }; + + let credential_json = serde_json::to_value(&passkey) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + Ok(PreparedPasskey { + id: Uuid::new_v4(), + credential_id: credential_id_vec, + public_key: public_key_bytes, + credential: credential_json, + sign_count: credential_struct.counter as i64, + transports, + aaguid, + }) + } + pub fn revoke_passkey( &self, conn: &mut PgConnection, diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 49ce63e..3bfeb72 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -8,7 +8,8 @@ use uuid::Uuid; paths( doc::health_check, doc::login, - doc::signup, + doc::signup_start, + doc::signup_finish, doc::refresh, doc::logout, doc::me, @@ -60,7 +61,9 @@ use uuid::Uuid; components( schemas( schemas::LoginRequest, - schemas::SignupRequest, + schemas::SignupStartRequest, + schemas::SignupStartResponse, + schemas::SignupFinishRequest, schemas::AccessTokenResponse, schemas::TenantSnippet, schemas::TenantSelectionResponse, @@ -167,16 +170,29 @@ mod doc { #[utoipa::path( post, - path = "/api/auth/signup", - request_body = SignupRequest, + path = "/api/auth/signup/start", + request_body = SignupStartRequest, responses( - (status = 200, description = "Signup succeeded", body = LoginResponseVariants), + (status = 200, description = "Signup challenge created", body = SignupStartResponse), (status = 400, description = "Invalid signup request"), (status = 409, description = "Username already exists") ), tag = "Auth" )] - pub(super) fn signup() {} + pub(super) fn signup_start() {} + + #[utoipa::path( + post, + path = "/api/auth/signup/finish", + request_body = SignupFinishRequest, + responses( + (status = 200, description = "Signup completed", body = LoginResponseVariants), + (status = 400, description = "Invalid signup completion"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" + )] + pub(super) fn signup_finish() {} #[utoipa::path( post, @@ -678,6 +694,7 @@ pub mod schemas { AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, }; + use webauthn_rs::prelude::RegisterPublicKeyCredential; #[derive(Serialize, Deserialize, ToSchema)] pub struct LoginRequest { @@ -688,9 +705,22 @@ pub mod schemas { } #[derive(Serialize, Deserialize, ToSchema)] - pub struct SignupRequest { + pub struct SignupStartRequest { pub username: String, - pub password: String, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct SignupStartResponse { + pub signup_token: String, + pub challenge: RegistrationChallengeResponse, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct SignupFinishRequest { + pub signup_token: String, + pub credential: RegisterPublicKeyCredential, + #[schema(nullable)] + pub nickname: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 035cd1a..012dbc1 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -27,12 +27,13 @@ use crate::{ models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, schema::{ refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, - users::dsl, + user_passkeys::dsl as passkey_dsl, users::dsl, }, state::AppState, }; use crate::schema::refresh_tokens::dsl as refresh_dsl; +use webauthn_rs::prelude::RegisterPublicKeyCredential; const REFRESH_COOKIE_NAME: &str = "refresh_token"; @@ -44,13 +45,7 @@ pub struct LoginRequest { pub preferred_tenant_id: Option, } -#[derive(Deserialize)] -pub struct SignupRequest { - pub username: String, - pub password: String, -} - -#[derive(Serialize)] +#[derive(Deserialize, Serialize)] pub struct LoginResponse { pub access_token: String, pub token_type: String, @@ -58,7 +53,7 @@ pub struct LoginResponse { pub tenant: TenantSnippet, } -#[derive(Serialize)] +#[derive(Serialize, Deserialize)] pub struct TenantSnippet { pub id: Uuid, pub name: String, @@ -80,48 +75,22 @@ pub struct TenantSelectionRequest { pub tenant_id: Uuid, } -pub async fn signup( - State(state): State, - Json(payload): Json, -) -> AppResult { - let username = payload.username.trim(); - let password = payload.password.trim(); +#[derive(Deserialize)] +pub struct SignupStartRequest { + pub username: String, +} - if username.is_empty() || password.is_empty() { - return Err(AppError::bad_request( - "username and password must not be empty", - )); - } +#[derive(Serialize)] +pub struct SignupStartResponse { + pub signup_token: String, + pub challenge: RegistrationChallengeResponse, +} - let mut conn = state.db_unscoped()?; - - let exists: bool = dsl::users - .filter(dsl::username.eq(username)) - .first::(&mut conn) - .optional()? - .is_some(); - if exists { - return Err(AppError::conflict("username already exists")); - } - - let user_id = Uuid::new_v4(); - let password_hash = password::hash_password(password)?; - - insert_user(&mut conn, user_id, username, &password_hash)?; - - let tenant_name = username.to_string(); - let tenant = state.tenants.create_tenant( - &tenant_name, - None, - None, - TenantStatus::Creating, - &[user_id], - Some(user_id), - )?; - - let user: User = dsl::users.find(user_id).first(&mut conn)?; - - issue_session(&state, &mut conn, &user, tenant.id) +#[derive(Deserialize)] +pub struct SignupFinishRequest { + pub signup_token: String, + pub credential: RegisterPublicKeyCredential, + pub nickname: Option, } pub async fn login( @@ -150,6 +119,102 @@ pub async fn login( complete_login(&state, &mut conn, &user, payload.preferred_tenant_id) } +pub async fn signup_start( + State(state): State, + Json(payload): Json, +) -> AppResult> { + let username = payload.username.trim(); + if username.is_empty() { + return Err(AppError::bad_request("username must not be empty")); + } + + let mut conn = state.db_unscoped()?; + let exists: bool = dsl::users + .filter(dsl::username.eq(username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let user_id = Uuid::new_v4(); + let challenge = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))? + .start_signup_registration(&mut conn, user_id, username)?; + + let signup_token = state + .jwt + .generate_signup_token(user_id, challenge.challenge_id, username.to_owned()) + .map_err(AppError::from)?; + + Ok(Json(SignupStartResponse { + signup_token, + challenge, + })) +} + +pub async fn signup_finish( + State(state): State, + Json(payload): Json, +) -> AppResult { + let claims = state + .jwt + .verify_signup_token(&payload.signup_token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = state.db_unscoped()?; + + let exists: bool = dsl::users + .filter(dsl::username.eq(&claims.username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let service = state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let prepared_passkey = + service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?; + + let state_clone = state.clone(); + let response = conn.transaction::(|conn| { + let password_seed = format!("passkey-only-{}", claims.sub); + let password_hash = password::hash_password(&password_seed)?; + insert_user(conn, claims.sub, &claims.username, &password_hash)?; + + let tenant = state_clone.tenants.create_tenant_with_conn( + conn, + &claims.username, + None, + None, + TenantStatus::Creating, + &[claims.sub], + Some(claims.sub), + )?; + + let passkey_insert = + prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&passkey_insert) + .execute(conn) + .map_err(AppError::from)?; + + let user: User = dsl::users.find(claims.sub).first(conn)?; + issue_session(&state_clone, conn, &user, tenant.id) + })?; + + Ok(response) +} + pub async fn refresh( State(state): State, jar: Option>, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 47b76df..db3200e 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -54,7 +54,8 @@ pub fn create_router(state: AppState) -> Router<()> { }; let auth_routes = Router::new() - .route("/signup", post(auth::signup)) + .route("/signup/start", post(auth::signup_start)) + .route("/signup/finish", post(auth::signup_finish)) .route("/login", post(auth::login)) .route("/refresh", post(auth::refresh)) .route("/logout", post(auth::logout)) diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index fc97ac7..4fe9fa5 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -55,15 +55,46 @@ impl TenantService { initial_members: &[Uuid], created_by: Option, ) -> AppResult { - let name = name.trim(); - if name.is_empty() { - return Err(AppError::bad_request("tenant name must not be empty")); - } - let mut conn = self.pool.get().map_err(|err| { tracing::error!(error = ?err, "database pool error"); AppError::internal("database pool error") })?; + self.create_tenant_with_conn( + &mut conn, + name, + storage_root, + quickwit_index, + status, + initial_members, + created_by, + ) + } + + fn load(&self, loader: F) -> AppResult + where + F: FnOnce(&mut PgConnection) -> AppResult, + { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + let tenant = loader(&mut conn)?; + Ok(tenant) + } + pub fn create_tenant_with_conn( + &self, + conn: &mut PgConnection, + name: &str, + storage_root: Option<&str>, + quickwit_index: Option<&str>, + status: TenantStatus, + initial_members: &[Uuid], + created_by: Option, + ) -> AppResult { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::bad_request("tenant name must not be empty")); + } let id = Uuid::new_v4(); let storage_root = normalize_storage_root(storage_root, id); @@ -79,32 +110,20 @@ impl TenantService { dsl::status.eq(status), dsl::created_by.eq(created_by), )) - .execute(&mut conn)?; + .execute(conn)?; if status == TenantStatus::Creating { let payload = json!({ "members": initial_members, }); - enqueue_job(&mut conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| { + enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| { tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job"); AppError::internal("failed to enqueue tenant provisioning job") })?; } - TenantRepository::get_by_id(&mut conn, id) - } - - fn load(&self, loader: F) -> AppResult - where - F: FnOnce(&mut PgConnection) -> AppResult, - { - let mut conn = self.pool.get().map_err(|err| { - tracing::error!(error = ?err, "database pool error"); - AppError::internal("database pool error") - })?; - let tenant = loader(&mut conn)?; - Ok(tenant) + TenantRepository::get_by_id(conn, id) } } diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 2106c67..0fa2431 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -7,10 +7,9 @@ use backend::auth::passkeys::{ PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, RegistrationChallengeResponse, }; -use backend::jobs::JOB_PROVISION_TENANT; -use backend::models::{Job, NewUserMembership, TenantStatus, UserPasskey}; +use backend::models::{NewUserMembership, TenantStatus, UserPasskey}; use backend::openapi::schemas::PasskeySummary; -use backend::schema::{jobs, tenants, user_memberships, users}; +use backend::schema::{tenants, user_memberships, users}; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; use serde::Deserialize; @@ -43,6 +42,12 @@ struct LoginResponse { tenant: LoginTenant, } +#[derive(Deserialize)] +struct SignupStartResponse { + signup_token: String, + challenge: RegistrationChallengeResponse, +} + #[derive(Deserialize)] struct TenantSelectionResponse { access_token: String, @@ -93,72 +98,40 @@ async fn login_rejects_unknown_user() -> Result<()> { } #[tokio::test] -async fn signup_creates_user_tenant_and_membership() -> Result<()> { +async fn signup_start_and_finish_require_valid_passkey() -> Result<()> { let _lock = acquire_db_lock().await; let app = TestApp::new().await?; - let payload = json!({ - "username": "signup-user", - "password": "super-secret", - }); + let payload = json!({ "username": "signup-user" }); - let response = app.post_json("/api/auth/signup", &payload, None).await?; + let response = app + .post_json("/api/auth/signup/start", &payload, None) + .await?; assert_eq!(response.status(), StatusCode::OK); let body = body_to_vec(response.into_body()).await?; - let login: LoginResponse = serde_json::from_slice(&body)?; + let start: SignupStartResponse = serde_json::from_slice(&body)?; + assert!(!start.signup_token.is_empty()); + assert_ne!(start.challenge.challenge_id, Uuid::nil()); - // session works immediately - let me = app.get("/api/auth/me", Some(&login.access_token)).await?; - assert_eq!(me.status(), StatusCode::OK); - - app.with_conn(move |conn| { - let user: backend::models::User = users::table - .filter(users::username.eq("signup-user")) - .first(conn)?; - - let tenant: backend::models::Tenant = tenants::table - .filter(tenants::name.eq("signup-user")) - .first(conn)?; - - assert_eq!(tenant.status, TenantStatus::Creating); - assert_eq!(tenant.created_by, Some(user.id)); - - let membership_exists: bool = diesel::select(diesel::dsl::exists( - user_memberships::table - .filter(user_memberships::user_id.eq(user.id)) - .filter(user_memberships::tenant_id.eq(tenant.id)), + app.with_conn(|conn| { + let exists: bool = diesel::select(diesel::dsl::exists( + users::table.filter(users::username.eq("signup-user")), )) .get_result(conn)?; - assert!( - !membership_exists, - "membership should be enqueued, not created synchronously" - ); - - let job: Job = jobs::table - .filter(jobs::tenant_id.eq(tenant.id)) - .filter(jobs::job_type.eq(JOB_PROVISION_TENANT)) - .first(conn) - .context("provision job missing")?; - - let members = job - .payload - .get("members") - .and_then(|value| value.as_array()) - .cloned() - .unwrap_or_default(); - - assert!(members.iter().any(|value| { - value - .as_str() - .and_then(|id| Uuid::parse_str(id).ok()) - .map(|parsed| parsed == user.id) - .unwrap_or(false) - })); - + assert!(!exists); Ok(()) }) .await?; + let finish_payload = json!({ + "signup_token": start.signup_token, + "credential": fake_register_credential(), + }); + let finish_response = app + .post_json("/api/auth/signup/finish", &finish_payload, None) + .await?; + assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST); + app.cleanup().await?; Ok(()) } @@ -329,6 +302,7 @@ async fn delete_passkey_soft_revokes() -> Result<()> { 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?; + app.insert_passkey(user_id, Some("Backup")).await?; let (session, _) = login_with_session(&app, "passkey-delete", password).await?; let response = app @@ -355,6 +329,37 @@ async fn delete_passkey_soft_revokes() -> Result<()> { Ok(()) } +#[tokio::test] +async fn delete_passkey_prevents_last() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app.insert_user("passkey-guard", password, "admin").await?; + let first_id = app.insert_passkey(user_id, Some("Key A")).await?; + let last_id = app.insert_passkey(user_id, Some("Key B")).await?; + let (session, _) = login_with_session(&app, "passkey-guard", password).await?; + + let response = app + .delete( + &format!("/api/profile/passkeys/{}", first_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let block_response = app + .delete( + &format!("/api/profile/passkeys/{}", last_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(block_response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + fn fake_register_credential() -> RegisterPublicKeyCredential { RegisterPublicKeyCredential { id: "fake-passkey".to_string(), diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 4514b7c..86071cd 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -240,12 +240,14 @@ impl TestApp { let passkey_id = Uuid::new_v4(); let nickname = nickname.map(|value| value.to_string()); self.with_conn(move |conn| { + let credential_id = passkey_id.as_bytes().to_vec(); + let public_key = passkey_id.as_bytes().iter().copied().collect::>(); let passkey = NewUserPasskey { id: passkey_id, user_id, - credential_id: vec![1, 2, 3], - public_key: vec![4, 5, 6], - credential: json!({ "dummy": true }), + credential_id, + public_key, + credential: json!({ "dummy": passkey_id.to_string() }), sign_count: 0, transports: vec![Some("usb".to_string())], aaguid: None,