cheers-axum 0.8.46

axum 0.8 route handlers for cheers — OIDC login/callback (Google, Apple), passkey, magic-link wiring into a SessionAuthority.
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
//! Passkey routes — registration + authentication ceremonies.
//!
//! Each ceremony is a two-request flow: the client POSTs `start` with the
//! user identity, the server stashes the `PasskeyRegistration` /
//! `PasskeyAuthentication` state server-side keyed on a fresh `flow_id`, and
//! returns the WebAuthn challenge to send to the authenticator. The client
//! posts the authenticator's response back to `finish` with the same
//! `flow_id`. The server reclaims the state, verifies the response, and (on
//! success) mints a session.
//!
//! ## Why a flow store instead of a cookie
//!
//! The two cheers-axum OIDC modules bind state to a CSRF cookie because the
//! flow is *browser-driven*: the user is sent off-site to the IdP, returns
//! via a cross-site GET/POST, and the cookie is the only thing the browser
//! preserves. Passkey ceremonies never leave the relying party's origin —
//! WebAuthn's challenge is itself the CSRF binding (the authenticator signs
//! the origin into the assertion). A client-supplied `flow_id` in the body
//! is enough; we keep it explicit so a non-browser client (a native iOS app
//! posting JSON over `URLSession`) doesn't have to wrangle cookies.
//!
//! ## Persist the result, then mint a session
//!
//! `finish_registration` lands a fresh [`Passkey`] into
//! [`PasskeyCredentialStore`] via [`passkey_to_credential`], then establishes
//! a session through the [`SessionAuthority`] with
//! [`DeviceBinding::Passkey`]. The same shape on `finish_authentication`:
//! verify, fold any counter advance back into the stored credential via
//! [`apply_authentication_result`], then mint.
//!
//! ## Wiring
//!
//! ```no_run
//! # use std::sync::Arc;
//! # use axum::Router;
//! # use cheers::passkey::{PasskeyRelyingParty, Url};
//! # use cheers_axum::passkey::{
//! #     router, MemoryPasskeyFlowStore, NoPasskeyRegistrationObserver, PasskeyAuthState,
//! # };
//! # use cheers_axum::me::NoSessionRecorder;
//! # use cheers_server::{PasskeyCredentialStore, SessionAuthority};
//! # async fn run<M, R, U, W, P>(
//! #     rp: Arc<PasskeyRelyingParty>,
//! #     authority: Arc<SessionAuthority<M, R, U, W>>,
//! #     credentials: Arc<P>,
//! # ) -> Result<(), Box<dyn std::error::Error>>
//! # where
//! #     M: cheers_core::TokenMinter + Send + Sync + 'static,
//! #     R: cheers_server::RefreshStore + 'static,
//! #     U: cheers_server::UserStore + 'static,
//! #     W: cheers_server::RevocationWriter + 'static,
//! #     P: PasskeyCredentialStore + 'static,
//! # {
//! let state = PasskeyAuthState {
//!     relying_party: rp,
//!     authority,
//!     credentials,
//!     flows: Arc::new(MemoryPasskeyFlowStore::new()),
//!     // Swap in your own `SessionRecorder` to power `GET /me/sessions`.
//!     recorder: Arc::new(NoSessionRecorder),
//!     // Swap in your own `PasskeyRegistrationObserver` for aggregate,
//!     // non-identifying telemetry about newly persisted credentials.
//!     registration_observer: Arc::new(NoPasskeyRegistrationObserver),
//! };
//!
//! let app: Router = Router::new().nest("/auth", router(Arc::new(state)));
//! # Ok(()) }
//! ```
//!
//! @yah:relay(R729, "Publish cheers-axum with PasskeyRegistrationObserver (committed 8fb16523, unpublished) — noisetable R131-T37 compiles only under a devcrate burst until it ships")
//! @yah:status(review)
//! @yah:at(2026-10-01T21:44:01Z)
//! @yah:assignee(agent:bundle-anthropic-ashguard)
//! @yah:next("OPERATOR: run `scripts/release-all.sh 0.8.42 --execute --allow-dirty-tree` (operator chose a full lockstep 0.8.42 on 2026-10-01; camp git-policy=defer, so agents cannot commit, tag, push, or publish). Preflight passed on 2026-10-01 (log /tmp/che-r729-pre1.log): all 12 oss workspaces are clean at 0.8.41; root crates are still blocked on workspace-hack (pre-existing, not this ticket).")
//! @yah:next("After publish: confirm crates.io cheers-axum max_version is 0.8.42, then in noisetable bump the five cheers* pins to 0.8.42 and run `scripts/devcrate.sh off cheers` (R131-T37), and rerun noisetable QED web-services-published. Then send CHE-R729 to review.")
//! @yah:blocked_on(operator)
//! @yah:handoff("2026-10-01 (Ashguard): verified that PasskeyRegistrationObserver is in the tree. `cargo test -p cheers-axum` is green (60+68+10 passed). The operator chose a lockstep 0.8.42 via the release-all wizard rather than a cheers-only release (cheers depends on mshr at 0.8.41, so a single-repo bump would desync the train). The release-all.sh 0.8.42 preflight exited 0 and wrote nothing. Git and publish steps are the operator's to run under git-policy=defer.")
//! @yah:handoff("Tree anchor at handoff: 84caba1edf35c5c47b6c9b66c74e703568d46612 — the shared tree as I left it. Diff against it (`git diff 84caba1edf35c5c47b6c9b66c74e703568d46612..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
//! @yah:handoff("cheers-axum 0.8.42 is on crates.io (max_version 0.8.42) with PasskeyRegistrationObserver. The consumer side landed in noisetable R131-T38: five cheers* pins bumped to 0.8.42, the cheers devcrate burst ended, and web/services/Cargo.lock now resolves all six cheers* packages from the registry at 0.8.42. Done by the noisetable R131 leader @Ashguard:polaris (session:08934dc6).")
//! @yah:verify("Independent second-agent check (noisetable session:6be910b8): `cd web/services && cargo test -p noisetable-account --no-fail-fast` gave 368 pass / 0 fail, matching the 368/0 baseline. The noisetable QED pipeline web-services-published, which resolves against crates.io only, came back green.")
//! @yah:gotcha("0.8.42 is semver-compatible with ^0.8 in cargo, but the new required PasskeyAuthState.registration_observer field breaks any external constructor on `cargo update`. The operator accepted this on 2026-10-01 (pre-1.0, consumers are internal).")

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use axum::Json;
use axum::Router;
use axum::extract::State;
use axum::routing::post;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::{Deserialize, Serialize};

use cheers::passkey::{
    CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration,
    PasskeyRelyingParty, PasskeyUpdate, PublicKeyCredential, RegisterPublicKeyCredential,
    RequestChallengeResponse, Uuid, apply_authentication_result, passkey_from_credential,
    passkey_to_credential,
};
use cheers_core::{DeviceBinding, DeviceId, TokenMinter, UserId};
use cheers_server::{
    PasskeyCredentialStore, RefreshStore, RevocationWriter, SessionAuthority, UserStore,
};

use crate::error::RouteError;
use crate::me::SessionRecorder;
use crate::session::SessionBody;

/// Server-side stash for a passkey registration ceremony in flight.
///
/// The `user_id` and `device_id` are chosen at `start_registration` time so
/// `finish_registration` can persist the resulting [`Passkey`] without
/// trusting the client to supply them again.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StashedRegistration {
    pub user_id: UserId,
    pub device_id: DeviceId,
    pub state: PasskeyRegistration,
}

/// Server-side stash for a passkey authentication ceremony in flight.
///
/// The `user_id` is pinned at `start_authentication` so `finish_authentication`
/// always re-lists credentials for the same user the challenge was scoped to.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StashedAuthentication {
    pub user_id: UserId,
    pub state: PasskeyAuthentication,
}

/// Persistence for in-flight passkey ceremony state.
///
/// Implementations MUST treat each entry as single-use (a successful
/// `take_*` removes it) so a replayed `finish` cannot re-verify a stale
/// assertion. The state itself carries the WebAuthn challenge; losing the
/// state to the client or reusing it reopens replay attacks — the same
/// contract `OidcFlowStore` carries for OIDC.
#[async_trait]
pub trait PasskeyFlowStore: Send + Sync {
    async fn put_registration(
        &self,
        flow_id: &str,
        value: StashedRegistration,
    ) -> Result<(), String>;
    async fn take_registration(
        &self,
        flow_id: &str,
    ) -> Result<Option<StashedRegistration>, String>;
    async fn put_authentication(
        &self,
        flow_id: &str,
        value: StashedAuthentication,
    ) -> Result<(), String>;
    async fn take_authentication(
        &self,
        flow_id: &str,
    ) -> Result<Option<StashedAuthentication>, String>;
}

/// In-process [`PasskeyFlowStore`] for tests, dev, and single-replica
/// deployments. Production multi-replica deployments want a shared backend
/// (Redis, Postgres, …).
#[derive(Default, Debug)]
pub struct MemoryPasskeyFlowStore {
    reg: Mutex<HashMap<String, StashedRegistration>>,
    auth: Mutex<HashMap<String, StashedAuthentication>>,
}

impl MemoryPasskeyFlowStore {
    pub fn new() -> Self {
        Self::default()
    }
}

#[async_trait]
impl PasskeyFlowStore for MemoryPasskeyFlowStore {
    async fn put_registration(
        &self,
        flow_id: &str,
        value: StashedRegistration,
    ) -> Result<(), String> {
        self.reg.lock().unwrap().insert(flow_id.to_owned(), value);
        Ok(())
    }
    async fn take_registration(
        &self,
        flow_id: &str,
    ) -> Result<Option<StashedRegistration>, String> {
        Ok(self.reg.lock().unwrap().remove(flow_id))
    }
    async fn put_authentication(
        &self,
        flow_id: &str,
        value: StashedAuthentication,
    ) -> Result<(), String> {
        self.auth.lock().unwrap().insert(flow_id.to_owned(), value);
        Ok(())
    }
    async fn take_authentication(
        &self,
        flow_id: &str,
    ) -> Result<Option<StashedAuthentication>, String> {
        Ok(self.auth.lock().unwrap().remove(flow_id))
    }
}

/// Observes a passkey credential landing in [`PasskeyCredentialStore`] at
/// registration finish — after persistence succeeds, before the session is
/// established.
///
/// Exists for products that want aggregate, non-identifying telemetry about
/// the credential population (e.g. calibrating a decoy width distribution
/// against the real one) without cheers taking an opinion on where that
/// telemetry lives — the same "guide by omission" reasoning
/// [`SessionRecorder`] documents for session listing.
///
/// `cred_id_len` is the WebAuthn credential id's byte length; `credential_count`
/// is `user_id`'s total registered-credential count immediately *after* this
/// one persisted (so two registrations on one account report `1` then `2`,
/// not `2` twice) — callers wanting a running histogram of credentials-per-
/// account, rather than only a final tally, want exactly this shape.
///
/// Best-effort by design, unlike [`SessionRecorder::record_established`]: a
/// dropped counter increment does not leave the caller with a credential they
/// can neither see nor use, so a failure here is the implementation's own to
/// log, not the ceremony's to fail on.
#[async_trait]
pub trait PasskeyRegistrationObserver: Send + Sync {
    async fn record_registered(&self, user_id: &UserId, cred_id_len: usize, credential_count: usize);
}

/// No-op default — opt out on purpose, matching [`NoSessionRecorder`].
#[derive(Debug, Clone, Copy, Default)]
pub struct NoPasskeyRegistrationObserver;

#[async_trait]
impl PasskeyRegistrationObserver for NoPasskeyRegistrationObserver {
    async fn record_registered(&self, _user_id: &UserId, _cred_id_len: usize, _credential_count: usize) {}
}

/// State bundle held by the passkey handlers.
pub struct PasskeyAuthState<M, R, U, W, P, F> {
    pub relying_party: Arc<PasskeyRelyingParty>,
    pub authority: Arc<SessionAuthority<M, R, U, W>>,
    pub credentials: Arc<P>,
    pub flows: Arc<F>,
    /// Where a freshly established session is handed to the product, so it
    /// can answer `GET /me/sessions` later. Both ceremonies here establish a
    /// session (register and authenticate), and both report through it.
    /// Required rather than optional — wire
    /// [`NoSessionRecorder`](crate::me::NoSessionRecorder) to opt out on
    /// purpose.
    pub recorder: Arc<dyn SessionRecorder>,
    /// Fired once per successful registration, after the credential is
    /// persisted. Required rather than optional, matching `recorder` above —
    /// wire [`NoPasskeyRegistrationObserver`] to opt out on purpose.
    pub registration_observer: Arc<dyn PasskeyRegistrationObserver>,
}

impl<M, R, U, W, P, F> std::fmt::Debug for PasskeyAuthState<M, R, U, W, P, F> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PasskeyAuthState")
            .field("rp_id", &self.relying_party.rp_id())
            .finish_non_exhaustive()
    }
}

/// Build a router mounting the four passkey routes. The product mounts it
/// under whatever base path it chose (`/auth`, `/api/auth`, …).
pub fn router<M, R, U, W, P, F>(state: Arc<PasskeyAuthState<M, R, U, W, P, F>>) -> Router
where
    M: TokenMinter + Send + Sync + 'static,
    R: RefreshStore + Send + Sync + 'static,
    U: UserStore + Send + Sync + 'static,
    W: RevocationWriter + Send + Sync + 'static,
    P: PasskeyCredentialStore + Send + Sync + 'static,
    F: PasskeyFlowStore + Send + Sync + 'static,
{
    Router::new()
        .route(
            "/passkey/register/start",
            post(register_start::<M, R, U, W, P, F>),
        )
        .route(
            "/passkey/register/finish",
            post(register_finish::<M, R, U, W, P, F>),
        )
        .route(
            "/passkey/authenticate/start",
            post(authenticate_start::<M, R, U, W, P, F>),
        )
        .route(
            "/passkey/authenticate/finish",
            post(authenticate_finish::<M, R, U, W, P, F>),
        )
        .with_state(state)
}

/// Request body for `POST /passkey/register/start`.
///
/// `user_id` + `device_id` pin the ceremony — `finish_registration` writes
/// the resulting passkey into the credential store at that key. Products
/// that want a server-chosen `device_id` (the common case for "add a new
/// authenticator to this account") should supply one here; the value is
/// opaque to the WebAuthn flow.
#[derive(Debug, Clone, Deserialize)]
pub struct RegisterStartRequest {
    pub user_id: String,
    pub device_id: String,
    /// Friendly account label (typically an email). Surfaced in some
    /// authenticator UIs; must not be treated as a key.
    pub user_name: String,
    /// Display name shown to the user during the ceremony.
    pub user_display_name: String,
}

#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct RegisterStartResponse {
    pub flow_id: String,
    pub challenge: CreationChallengeResponse,
}

pub async fn register_start<M, R, U, W, P, F>(
    State(state): State<Arc<PasskeyAuthState<M, R, U, W, P, F>>>,
    Json(req): Json<RegisterStartRequest>,
) -> Result<Json<RegisterStartResponse>, RouteError>
where
    M: TokenMinter + Send + Sync + 'static,
    R: RefreshStore + Send + Sync + 'static,
    U: UserStore + Send + Sync + 'static,
    W: RevocationWriter + Send + Sync + 'static,
    P: PasskeyCredentialStore + Send + Sync + 'static,
    F: PasskeyFlowStore + Send + Sync + 'static,
{
    let user_id = UserId::new(req.user_id);
    let device_id = DeviceId::new(req.device_id);

    // Exclude already-registered passkeys so an authenticator can't enroll a
    // duplicate against the same account (a different authenticator can still
    // enroll a separate credential — that's the multi-credential model).
    let existing = state.credentials.list_for_user(&user_id).await?;
    let exclude: Vec<Passkey> = existing
        .iter()
        .map(passkey_from_credential)
        .collect::<Result<_, _>>()?;

    let user_handle = uuid_from_user_id(&user_id);
    let (challenge, registration) = state.relying_party.start_registration(
        user_handle,
        &req.user_name,
        &req.user_display_name,
        &exclude,
    )?;

    let flow_id = random_flow_id();
    let stashed = StashedRegistration {
        user_id,
        device_id,
        state: registration,
    };
    state
        .flows
        .put_registration(&flow_id, stashed)
        .await
        .map_err(RouteError::Store)?;

    Ok(Json(RegisterStartResponse { flow_id, challenge }))
}

