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:
parent
02235426c4
commit
5a0321b226
6 changed files with 264 additions and 2 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue