passki 0.1.3

A simple and secure WebAuthn/Passkey authentication library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
// Copyright 2026 Grzegorz Blach
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! # Passkeys Demo Server (Warp)
//!
//! This example demonstrates WebAuthn/Passkey authentication using the Passki library
//! with the Warp web framework.
//!
//! ## Authentication Flows
//!
//! ### Registration (creating a new passkey)
//! 1. Client sends username to `/register/start`
//! 2. Server generates a challenge and returns WebAuthn options
//! 3. Client calls `navigator.credentials.create()` with these options
//! 4. User authenticates with their device (fingerprint, face, PIN, etc.)
//! 5. Client sends the credential to `/register/finish`
//! 6. Server verifies and stores the passkey
//!
//! ### Authentication (using an existing passkey)
//! Two modes are supported:
//!
//! **Passwordless** (username provided):
//! - Server returns only the credentials registered to that user
//! - Browser shows only matching passkeys
//!
//! **Usernameless** (no username):
//! - Server returns empty credential list
//! - Browser shows all available passkeys (discoverable credentials)
//! - Server identifies the user by the credential used
//!
//! ## Running
//! ```sh
//! cargo run --example warp
//! ```
//! Then open http://localhost:3000 in your browser.

use passki::{
    AttestationConveyancePreference, AuthenticationCredential, AuthenticationState, ClientData,
    Passki, RegistrationCredential, RegistrationState, ResidentKeyRequirement, StoredPasskey,
    UserVerificationRequirement,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::convert::Infallible;
use std::fs;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
use warp::{Filter, Reply, http::StatusCode, reject::Reject, reply};

// =============================================================================
// Error handling
// =============================================================================

#[derive(Debug)]
struct AppError(String);

impl Reject for AppError {}

/// Converts rejections into HTTP 400 responses with the error message.
async fn handle_rejection(err: warp::Rejection) -> Result<impl Reply, Infallible> {
    let message = if let Some(e) = err.find::<AppError>() {
        e.0.clone()
    } else {
        "Internal server error".to_string()
    };

    Ok(reply::with_status(message, StatusCode::BAD_REQUEST))
}

// =============================================================================
// Storage
// =============================================================================

/// In-memory storage for users and pending WebAuthn ceremonies.
///
/// In production, you would use a database for users and a cache (e.g., Redis)
/// for pending states with appropriate expiration.
#[derive(Clone, Default)]
struct Store {
    /// Maps username -> User data
    users: Arc<Mutex<HashMap<String, User>>>,

    /// Pending registration ceremonies, keyed by challenge.
    /// The state must be kept between start and finish calls.
    pending_registrations: Arc<Mutex<HashMap<String, RegistrationState>>>,

    /// Pending authentication ceremonies, keyed by challenge.
    /// The state must be kept between start and finish calls.
    pending_authentications: Arc<Mutex<HashMap<String, AuthenticationState>>>,
}

/// User record containing their profile and registered passkeys.
#[derive(Clone)]
#[allow(unused)]
struct User {
    /// Unique user identifier.
    id: Uuid,
    /// Username or account identifier.
    username: String,
    /// Human-readable display name.
    display_name: String,
    /// All passkeys registered by this user. A user can have multiple passkeys
    /// (e.g., one on their phone, one on their laptop, one security key).
    passkeys: Vec<StoredPasskey>,
}

// =============================================================================
// Request/Response types
// =============================================================================

#[derive(Deserialize)]
struct RegisterStartRequest {
    username: String,
}

/// Data sent by the client after WebAuthn credential creation.
#[derive(Deserialize)]
struct RegisterFinishRequest {
    /// Base64url-encoded credential ID from the authenticator
    credential_id: String,
    /// Base64url-encoded attestation object containing the public key
    public_key: String,
    /// Base64url-encoded client data JSON
    client_data_json: String,
}

/// Request to start authentication. Username is optional:
/// - If provided: passwordless flow (server specifies allowed credentials)
/// - If omitted: usernameless flow (browser shows all available passkeys)
#[derive(Deserialize, Default)]
struct AuthStartRequest {
    #[serde(default)]
    username: Option<String>,
}

/// Data sent by the client after WebAuthn authentication.
#[derive(Deserialize)]
struct AuthFinishRequest {
    /// Base64url-encoded credential ID identifying which passkey was used
    credential_id: String,
    /// Base64url-encoded authenticator data (contains flags and counter)
    authenticator_data: String,
    /// Base64url-encoded client data JSON
    client_data_json: String,
    /// Base64url-encoded signature over authenticator_data + hash(client_data_json)
    signature: String,
}

#[derive(Serialize)]
struct ApiResponse {
    success: bool,
    message: String,
    /// In usernameless flow, returns the identified username
    #[serde(skip_serializing_if = "Option::is_none")]
    username: Option<String>,
}

// =============================================================================
// Application state
// =============================================================================

#[derive(Clone)]
struct AppState {
    passki: Arc<Passki>,
    store: Store,
}

// =============================================================================
// Filters
// =============================================================================

/// Extracts AppState for injection into handlers.
fn with_state(
    state: AppState,
) -> impl Filter<Extract = (AppState,), Error = Infallible> + Clone {
    warp::any().map(move || state.clone())
}

// =============================================================================
// Handlers
// =============================================================================

async fn index() -> Result<impl Reply, warp::Rejection> {
    let html = fs::read_to_string("examples/index.html")
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;
    Ok(reply::html(html))
}

/// POST /register/start - Begin passkey registration
///
/// Generates a challenge and WebAuthn options for the client to create a credential.
/// The challenge is random bytes that the authenticator must sign, preventing replay attacks.
async fn register_start(
    state: AppState,
    req: RegisterStartRequest,
) -> Result<impl Reply, warp::Rejection> {
    // Generate a unique user ID. This should be random and opaque (not the username)
    // to prevent tracking users across sites.
    let user_id = Uuid::new_v4().as_bytes().to_vec();

    // If user exists, get their existing passkeys to exclude them from re-registration
    let existing = state.store.users.lock().unwrap()
        .get(&req.username).map(|u| u.passkeys.clone());

    let (challenge, reg_state) = state.passki.start_passkey_registration(
        &user_id,
        &req.username,                          // User handle (displayed by authenticator)
        &req.username,                          // Display name
        60000,                                  // Timeout in milliseconds
        AttestationConveyancePreference::None,  // Don't request attestation
        ResidentKeyRequirement::Preferred,      // Request discoverable credential if possible
        UserVerificationRequirement::Preferred, // Request user verification if available
        existing.as_deref(),                    // Exclude existing credentials
    ).map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Store state for verification in finish step, keyed by the challenge
    state.store.pending_registrations.lock().unwrap().insert(challenge.challenge.clone(), reg_state);

    // Return challenge to client (will be passed to navigator.credentials.create())
    Ok(reply::json(&challenge))
}

/// POST /register/finish - Complete passkey registration
///
/// Verifies the credential created by the authenticator and stores it.
async fn register_finish(
    state: AppState,
    req: RegisterFinishRequest,
) -> Result<impl Reply, warp::Rejection> {
    // Parse client data to extract challenge
    let client_data = ClientData::from_base64(&req.client_data_json)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Retrieve and remove the pending registration state
    let reg_state = state.store.pending_registrations.lock().unwrap()
        .remove(&client_data.challenge)
        .ok_or_else(|| warp::reject::custom(AppError("No pending registration".into())))?;

    // Package the credential data from the client
    let credential = RegistrationCredential {
        credential_id: req.credential_id,
        public_key: req.public_key,
        client_data_json: req.client_data_json,
    };

    // Verify the credential (checks origin, challenge, parses public key)
    let passkey = state.passki.finish_passkey_registration(&credential, &reg_state)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Decode user ID from base64url to UUID
    let user_id_bytes = Passki::base64_decode(&reg_state.user.id)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;
    let user_id = Uuid::from_slice(&user_id_bytes)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Store the passkey for future authentication.
    let mut users = state.store.users.lock().unwrap();
    users
        .entry(reg_state.user.name.clone())
        // If user exists, add passkey to their list.
        .and_modify(|user| user.passkeys.push(passkey.clone()))
        // If new user, create user record with their info.
        .or_insert(User {
            id: user_id,
            username: reg_state.user.name,
            display_name: reg_state.user.display_name,
            passkeys: vec![passkey],
        });

    Ok(reply::json(&ApiResponse { success: true, message: "Registration successful".into(), username: None }))
}

/// POST /auth/start - Begin passkey authentication
///
/// Two modes based on whether username is provided:
/// - Passwordless: returns challenge with user's credential IDs (browser filters to these)
/// - Usernameless: returns challenge with empty credential list (browser shows all passkeys)
///
/// The challenge is used to correlate start and finish requests.
async fn auth_start(
    state: AppState,
    req: AuthStartRequest,
) -> Result<impl Reply, warp::Rejection> {
    let passkeys = if let Some(ref username) = req.username {
        // Passwordless flow: get user's passkeys to include in allowCredentials
        let users = state.store.users.lock().unwrap();
        let user = users.get(username)
            .ok_or_else(|| warp::reject::custom(AppError("User not found".into())))?;
        user.passkeys.clone()
    } else {
        // Usernameless flow: empty allowCredentials lets browser show all passkeys
        vec![]
    };

    let (challenge, auth_state) = state.passki.start_passkey_authentication(
        &passkeys,
        60000,                                  // Timeout in milliseconds
        UserVerificationRequirement::Preferred, // Request user verification if available
    );

    // Store state for verification in finish step, keyed by the challenge
    state.store.pending_authentications.lock().unwrap().insert(challenge.challenge.clone(), auth_state);

    // Return challenge to client
    Ok(reply::json(&challenge))
}

/// POST /auth/finish - Complete passkey authentication
///
/// Verifies the signature from the authenticator. The signature proves:
/// 1. The user possesses the private key for this credential
/// 2. The user approved this specific authentication (via the signed challenge)
/// 3. User verification was performed (if required)
async fn auth_finish(
    state: AppState,
    req: AuthFinishRequest,
) -> Result<impl Reply, warp::Rejection> {
    // Parse client data to extract challenge
    let client_data = ClientData::from_base64(&req.client_data_json)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Retrieve pending state using challenge
    let auth_state = state.store.pending_authentications.lock().unwrap()
        .remove(&client_data.challenge)
        .ok_or_else(|| warp::reject::custom(AppError("No pending authentication".into())))?;

    // Decode credential ID to find the matching passkey
    let credential_id = Passki::base64_decode(&req.credential_id)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Find user by credential_id
    let mut users = state.store.users.lock().unwrap();
    let (username, passkey) = users.iter_mut()
        .find_map(|(name, user)| {
            user.passkeys.iter_mut()
                .find(|pk| pk.credential_id == credential_id)
                .map(|pk| (name.clone(), pk))
        })
        .ok_or_else(|| warp::reject::custom(AppError("Unknown credential".into())))?;

    // Package the authentication response from the client
    let credential = AuthenticationCredential {
        credential_id: req.credential_id,
        authenticator_data: req.authenticator_data,
        client_data_json: req.client_data_json,
        signature: req.signature,
    };

    // Verify the signature (checks origin, challenge, signature, counter)
    let result = state.passki.finish_passkey_authentication(&credential, &auth_state, passkey)
        .map_err(|e| warp::reject::custom(AppError(e.to_string())))?;

    // Update the counter to detect cloned authenticators.
    // If counter goes backwards, it may indicate the credential was cloned.
    passkey.counter = result.counter;

    Ok(reply::json(&ApiResponse {
        success: true,
        message: format!("Welcome back, {}!", username),
        username: Some(username),
    }))
}

// =============================================================================
// Main
// =============================================================================

#[tokio::main]
async fn main() {
    // Initialize Passki with relying party information.
    // - rp_id: The domain name (no protocol or port). Credentials are bound to this.
    // - origin: The full origin URL. Must match what the browser sends.
    // - rp_name: Human-readable name shown by authenticators.
    let state = AppState {
        passki: Arc::new(Passki::new(
            "localhost",
            "http://localhost:3000",
            "Passkeys Demo",
        )),
        store: Store::default(),
    };

    let index = warp::path::end()
        .and(warp::get())
        .and_then(index);

    let register_start = warp::path!("register" / "start")
        .and(warp::post())
        .and(with_state(state.clone()))
        .and(warp::body::json())
        .and_then(register_start);

    let register_finish = warp::path!("register" / "finish")
        .and(warp::post())
        .and(with_state(state.clone()))
        .and(warp::body::json())
        .and_then(register_finish);

    let auth_start = warp::path!("auth" / "start")
        .and(warp::post())
        .and(with_state(state.clone()))
        .and(warp::body::json())
        .and_then(auth_start);

    let auth_finish = warp::path!("auth" / "finish")
        .and(warp::post())
        .and(with_state(state.clone()))
        .and(warp::body::json())
        .and_then(auth_finish);

    let routes = index
        .or(register_start)
        .or(register_finish)
        .or(auth_start)
        .or(auth_finish)
        .recover(handle_rejection);

    println!("Server starting on http://localhost:3000");
    warp::serve(routes).run(([0, 0, 0, 0], 3000)).await;
}