sui-id-core 0.59.0

Authentication / authorization core (OIDC / OAuth2 + PKCE) for sui-id, a self-hosted Rust OIDC provider.
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
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
//! Step-up authentication: requiring a fresh proof of a strong factor
//! before a sensitive action.
//!
//! ## What "step-up" means here
//!
//! A regular session is fine for routine reads (the dashboard, your
//! own profile, the audit log) but should not, on its own, be enough
//! to authorise a destructive or security-relevant action — change a
//! password, revoke every other session at once, delete a client,
//! force-reset another user's MFA, rotate a signing key. The
//! prevailing risk is **session theft**: someone who has stolen a
//! cookie can navigate the UI, but should not be able to immediately
//! ratchet that into permanent damage.
//!
//! Step-up auth closes that gap by requiring a fresh MFA challenge
//! shortly before the sensitive action, regardless of how the
//! session was originally established. The freshness window is
//! short — five minutes by default — so a stolen cookie is useful
//! for almost no destructive purpose.
//!
//! ## What we *don't* do here
//!
//! - **Force step-up on accounts that don't have MFA enrolled.** If
//!   the user's only factor is a password, a "step-up" challenge
//!   would be a password re-prompt — same factor, same theft model,
//!   no security gain, large UX cost. We document this clearly and
//!   leave it to operators (and to this project's own roadmap of
//!   making MFA mandatory) to close the gap by enrolling MFA.
//! - **Per-action policy customisation.** The freshness window is
//!   one number. If you need different windows for different
//!   actions, the better answer is to require MFA for the user
//!   instead of building a fine-grained policy DSL.
//! - **Storing per-action proof tokens.** Some systems mint a
//!   short-lived "this user proved MFA at 14:07; the action
//!   submitted at 14:09 carries the proof token". We instead just
//!   compare `now - session.last_step_up_at` because the session
//!   is the action's authorisation context already, and an extra
//!   token is one more piece of state to lose.

use crate::errors::{CoreError, CoreResult};
use crate::time::SharedClock;
use crate::webauthn;
use chrono::{DateTime, Duration};
use sui_id_shared::ids::{SessionId, UserId};
use sui_id_store::repos::{sessions, user_totp};
use sui_id_store::Database;

/// Default freshness window: a session whose last step-up
/// happened within this many seconds is treated as fresh. Long
/// enough to avoid retyping a TOTP code three times in one
/// admin-cleanup pass, short enough that a session stolen
/// hours after a previous step-up gets re-challenged.
pub const STEP_UP_FRESHNESS_SECS: i64 = 300; // 5 minutes

/// Outcome of `policy_for_session`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepUpDecision {
    /// The session is allowed through without a challenge — either
    /// because the user has no MFA enrolled (so a step-up would
    /// just be a password re-prompt and not a meaningful gate), or
    /// because the session's `last_step_up_at` is within the
    /// freshness window.
    Allow,
    /// The session must complete a step-up challenge before the
    /// caller proceeds. The handler typically responds with a
    /// redirect to `/me/security/step-up?return_to=...`.
    Challenge,
}

/// Decide whether a session can perform a sensitive action right
/// now, or must first complete a step-up challenge.
///
/// The rule:
///
/// - If the user has *no* MFA factor enrolled (no TOTP, no
///   passkeys), allow. Step-up for a password-only account would
///   be a password re-prompt, which buys nothing against an
///   attacker who already has the cookie *and* the password.
/// - Otherwise, require `last_step_up_at >= now - freshness_secs`.
///   Sessions with `last_step_up_at = None` (password-only
///   established, or pre-migration row) always need to challenge.
pub async fn policy_for_session(
    db: &Database,
    clock: &SharedClock,
    user_id: UserId,
    last_step_up_at: Option<DateTime<chrono::Utc>>,
    freshness_secs: i64,
) -> CoreResult<StepUpDecision> {
    if !user_has_mfa(db, user_id).await? {
        return Ok(StepUpDecision::Allow);
    }
    let last = match last_step_up_at {
        Some(t) => t,
        None => return Ok(StepUpDecision::Challenge),
    };
    let cutoff = clock.now() - Duration::seconds(freshness_secs);
    if last >= cutoff {
        Ok(StepUpDecision::Allow)
    } else {
        Ok(StepUpDecision::Challenge)
    }
}

