did-hosting-common 0.8.6

DID Hosting Common, Common methods and structs related to WebVH Content
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
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use uuid::Uuid;
use webauthn_rs::prelude::*;

use super::{PasskeyState, store};
use crate::server::acl::{self, AclEntry, Role, check_acl};
use crate::server::auth::extractor::{AdminAuth, AuthClaims, StepUpAuth};
use crate::server::auth::session::{
    TokenResponse, create_authenticated_session, elevate_session, now_epoch,
};
use crate::server::error::AppError;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// First-8-character prefix of a token, for log correlation without exposing the
/// full secret. Tokens are 32 bytes hex-encoded (64 chars), so 8 chars give an
/// adversary 32 bits of identifier — useful for correlation but not enough to
/// guess the remaining 56 chars.
fn token_prefix(token: &str) -> &str {
    &token[..token.len().min(8)]
}

fn require_webauthn<S: PasskeyState>(state: &S) -> Result<&Webauthn, AppError> {
    state.webauthn().map(|w| w.as_ref()).ok_or_else(|| {
        warn!("passkey request rejected: WebAuthn not configured (set public_url)");
        AppError::Authentication("passkey auth not configured (set public_url)".into())
    })
}

fn require_jwt_keys<S: PasskeyState>(
    state: &S,
) -> Result<&crate::server::auth::jwt::JwtKeys, AppError> {
    state
        .jwt_keys()
        .map(|k| k.as_ref())
        .ok_or_else(|| AppError::Authentication("JWT keys not configured".into()))
}

// ---------------------------------------------------------------------------
// POST /auth/passkey/enroll/start
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
pub struct EnrollStartRequest {
    pub token: String,
}

// `token` is the 32-byte CSPRNG bearer secret that gates passkey enrollment;
// redeeming it creates an ACL entry with the invite's baked-in role (up to
// Admin). Manual Debug prevents a tracing::debug!(?req, …) from logging
// what is effectively an account-creation credential.
impl std::fmt::Debug for EnrollStartRequest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EnrollStartRequest")
            .field("token", &"<redacted>")
            .finish()
    }
}

#[derive(Debug, Serialize)]
pub struct EnrollStartResponse {
    pub registration_id: String,
    pub options: CreationChallengeResponse,
}

