oauth2-passkey 0.6.1

OAuth2 and Passkey authentication library for Rust web applications
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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
use super::*;
use crate::storage::{CacheData, CacheKey, CachePrefix, GENERIC_CACHE_STORE};
use crate::test_utils::init_test_environment;
use std::time::{SystemTime, UNIX_EPOCH};

// Create a module alias for our test utils
use crate::passkey::main::test_utils as passkey_test_utils;

fn create_valid_stored_options() -> StoredOptions {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();

    StoredOptions {
        challenge: "test_challenge".to_string(),
        user: crate::passkey::types::PublicKeyCredentialUserEntity {
            user_handle: "test_user_handle".to_string(),
            name: "test_user".to_string(),
            display_name: "Test User".to_string(),
        },
        timestamp: now,
        ttl: 300, // 5 minutes
    }
}

fn create_expired_stored_options() -> StoredOptions {
    let old_timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
        - 400; // 400 seconds ago

    StoredOptions {
        challenge: "expired_challenge".to_string(),
        user: crate::passkey::types::PublicKeyCredentialUserEntity {
            user_handle: "expired_user_handle".to_string(),
            name: "expired_user".to_string(),
            display_name: "Expired User".to_string(),
        },
        timestamp: old_timestamp,
        ttl: 300, // 5 minutes, but timestamp is old
    }
}

/// Test get and validate options success
///
/// This test verifies that `get_and_validate_options` successfully retrieves and validates
/// stored challenge options from the cache. It stores valid options, retrieves them,
/// and validates that all fields are preserved correctly during the roundtrip.
#[tokio::test]
async fn test_get_and_validate_options_success() {
    init_test_environment().await;
    let challenge_type = "registration";
    let id = "test_challenge_id_success";
    let stored_options = create_valid_stored_options();

    // Store the options first using simplified cache operations
    use crate::storage::{CacheKey, CachePrefix, store_cache_keyed};
    let cache_prefix =
        CachePrefix::new(challenge_type.to_string()).expect("Failed to create cache prefix");
    let cache_key = CacheKey::new(id.to_string()).expect("Failed to create cache key");
    store_cache_keyed::<_, PasskeyError>(
        cache_prefix,
        cache_key,
        stored_options.clone(),
        300, // TTL in seconds
    )
    .await
    .expect("Failed to store options");

    // Test retrieval and validation
    let challenge_type_typed =
        crate::passkey::types::ChallengeType::new(challenge_type.to_string()).unwrap();
    let challenge_id_typed = crate::passkey::types::ChallengeId::new(id.to_string()).unwrap();
    let result = get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;
    assert!(result.is_ok());
    let retrieved_options = result.unwrap();
    assert_eq!(retrieved_options.challenge, stored_options.challenge);
    assert_eq!(retrieved_options.user.name, stored_options.user.name);
}

/// Test get and validate options not found
///
/// This test verifies that `get_and_validate_options` returns appropriate errors when
/// attempting to retrieve non-existent challenge options from the cache. It validates
/// the error handling for missing challenge data.
#[tokio::test]
async fn test_get_and_validate_options_not_found() {
    init_test_environment().await;
    let challenge_type_typed = crate::passkey::types::ChallengeType::registration();
    let challenge_id_typed =
        crate::passkey::types::ChallengeId::new("nonexistent".to_string()).unwrap();
    let result = get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;
    assert!(result.is_err());
    match result.unwrap_err() {
        PasskeyError::NotFound(msg) => assert_eq!(msg, "Challenge not found"),
        _ => panic!("Expected NotFound error"),
    }
}

/// Test get and validate options expired
///
/// This test verifies that `get_and_validate_options` correctly handles expired challenge
/// options by returning appropriate errors. It stores options with a past expiration time
/// and validates that they are properly rejected as expired.
#[tokio::test]
async fn test_get_and_validate_options_expired() {
    init_test_environment().await;
    let challenge_type = "registration";
    let id = "expired_challenge_id_test";
    let expired_options = create_expired_stored_options();

    // Store the expired options
    use crate::storage::{CacheKey, CachePrefix, store_cache_keyed};
    let cache_prefix =
        CachePrefix::new(challenge_type.to_string()).expect("Failed to create cache prefix");
    let cache_key = CacheKey::new(id.to_string()).expect("Failed to create cache key");
    store_cache_keyed::<_, PasskeyError>(
        cache_prefix,
        cache_key,
        expired_options,
        300, // TTL in seconds
    )
    .await
    .expect("Failed to store expired options");

    // Test retrieval - should fail due to expiration
    let challenge_type_typed =
        crate::passkey::types::ChallengeType::new(challenge_type.to_string()).unwrap();
    let challenge_id_typed = crate::passkey::types::ChallengeId::new(id.to_string()).unwrap();
    let result = get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;
    assert!(result.is_err());
    match result.unwrap_err() {
        PasskeyError::Authentication(msg) => {
            assert!(msg.contains("Challenge has expired"));
        }
        _ => panic!("Expected Authentication error for expired challenge"),
    }
}

