feat: add WebAuthn Conditional UI for passkey autofill

Enables password managers (1Password, iCloud Keychain) to offer passkey
suggestions via autofill on the login page, matching behavior of other
passkey-enabled websites.
This commit is contained in:
Jon Seager 2026-02-15 11:38:53 +00:00
parent 02235426c4
commit 5a0321b226
No known key found for this signature in database
6 changed files with 264 additions and 2 deletions

View file

@ -38,7 +38,7 @@ tower-http = { version = "0.6", features = ["compression-gzip", "limit", "set-he
slug = "0.1.6"
url = "2"
uuid = { version = "1", features = ["v4"] }
webauthn-rs = "0.5"
webauthn-rs = { version = "0.5", features = ["conditional-ui"] }
webauthn-rs-proto = "0.5"
kamadak-exif = "0.6.1"

View file

@ -483,6 +483,135 @@ pub(crate) async fn passkey_add_finish(
Ok(StatusCode::OK)
}
// --- Discoverable authentication (Conditional UI) ---
#[tracing::instrument(skip(state))]
pub(crate) async fn discoverable_auth_start(
State(state): State<AppState>,
) -> Result<Json<AuthStartResponse>, StatusCode> {
let (rcr, auth_state) = state
.webauthn
.start_discoverable_authentication()
.map_err(|err| {
error!(error = %err, "failed to start discoverable authentication");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let challenge_id = generate_session_token();
state
.challenge_store
.store_discoverable_authentication(challenge_id.clone(), auth_state)
.await;
Ok(Json(AuthStartResponse {
challenge_id,
options: rcr,
}))
}
#[tracing::instrument(skip(state, cookies, payload))]
pub(crate) async fn discoverable_auth_finish(
State(state): State<AppState>,
cookies: Cookies,
Json(payload): Json<AuthFinishRequest>,
) -> Result<Json<AuthFinishResponse>, StatusCode> {
let auth_state = state
.challenge_store
.take_discoverable_authentication(&payload.challenge_id)
.await
.ok_or(StatusCode::BAD_REQUEST)?;
// Extract user UUID and credential ID from the credential
let (user_uuid, _credential_id) = state
.webauthn
.identify_discoverable_authentication(&payload.credential)
.map_err(|err| {
warn!(error = %err, "failed to identify discoverable credential");
StatusCode::UNAUTHORIZED
})?;
// Look up user by UUID
let user = state
.user_repo
.get_by_uuid(&user_uuid.to_string())
.await
.map_err(|err| {
warn!(error = %err, uuid = %user_uuid, "user not found for discoverable auth");
StatusCode::UNAUTHORIZED
})?;
// Load user's passkeys and convert to DiscoverableKey
let credentials = state
.passkey_repo
.list_by_user(user.id)
.await
.map_err(|err| {
error!(error = %err, user_id = %user.id, "failed to list passkeys for discoverable auth");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut discoverable_keys: Vec<DiscoverableKey> = Vec::new();
for cred in &credentials {
let passkey: Passkey = serde_json::from_str(&cred.credential_json).map_err(|err| {
error!(error = %err, credential_id = %cred.id, "failed to deserialize passkey for discoverable auth");
StatusCode::INTERNAL_SERVER_ERROR
})?;
discoverable_keys.push(DiscoverableKey::from(&passkey));
}
// Complete discoverable authentication
let auth_result = state
.webauthn
.finish_discoverable_authentication(&payload.credential, auth_state, &discoverable_keys)
.map_err(|err| {
warn!(error = %err, "discoverable authentication failed");
StatusCode::UNAUTHORIZED
})?;
// Update credential counter if needed
let credential_id = auth_result.cred_id();
for cred in &credentials {
let passkey: Passkey = match serde_json::from_str(&cred.credential_json) {
Ok(p) => p,
Err(_) => continue,
};
if passkey.cred_id() == credential_id && auth_result.needs_update() {
let mut updated_passkey = passkey;
updated_passkey.update_credential(&auth_result);
match serde_json::to_string(&updated_passkey) {
Ok(updated_json) => {
if let Err(err) = state
.passkey_repo
.update_credential_json(cred.id, &updated_json)
.await
{
warn!(error = %err, credential_id = %cred.id, "failed to update passkey credential counter");
}
}
Err(err) => {
warn!(error = %err, "failed to serialize updated passkey credential");
}
}
// Update last used timestamp
let passkey_repo = state.passkey_repo.clone();
let cred_id = cred.id;
tokio::spawn(async move {
if let Err(err) = passkey_repo.update_last_used(cred_id).await {
warn!(error = %err, credential_id = %cred_id, "failed to update passkey last_used");
}
});
break;
}
}
create_session(&state, &cookies, user.id).await;
info!(user_id = %user.id, "user authenticated via discoverable passkey");
Ok(Json(AuthFinishResponse { redirect: None }))
}
// --- Helpers ---
/// Reject CLI callback URLs that don't point to localhost.

View file

@ -122,4 +122,12 @@ pub(super) fn webauthn_router() -> axum::Router<AppState> {
.route("/auth/finish", post(webauthn::auth_finish))
.route("/passkey/start", post(webauthn::passkey_add_start))
.route("/passkey/finish", post(webauthn::passkey_add_finish))
.route(
"/auth/discoverable/start",
get(webauthn::discoverable_auth_start),
)
.route(
"/auth/discoverable/finish",
post(webauthn::discoverable_auth_finish),
)
}

View file

@ -3,7 +3,9 @@ use std::sync::Arc;
use chrono::{DateTime, Duration, Utc};
use tokio::sync::RwLock;
use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration};
use webauthn_rs::prelude::{
DiscoverableAuthentication, PasskeyAuthentication, PasskeyRegistration,
};
use crate::domain::ids::UserId;
@ -13,6 +15,7 @@ use crate::domain::ids::UserId;
pub struct ChallengeStore {
registrations: Arc<RwLock<HashMap<String, RegistrationEntry>>>,
authentications: Arc<RwLock<HashMap<String, AuthenticationEntry>>>,
discoverable_authentications: Arc<RwLock<HashMap<String, DiscoverableAuthEntry>>>,
}
struct RegistrationEntry {
@ -27,6 +30,11 @@ struct AuthenticationEntry {
pub cli_callback: Option<CliCallbackInfo>,
}
struct DiscoverableAuthEntry {
pub state: DiscoverableAuthentication,
pub expires_at: DateTime<Utc>,
}
#[derive(Clone)]
pub struct CliCallbackInfo {
pub callback_url: String,
@ -47,6 +55,7 @@ impl ChallengeStore {
Self {
registrations: Arc::new(RwLock::new(HashMap::new())),
authentications: Arc::new(RwLock::new(HashMap::new())),
discoverable_authentications: Arc::new(RwLock::new(HashMap::new())),
}
}
@ -106,6 +115,32 @@ impl ChallengeStore {
Some((entry.state, entry.cli_callback))
}
pub async fn store_discoverable_authentication(
&self,
challenge_id: String,
state: DiscoverableAuthentication,
) {
let entry = DiscoverableAuthEntry {
state,
expires_at: Utc::now() + Duration::minutes(CHALLENGE_TTL_MINUTES),
};
let mut map = self.discoverable_authentications.write().await;
Self::cleanup_expired_discoverable(&mut map);
map.insert(challenge_id, entry);
}
pub async fn take_discoverable_authentication(
&self,
challenge_id: &str,
) -> Option<DiscoverableAuthentication> {
let mut map = self.discoverable_authentications.write().await;
let entry = map.remove(challenge_id)?;
if Utc::now() > entry.expires_at {
return None;
}
Some(entry.state)
}
fn cleanup_expired_registrations(map: &mut HashMap<String, RegistrationEntry>) {
let now = Utc::now();
map.retain(|_, entry| entry.expires_at > now);
@ -115,4 +150,9 @@ impl ChallengeStore {
let now = Utc::now();
map.retain(|_, entry| entry.expires_at > now);
}
fn cleanup_expired_discoverable(map: &mut HashMap<String, DiscoverableAuthEntry>) {
let now = Utc::now();
map.retain(|_, entry| entry.expires_at > now);
}
}

View file

@ -163,6 +163,55 @@ const startPasskeyAuthentication = async (queryParams) => {
return finishResponse.json();
};
// Start conditional UI authentication (passkey autofill)
const startConditionalUIAuthentication = async (signal) => {
// 1. Get discoverable challenge from server
const startResponse = await fetch(
"/api/v1/webauthn/auth/discoverable/start",
{
credentials: "same-origin",
signal,
},
);
if (!startResponse.ok) {
throw new Error(
`Failed to start discoverable auth (HTTP ${startResponse.status}).`,
);
}
const { challenge_id, options } = await startResponse.json();
// 2. Get assertion via browser WebAuthn API with conditional mediation
const requestOptions = prepareRequestOptions(options);
requestOptions.mediation = "conditional";
requestOptions.signal = signal;
const credential = await navigator.credentials.get(requestOptions);
// 3. Send assertion to server
const finishResponse = await fetch(
"/api/v1/webauthn/auth/discoverable/finish",
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_id,
credential: serializeAuthenticationCredential(credential),
}),
signal,
},
);
if (!finishResponse.ok) {
throw new Error(
`Discoverable authentication failed (HTTP ${finishResponse.status}).`,
);
}
return finishResponse.json();
};
// Add a passkey to an existing authenticated account
const addPasskey = async (name) => {
// 1. Get challenge from server

View file

@ -29,6 +29,15 @@
</div>
<div class="mt-6">
<input
type="text"
autocomplete="username webauthn"
id="conditional-ui-input"
class="hidden"
aria-hidden="true"
tabindex="-1"
/>
<button
id="login-button"
type="button"
@ -49,7 +58,14 @@
</div>
<script>
let conditionalAbort = null;
const handleLogin = async () => {
if (conditionalAbort) {
conditionalAbort.abort();
conditionalAbort = null;
}
const button = document.getElementById("login-button");
const errorDiv = document.getElementById("login-error");
const loadingDiv = document.getElementById("login-loading");
@ -75,9 +91,29 @@
}
};
const initConditionalUI = async () => {
if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
const available =
await PublicKeyCredential.isConditionalMediationAvailable();
if (!available) return;
conditionalAbort = new AbortController();
try {
const result = await startConditionalUIAuthentication(
conditionalAbort.signal,
);
window.location.href = result.redirect || "/";
} catch {
// Silently ignored — user may use the button instead, or the
// abort controller was triggered by handleLogin().
}
};
if (!window.PublicKeyCredential) {
document.getElementById("login-button").disabled = true;
document.getElementById("login-unsupported").classList.remove("hidden");
} else {
initConditionalUI();
}
</script>
{% endblock %}