#[derive(Debug, Clone, Deserialize)]
pub struct RegisterFinishRequest {
    pub flow_id: String,
    pub credential: RegisterPublicKeyCredential,
}

pub async fn register_finish<M, R, U, W, P, F>(
    State(state): State<Arc<PasskeyAuthState<M, R, U, W, P, F>>>,
    Json(req): Json<RegisterFinishRequest>,
) -> Result<Json<SessionBody>, RouteError>
where
    M: TokenMinter + Send + Sync + 'static,
    R: RefreshStore + Send + Sync + 'static,
    U: UserStore + Send + Sync + 'static,
    W: RevocationWriter + Send + Sync + 'static,
    P: PasskeyCredentialStore + Send + Sync + 'static,
    F: PasskeyFlowStore + Send + Sync + 'static,
{
    let stashed = state
        .flows
        .take_registration(&req.flow_id)
        .await
        .map_err(RouteError::Store)?
        .ok_or(RouteError::UnknownFlow)?;

    let passkey = state
        .relying_party
        .finish_registration(&req.credential, &stashed.state)?;
    let credential =
        passkey_to_credential(stashed.user_id.clone(), stashed.device_id.clone(), &passkey)?;
    state.credentials.put(&credential).await?;

    let credential_count = state.credentials.list_for_user(&stashed.user_id).await?.len();
    state
        .registration_observer
        .record_registered(&stashed.user_id, passkey.cred_id().len(), credential_count)
        .await;

    let now = now_unix();
    let session = state
        .authority
        .establish(
            stashed.user_id,
            stashed.device_id,
            DeviceBinding::Passkey,
            now,
        )
        .await?;
    state.recorder.record_new_session(&session).await?;
    Ok(Json(SessionBody::from_new_session(session)))
}

#[derive(Debug, Clone, Deserialize)]
pub struct AuthenticateStartRequest {
    pub user_id: String,
}

#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct AuthenticateStartResponse {
    pub flow_id: String,
    pub challenge: RequestChallengeResponse,
}

pub async fn authenticate_start<M, R, U, W, P, F>(
    State(state): State<Arc<PasskeyAuthState<M, R, U, W, P, F>>>,
    Json(req): Json<AuthenticateStartRequest>,
) -> Result<Json<AuthenticateStartResponse>, RouteError>
where
    M: TokenMinter + Send + Sync + 'static,
    R: RefreshStore + Send + Sync + 'static,
    U: UserStore + Send + Sync + 'static,
    W: RevocationWriter + Send + Sync + 'static,
    P: PasskeyCredentialStore + Send + Sync + 'static,
    F: PasskeyFlowStore + Send + Sync + 'static,
{
    let user_id = UserId::new(req.user_id);
    let credentials = state.credentials.list_for_user(&user_id).await?;
    if credentials.is_empty() {
        // start_authentication with no candidates would still produce a
        // challenge, but no authenticator can answer it — fail loud here.
        return Err(RouteError::UnknownCredential);
    }
    let passkeys: Vec<Passkey> = credentials
        .iter()
        .map(passkey_from_credential)
        .collect::<Result<_, _>>()?;

    let (challenge, authentication) = state.relying_party.start_authentication(&passkeys)?;

    let flow_id = random_flow_id();
    let stashed = StashedAuthentication {
        user_id,
        state: authentication,
    };
    state
        .flows
        .put_authentication(&flow_id, stashed)
        .await
        .map_err(RouteError::Store)?;
    Ok(Json(AuthenticateStartResponse { flow_id, challenge }))
}

#[derive(Debug, Clone, Deserialize)]
pub struct AuthenticateFinishRequest {
    pub flow_id: String,
    pub credential: PublicKeyCredential,
}

