use crate::errors::{CoreError, CoreResult};
use crate::time::SharedClock;
use chrono::Duration;
use sui_id_shared::ids::{UserId, WebauthnCredentialId, WebauthnPendingId};
use sui_id_store::models::{
UserWebauthnCredentialRow, WebauthnPendingKind, WebauthnPendingRow,
};
use sui_id_store::repos::{user_webauthn_credentials, users, webauthn_pending};
use sui_id_store::Database;
use webauthn_rs::prelude::{
Passkey, PasskeyAuthentication, PasskeyRegistration, PublicKeyCredential,
RegisterPublicKeyCredential, Webauthn, WebauthnBuilder,
};
pub async fn build(issuer_url: &str) -> CoreResult<Webauthn> {
let parsed = url::Url::parse(issuer_url)
.map_err(|_| CoreError::Internal)?;
let scheme = parsed.scheme();
let host = parsed.host_str().ok_or(CoreError::Internal)?;
let is_localhost = matches!(host, "localhost" | "127.0.0.1" | "::1");
if scheme != "https" && !(scheme == "http" && is_localhost) {
return Err(CoreError::ConfigError(format!(
"WebAuthn requires https, or http on localhost; \
got {scheme}://{host} — update server.public_url to an https URL \
(or use http://localhost for local development)"
)));
}
let rp_id = host.to_owned();
let mut origin_str = format!("{scheme}://{host}");
if let Some(port) = parsed.port() {
origin_str.push_str(&format!(":{port}"));
}
let origin = url::Url::parse(&origin_str).map_err(|_| CoreError::Internal)?;
let builder =
WebauthnBuilder::new(&rp_id, &origin).map_err(|_| CoreError::Internal)?;
let builder = builder.rp_name("sui-id");
builder.build().map_err(|_| CoreError::Internal)
}
const PENDING_TTL_SECS: i64 = 5 * 60;
pub struct RegistrationStart {
pub challenge_json: String,
pub pending_id: WebauthnPendingId,
}
pub async fn start_registration(
db: &Database,
clock: &SharedClock,
issuer_url: &str,
user_id: UserId,
) -> CoreResult<RegistrationStart> {
let webauthn = build(issuer_url).await?;
let user = users::get(db, user_id).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
let display = user
.display_name
.clone()
.unwrap_or_else(|| user.username.clone());
let exclude: Vec<webauthn_rs::prelude::CredentialID> =
user_webauthn_credentials::list_for_user(db, user_id).await?
.into_iter()
.map(|c| webauthn_rs::prelude::CredentialID::from(c.credential_id))
.collect();
let exclude = if exclude.is_empty() { None } else { Some(exclude) };
let (ccr, reg_state) = webauthn
.start_passkey_registration(user.user_uuid, &user.username, &display, exclude)
.map_err(|_| CoreError::Internal)?;
let state_json = serde_json::to_string(®_state).map_err(|_| CoreError::Internal)?;
let now = clock.now();
let pending = WebauthnPendingRow {
id: WebauthnPendingId::new(),
kind: WebauthnPendingKind::Register,
user_id: Some(user_id),
state_json,
expires_at: now + Duration::seconds(PENDING_TTL_SECS),
created_at: now,
};
webauthn_pending::insert(db, &pending).await?;
let challenge_json = serde_json::to_string(&ccr).map_err(|_| CoreError::Internal)?;
Ok(RegistrationStart {
challenge_json,
pending_id: pending.id,
})
}
pub async fn finish_registration(
db: &Database,
clock: &SharedClock,
issuer_url: &str,
pending_id: WebauthnPendingId,
user_id: UserId,
nickname: &str,
credential: &RegisterPublicKeyCredential,
) -> CoreResult<UserWebauthnCredentialRow> {
let webauthn = build(issuer_url).await?;
let pending = webauthn_pending::get(db, pending_id).await?
.ok_or(CoreError::Unauthenticated)?;
if pending.expires_at < clock.now()
|| pending.kind != WebauthnPendingKind::Register
|| pending.user_id != Some(user_id)
{
let _ = webauthn_pending::delete(db, pending_id).await;
return Err(CoreError::Unauthenticated);
}
let reg_state: PasskeyRegistration = serde_json::from_str(&pending.state_json)
.map_err(|_| CoreError::Internal)?;
let passkey = webauthn
.finish_passkey_registration(credential, ®_state)
.map_err(|_| CoreError::BadRequest("WebAuthn registration verification failed".into()))?;
let credential_id_bytes: Vec<u8> = passkey.cred_id().as_ref().to_vec();
let passkey_json = serde_json::to_vec(&passkey).map_err(|_| CoreError::Internal)?;
let now = clock.now();
let nickname = if nickname.trim().is_empty() {
"Passkey".to_string()
} else {
nickname.trim().to_string()
};
let row = UserWebauthnCredentialRow {
id: WebauthnCredentialId::new(),
user_id,
credential_id: credential_id_bytes,
passkey_enc: Vec::new(), nickname,
created_at: now,
last_used_at: None,
};
user_webauthn_credentials::create(db, &row, &passkey_json).await?;
let _ = webauthn_pending::delete(db, pending_id).await;
Ok(row)
}
pub struct AuthenticationStart {
pub challenge_json: String,
pub pending_id: WebauthnPendingId,
}
pub async fn start_authentication(
db: &Database,
clock: &SharedClock,
issuer_url: &str,
user_id: UserId,
) -> CoreResult<AuthenticationStart> {
let webauthn = build(issuer_url).await?;
let creds = user_webauthn_credentials::list_for_user(db, user_id).await?;
if creds.is_empty() {
return Err(CoreError::BadRequest(
"no WebAuthn credentials enrolled for this user".into(),
));
}
let mut passkeys: Vec<Passkey> = Vec::with_capacity(creds.len());
for c in &creds {
let blob = user_webauthn_credentials::decrypt_passkey(db, c).await?;
let pk: Passkey = serde_json::from_slice(&blob).map_err(|_| CoreError::Internal)?;
passkeys.push(pk);
}
let (rcr, auth_state) = webauthn
.start_passkey_authentication(&passkeys)
.map_err(|_| CoreError::Internal)?;
let state_json = serde_json::to_string(&auth_state).map_err(|_| CoreError::Internal)?;
let now = clock.now();
let pending = WebauthnPendingRow {
id: WebauthnPendingId::new(),
kind: WebauthnPendingKind::Authenticate,
user_id: Some(user_id),
state_json,
expires_at: now + Duration::seconds(PENDING_TTL_SECS),
created_at: now,
};
webauthn_pending::insert(db, &pending).await?;
let challenge_json = serde_json::to_string(&rcr).map_err(|_| CoreError::Internal)?;
Ok(AuthenticationStart {
challenge_json,
pending_id: pending.id,
})
}
pub async fn finish_authentication(
db: &Database,
clock: &SharedClock,
issuer_url: &str,
pending_id: WebauthnPendingId,
expected_user_id: UserId,
credential: &PublicKeyCredential,
) -> CoreResult<()> {
let webauthn = build(issuer_url).await?;
let pending = webauthn_pending::get(db, pending_id).await?
.ok_or(CoreError::Unauthenticated)?;
if pending.expires_at < clock.now()
|| pending.kind != WebauthnPendingKind::Authenticate
|| pending.user_id != Some(expected_user_id)
{
let _ = webauthn_pending::delete(db, pending_id).await;
return Err(CoreError::Unauthenticated);
}
let auth_state: PasskeyAuthentication = serde_json::from_str(&pending.state_json)
.map_err(|_| CoreError::Internal)?;
let result = webauthn
.finish_passkey_authentication(credential, &auth_state)
.map_err(|_| CoreError::Unauthenticated)?;
let row = user_webauthn_credentials::find_by_credential_id(db, result.cred_id().as_ref()).await?
.ok_or(CoreError::Unauthenticated)?;
if row.user_id != expected_user_id {
return Err(CoreError::Unauthenticated);
}
let mut passkey: Passkey = {
let blob = user_webauthn_credentials::decrypt_passkey(db, &row).await?;
serde_json::from_slice(&blob).map_err(|_| CoreError::Internal)?
};
let _changed = passkey.update_credential(&result);
let new_blob = serde_json::to_vec(&passkey).map_err(|_| CoreError::Internal)?;
user_webauthn_credentials::update_passkey(db, row.id, &new_blob).await?;
let _ = webauthn_pending::delete(db, pending_id).await;
Ok(())
}
pub struct CredentialDescriptor {
pub id: WebauthnCredentialId,
pub nickname: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
}
pub async fn list_for_user(db: &Database, user_id: UserId) -> CoreResult<Vec<CredentialDescriptor>> {
Ok(user_webauthn_credentials::list_for_user(db, user_id).await?
.into_iter()
.map(|r| CredentialDescriptor {
id: r.id,
nickname: r.nickname,
created_at: r.created_at,
last_used_at: r.last_used_at,
})
.collect())
}
pub async fn delete(db: &Database, user_id: UserId, credential_id: WebauthnCredentialId) -> CoreResult<()> {
user_webauthn_credentials::delete(db, credential_id, user_id).await.map_err(|e| match e {
sui_id_store::StoreError::NotFound => CoreError::NotFound,
other => CoreError::from(other),
})?;
Ok(())
}
pub async fn has_credentials(db: &Database, user_id: UserId) -> CoreResult<bool> {
Ok(user_webauthn_credentials::count_for_user(db, user_id).await? > 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn build_accepts_https_url() {
let w = build("https://idp.example/").await.expect("build");
let _ = w; }
#[tokio::test]
async fn build_accepts_https_with_port() {
let w = build("https://idp.example:8443/").await.expect("build");
let _ = w;
}
#[tokio::test]
async fn build_accepts_localhost_http() {
let w = build("http://localhost:8080/").await.expect("localhost http");
let _ = w;
}
#[tokio::test]
async fn build_accepts_127_0_0_1_http() {
let r = build("http://127.0.0.1:8801/").await;
match r {
Ok(_) => {} Err(CoreError::ConfigError(_)) => {
panic!("127.0.0.1 http must not be rejected by our transport guard (RFC 011)")
}
Err(_) => {} }
}
#[tokio::test]
async fn build_rejects_http_on_public_host() {
let r = build("http://idp.example/").await;
assert!(
r.is_err(),
"http on a non-localhost host must be rejected (RFC 011)"
);
let err = r.unwrap_err();
assert!(
matches!(err, CoreError::ConfigError(_)),
"expected ConfigError, got: {err}"
);
}
#[tokio::test]
async fn build_rejects_url_without_host() {
let r = build("file:///etc/passwd").await;
assert!(r.is_err());
}
}
#[cfg(test)]
mod integration_tests {
use super::*;
use crate::time::system_clock;
use sui_id_store::crypto::MasterKey;
use sui_id_store::models::UserRow;
use sui_id_store::repos::{users, webauthn_pending};
use sui_id_store::Database;
async fn fresh_db_with_user() -> (Database, UserId) {
let key = MasterKey::generate();
let db = Database::open_in_memory(key).expect("db");
let uid = UserId::new();
users::create(
&db,
&UserRow {
id: uid,
username: "alice".into(),
display_name: None,
is_admin: true,
role: if true { sui_id_store::models::Role::Admin } else { sui_id_store::models::Role::User },
is_disabled: false,
is_deleted: false,
user_uuid: uuid::Uuid::new_v4(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
failed_login_count: 0,
locked_until: None,
email: None,
preferred_lang: None,
email_normalized: None,
email_verified_at: None,
},
).await
.expect("insert user");
(db, uid)
}
#[tokio::test]
async fn start_registration_persists_pending_row_and_returns_challenge_json() {
let (db, uid) = fresh_db_with_user().await;
let clock = system_clock();
let started =
start_registration(&db, &clock, "https://idp.example", uid).await.expect("start");
let row = webauthn_pending::get(&db, started.pending_id).await
.expect("get")
.expect("present");
assert_eq!(
row.kind,
sui_id_store::models::WebauthnPendingKind::Register
);
assert_eq!(row.user_id, Some(uid));
let v: serde_json::Value =
serde_json::from_str(&started.challenge_json).expect("json");
assert!(v.get("publicKey").is_some(), "got: {v}");
}
#[tokio::test]
async fn start_authentication_rejects_users_with_no_credentials() {
let (db, uid) = fresh_db_with_user().await;
let clock = system_clock();
let r = start_authentication(&db, &clock, "https://idp.example", uid).await;
assert!(matches!(r, Err(crate::errors::CoreError::BadRequest(_))));
}
#[tokio::test]
async fn finish_registration_rejects_expired_pending_row() {
use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
let (db, uid) = fresh_db_with_user().await;
let clock = system_clock();
let now = clock.now();
let pending_id = sui_id_shared::ids::WebauthnPendingId::new();
webauthn_pending::insert(
&db,
&WebauthnPendingRow {
id: pending_id,
kind: WebauthnPendingKind::Register,
user_id: Some(uid),
state_json: "{}".into(),
expires_at: now - chrono::Duration::seconds(1),
created_at: now - chrono::Duration::seconds(601),
},
).await
.expect("insert");
let dummy: webauthn_rs::prelude::RegisterPublicKeyCredential = serde_json::from_str(
r#"{"id":"AA","rawId":"AA","type":"public-key","response":{"attestationObject":"AA","clientDataJSON":"AA"},"extensions":{}}"#,
)
.expect("parse dummy");
let r = finish_registration(
&db,
&clock,
"https://idp.example",
pending_id,
uid,
"test",
&dummy,
).await;
assert!(matches!(
r,
Err(crate::errors::CoreError::Unauthenticated)
));
}
}