/// Test remove options success
///
/// This test verifies that `remove_options` successfully removes stored challenge options
/// from the cache. It stores options, removes them, and validates that they are no longer
/// retrievable from the cache system.
#[tokio::test]
async fn test_remove_options_success() {
    init_test_environment().await;
    let challenge_type = "authentication";
    let id = "remove_challenge_test_id";
    let stored_options = create_valid_stored_options();

    // Store the options first
    use crate::storage::{CacheKey, CachePrefix, store_cache_keyed};
    let cache_prefix =
        CachePrefix::new(challenge_type.to_string()).expect("Failed to create cache prefix");
    let cache_key = CacheKey::new(id.to_string()).expect("Failed to create cache key");
    store_cache_keyed::<_, PasskeyError>(
        cache_prefix,
        cache_key,
        stored_options,
        300, // TTL in seconds
    )
    .await
    .expect("Failed to store options");

    // Verify it exists
    let (cache_prefix_verify, cache_key_verify) = (
        CachePrefix::new(challenge_type.to_string()).unwrap(),
        CacheKey::new(id.to_string()).unwrap(),
    );
    use crate::storage::get_data;
    let before_removal: Option<StoredOptions> =
        get_data::<_, PasskeyError>(cache_prefix_verify, cache_key_verify)
            .await
            .expect("Failed to get from cache");
    assert!(before_removal.is_some());

    // Remove it
    let (cache_prefix_remove, cache_key_remove) = (
        CachePrefix::new(challenge_type.to_string()).unwrap(),
        CacheKey::new(id.to_string()).unwrap(),
    );
    let result = remove_options(cache_prefix_remove, cache_key_remove).await;
    assert!(result.is_ok());

    // Verify it's gone
    let (cache_prefix_after, cache_key_after) = (
        CachePrefix::new(challenge_type.to_string()).unwrap(),
        CacheKey::new(id.to_string()).unwrap(),
    );
    let after_removal: Option<StoredOptions> =
        get_data::<_, PasskeyError>(cache_prefix_after, cache_key_after)
            .await
            .expect("Failed to get from cache");
    assert!(after_removal.is_none());
}

/// Test remove options nonexistent
///
/// This test verifies that `remove_options` handles attempts to remove non-existent
/// challenge options gracefully without errors. It validates that the function
/// succeeds even when the target options don't exist in the cache.
#[tokio::test]
async fn test_remove_options_nonexistent() {
    init_test_environment().await;
    // Removing a non-existent entry should not fail
    let (cache_prefix, cache_key) = (
        CachePrefix::new("authentication".to_string()).unwrap(),
        CacheKey::new("nonexistent".to_string()).unwrap(),
    );
    let result = remove_options(cache_prefix, cache_key).await;
    assert!(result.is_ok());
}

/// Test ttl validation with passkey timeout
///
/// This test verifies TTL (time-to-live) validation logic for challenge options using
/// the passkey timeout configuration. It tests that TTL calculations are performed
/// correctly and that timeout validations work as expected.
#[tokio::test]
async fn test_ttl_validation_with_passkey_timeout() {
    init_test_environment().await;
    let challenge_type = "registration";
    let id = "ttl_validation_test_id";

    // Create options with very long TTL but should be limited by PASSKEY_CHALLENGE_TIMEOUT
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();

    let stored_options = StoredOptions {
        challenge: "ttl_test_challenge".to_string(),
        user: crate::passkey::types::PublicKeyCredentialUserEntity {
            user_handle: "ttl_test_user_handle".to_string(),
            name: "ttl_test_user".to_string(),
            display_name: "TTL Test User".to_string(),
        },
        timestamp: now - (*PASSKEY_CHALLENGE_TIMEOUT as u64) - 1, // Just past timeout
        ttl: 86400,                                               // 24 hours - should be ignored
    };

    use crate::storage::{CacheKey, CachePrefix, store_cache_keyed};
    let cache_prefix =
        CachePrefix::new(challenge_type.to_string()).expect("Failed to create cache prefix");
    let cache_key = CacheKey::new(id.to_string()).expect("Failed to create cache key");
    store_cache_keyed::<_, PasskeyError>(
        cache_prefix,
        cache_key,
        stored_options,
        300, // TTL in seconds
    )
    .await
    .expect("Failed to store options");

    // Should be expired due to PASSKEY_CHALLENGE_TIMEOUT limit
    let challenge_type_typed =
        crate::passkey::types::ChallengeType::new(challenge_type.to_string()).unwrap();
    let challenge_id_typed = crate::passkey::types::ChallengeId::new(id.to_string()).unwrap();
    let result = get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;
    assert!(result.is_err());
    match result.unwrap_err() {
        PasskeyError::Authentication(msg) => {
            assert!(msg.contains("Challenge has expired"));
        }
        _ => panic!("Expected Authentication error for timeout"),
    }
}

