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"
|
slug = "0.1.6"
|
||||||
url = "2"
|
url = "2"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
webauthn-rs = "0.5"
|
webauthn-rs = { version = "0.5", features = ["conditional-ui"] }
|
||||||
webauthn-rs-proto = "0.5"
|
webauthn-rs-proto = "0.5"
|
||||||
kamadak-exif = "0.6.1"
|
kamadak-exif = "0.6.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,135 @@ pub(crate) async fn passkey_add_finish(
|
||||||
Ok(StatusCode::OK)
|
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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
/// Reject CLI callback URLs that don't point to localhost.
|
/// 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("/auth/finish", post(webauthn::auth_finish))
|
||||||
.route("/passkey/start", post(webauthn::passkey_add_start))
|
.route("/passkey/start", post(webauthn::passkey_add_start))
|
||||||
.route("/passkey/finish", post(webauthn::passkey_add_finish))
|
.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 chrono::{DateTime, Duration, Utc};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use webauthn_rs::prelude::{PasskeyAuthentication, PasskeyRegistration};
|
use webauthn_rs::prelude::{
|
||||||
|
DiscoverableAuthentication, PasskeyAuthentication, PasskeyRegistration,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::domain::ids::UserId;
|
use crate::domain::ids::UserId;
|
||||||
|
|
||||||
|
|
@ -13,6 +15,7 @@ use crate::domain::ids::UserId;
|
||||||
pub struct ChallengeStore {
|
pub struct ChallengeStore {
|
||||||
registrations: Arc<RwLock<HashMap<String, RegistrationEntry>>>,
|
registrations: Arc<RwLock<HashMap<String, RegistrationEntry>>>,
|
||||||
authentications: Arc<RwLock<HashMap<String, AuthenticationEntry>>>,
|
authentications: Arc<RwLock<HashMap<String, AuthenticationEntry>>>,
|
||||||
|
discoverable_authentications: Arc<RwLock<HashMap<String, DiscoverableAuthEntry>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RegistrationEntry {
|
struct RegistrationEntry {
|
||||||
|
|
@ -27,6 +30,11 @@ struct AuthenticationEntry {
|
||||||
pub cli_callback: Option<CliCallbackInfo>,
|
pub cli_callback: Option<CliCallbackInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DiscoverableAuthEntry {
|
||||||
|
pub state: DiscoverableAuthentication,
|
||||||
|
pub expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CliCallbackInfo {
|
pub struct CliCallbackInfo {
|
||||||
pub callback_url: String,
|
pub callback_url: String,
|
||||||
|
|
@ -47,6 +55,7 @@ impl ChallengeStore {
|
||||||
Self {
|
Self {
|
||||||
registrations: Arc::new(RwLock::new(HashMap::new())),
|
registrations: Arc::new(RwLock::new(HashMap::new())),
|
||||||
authentications: 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))
|
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>) {
|
fn cleanup_expired_registrations(map: &mut HashMap<String, RegistrationEntry>) {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
map.retain(|_, entry| entry.expires_at > now);
|
map.retain(|_, entry| entry.expires_at > now);
|
||||||
|
|
@ -115,4 +150,9 @@ impl ChallengeStore {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
map.retain(|_, entry| entry.expires_at > 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();
|
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
|
// Add a passkey to an existing authenticated account
|
||||||
const addPasskey = async (name) => {
|
const addPasskey = async (name) => {
|
||||||
// 1. Get challenge from server
|
// 1. Get challenge from server
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,15 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="username webauthn"
|
||||||
|
id="conditional-ui-input"
|
||||||
|
class="hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="login-button"
|
id="login-button"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -49,7 +58,14 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
let conditionalAbort = null;
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
if (conditionalAbort) {
|
||||||
|
conditionalAbort.abort();
|
||||||
|
conditionalAbort = null;
|
||||||
|
}
|
||||||
|
|
||||||
const button = document.getElementById("login-button");
|
const button = document.getElementById("login-button");
|
||||||
const errorDiv = document.getElementById("login-error");
|
const errorDiv = document.getElementById("login-error");
|
||||||
const loadingDiv = document.getElementById("login-loading");
|
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) {
|
if (!window.PublicKeyCredential) {
|
||||||
document.getElementById("login-button").disabled = true;
|
document.getElementById("login-button").disabled = true;
|
||||||
document.getElementById("login-unsupported").classList.remove("hidden");
|
document.getElementById("login-unsupported").classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
initConditionalUI();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue