Skip to main content

oauth2_passkey/coordination/
passkey.rs

1use chrono::Utc;
2use http::HeaderMap;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::{env, sync::LazyLock};
6
7use crate::audit::{AuthMethod, AuthMethodDetails, LoginContext};
8use crate::passkey::{
9    AuthenticationOptions, AuthenticatorResponse, CredentialId, CredentialSearchField,
10    PasskeyCredential, PasskeyError, PasskeyStore, RegisterCredential, RegistrationOptions,
11    commit_registration, finish_authentication, prepare_registration_storage, start_authentication,
12    start_registration, validate_registration_challenge, verify_session_then_finish_registration,
13};
14use crate::session::{User as SessionUser, UserId, new_session_header};
15use crate::userdb::{User, UserStore};
16
17use super::errors::CoordinationError;
18use super::login_history::{record_login_failure, record_login_success};
19use super::user::gen_new_user_id;
20
21/// Passkey user account field mapping configuration
22static PASSKEY_USER_ACCOUNT_FIELD: LazyLock<String> =
23    LazyLock::new(|| env::var("PASSKEY_USER_ACCOUNT_FIELD").unwrap_or_else(|_| "name".to_string()));
24
25/// Passkey user label field mapping configuration
26static PASSKEY_USER_LABEL_FIELD: LazyLock<String> = LazyLock::new(|| {
27    env::var("PASSKEY_USER_LABEL_FIELD").unwrap_or_else(|_| "display_name".to_string())
28});
29
30/// Get the configured Passkey field mappings or defaults
31fn get_passkey_field_mappings() -> (String, String) {
32    (
33        PASSKEY_USER_ACCOUNT_FIELD.clone(),
34        PASSKEY_USER_LABEL_FIELD.clone(),
35    )
36}
37
38/// Mode of registration operation to explicitly indicate user intent.
39///
40/// This enum defines the available modes for passkey registration, determining
41/// whether a new user account should be created or a passkey should be added to
42/// an existing authenticated user.
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum RegistrationMode {
46    /// Adding a passkey to an existing user (requires authentication).
47    ///
48    /// This mode is used when an authenticated user wants to add another
49    /// passkey to their account, such as registering a new device or
50    /// security key as a backup.
51    AddToUser,
52
53    /// Creating a new user with a passkey (no authentication required).
54    ///
55    /// This mode is used for new user registration, where the user doesn't
56    /// have an existing account and wants to create one using a passkey
57    /// as their authentication method.
58    CreateUser,
59}
60
61/// Request for starting passkey registration with explicit mode.
62///
63/// This struct represents the data needed to begin a passkey registration process.
64/// It specifies the user information and the registration mode (whether adding a
65/// new passkey to an existing user or creating a new user).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RegistrationStartRequest {
68    /// Username for the passkey registration (login identifier)
69    pub username: String,
70    /// Display name for the passkey registration (user-friendly name)
71    pub displayname: String,
72    /// Mode of registration (add to existing user or create new user)
73    pub mode: RegistrationMode,
74}
75
76/// Core function that handles the business logic of starting registration with provided user info
77///
78/// This function takes an optional reference to a SessionUser, extracts username and displayname
79/// from the request body, and returns registration options.
80#[tracing::instrument(skip(auth_user), fields(user_id = auth_user.as_ref().map(|u| u.id.as_str()), username = %body.username, display_name = %body.displayname, mode = ?body.mode))]
81pub async fn handle_start_registration_core(
82    auth_user: Option<&SessionUser>,
83    body: RegistrationStartRequest,
84) -> Result<RegistrationOptions, CoordinationError> {
85    tracing::info!("Starting passkey registration flow");
86    match body.mode {
87        RegistrationMode::AddToUser => {
88            let auth_user = match auth_user {
89                Some(user) => user,
90                None => return Err(CoordinationError::Unauthorized.log()),
91            };
92
93            let result =
94                start_registration(Some(auth_user.clone()), body.username, body.displayname)
95                    .await?;
96            Ok(result)
97        }
98        RegistrationMode::CreateUser => {
99            match auth_user {
100                Some(_) => return Err(CoordinationError::UnexpectedlyAuthorized.log()),
101                None => {
102                    tracing::trace!("handle_start_registration_core: Create User");
103                }
104            };
105
106            let result = start_registration(None, body.username, body.displayname).await?;
107            Ok(result)
108        }
109    }
110}
111
112/// Core function that handles the business logic of finishing registration
113///
114/// This function takes an optional reference to a SessionUser and registration data,
115/// and either registers a new credential for an existing user or creates a new user
116/// with the credential.
117#[tracing::instrument(skip(auth_user, reg_data), fields(user_id = auth_user.as_ref().map(|u| u.id.as_str())))]
118pub async fn handle_finish_registration_core(
119    auth_user: Option<&SessionUser>,
120    reg_data: RegisterCredential,
121) -> Result<(HeaderMap, String), CoordinationError> {
122    tracing::info!("Finishing passkey registration flow");
123    match auth_user {
124        Some(session_user) => {
125            tracing::debug!("handle_finish_registration_core: User: {:#?}", session_user);
126
127            // Handle authenticated user registration
128            let message =
129                verify_session_then_finish_registration(session_user.clone(), reg_data).await?;
130
131            Ok((HeaderMap::new(), message))
132        }
133        None => {
134            let result = create_user_then_finish_registration(reg_data).await;
135
136            match result {
137                Ok((message, stored_user_id)) => {
138                    // Create session with the user_id
139                    let user_id = UserId::new(stored_user_id).map_err(|e| {
140                        CoordinationError::Validation(format!("Invalid user ID: {e}"))
141                    })?;
142                    let headers = new_session_header(user_id).await?;
143
144                    Ok((headers, message))
145                }
146                Err(err) => Err(err),
147            }
148        }
149    }
150}
151
152async fn create_user_then_finish_registration(
153    reg_data: RegisterCredential,
154) -> Result<(String, String), CoordinationError> {
155    // We avoid calling finish_registration() directly since it expects an existing user,
156    // forcing us to create users before validation (which leaves orphaned records on failure).
157    // Instead, we use finish_registration()'s 3 constituent functions: validate_registration_challenge(),
158    // prepare_registration_storage(), and commit_registration() with user creation in between.
159
160    // Step 1: Pure validation (no user_id needed, no side effects)
161    // This prevents orphaned user records if validation fails
162    let validated_data = validate_registration_challenge(&reg_data).await?;
163    let user_handle = validated_data.user_handle.clone();
164
165    // Step 2: Create user after successful challenge validation
166    let (account, label) = get_account_and_label_from_passkey(&reg_data).await;
167
168    let new_user = User {
169        id: gen_new_user_id().await?,
170        account,
171        label,
172        is_admin: *crate::config::O2P_DEMO_MODE,
173        sequence_number: None,
174        created_at: Utc::now(),
175        updated_at: Utc::now(),
176    };
177
178    let stored_user = UserStore::upsert_user(new_user).await?;
179
180    // Step 3: Prepare credential storage (cleanup existing credentials)
181    let user_id = UserId::new(stored_user.id.clone())
182        .map_err(|e| CoordinationError::Validation(format!("Invalid user ID: {e}")))?;
183    let credential = prepare_registration_storage(user_id, validated_data).await?;
184
185    // Step 4: Atomic commit (store credential + cleanup challenge)
186    let message = commit_registration(credential, &user_handle).await?;
187
188    Ok((message, stored_user.id))
189}
190
191async fn get_account_and_label_from_passkey(reg_data: &RegisterCredential) -> (String, String) {
192    // Get user name from registration data with fallback mechanism
193    let (name, display_name) = reg_data.get_registration_user_fields().await;
194
195    // Get field mappings from configuration
196    let (account_field, label_field) = get_passkey_field_mappings();
197
198    // Map fields based on configuration
199    let account = match account_field.as_str() {
200        "name" => name.clone(),
201        "display_name" => display_name.clone(),
202        _ => name.clone(), // Default to name if invalid mapping
203    };
204
205    let label = match label_field.as_str() {
206        "name" => name.clone(),
207        "display_name" => display_name.clone(),
208        _ => display_name.clone(), // Default to display_name if invalid mapping
209    };
210    (account, label)
211}
212
213/// Core function that handles the business logic of starting authentication
214///
215/// This function extracts the username from the request body and starts the
216/// authentication process.
217#[tracing::instrument(skip(body), fields(username))]
218pub async fn handle_start_authentication_core(
219    body: &Value,
220) -> Result<AuthenticationOptions, CoordinationError> {
221    tracing::info!("Starting passkey authentication flow");
222    // Extract username from the request body
223    let username = if body.is_object() {
224        body.get("username")
225            .and_then(|v| v.as_str())
226            .map(String::from)
227    } else if body.is_string() {
228        Some(body.as_str().unwrap().to_string()) // Directly use the string
229    } else {
230        None
231    };
232
233    // Record username in the tracing span
234    if let Some(ref username) = username {
235        tracing::Span::current().record("username", username);
236    }
237
238    // Start the authentication process
239    Ok(start_authentication(username).await?)
240}
241
242/// Response from successful passkey authentication, containing data needed for
243/// client-side authenticator synchronization via WebAuthn Signal API.
244#[derive(Debug, Serialize)]
245pub struct AuthenticationResponse {
246    /// The authenticated user's name
247    pub name: String,
248    /// The user handle for signalAllAcceptedCredentials
249    pub user_handle: String,
250    /// All valid credential IDs for this user (for signalAllAcceptedCredentials)
251    pub credential_ids: Vec<String>,
252}
253
254/// Core function that handles the business logic of finishing authentication
255///
256/// This function verifies the authentication response, creates a session for the
257/// authenticated user, and returns the authentication response data and session headers.
258///
259/// # Arguments
260///
261/// * `auth_response` - The authenticator response from the client
262/// * `headers` - Optional HTTP headers for extracting login context (IP, user-agent)
263#[tracing::instrument(skip(auth_response, headers), fields(user_id))]
264pub async fn handle_finish_authentication_core(
265    auth_response: AuthenticatorResponse,
266    headers: Option<&HeaderMap>,
267) -> Result<(AuthenticationResponse, HeaderMap), CoordinationError> {
268    tracing::info!("Finishing passkey authentication flow");
269    tracing::debug!("Auth response: {:#?}", auth_response);
270
271    // Extract login context from headers for history recording
272    let login_context = headers.map(LoginContext::from_headers).unwrap_or_default();
273
274    // Extract credential_id for login history recording (success and failure)
275    let credential_id_str = auth_response.credential_id().to_string();
276
277    // Verify the authentication and get the user ID, name, and user handle
278    let auth_result = match finish_authentication(auth_response).await {
279        Ok(result) => result,
280        Err(e) => {
281            record_auth_failure(login_context, credential_id_str, &e).await;
282            return Err(e.into());
283        }
284    };
285    let uid = auth_result.user_id;
286    let name = auth_result.user_name;
287    let user_handle = auth_result.user_handle;
288    let aaguid = auth_result.aaguid;
289
290    /// Record a passkey authentication failure to both tracing and login history DB.
291    async fn record_auth_failure(
292        login_context: LoginContext,
293        credential_id_str: String,
294        error: &PasskeyError,
295    ) {
296        tracing::warn!(
297            credential_id = %credential_id_str,
298            error = %error,
299            "Passkey authentication failed"
300        );
301
302        // Best-effort user identification from credential ID
303        let user_id = async {
304            let cred_id = CredentialId::new(credential_id_str.clone()).ok()?;
305            let cred = PasskeyStore::get_credential(cred_id).await.ok()??;
306            UserId::new(cred.user_id).ok()
307        }
308        .await;
309
310        let _ = record_login_failure(
311            user_id,
312            AuthMethod::Passkey,
313            login_context,
314            Some(credential_id_str),
315            error.to_string(),
316        )
317        .await;
318    }
319
320    // Record user_id in the tracing span
321    tracing::Span::current().record("user_id", &uid);
322    tracing::info!(user_id = %uid, user_name = %name, "Passkey authentication successful");
323    tracing::debug!("User ID: {:#?}", uid);
324
325    // Create a session for the authenticated user
326    let user_id = UserId::new(uid.clone())
327        .map_err(|e| CoordinationError::Validation(format!("Invalid user ID: {e}")))?;
328    let response_headers = new_session_header(user_id.clone()).await?;
329
330    // Record login history (fire-and-forget: errors are logged but don't fail the login)
331    let _ = record_login_success(
332        user_id.clone(),
333        AuthMethod::Passkey,
334        login_context,
335        AuthMethodDetails {
336            credential_id: Some(credential_id_str),
337            aaguid: Some(aaguid),
338            ..Default::default()
339        },
340    )
341    .await;
342
343    // Retrieve all credential IDs for authenticator synchronization
344    let credentials = list_credentials_core(user_id).await?;
345    let credential_ids = credentials
346        .iter()
347        .map(|c| c.credential_id.clone())
348        .collect();
349
350    Ok((
351        AuthenticationResponse {
352            name,
353            user_handle,
354            credential_ids,
355        },
356        response_headers,
357    ))
358}
359
360/// Core function that handles the business logic of listing passkey credentials
361///
362/// This function takes a user ID and returns the list of stored credentials
363/// associated with that user, or an error if the user is not logged in.
364#[tracing::instrument(fields(user_id))]
365pub async fn list_credentials_core(
366    user_id: UserId,
367) -> Result<Vec<PasskeyCredential>, CoordinationError> {
368    tracing::debug!("Listing passkey credentials for user");
369    let credentials =
370        PasskeyStore::get_credentials_by(CredentialSearchField::UserId(user_id)).await?;
371    tracing::info!(
372        credential_count = credentials.len(),
373        "Retrieved passkey credentials"
374    );
375    Ok(credentials)
376}
377
378/// Response from deleting a passkey credential, containing remaining credentials
379/// for client-side synchronization with the authenticator via WebAuthn Signal API.
380#[derive(Debug, Serialize)]
381pub struct DeleteCredentialResponse {
382    /// Credential IDs still registered for this user after deletion
383    pub remaining_credential_ids: Vec<String>,
384    /// The user handle associated with the deleted credential
385    pub user_handle: String,
386}
387
388/// Delete a passkey credential for a user
389///
390/// This function checks that the credential belongs to the authenticated user
391/// before deleting it to prevent unauthorized deletions.
392/// Returns the remaining credential IDs and user handle for client-side
393/// authenticator synchronization via the WebAuthn Signal API.
394#[tracing::instrument(fields(user_id, credential_id))]
395pub async fn delete_passkey_credential_core(
396    user_id: UserId,
397    credential_id: CredentialId,
398) -> Result<DeleteCredentialResponse, CoordinationError> {
399    tracing::info!("Attempting to delete passkey credential");
400
401    let credential = PasskeyStore::get_credentials_by(CredentialSearchField::CredentialId(
402        credential_id.clone(),
403    ))
404    .await?
405    .into_iter()
406    .next()
407    .ok_or(
408        CoordinationError::ResourceNotFound {
409            resource_type: "Passkey".to_string(),
410            resource_id: credential_id.as_str().to_string(),
411        }
412        .log(),
413    )?;
414
415    // Verify the credential belongs to the authenticated user
416    if credential.user_id != user_id.as_str() {
417        return Err(CoordinationError::Unauthorized.log());
418    }
419
420    let user_handle = credential.user.user_handle.clone();
421
422    // Delete the credential using the raw credential ID format from the database
423    PasskeyStore::delete_credential_by(CredentialSearchField::CredentialId(credential_id)).await?;
424
425    // Retrieve remaining credentials for authenticator synchronization
426    // Filter to only include credentials with the same user_handle, since
427    // signalAllAcceptedCredentials is scoped by userId (user_handle)
428    let remaining = list_credentials_core(user_id).await?;
429    let remaining_credential_ids = remaining
430        .iter()
431        .filter(|c| c.user.user_handle == user_handle)
432        .map(|c| c.credential_id.clone())
433        .collect();
434
435    tracing::debug!("Successfully deleted credential");
436
437    Ok(DeleteCredentialResponse {
438        remaining_credential_ids,
439        user_handle,
440    })
441}
442
443/// Update the name and display name of a passkey credential
444///
445/// This function updates the name and display name fields of a passkey credential
446/// and returns the updated credential information.
447///
448/// # Arguments
449/// * `credential_id` - The ID of the credential to update
450/// * `name` - The new name for the credential
451/// * `display_name` - The new display name for the credential
452/// * `session_user` - The authenticated user session
453///
454/// # Returns
455/// * The updated credential information in a Result
456#[tracing::instrument(skip(session_user), fields(user_id = session_user.as_ref().map(|u| u.id.as_str()), credential_id, name, display_name))]
457pub async fn update_passkey_credential_core(
458    credential_id: CredentialId,
459    name: &str,
460    display_name: &str,
461    session_user: Option<SessionUser>,
462) -> Result<serde_json::Value, CoordinationError> {
463    tracing::info!("Updating passkey credential details");
464    // Ensure the user is authenticated
465    let user_id = match session_user {
466        Some(user) => user.id,
467        None => {
468            return Err(CoordinationError::Unauthorized.log());
469        }
470    };
471
472    // Get the credential to verify ownership
473    let credential = match PasskeyStore::get_credential(credential_id.clone()).await? {
474        Some(cred) => cred,
475        None => {
476            return Err(CoordinationError::ResourceNotFound {
477                resource_type: "Passkey".to_string(),
478                resource_id: credential_id.as_str().to_string(),
479            });
480        }
481    };
482
483    // Verify that the credential belongs to the authenticated user
484    if credential.user_id != user_id {
485        return Err(CoordinationError::Unauthorized.log());
486    }
487
488    // Update the credential in the database
489    PasskeyStore::update_credential(credential_id.clone(), name, display_name).await?;
490
491    // Get the updated credential
492    let updated_credential = match PasskeyStore::get_credential(credential_id.clone()).await? {
493        Some(cred) => cred,
494        None => {
495            return Err(CoordinationError::ResourceNotFound {
496                resource_type: "Passkey".to_string(),
497                resource_id: credential_id.as_str().to_string(),
498            });
499        }
500    };
501
502    tracing::debug!("Successfully updated credential");
503
504    // Return the credential information in JSON format
505    Ok(serde_json::json!({
506        "credentialId": credential_id.as_str(),
507        "name": updated_credential.user.name,
508        "displayName": updated_credential.user.display_name,
509        "userHandle": updated_credential.user.user_handle,
510    }))
511}
512
513#[cfg(test)]
514mod tests;