/// Test options cache basics
///
/// This test verifies basic cache operations for challenge options including storage,
/// retrieval, and validation. It tests the fundamental caching functionality used
/// throughout the challenge management system.
#[tokio::test]
async fn test_options_cache_basics() {
    init_test_environment().await;

    // Instead of using complex types that depend on serde, test the basic cache functionality
    let test_key = "test_cache_key";
    let test_category = "test_category";
    let test_value = "test_value_123".to_string();

    // Put a simple string value in the cache
    let cache_data = CacheData {
        value: test_value.clone(),
    };

    // Store in cache
    let cache_prefix = CachePrefix::new(test_category.to_string()).unwrap();
    let cache_key = CacheKey::new(test_key.to_string()).unwrap();
    let result = GENERIC_CACHE_STORE
        .lock()
        .await
        .put_with_ttl(cache_prefix, cache_key, cache_data, 300)
        .await;
    assert!(result.is_ok(), "Failed to put test data in cache");

    // Retrieve from cache
    let cache_prefix = CachePrefix::new(test_category.to_string()).unwrap();
    let cache_key = CacheKey::new(test_key.to_string()).unwrap();
    let get_result = GENERIC_CACHE_STORE
        .lock()
        .await
        .get(cache_prefix, cache_key)
        .await;
    assert!(get_result.is_ok(), "Failed to get test data from cache");

    let retrieved_data = get_result.unwrap();
    assert!(
        retrieved_data.is_some(),
        "Cache should contain our test data"
    );
    assert_eq!(retrieved_data.unwrap().value, test_value);

    // Remove from cache
    let (cache_prefix_remove, cache_key_remove) = (
        CachePrefix::new(test_category.to_string()).unwrap(),
        CacheKey::new(test_key.to_string()).unwrap(),
    );
    let remove_result =
        passkey_test_utils::remove_from_cache(cache_prefix_remove, cache_key_remove).await;
    assert!(
        remove_result.is_ok(),
        "Failed to remove test data from cache"
    );

    // Verify it's removed
    let cache_prefix = CachePrefix::new(test_category.to_string()).unwrap();
    let cache_key = CacheKey::new(test_key.to_string()).unwrap();
    let final_result = GENERIC_CACHE_STORE
        .lock()
        .await
        .get(cache_prefix, cache_key)
        .await
        .unwrap();
    assert!(
        final_result.is_none(),
        "Cache should be empty after removal"
    );
}