/// Whether the user has any MFA factor enrolled (TOTP enabled
/// *or* at least one WebAuthn credential).
pub async fn user_has_mfa(db: &Database, user_id: UserId) -> CoreResult<bool> {
    let totp = user_totp::get(db, user_id).await?
        .map(|r| r.enabled)
        .unwrap_or(false);
    if totp {
        return Ok(true);
    }
    let has_passkey = webauthn::has_credentials(db, user_id).await?;
    Ok(has_passkey)
}

/// Mark a session as having just successfully completed a step-up
/// challenge. The caller has *already* verified the second factor
/// (TOTP code or WebAuthn assertion). This function only updates
/// the session row's `last_step_up_at`.
pub async fn touch_step_up(
    db: &Database,
    clock: &SharedClock,
    session_id: SessionId,
) -> CoreResult<()> {
    sessions::touch_step_up(db, session_id, clock.now()).await?;
    Ok(())
}

/// Verify a TOTP code (or single-use recovery code) entered into a
/// step-up form by an already-signed-in user.
///
/// Unlike [`crate::mfa::verify_pending`], this does **not** create
/// a new session — the user already has one. On success it updates
/// `last_step_up_at` on the supplied session and returns
/// `Ok(())`. On failure it returns `Err(CoreError::InvalidCredentials)`
/// without revealing whether the code was wrong, expired, or never
/// configured: a step-up form should look the same to a user with
/// a typo as to an attacker probing whether MFA is enabled.
///
/// Recovery codes are accepted here for the same reason they're
/// accepted on the login MFA challenge: a user who has lost their
/// authenticator app needs *some* path to perform a destructive
/// action. The code is consumed (single-use) on a hit.
pub async fn verify_totp_code(
    db: &Database,
    clock: &SharedClock,
    user_id: UserId,
    session_id: SessionId,
    code_input: &str,
) -> CoreResult<()> {
    use crate::mfa;
    use crate::totp;
    use zeroize::Zeroize;

    let totp_row = user_totp::get(db, user_id).await?
        .ok_or(CoreError::InvalidCredentials)?;
    if !totp_row.enabled {
        return Err(CoreError::InvalidCredentials);
    }

    let trimmed = code_input.trim();
    let accepted = if let Ok(digits) = trimmed.parse::<u32>() {
        let mut secret = user_totp::decrypt_secret(db, &totp_row).await?;
        let now = clock.now().timestamp();
        let result = totp::verify(&secret, now, digits, totp_row.last_used_step).await;
        secret.zeroize();
        match result {
            Some(step) => {
                user_totp::set_last_used_step(db, user_id, step).await?;
                true
            }
            None => false,
        }
    } else {
        // Recovery code path — same shape as in `mfa::verify_pending`.
        mfa::consume_recovery_code(db, user_id, &totp_row, trimmed).await?
    };

    if !accepted {
        return Err(CoreError::InvalidCredentials);
    }

    touch_step_up(db, clock, session_id).await?;
    Ok(())
}

// ---------- WebAuthn-driven step-up ----------
//
// The TOTP / recovery-code path above covers users with an
// authenticator app. Users whose only second factor is a passkey
// also need a way to satisfy a step-up gate. The webauthn-rs
// assertion flow is already split into pure start / finish halves
// (see `webauthn::start_authentication` / `finish_authentication`),
// so the step-up versions are thin wrappers: same low-level
// ceremony, different bookkeeping. The ceremony-state row is tagged
// with `WebauthnPendingKind::StepUp` so a pending login-MFA row
// can never satisfy a step-up gate (and vice versa) even if a
// pending_id ever leaked across contexts.
//
// The handler-side flow is:
//
//   1. POST /me/security/step-up/webauthn/start
//      → step_up::start_webauthn(...).await → returns (challenge_json, pending_id)
//      → handler streams challenge_json to JS, sets pending_id in
//        a short-lived cookie
//   2. JS calls navigator.credentials.get(...) and POSTs the
//      assertion back to
//      POST /me/security/step-up/webauthn/finish
//      → step_up::finish_webauthn(...).await → on success, last_step_up_at
//        is set
//      → handler clears the pending_id cookie and 303s back to
//        return_to

/// Output of [`start_webauthn`].
pub struct WebauthnStepUpStart {
    /// JSON the browser hands to navigator.credentials.get().
    pub challenge_json: String,
    /// Opaque id of the pending row that holds the auth state.
    /// The handler stuffs this into a short-lived cookie.
    pub pending_id: sui_id_shared::ids::WebauthnPendingId,
}

/// Begin a WebAuthn step-up ceremony for an already-signed-in user.
///
/// Wraps [`crate::webauthn::start_authentication`] with the
/// step-up-specific bookkeeping: the resulting pending row is tagged
/// `kind = StepUp`. The user must already have at least one
/// passkey enrolled; an empty credential list returns
/// `BadRequest` with the same message the login flow uses.
pub async fn start_webauthn(
    db: &Database,
    clock: &SharedClock,
    issuer_url: &str,
    user_id: UserId,
) -> CoreResult<WebauthnStepUpStart> {
    use crate::webauthn;
    use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
    use sui_id_store::repos::webauthn_pending;

    // Reuse the existing start function — it does the heavy
    // lifting (collect passkeys, build the challenge). Then we
    // peel its pending row out and re-tag it with our kind.
    let started = webauthn::start_authentication(db, clock, issuer_url, user_id).await?;
    // start_authentication wrote a `kind = Authenticate` row.
    // Read it, replace it with a `kind = StepUp` row at the same
    // id, so the finish path can demand the right kind. This is
    // a tiny re-write, but the alternative — duplicating
    // start_authentication's body — would mean two places to keep
    // in sync if webauthn-rs ever changes shape.
    let row = webauthn_pending::get(db, started.pending_id).await?
        .ok_or(CoreError::Internal)?;
    let stepped = WebauthnPendingRow {
        id: row.id,
        kind: WebauthnPendingKind::StepUp,
        user_id: row.user_id,
        state_json: row.state_json,
        expires_at: row.expires_at,
        created_at: row.created_at,
    };
    webauthn_pending::delete(db, row.id).await?;
    webauthn_pending::insert(db, &stepped).await?;

    Ok(WebauthnStepUpStart {
        challenge_json: started.challenge_json,
        pending_id: started.pending_id,
    })
}

/// Finish a WebAuthn step-up ceremony.
///
/// Reads the pending row, refuses if its `kind` isn't `StepUp` (so a
/// stale login-MFA pending row can never satisfy a step-up gate),
/// runs the assertion verify, and on success bumps the session's
/// `last_step_up_at`. The pending row is consumed in either branch.
///
/// Failures collapse to `InvalidCredentials` for the same
/// information-hiding reason the TOTP path does — a step-up form
/// must look the same to a typo as to an attacker probing.
pub async fn finish_webauthn(
    db: &Database,
    clock: &SharedClock,
    issuer_url: &str,
    user_id: UserId,
    session_id: SessionId,
    pending_id: sui_id_shared::ids::WebauthnPendingId,
    credential_json: &str,
) -> CoreResult<()> {
    use crate::webauthn;
    use sui_id_store::models::WebauthnPendingKind;
    use sui_id_store::repos::webauthn_pending;
    use webauthn_rs::prelude::PublicKeyCredential;

    // Verify the kind *before* we burn the row, so a wrong-kind
    // pending row's failure doesn't also delete it (the legitimate
    // login-MFA flow that owns the row should still be able to
    // complete). The invariant we want: a step-up finish on a
    // login-MFA pending row is a no-op for the row.
    let pending = webauthn_pending::get(db, pending_id).await?
        .ok_or(CoreError::InvalidCredentials)?;
    if pending.kind != WebauthnPendingKind::StepUp {
        return Err(CoreError::InvalidCredentials);
    }
    if pending.user_id != Some(user_id) {
        // pending row belongs to someone else — refuse without
        // burning it (so the rightful owner's parallel flow can
        // still complete) and don't reveal the mismatch.
        return Err(CoreError::InvalidCredentials);
    }

    let credential: PublicKeyCredential = serde_json::from_str(credential_json)
        .map_err(|_| CoreError::InvalidCredentials)?;

    // Hand off to the existing finish function — it consumes the
    // pending row on success or expiry, runs the webauthn-rs
    // verify, and updates the credential's signature counter.
    // Because we already validated the kind above, finish_authentication
    // sees a well-formed Authenticate-shaped row from its perspective:
    // the kind check inside webauthn::finish_authentication compares
    // against `Authenticate`, so we have to swap the kind back
    // momentarily.
    //
    // The cleanest way is to re-write the row to Authenticate, then
    // delegate. The pending row's id and state_json are unchanged.
    {
        use sui_id_store::models::WebauthnPendingRow;
        let switched = WebauthnPendingRow {
            id: pending.id,
            kind: WebauthnPendingKind::Authenticate,
            user_id: pending.user_id,
            state_json: pending.state_json.clone(),
            expires_at: pending.expires_at,
            created_at: pending.created_at,
        };
        webauthn_pending::delete(db, pending.id).await?;
        webauthn_pending::insert(db, &switched).await?;
    }

    match webauthn::finish_authentication(
        db,
        clock,
        issuer_url,
        pending_id,
        user_id,
        &credential,
    ).await {
        Ok(()) => {
            touch_step_up(db, clock, session_id).await?;
            Ok(())
        }
        Err(_) => Err(CoreError::InvalidCredentials),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::password;
    use chrono::Utc;
    use sui_id_shared::ids::UserId;
    use sui_id_store::crypto::MasterKey;
    use sui_id_store::models::{CredentialRow, UserRow};
    use sui_id_store::repos::{credentials, user_totp, users};

    fn fresh_db() -> Database {
        Database::open_in_memory(MasterKey::generate()).expect("db")
    }

    async fn create_user(db: &Database) -> UserId {
        let id = UserId::new();
        let now = Utc::now();
        users::create(
            db,
            &UserRow {
                id,
                username: "u".into(),
                display_name: None,
                is_admin: false,
        role: if false { sui_id_store::models::Role::Admin } else { sui_id_store::models::Role::User },
                is_disabled: false,
                is_deleted: false,
                user_uuid: uuid::Uuid::new_v4(),
                created_at: now,
                updated_at: now,
                failed_login_count: 0,
                locked_until: None,
                email: None,
                preferred_lang: None,
                email_normalized: None,
                email_verified_at: None,
            },
        ).await
        .expect("create user");
        let phc = password::hash_password("the-tester-password").expect("hash");
        credentials::upsert(
            db,
            &CredentialRow {
                user_id: id,
                password_hash: phc,
                must_change: false,
                updated_at: now,
            },
        ).await
        .expect("cred");
        id
    }

    async fn enrol_totp(db: &Database, user_id: UserId) {
        // Enrolment goes through pending-then-confirm; for tests we
        // just need an "MFA enrolled" row, so do both halves.
        user_totp::upsert_pending(db, user_id, b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09").await
            .expect("upsert pending");
        user_totp::confirm_with_recovery(db, user_id, b"[]").await.expect("confirm");
    }

    #[tokio::test]
    async     fn user_with_no_mfa_is_always_allowed() {
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        let r = policy_for_session(&db, &clock, uid, None, STEP_UP_FRESHNESS_SECS).await.unwrap();
        assert_eq!(r, StepUpDecision::Allow);
        let r = policy_for_session(
            &db,
            &clock,
            uid,
            Some(Utc::now() - Duration::days(7)),
            STEP_UP_FRESHNESS_SECS,
        ).await
        .unwrap();
        assert_eq!(r, StepUpDecision::Allow);
    }

    #[tokio::test]
    async     fn mfa_user_with_no_step_up_must_challenge() {
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        enrol_totp(&db, uid).await;
        let r = policy_for_session(&db, &clock, uid, None, STEP_UP_FRESHNESS_SECS).await.unwrap();
        assert_eq!(r, StepUpDecision::Challenge);
    }

    #[tokio::test]
    async     fn mfa_user_with_fresh_step_up_is_allowed() {
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        enrol_totp(&db, uid).await;
        let now = clock.now();
        let r = policy_for_session(&db, &clock, uid, Some(now), STEP_UP_FRESHNESS_SECS).await.unwrap();
        assert_eq!(r, StepUpDecision::Allow);
    }

    #[tokio::test]
    async     fn mfa_user_with_stale_step_up_must_challenge_again() {
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        enrol_totp(&db, uid).await;
        let stale = clock.now() - Duration::seconds(STEP_UP_FRESHNESS_SECS + 60);
        let r = policy_for_session(&db, &clock, uid, Some(stale), STEP_UP_FRESHNESS_SECS).await.unwrap();
        assert_eq!(r, StepUpDecision::Challenge);
    }

    #[tokio::test]
    async fn touch_step_up_updates_session_row() {
        use sui_id_store::models::SessionRow;
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        let session_id = SessionId::new();
        let now = clock.now();
        sessions::insert(
            &db,
            &SessionRow {
                id: session_id,
                user_id: uid,
                expires_at: now + Duration::hours(8),
                created_at: now,
                revoked_at: None,
                auth_methods: vec![sui_id_shared::AuthMethod::Pwd],
                last_step_up_at: None,
                last_used_at: None,
            },
        ).await
        .expect("insert");

        touch_step_up(&db, &clock, session_id).await.expect("touch");
        let row = sessions::get(&db, session_id).await.expect("get");
        assert!(row.last_step_up_at.is_some());
    }

    async fn fresh_session(db: &Database, clock: &SharedClock, uid: UserId) -> SessionId {
        use sui_id_store::models::SessionRow;
        let session_id = SessionId::new();
        let now = clock.now();
        sessions::insert(
            db,
            &SessionRow {
                id: session_id,
                user_id: uid,
                expires_at: now + Duration::hours(8),
                created_at: now,
                revoked_at: None,
                auth_methods: vec![sui_id_shared::AuthMethod::Pwd],
                last_step_up_at: None,
                last_used_at: None,
            },
        ).await
        .expect("insert session");
        session_id
    }

    #[tokio::test]
    async fn verify_totp_code_with_correct_code_marks_session_fresh() {
        use crate::totp;
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        // Enrol TOTP with a known secret so we can compute the
        // expected code locally.
        let secret = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
        user_totp::upsert_pending(&db, uid, secret).await.expect("pending");
        user_totp::confirm_with_recovery(&db, uid, b"[]").await.expect("confirm");

        let session_id = fresh_session(&db, &clock, uid).await;
        let now = clock.now().timestamp();
        let step = now / 30;
        let code = totp::code_for_step(secret, step).await;

        verify_totp_code(&db, &clock, uid, session_id, &code.to_string()).await
            .expect("verify ok");

        let row = sessions::get(&db, session_id).await.expect("get");
        assert!(row.last_step_up_at.is_some(), "session should be fresh");
    }

    #[tokio::test]
    async fn verify_totp_code_with_wrong_code_does_not_touch_session() {
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        let secret = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
        user_totp::upsert_pending(&db, uid, secret).await.expect("pending");
        user_totp::confirm_with_recovery(&db, uid, b"[]").await.expect("confirm");

        let session_id = fresh_session(&db, &clock, uid).await;

        // Pass a code that's almost certainly wrong (a fixed value
        // that's unlikely to coincide with the real one — and even
        // if it did, the next pass would still be wrong).
        let result = verify_totp_code(&db, &clock, uid, session_id, "000000").await;
        assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));

        let row = sessions::get(&db, session_id).await.expect("get");
        assert!(
            row.last_step_up_at.is_none(),
            "session must NOT be marked fresh on a failed verify"
        );
    }

    #[tokio::test]
    async     fn verify_totp_code_for_user_without_totp_returns_invalid_credentials() {
        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        let session_id = fresh_session(&db, &clock, uid).await;

        let result = verify_totp_code(&db, &clock, uid, session_id, "123456").await;
        // Same error shape as a wrong code: a step-up form should
        // not leak whether MFA is enrolled.
        assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));
    }

    #[tokio::test]
    async fn finish_webauthn_refuses_pending_with_wrong_kind() {
        // A pending row tagged `Authenticate` (i.e. a login-MFA
        // ceremony) must NOT satisfy a step-up gate, even if the
        // user_id matches and the row hasn't expired. This test
        // pins that invariant — the kind check is the *whole*
        // reason migration 0013 widened the CHECK constraint.
        use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
        use sui_id_store::repos::webauthn_pending;
        use sui_id_shared::ids::WebauthnPendingId;

        let db = fresh_db();
        let clock = crate::time::system_clock();
        let uid = create_user(&db).await;
        let session_id = fresh_session(&db, &clock, uid).await;
        let pending_id = WebauthnPendingId::new();
        let now = clock.now();
        webauthn_pending::insert(
            &db,
            &WebauthnPendingRow {
                id: pending_id,
                kind: WebauthnPendingKind::Authenticate, // wrong kind
                user_id: Some(uid),
                state_json: "{}".into(),
                expires_at: now + Duration::seconds(60),
                created_at: now,
            },
        ).await
        .expect("insert");

        let issuer = "https://test.example";
        // The credential JSON is moot — the kind check fails first.
        let result = finish_webauthn(
            &db,
            &clock,
            issuer,
            uid,
            session_id,
            pending_id,
            r#"{"id":"x","rawId":"x","type":"public-key","response":{}}"#,
        ).await;
        assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));

        // The pending row is intact — refusing a step-up finish on
        // an Authenticate row must not consume it, so the legitimate
        // login-MFA flow that owns the row can still complete.
        let still_there = webauthn_pending::get(&db, pending_id).await
            .expect("query")
            .expect("row preserved");
        assert_eq!(still_there.kind, WebauthnPendingKind::Authenticate);
    }

    #[tokio::test]
    async fn finish_webauthn_refuses_pending_for_other_user() {
        // Even a kind = StepUp pending must be refused if it
        // belongs to a different user. Prevents pending-id
        // smuggling across sessions.
        use sui_id_store::models::{WebauthnPendingKind, WebauthnPendingRow};
        use sui_id_store::repos::webauthn_pending;
        use sui_id_shared::ids::WebauthnPendingId;

        let db = fresh_db();
        let clock = crate::time::system_clock();
        let real_owner = create_user(&db).await;
        // Create a *different* user we'll pretend is the one
        // signed in.
        let imposter = {
            let id = UserId::new();
            let now = Utc::now();
            users::create(
                &db,
                &sui_id_store::models::UserRow {
                    id,
                    username: "imposter".into(),
                    display_name: None,
                    is_admin: false,
        role: if false { sui_id_store::models::Role::Admin } else { sui_id_store::models::Role::User },
                    is_disabled: false,
                    is_deleted: false,
                    user_uuid: uuid::Uuid::new_v4(),
                    created_at: now,
                    updated_at: now,
                    failed_login_count: 0,
                    locked_until: None,
                    email: None,
                preferred_lang: None,
                    email_normalized: None,
                    email_verified_at: None,
                },
            ).await
            .expect("imposter");
            id
        };
        let session_id = fresh_session(&db, &clock, imposter).await;
        let pending_id = WebauthnPendingId::new();
        let now = clock.now();
        webauthn_pending::insert(
            &db,
            &WebauthnPendingRow {
                id: pending_id,
                kind: WebauthnPendingKind::StepUp,
                user_id: Some(real_owner), // the rightful owner
                state_json: "{}".into(),
                expires_at: now + Duration::seconds(60),
                created_at: now,
            },
        ).await
        .expect("insert");

        let result = finish_webauthn(
            &db,
            &clock,
            "https://test.example",
            imposter,
            session_id,
            pending_id,
            r#"{"id":"x","rawId":"x","type":"public-key","response":{}}"#,
        ).await;
        assert!(matches!(result, Err(crate::errors::CoreError::InvalidCredentials)));

        // Pending row was NOT consumed — owner can still complete.
        assert!(webauthn_pending::get(&db, pending_id).await.expect("query").is_some());
    }
}