pub async fn enroll_start<S: PasskeyState>(
    State(state): State<S>,
    Json(req): Json<EnrollStartRequest>,
) -> Result<Json<EnrollStartResponse>, AppError> {
    let webauthn = require_webauthn(&state)?;
    let sessions_ks = state.sessions_ks();
    let acl_ks = state.acl_ks();

    // Read (don't consume) the enrollment. The actual `take` happens
    // in `enroll_finish` after the WebAuthn ceremony succeeds — that
    // way a failed ceremony (browser closed, key not present, RP
    // mismatch, attacker decline-after-clicking) leaves the invite
    // intact for the legitimate user to retry. To prevent the race
    // where two concurrent `enroll_start` calls both proceed past
    // this point, we record `claimed_at` and refuse a second
    // start within the claim window.
    let mut enrollment = store::get_enrollment(sessions_ks, &req.token)
        .await?
        .ok_or_else(|| {
            warn!("passkey enrollment rejected: token not found or already used");
            AppError::Authentication("enrollment not found or already used".into())
        })?;

    let now = now_epoch();

    // Expiry runs first — an expired claim is moot.
    if now > enrollment.expires_at {
        warn!(did = %enrollment.did, "passkey enrollment rejected: link expired");
        return Err(AppError::Authentication(
            "enrollment link has expired".into(),
        ));
    }

    // Concurrent-claim check. Within the window, reject; outside the
    // window, the previous claim is stale (failed ceremony) and we
    // overwrite.
    if let Some(claimed_at) = enrollment.claimed_at
        && now.saturating_sub(claimed_at) < store::ENROLLMENT_CLAIM_WINDOW_SECS
    {
        warn!(did = %enrollment.did, "passkey enrollment rejected: ceremony already in progress");
        return Err(AppError::Authentication(
            "enrollment is already in progress; retry after the current ceremony completes or expires".into(),
        ));
    }
    enrollment.claimed_at = Some(now);
    store::store_enrollment(sessions_ks, &enrollment).await?;

    // Ensure DID is in ACL — the enrollment itself is the admin's authorization,
    // so create the ACL entry if it doesn't already exist.
    let role = enrollment.role.parse::<Role>()?;
    if acl::get_acl_entry(acl_ks, &enrollment.did).await?.is_none() {
        let entry = AclEntry {
            did: enrollment.did.clone(),
            role: role.clone(),
            label: Some("enrolled via passkey invite".into()),
            created_at: now_epoch(),
            max_total_size: None,
            max_did_count: None,

            domains: crate::server::domain::DomainScope::All,
        };
        acl::store_acl_entry(acl_ks, &entry).await?;
        info!(did = %enrollment.did, role = %role, "ACL entry created from enrollment");
    }

    // Create or look up PasskeyUser for this DID
    let user = match store::get_passkey_user_by_did(sessions_ks, &enrollment.did).await? {
        Some(u) => u,
        None => store::PasskeyUser {
            user_uuid: Uuid::new_v4(),
            did: enrollment.did.clone(),
            display_name: enrollment.did.clone(),
            credentials: Vec::new(),
        },
    };

    // Collect existing credential IDs to exclude (prevent re-registration)
    let exclude: Option<Vec<CredentialID>> = if user.credentials.is_empty() {
        None
    } else {
        Some(
            user.credentials
                .iter()
                .map(|c| c.cred_id().clone())
                .collect(),
        )
    };

    // Start registration ceremony
    let (ccr, reg_state) = webauthn
        .start_passkey_registration(user.user_uuid, &user.did, &user.display_name, exclude)
        .map_err(|e| AppError::Internal(format!("webauthn registration start failed: {e}")))?;

    // Persist the user (so finish can find it), registration state, and user mapping
    store::store_passkey_user(sessions_ks, &user).await?;

    let reg_id = Uuid::new_v4().to_string();
    store::store_registration_state(sessions_ks, &reg_id, &reg_state).await?;
    store::store_registration_user(sessions_ks, &reg_id, &user.user_uuid).await?;
    // Carry the enrollment token forward so `enroll_finish` can
    // consume it after the WebAuthn ceremony succeeds.
    store::store_registration_enrollment(sessions_ks, &reg_id, &req.token).await?;

    info!(did = %user.did, reg_id = %reg_id, "passkey enrollment started");

    Ok(Json(EnrollStartResponse {
        registration_id: reg_id,
        options: ccr,
    }))
}

// ---------------------------------------------------------------------------
// POST /auth/passkey/enroll/finish
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub struct EnrollFinishRequest {
    pub registration_id: String,
    pub credential: RegisterPublicKeyCredential,
}

pub async fn enroll_finish<S: PasskeyState>(
    State(state): State<S>,
    Json(req): Json<EnrollFinishRequest>,
) -> Result<Json<TokenResponse>, AppError> {
    let webauthn = require_webauthn(&state)?;
    let jwt_keys = require_jwt_keys(&state)?;
    let sessions_ks = state.sessions_ks();
    let acl_ks = state.acl_ks();

    // Atomically load and delete registration state (prevents race conditions)
    let reg_state = store::take_registration_state(sessions_ks, &req.registration_id)
        .await?
        .ok_or_else(|| {
            AppError::Authentication("registration state not found or expired".into())
        })?;

    // Complete registration ceremony. If this fails, the enrollment
    // token is NOT consumed — the legitimate user can retry once the
    // claim window expires.
    let passkey = webauthn
        .finish_passkey_registration(&req.credential, &reg_state)
        .map_err(|e| {
            warn!(reg_id = %req.registration_id, error = %e, "passkey registration ceremony failed");
            AppError::Authentication(format!("passkey registration failed: {e}"))
        })?;

    // Now consume the enrollment token. The take is atomic across
    // replicas (refresh-token rotation pattern); a second concurrent
    // finish that races us sees None and would already have failed
    // the registration-state take above.
    if let Some(token) =
        store::take_registration_enrollment(sessions_ks, &req.registration_id).await?
    {
        let _ = store::take_enrollment(sessions_ks, &token).await?;
    }
    // Backwards-compat path: no mapping found (e.g. an in-flight
    // ceremony that started before this version was deployed) — fall
    // through; the next `enroll_start` will re-take it.

    // Load user UUID from registration-to-user mapping
    let user_uuid = store::get_registration_user(sessions_ks, &req.registration_id)
        .await?
        .ok_or_else(|| AppError::Internal("registration user mapping not found".into()))?;
    store::delete_registration_user(sessions_ks, &req.registration_id).await?;

    let mut user = store::get_passkey_user(sessions_ks, &user_uuid)
        .await?
        .ok_or_else(|| AppError::Internal("passkey user not found".into()))?;

    // Store credential mapping
    let cred_id_hex = hex::encode(passkey.cred_id());
    store::store_credential_mapping(sessions_ks, &cred_id_hex, user.user_uuid).await?;

    // Append the new credential
    user.credentials.push(passkey);
    store::store_passkey_user(sessions_ks, &user).await?;

    // Check ACL role
    let role = check_acl(acl_ks, &user.did).await?;

    // Enrollment doesn't supply a session pubkey today — the UI
    // sends it on subsequent /login/finish calls (so each enrolled
    // device gets a fresh ephemeral key per session). Pass `None`.
    // Passkey enrollment-as-login mints at aal2 (the WebAuthn
    // assertion IS the second factor).
    let token_resp = create_authenticated_session(
        sessions_ks,
        jwt_keys,
        &user.did,
        &role,
        state.access_token_expiry(),
        state.refresh_token_expiry(),
        None,
        Some((vec!["passkey".to_string()], "aal2".to_string())),
    )
    .await?;

    info!(did = %user.did, "passkey enrollment completed");

    Ok(Json(token_resp))
}

// ---------------------------------------------------------------------------
// POST /auth/passkey/login/start
// ---------------------------------------------------------------------------

#[derive(Debug, Serialize)]
pub struct LoginStartResponse {
    pub auth_id: String,
    pub options: RequestChallengeResponse,
}

pub async fn login_start<S: PasskeyState>(
    State(state): State<S>,
) -> Result<Json<LoginStartResponse>, AppError> {
    let webauthn = require_webauthn(&state)?;
    let sessions_ks = state.sessions_ks();

    // Collect all stored passkeys for discoverable authentication
    let all_passkeys = store::get_all_passkeys(sessions_ks).await?;

    if all_passkeys.is_empty() {
        warn!("passkey login failed: no passkeys registered on this server");
        return Err(AppError::Authentication(
            "no passkeys registered on this server".into(),
        ));
    }

    let (rcr, auth_state) = webauthn
        .start_passkey_authentication(&all_passkeys)
        .map_err(|e| AppError::Internal(format!("webauthn auth start failed: {e}")))?;

    let auth_id = Uuid::new_v4().to_string();
    store::store_auth_state(sessions_ks, &auth_id, &auth_state).await?;

    info!(auth_id = %auth_id, passkey_count = all_passkeys.len(), "passkey login challenge issued");

    Ok(Json(LoginStartResponse {
        auth_id,
        options: rcr,
    }))
}

// ---------------------------------------------------------------------------
// POST /auth/passkey/login/finish
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub struct LoginFinishRequest {
    pub auth_id: String,
    pub credential: PublicKeyCredential,
    /// Optional ephemeral session pubkey — Ed25519 multikey
    /// base58btc-encoded (`z6Mk…`). When the client supplies this on
    /// login, the server stores it on the session record and uses it
    /// to verify Data Integrity proofs on REQUIRED-spec trust-task
    /// requests. Absent for backend callers that sign with their own
    /// DID's verificationMethods (and for legacy UI builds without
    /// in-band signing).
    #[serde(default)]
    pub session_pubkey_b58btc: Option<String>,
}

pub async fn login_finish<S: PasskeyState>(
    State(state): State<S>,
    Json(req): Json<LoginFinishRequest>,
) -> Result<Json<TokenResponse>, AppError> {
    let webauthn = require_webauthn(&state)?;
    let jwt_keys = require_jwt_keys(&state)?;
    let sessions_ks = state.sessions_ks();
    let acl_ks = state.acl_ks();

    // Atomically load and delete auth state (prevents race conditions)
    let auth_state = store::take_auth_state(sessions_ks, &req.auth_id)
        .await?
        .ok_or_else(|| AppError::Authentication("auth state not found or expired".into()))?;

    // Complete authentication ceremony
    let auth_result = webauthn
        .finish_passkey_authentication(&req.credential, &auth_state)
        .map_err(|e| {
            warn!(auth_id = %req.auth_id, error = %e, "passkey authentication ceremony failed");
            AppError::Authentication(format!("passkey authentication failed: {e}"))
        })?;

    // Look up user by credential ID
    let cred_id_hex = hex::encode(auth_result.cred_id());
    let mut user = store::get_passkey_user_by_cred(sessions_ks, &cred_id_hex)
        .await?
        .ok_or_else(|| AppError::Authentication("credential not registered".into()))?;

    // Update credential counter (replay protection)
    for cred in &mut user.credentials {
        cred.update_credential(&auth_result);
    }
    store::store_passkey_user(sessions_ks, &user).await?;

    // Check DID still in ACL
    let role = check_acl(acl_ks, &user.did).await?;

    // Validate the client-supplied session pubkey shape minimally —
    // only Ed25519 multikey is supported today. `z6Mk` is the
    // base58btc-encoded multicodec 0xed01 prefix; anything else is
    // rejected so the server doesn't accept a key shape it can't
    // later resolve via `did:key`.
    let session_pubkey = match req.session_pubkey_b58btc.as_deref() {
        Some(pk) if pk.starts_with("z6Mk") => Some(pk.to_string()),
        Some(pk) => {
            warn!(
                prefix = %&pk[..pk.len().min(8)],
                "rejected unsupported session-key shape on passkey login"
            );
            return Err(AppError::Authentication(
                "session_pubkey_b58btc must be an Ed25519 multikey (z6Mk… prefix)".into(),
            ));
        }
        None => None,
    };

    // Issue session at aal2 (WebAuthn assertion is the second
    // factor). Session row + JWT both carry amr=["passkey"], acr=
    // "aal2"; refresh preserves the level across token rotation.
    let token_resp = create_authenticated_session(
        sessions_ks,
        jwt_keys,
        &user.did,
        &role,
        state.access_token_expiry(),
        state.refresh_token_expiry(),
        session_pubkey,
        Some((vec!["passkey".to_string()], "aal2".to_string())),
    )
    .await?;

    info!(did = %user.did, "passkey login successful");

    Ok(Json(token_resp))
}

// ---------------------------------------------------------------------------
// Step-up: elevate an existing (aal1) session to aal2 via a WebAuthn
// assertion. Both endpoints require an authenticated caller; the assertion
// is scoped to — and must belong to — that caller's own DID.
// ---------------------------------------------------------------------------

#[derive(Debug, Serialize)]
pub struct StepUpStartResponse {
    pub auth_id: String,
    pub options: RequestChallengeResponse,
}

/// POST /auth/step-up/passkey/start — issue a WebAuthn assertion challenge
/// restricted to the authenticated DID's registered passkeys.
pub async fn step_up_start<S: PasskeyState>(
    auth: AuthClaims,
    State(state): State<S>,
) -> Result<Json<StepUpStartResponse>, AppError> {
    let webauthn = require_webauthn(&state)?;
    let sessions_ks = state.sessions_ks();

    let user = store::get_passkey_user_by_did(sessions_ks, &auth.did)
        .await?
        .filter(|u| !u.credentials.is_empty())
        .ok_or_else(|| {
            warn!(did = %auth.did, "step-up start failed: no passkey enrolled for this DID");
            AppError::Authentication(
                "no passkey enrolled for this DID; enroll one before stepping up".into(),
            )
        })?;

    let (rcr, auth_state) = webauthn
        .start_passkey_authentication(&user.credentials)
        .map_err(|e| AppError::Internal(format!("webauthn auth start failed: {e}")))?;

    let auth_id = Uuid::new_v4().to_string();
    store::store_auth_state(sessions_ks, &auth_id, &auth_state).await?;

    info!(did = %auth.did, auth_id = %auth_id, "step-up challenge issued");
    Ok(Json(StepUpStartResponse {
        auth_id,
        options: rcr,
    }))
}

#[derive(Debug, Deserialize)]
pub struct StepUpFinishRequest {
    pub auth_id: String,
    pub credential: PublicKeyCredential,
}

/// POST /auth/step-up/passkey/finish — verify the assertion and elevate the
/// caller's current session to `aal2` (`amr` += `webauthn`). Rejects if the
/// presented passkey belongs to a DID other than the authenticated one.
pub async fn step_up_finish<S: PasskeyState>(
    auth: AuthClaims,
    State(state): State<S>,
    Json(req): Json<StepUpFinishRequest>,
) -> Result<Json<TokenResponse>, AppError> {
    let webauthn = require_webauthn(&state)?;
    let jwt_keys = require_jwt_keys(&state)?;
    let sessions_ks = state.sessions_ks();
    let acl_ks = state.acl_ks();

    let auth_state = store::take_auth_state(sessions_ks, &req.auth_id)
        .await?
        .ok_or_else(|| AppError::Authentication("auth state not found or expired".into()))?;

    let auth_result = webauthn
        .finish_passkey_authentication(&req.credential, &auth_state)
        .map_err(|e| {
            warn!(auth_id = %req.auth_id, error = %e, "step-up ceremony failed");
            AppError::Authentication(format!("passkey authentication failed: {e}"))
        })?;

    // The presented credential must belong to the authenticated DID — a
    // passkey for some other DID must not elevate this session.
    let cred_id_hex = hex::encode(auth_result.cred_id());
    let mut user = store::get_passkey_user_by_cred(sessions_ks, &cred_id_hex)
        .await?
        .ok_or_else(|| AppError::Authentication("credential not registered".into()))?;
    if user.did != auth.did {
        warn!(authed = %auth.did, cred_did = %user.did, "step-up rejected: passkey belongs to a different DID");
        return Err(AppError::Forbidden(
            "passkey does not belong to the authenticated DID".into(),
        ));
    }

    // Counter update (replay protection).
    for cred in &mut user.credentials {
        cred.update_credential(&auth_result);
    }
    store::store_passkey_user(sessions_ks, &user).await?;

    // ACL still gates — elevation doesn't bypass it.
    let role = check_acl(acl_ks, &auth.did).await?;

    let token_resp = elevate_session(
        sessions_ks,
        jwt_keys,
        &auth.session_id,
        &role,
        vec!["did".to_string(), "webauthn".to_string()],
        "aal2",
        state.access_token_expiry(),
        state.refresh_token_expiry(),
    )
    .await?;

    info!(did = %auth.did, "step-up complete: session elevated to aal2");
    Ok(Json(token_resp))
}

#[derive(Debug, Serialize)]
pub struct StepUpCheckResponse {
    pub ok: bool,
    pub did: String,
    pub acr: String,
    pub amr: Vec<String>,
}

/// GET /auth/step-up/check — a demo "sensitive operation" gated by
/// [`StepUpAuth`]. A base (`aal1`) session is rejected with
/// `step_up_required` (403); an elevated (`aal2`) session gets `200` with
/// its assurance echoed back. Exists so the demo has something to gate.
pub async fn step_up_check(StepUpAuth(claims): StepUpAuth) -> Json<StepUpCheckResponse> {
    Json(StepUpCheckResponse {
        ok: true,
        did: claims.did,
        acr: claims.acr,
        amr: claims.amr,
    })
}

// ---------------------------------------------------------------------------
// POST /auth/passkey/invite  (admin-only)
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub struct CreateInviteRequest {
    pub did: String,
    #[serde(default = "default_invite_role")]
    pub role: String,
}

fn default_invite_role() -> String {
    "owner".into()
}

#[derive(Serialize)]
pub struct CreateInviteResponse {
    pub token: String,
    pub enrollment_url: String,
    pub expires_at: u64,
}

// Both `token` and `enrollment_url` carry the enrollment bearer secret
// (the URL embeds the token); redact both.
impl std::fmt::Debug for CreateInviteResponse {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("CreateInviteResponse")
            .field("token", &"<redacted>")
            .field("enrollment_url", &"<redacted>")
            .field("expires_at", &self.expires_at)
            .finish()
    }
}

/// Core logic shared by the REST handler and CLI subcommand.
pub async fn create_enrollment_invite(
    sessions_ks: &crate::server::store::KeyspaceHandle,
    base_url: &str,
    enrollment_ttl: u64,
    did: &str,
    role: &str,
) -> Result<CreateInviteResponse, AppError> {
    // Validate role
    let _ = role.parse::<Role>()?;

    // Generate a 32-byte random token
    let token = {
        use rand::RngExt;
        let mut bytes = [0u8; 32];
        rand::rng().fill(&mut bytes);
        hex::encode(bytes)
    };

    let now = now_epoch();
    let enrollment = store::Enrollment {
        token: token.clone(),
        did: did.to_string(),
        role: role.to_string(),
        created_at: now,
        expires_at: now + enrollment_ttl,
        claimed_at: None,
    };

    store::store_enrollment(sessions_ks, &enrollment).await?;

    let enrollment_url = format!("{base_url}/enroll?token={token}");

    info!(did = %did, role = %role, "enrollment invite created");

    Ok(CreateInviteResponse {
        token,
        enrollment_url,
        expires_at: enrollment.expires_at,
    })
}

/// Axum handler — requires admin auth.
pub async fn create_invite<S: PasskeyState>(
    _auth: AdminAuth,
    State(state): State<S>,
    Json(req): Json<CreateInviteRequest>,
) -> Result<Json<CreateInviteResponse>, AppError> {
    let base_url = state
        .public_url()
        .ok_or_else(|| AppError::Config("public_url is required for enrollment invites".into()))?;

    let resp = create_enrollment_invite(
        state.sessions_ks(),
        base_url,
        state.enrollment_ttl(),
        &req.did,
        &req.role,
    )
    .await?;

    Ok(Json(resp))
}

// ---------------------------------------------------------------------------
// GET /auth/passkey/invites  (admin-only) — list pending invites
// PUT /auth/passkey/invite/{token}  (admin-only) — change role (or extend TTL)
// DELETE /auth/passkey/invite/{token}  (admin-only) — revoke
// ---------------------------------------------------------------------------

#[derive(Serialize)]
pub struct InviteListItem {
    pub token: String,
    pub did: String,
    pub role: String,
    pub created_at: u64,
    pub expires_at: u64,
    pub enrollment_url: String,
    /// True when `expires_at < now_epoch()` — the invite can no longer
    /// be claimed but is still in the store (the claim path deletes
    /// atomically; expired invites only disappear after a cleanup
    /// pass). Useful so admins see history instead of silent drop.
    pub expired: bool,
}

// Manual Debug. Both `token` (an unredeemed invite token grants the
// holder a passkey enrollment on the encoded DID) and
// `enrollment_url` (carries the same token in its query string) must
// never reach logs. Wave-2 redacted these on `CreateInviteResponse`;
// the list-item shape that an admin diagnostics dump might pretty-
// print is the second leak surface to close.
impl std::fmt::Debug for InviteListItem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("InviteListItem")
            .field("token", &"<redacted>")
            .field("did", &self.did)
            .field("role", &self.role)
            .field("created_at", &self.created_at)
            .field("expires_at", &self.expires_at)
            .field("enrollment_url", &"<redacted>")
            .field("expired", &self.expired)
            .finish()
    }
}

#[derive(Debug, Serialize)]
pub struct InviteListResponse {
    pub invites: Vec<InviteListItem>,
}

/// List every pending enrollment invite. Admin-only.
pub async fn list_invites<S: PasskeyState>(
    _auth: AdminAuth,
    State(state): State<S>,
) -> Result<Json<InviteListResponse>, AppError> {
    let base_url = state.public_url().unwrap_or("").to_string();
    let now = now_epoch();

    let pairs = store::list_enrollments(state.sessions_ks()).await?;
    let mut invites: Vec<InviteListItem> = pairs
        .into_iter()
        .map(|e| InviteListItem {
            enrollment_url: if base_url.is_empty() {
                format!("/enroll?token={}", e.token)
            } else {
                format!("{base_url}/enroll?token={}", e.token)
            },
            expired: e.expires_at < now,
            token: e.token,
            did: e.did,
            role: e.role,
            created_at: e.created_at,
            expires_at: e.expires_at,
        })
        .collect();

    // Newest first — most recently created invites are what admins
    // want to see at the top of the list.
    invites.sort_by_key(|b| std::cmp::Reverse(b.created_at));

    Ok(Json(InviteListResponse { invites }))
}

#[derive(Debug, Deserialize)]
pub struct UpdateInviteRequest {
    /// New role to assign. If absent, role is left unchanged.
    #[serde(default)]
    pub role: Option<String>,
    /// New absolute expiry (unix seconds). Mutually exclusive with
    /// `extend_ttl`. If both are absent, expiry is left unchanged.
    #[serde(default)]
    pub expires_at: Option<u64>,
    /// Extend expiry by this many seconds from `now`. Mutually
    /// exclusive with `expires_at`.
    #[serde(default)]
    pub extend_ttl: Option<u64>,
}

/// Update an existing invite's role and/or expiry. Admin-only.
pub async fn update_invite<S: PasskeyState>(
    _auth: AdminAuth,
    State(state): State<S>,
    Path(token): Path<String>,
    Json(req): Json<UpdateInviteRequest>,
) -> Result<Json<InviteListItem>, AppError> {
    if req.expires_at.is_some() && req.extend_ttl.is_some() {
        return Err(AppError::Validation(
            "`expires_at` and `extend_ttl` are mutually exclusive".into(),
        ));
    }

    if let Some(ref role) = req.role {
        role.parse::<Role>()?;
    }

    let sessions_ks = state.sessions_ks();
    let existing = store::get_enrollment(sessions_ks, &token)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("invite not found: {token}")))?;

    let now = now_epoch();
    let new_expires = match (req.expires_at, req.extend_ttl) {
        (Some(ts), None) => ts,
        (None, Some(seconds)) => now + seconds,
        (None, None) => existing.expires_at,
        (Some(_), Some(_)) => unreachable!(),
    };

    let updated = store::Enrollment {
        token: existing.token.clone(),
        did: existing.did.clone(),
        role: req.role.unwrap_or(existing.role),
        created_at: existing.created_at,
        expires_at: new_expires,
        claimed_at: existing.claimed_at,
    };
    store::store_enrollment(sessions_ks, &updated).await?;

    let base_url = state.public_url().unwrap_or("").to_string();
    info!(
        did = %updated.did,
        role = %updated.role,
        token_prefix = %token_prefix(&token),
        "invite updated",
    );

    Ok(Json(InviteListItem {
        enrollment_url: if base_url.is_empty() {
            format!("/enroll?token={}", updated.token)
        } else {
            format!("{base_url}/enroll?token={}", updated.token)
        },
        expired: updated.expires_at < now,
        token: updated.token,
        did: updated.did,
        role: updated.role,
        created_at: updated.created_at,
        expires_at: updated.expires_at,
    }))
}

/// Revoke (delete) a pending invite. Admin-only. 204 on success.
pub async fn revoke_invite<S: PasskeyState>(
    _auth: AdminAuth,
    State(state): State<S>,
    Path(token): Path<String>,
) -> Result<StatusCode, AppError> {
    // `take_enrollment` consumes the token whether or not it exists;
    // return 404 when there was nothing to revoke so the UI can
    // distinguish "already gone" from "just revoked".
    let removed = store::take_enrollment(state.sessions_ks(), &token).await?;
    if removed.is_none() {
        return Err(AppError::NotFound(format!("invite not found: {token}")));
    }
    info!(token_prefix = %token_prefix(&token), "invite revoked");
    Ok(StatusCode::NO_CONTENT)
}