pub async fn authenticate_finish<M, R, U, W, P, F>(
    State(state): State<Arc<PasskeyAuthState<M, R, U, W, P, F>>>,
    Json(req): Json<AuthenticateFinishRequest>,
) -> Result<Json<SessionBody>, RouteError>
where
    M: TokenMinter + Send + Sync + 'static,
    R: RefreshStore + Send + Sync + 'static,
    U: UserStore + Send + Sync + 'static,
    W: RevocationWriter + Send + Sync + 'static,
    P: PasskeyCredentialStore + Send + Sync + 'static,
    F: PasskeyFlowStore + Send + Sync + 'static,
{
    let stashed = state
        .flows
        .take_authentication(&req.flow_id)
        .await
        .map_err(RouteError::Store)?
        .ok_or(RouteError::UnknownFlow)?;

    let result = state
        .relying_party
        .finish_authentication(&req.credential, &stashed.state)?;

    // Re-list the user's credentials to find which device answered + persist
    // any counter advance the assertion produced.
    let credentials = state.credentials.list_for_user(&stashed.user_id).await?;
    let paired: Vec<(DeviceId, Passkey)> = credentials
        .iter()
        .map(|c| Ok::<_, RouteError>((c.device_id.clone(), passkey_from_credential(c)?)))
        .collect::<Result<_, _>>()?;
    let device_id = paired
        .iter()
        .find(|(_, p)| p.cred_id() == result.cred_id())
        .map(|(d, _)| d.clone())
        .ok_or(RouteError::UnknownCredential)?;

    let mut passkeys: Vec<Passkey> = paired.iter().map(|(_, p)| p.clone()).collect();
    match apply_authentication_result(&mut passkeys, &result) {
        PasskeyUpdate::Updated(updated) => {
            let cred =
                passkey_to_credential(stashed.user_id.clone(), device_id.clone(), updated)?;
            state.credentials.update(&cred).await?;
        }
        PasskeyUpdate::Unchanged(_) => {}
        PasskeyUpdate::UnknownCredential => return Err(RouteError::UnknownCredential),
        // PasskeyUpdate is #[non_exhaustive]; any future variant gets a
        // ceremony-failed mapping until a dedicated handler lands.
        other => return Err(RouteError::Ceremony(format!("{other:?}"))),
    }

    let now = now_unix();
    let session = state
        .authority
        .establish(stashed.user_id, device_id, DeviceBinding::Passkey, now)
        .await?;
    state.recorder.record_new_session(&session).await?;
    Ok(Json(SessionBody::from_new_session(session)))
}

/// WebAuthn user handles are opaque ≤64-byte UUIDs that must be stable per
/// user and must not be PII. We derive one by hashing the cheers `UserId`
/// into a v5 UUID so the same user always reaches the same handle without
/// the product having to maintain a `UserId ↔ Uuid` table.
///
/// `webauthn-rs` re-exports `Uuid` but its transitive `uuid` dep doesn't
/// enable the `v5` feature; we take a direct `uuid` dep (gated on the
/// `passkey` feature) for the constructor and convert via `into()` — the
/// concrete type is the same `uuid::Uuid`.
fn uuid_from_user_id(user_id: &UserId) -> Uuid {
    let derived = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, user_id.as_str().as_bytes());
    // `webauthn_rs::prelude::Uuid` IS `uuid::Uuid` — explicit conversion in
    // case a future webauthn-rs ever wraps it in a newtype.
    Uuid::from_bytes(*derived.as_bytes())
}

/// 128-bit random flow id, base64url-no-pad. Used as the in-flight ceremony
/// key — uniqueness only, not a secret (the secret is the challenge inside
/// the stashed state).
fn random_flow_id() -> String {
    let mut bytes = [0u8; 16];
    getrandom::fill(&mut bytes).expect("OS CSPRNG must be available");
    URL_SAFE_NO_PAD.encode(bytes)
}

fn now_unix() -> i64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn uuid_from_user_id_is_stable_per_user() {
        let a = uuid_from_user_id(&UserId::new("u-1"));
        let b = uuid_from_user_id(&UserId::new("u-1"));
        assert_eq!(a, b);
        let c = uuid_from_user_id(&UserId::new("u-2"));
        assert_ne!(a, c);
    }

    #[test]
    fn random_flow_id_is_unique() {
        let a = random_flow_id();
        let b = random_flow_id();
        assert_ne!(a, b);
        assert_eq!(a.len(), 22);
    }
}