/// Test challenge lifecycle integration
///
/// This test verifies the complete challenge lifecycle from creation to validation
/// in an integrated environment. It tests challenge generation, storage, retrieval,
/// and cleanup processes working together as a complete system.
#[tokio::test]
async fn test_challenge_lifecycle_integration() {
    use crate::passkey::main::test_utils as passkey_test_utils;
    use crate::test_utils::init_test_environment;

    init_test_environment().await;

    // 1. Create a test challenge
    let challenge_type = "test_challenge";
    let id = "test_challenge_lifecycle_id";
    let challenge_str = "test_challenge_123";
    let user_handle = "test_user_handle_challenge";
    let name = "Test User Challenge";
    let display_name = "Test User Display Name";
    let ttl = 300; // 5 minutes

    let create_result = passkey_test_utils::create_test_challenge(
        challenge_type,
        id,
        challenge_str,
        user_handle,
        name,
        display_name,
        ttl,
    )
    .await;
    assert!(create_result.is_ok(), "Failed to create test challenge");

    // 2. Verify the challenge exists in cache
    let (cache_prefix_exists, cache_key_exists) = (
        CachePrefix::new(challenge_type.to_string()).unwrap(),
        CacheKey::new(id.to_string()).unwrap(),
    );
    let exists =
        passkey_test_utils::check_cache_exists(cache_prefix_exists, cache_key_exists).await;
    assert!(exists, "Challenge should exist in cache");

    // 3. Validate the challenge
    let challenge_type_typed =
        crate::passkey::types::ChallengeType::new(challenge_type.to_string()).unwrap();
    let challenge_id_typed = crate::passkey::types::ChallengeId::new(id.to_string()).unwrap();
    let validate_result =
        super::get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;
    assert!(validate_result.is_ok(), "Challenge validation failed");
    let stored_options = validate_result.unwrap();

    // 4. Verify challenge contents
    assert_eq!(stored_options.challenge, challenge_str);
    assert_eq!(stored_options.user.user_handle, user_handle);
    assert_eq!(stored_options.user.name, name);
    assert_eq!(stored_options.user.display_name, display_name);

    // 5. Remove the challenge
    let (cache_prefix_remove, cache_key_remove) = (
        CachePrefix::new(challenge_type.to_string()).unwrap(),
        CacheKey::new(id.to_string()).unwrap(),
    );
    let remove_result = super::remove_options(cache_prefix_remove, cache_key_remove).await;
    assert!(remove_result.is_ok(), "Failed to remove challenge");

    // 6. Verify it's gone
    let (cache_prefix_check, cache_key_check) = (
        CachePrefix::new(challenge_type.to_string()).unwrap(),
        CacheKey::new(id.to_string()).unwrap(),
    );
    let exists_after =
        passkey_test_utils::check_cache_exists(cache_prefix_check, cache_key_check).await;
    assert!(!exists_after, "Challenge should be removed from cache");

    // 7. Try to validate again - should fail with NotFound
    let challenge_type_typed =
        crate::passkey::types::ChallengeType::new(challenge_type.to_string()).unwrap();
    let challenge_id_typed = crate::passkey::types::ChallengeId::new(id.to_string()).unwrap();
    let validate_again =
        super::get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;
    assert!(
        validate_again.is_err(),
        "Challenge should not exist anymore"
    );
    match validate_again.unwrap_err() {
        crate::passkey::errors::PasskeyError::NotFound(_) => {
            // Expected error, success
        }
        e => panic!("Expected NotFound error, got: {e:?}"),
    }
}

/// Test challenge expiration
///
/// This test verifies that challenge expiration mechanisms work correctly by testing
/// time-based challenge invalidation. It validates that expired challenges are properly
/// rejected and that expiration times are enforced correctly.
#[tokio::test]
async fn test_challenge_expiration() {
    use crate::passkey::main::test_utils as passkey_test_utils;
    use crate::test_utils::init_test_environment;

    init_test_environment().await;

    // Create a challenge with very short TTL
    let challenge_type = "test_challenge";
    let id = "test_challenge_expiry_id";
    let challenge_str = "test_challenge_expiry";
    let user_handle = "test_user_handle_expiry";
    let name = "Test User Expiry";
    let display_name = "Test User Expiry";
    let ttl = 1; // 1 second TTL

    let create_result = passkey_test_utils::create_test_challenge(
        challenge_type,
        id,
        challenge_str,
        user_handle,
        name,
        display_name,
        ttl,
    )
    .await;
    assert!(create_result.is_ok(), "Failed to create test challenge");

    // Wait for expiration (2 seconds)
    tokio::time::sleep(std::time::Duration::from_secs(2)).await;

    // Validate the challenge - should fail with expired error
    let challenge_type_typed =
        crate::passkey::types::ChallengeType::new(challenge_type.to_string()).unwrap();
    let challenge_id_typed = crate::passkey::types::ChallengeId::new(id.to_string()).unwrap();
    let validate_result =
        super::get_and_validate_options(&challenge_type_typed, &challenge_id_typed).await;

    match validate_result {
        Err(crate::passkey::errors::PasskeyError::Authentication(msg)) => {
            assert!(msg.contains("expired"), "Error should indicate expiration");
        }
        Err(crate::passkey::errors::PasskeyError::NotFound(_)) => {
            // This is also acceptable - cache might have already cleaned it up
        }
        _ => panic!("Expected Authentication or NotFound error for expired challenge"),
    }
}