holger-server-lib 0.6.9

Holger server library: config, wiring, gRPC service, Rust API
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
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
//! Authentication and authorization for holger-server.
//!
//! Two separable concerns. AuthN (who are you): `validate_request` walks the
//! configured `AuthMethodConfig`s — OIDC Bearer (verified OFFLINE against a
//! cached/pre-provisioned JWKS, or ONLINE against `/userinfo` for opaque tokens)
//! or mTLS (leaf-cert CN) — and yields an `AuthIdentity { subject, method }`, or
//! `Ok(None)` when no method is configured (open access), or `Err` on invalid
//! creds. AuthZ (what may you do): declarative RBAC policy from the RON config
//! (`roles` map + `default_role`) resolved by `role_for` into a `Role`
//! (Reader < Writer < Admin, `Ord` follows declaration order). This is the seam
//! both call sites — `grpc.rs` and `exposed/http.rs` — hit before a write.
//!
//! RBAC is off unless any policy is set (`rbac_enabled`), preserving the
//! pre-RBAC authN-only behaviour; when on, an unmapped identity fails closed to
//! least-privilege `Reader`. OIDC validation is TTL-cached keyed by
//! `(issuer, blake3(token))` on a shared pooled `reqwest::Client`, so the raw
//! token is never held and a burst of writes costs one `/userinfo` round-trip.
//!
//! The one gotcha (M12): the mTLS method's `ca_cert` is NOT path-validated here
//! — client-cert trust is delegated entirely to the transport `client_ca`
//! (rustls handshake, see `exposed/tls.rs`); we only receive the extracted CN.
//! A broader `client_ca` therefore admits certs a narrower `ca_cert` meant to
//! exclude. `warn_unenforced_mtls_ca` shouts about this at boot.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::Instant;
// `Duration` is used only by the online TTL helpers (OIDC/JWKS caches + the
// reqwest client timeouts); the airgap `mini` build compiles none of them.
#[cfg(feature = "online")]
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// A coarse authorization role. Ordered least → most privileged (the derived
/// `Ord` follows declaration order: `Reader < Writer < Admin`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Role {
    /// Download + list only.
    Reader,
    /// + upload / create repos (everything `Reader` can, plus writes).
    Writer,
    /// + promote / delete / manage config.
    Admin,
}

impl Role {
    /// May this role write (upload/put) artifacts?
    pub fn can_write(self) -> bool {
        matches!(self, Role::Writer | Role::Admin)
    }
    /// May this role perform admin actions (promote/delete/manage)?
    pub fn can_admin(self) -> bool {
        matches!(self, Role::Admin)
    }
}

/// Auth configuration for holger-server.
///
/// `methods` is authentication (who are you); `roles`/`default_role` are
/// authorization (what may you do). Authorization is **declarative policy**:
/// it lives here in the RON config (git-tracked, reviewable, immutable in the
/// static/drift flavor), never in a mutable store.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
pub struct AuthConfig {
    /// Which auth methods to accept. Empty = no auth (open access).
    pub methods: Vec<AuthMethodConfig>,

    /// Identity → role map. Identity is the OIDC `sub` claim or the mTLS client
    /// cert CN (the same string `AuthIdentity::subject` carries). Absent/empty
    /// (with no `default_role`) means **RBAC is off** — the server is
    /// authentication-only and any valid identity may write, preserving the
    /// pre-RBAC behaviour for existing configs.
    #[serde(default)]
    pub roles: HashMap<String, Role>,

    /// Role granted to an authenticated identity that has no explicit mapping.
    /// `None` + an empty `roles` map = RBAC off. When RBAC is on, an unmapped
    /// identity falls back to this (or `Reader` — least privilege — if unset).
    #[serde(default)]
    pub default_role: Option<Role>,

    /// **Per-repository** role overrides (ACL). Outer key = repository name; inner
    /// map = identity → role FOR THAT REPO. A repo-scoped mapping WINS over the
    /// global `roles`/`default_role`, so an identity can be a `Writer` on
    /// `team-a-dev` yet only a `Reader` everywhere else — the airgap "who may push
    /// to which shelf" control. Absent/empty ⇒ the global policy applies to every
    /// repo (unchanged behaviour). Configuring any per-repo ACL turns RBAC on.
    #[serde(default)]
    pub repo_roles: HashMap<String, HashMap<String, Role>>,
}

impl AuthConfig {
    /// RBAC is active iff any role policy is configured (a global mapping, a
    /// default role, or any per-repo ACL). When inactive the write path is
    /// authN-only.
    pub fn rbac_enabled(&self) -> bool {
        !self.roles.is_empty() || self.default_role.is_some() || !self.repo_roles.is_empty()
    }

    /// Resolve the role for an authenticated `subject`. An explicit mapping wins;
    /// otherwise the configured `default_role`; otherwise `Reader` (least
    /// privilege — fail closed).
    pub fn role_for(&self, subject: &str) -> Role {
        self.roles
            .get(subject)
            .copied()
            .or(self.default_role)
            .unwrap_or(Role::Reader)
    }

    /// Resolve the role for `subject` **scoped to `repo`**. A per-repo explicit
    /// mapping (`repo_roles[repo][subject]`) wins; otherwise falls back to the
    /// global [`role_for`](Self::role_for) (global explicit → `default_role` →
    /// least-privilege `Reader`). This is the seam the write/admin gates call once
    /// the target repository is known, so authorization can differ per repo.
    pub fn role_for_repo(&self, repo: &str, subject: &str) -> Role {
        if let Some(role) = self.repo_roles.get(repo).and_then(|m| m.get(subject)) {
            return *role;
        }
        self.role_for(subject)
    }

    /// Make the mTLS trust decision EXPLICIT (M12 / P18): the transport's
    /// `client_ca` (rustls handshake) is authoritative for client-cert trust;
    /// the per-method `ca_cert` is NOT independently path-validated here (we only
    /// receive the already-extracted leaf CN). If a method declares a non-empty
    /// `ca_cert`, warn at startup so the operator knows it must equal the
    /// transport `client_ca` — a broader `client_ca` would admit certs the
    /// method's narrower `ca_cert` was meant to exclude. Called once at boot.
    pub fn warn_unenforced_mtls_ca(&self) {
        for method in &self.methods {
            if let AuthMethodConfig::Mtls { ca_cert } = method {
                if !ca_cert.trim().is_empty() {
                    log::warn!(
                        "SECURITY (mTLS): the per-method `ca_cert` is NOT independently \
                         enforced — the transport `client_ca` (TLS handshake) is authoritative \
                         for client-cert trust. Ensure `client_ca` equals this method's intended \
                         CA; a broader `client_ca` admits any cert valid under it."
                    );
                }
            }
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AuthMethodConfig {
    /// Validate OIDC Bearer tokens. Two verification paths (see
    /// `validate_oidc_bearer`): the **offline** path verifies a JWT's signature +
    /// standard claims (`exp`/`iss`/`aud`) against a cached or pre-provisioned
    /// JWKS with no network — the airgap path; the **online** path falls back to
    /// the issuer's `/userinfo` for opaque (non-JWT) tokens when connected.
    Oidc {
        /// OIDC issuer URL (e.g. http://mannequin:9999). Also the expected `iss`
        /// claim on the offline JWT path.
        issuer_url: String,
        /// Expected `aud` (audience) claim on the offline JWT path. `None` skips
        /// the audience check (the `/userinfo` path never checked it either), so
        /// existing configs are unaffected.
        #[serde(default)]
        audience: Option<String>,
        /// Inline JWKS — the JSON `{"keys":[…]}` embedded **in the RON config
        /// itself**. The fully-airgapped pre-provisioning path: no file, no
        /// fetch, the keys travel with the sealed config.
        #[serde(default)]
        jwks: Option<String>,
        /// Filesystem path to a pre-provisioned JWKS JSON file. Read + cached on
        /// first use (airgap pre-provisioning by path), and the write-through
        /// target when JWKS is fetched online so a later disconnected restart
        /// still verifies.
        #[serde(default)]
        jwks_path: Option<String>,
        /// Where to fetch the JWKS when ONLINE. Unset ⇒ `{issuer_url}/jwks` (the
        /// OIDC discovery `jwks_uri`). Never contacted on the airgap path.
        #[serde(default)]
        jwks_uri: Option<String>,
    },
    /// Validate client certificates (mTLS). Requires TLS with client auth.
    Mtls {
        /// Trusted CA PEM for client certs.
        ca_cert: String,
    },
}


/// Resolved identity from auth validation.
#[derive(Debug, Clone)]
pub struct AuthIdentity {
    pub subject: String,
    pub method: String,
}

/// Validate an incoming request's auth.
/// Returns Some(identity) if valid, None if no auth configured (open access).
/// Returns Err if auth is configured but credentials are invalid.
pub async fn validate_request(
    config: &AuthConfig,
    bearer_token: Option<&str>,
    client_cert_cn: Option<&str>,
) -> Result<Option<AuthIdentity>, AuthError> {
    if config.methods.is_empty() {
        return Ok(None); // No auth configured — open access
    }

    // Try each configured method
    for method in &config.methods {
        match method {
            AuthMethodConfig::Oidc { .. } => {
                if let Some(token) = bearer_token {
                    match validate_oidc_bearer(method, token).await {
                        Ok(subject) => return Ok(Some(AuthIdentity {
                            subject,
                            method: "oidc".into(),
                        })),
                        Err(_) => continue,
                    }
                }
            }
            AuthMethodConfig::Mtls { ca_cert: _ } => {
                // FLAGGED FOR REVIEW (M12): the method's `ca_cert` is NOT
                // verified here. Client-cert trust is currently delegated
                // entirely to the TLS layer's `client_ca` (see
                // `exposed/tls.rs`): rustls validates the chain during the
                // handshake and we only receive the already-extracted leaf CN.
                // If `client_ca` is broader than this method's intended
                // `ca_cert`, a cert valid under the broader CA is accepted with
                // its CN as identity.
                //
                // Properly enforcing the method's `ca_cert` is a structural
                // change, not a local fix: `validate_request` would need the
                // full peer certificate chain (DER) — not just the CN string —
                // plus a path-validation step (e.g. webpki) against the
                // method's `ca_cert`, threaded through both call sites
                // (`grpc.rs`, `exposed/http.rs`). Left unimplemented pending a
                // review decision (verify-the-chain vs. drop the unused field /
                // make `client_ca` authoritative). Not faked here.
                if let Some(cn) = client_cert_cn {
                    return Ok(Some(AuthIdentity {
                        subject: cn.to_string(),
                        method: "mtls".into(),
                    }));
                }
            }
        }
    }

    Err(AuthError::Unauthorized)
}

#[derive(Debug)]
pub enum AuthError {
    Unauthorized,
}

/// How long a validated `(issuer, token)` → `subject` result is trusted before
/// the `/userinfo` endpoint is consulted again. Short enough that a revoked
/// token stops working quickly; long enough that a burst of writes with one
/// token costs a single round-trip. Override with `HOLGER_OIDC_CACHE_TTL_SECS`.
/// `online`-only: the token cache serves the outbound `/userinfo` path.
#[cfg(feature = "online")]
const OIDC_CACHE_TTL: Duration = Duration::from_secs(120);

#[cfg(feature = "online")]
fn oidc_cache_ttl() -> Duration {
    std::env::var("HOLGER_OIDC_CACHE_TTL_SECS")
        .ok()
        .and_then(|v| v.trim().parse::<u64>().ok())
        .map(Duration::from_secs)
        .unwrap_or(OIDC_CACHE_TTL)
}

/// One shared `reqwest::Client` for all OIDC validations: a connection pool +
/// reused TLS sessions instead of a fresh client (and fresh TCP+TLS handshake)
/// per write. Built once, lazily. Compiled ONLY under the `online` feature — the
/// airgap `mini` build links no HTTP client at all (see `Cargo.toml`).
#[cfg(feature = "online")]
fn oidc_client() -> &'static reqwest::Client {
    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
    CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .pool_idle_timeout(Duration::from_secs(90))
            .timeout(Duration::from_secs(10))
            .build()
            .unwrap_or_else(|_| reqwest::Client::new())
    })
}

/// Cache of validated tokens. Key = `(issuer, blake3(token))` so the raw bearer
/// token is never held and keys can't collide into an auth bypass. Value =
/// `(subject, inserted_at)`; entries past the TTL are ignored and purged.
/// `online`-only (the `/userinfo` result cache).
#[cfg(feature = "online")]
type OidcCacheKey = (String, [u8; 32]);
#[cfg(feature = "online")]
fn oidc_cache() -> &'static Mutex<HashMap<OidcCacheKey, (String, Instant)>> {
    static CACHE: OnceLock<Mutex<HashMap<OidcCacheKey, (String, Instant)>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

#[cfg(feature = "online")]
fn token_digest(token: &str) -> [u8; 32] {
    *blake3::hash(token.as_bytes()).as_bytes()
}

/// Validate an OIDC Bearer token, returning its `subject` (`sub` claim).
///
/// Fast path: a cached, unexpired result for this `(issuer, token)` returns
/// without any network call. Slow path (cache miss/expiry): one `/userinfo`
/// round-trip on the shared pooled client, then the result is cached for the TTL.
///
/// Compiled ONLY under the `online` feature: this is the outbound-HTTP path, so
/// the airgap `mini` build does not carry it (opaque-token validation is
/// unavailable there and fails closed — see `validate_oidc_bearer`).
#[cfg(feature = "online")]
async fn validate_oidc_token(issuer_url: &str, token: &str) -> Result<String, AuthError> {
    let ttl = oidc_cache_ttl();
    let key: OidcCacheKey = (issuer_url.to_string(), token_digest(token));

    // Fast path — cache hit within the TTL skips the round-trip.
    if let Ok(cache) = oidc_cache().lock() {
        if let Some((subject, inserted)) = cache.get(&key) {
            if inserted.elapsed() < ttl {
                return Ok(subject.clone());
            }
        }
    }

    let userinfo_url = format!("{}/userinfo", issuer_url.trim_end_matches('/'));
    let resp = oidc_client()
        .get(&userinfo_url)
        .header("Authorization", format!("Bearer {}", token))
        .send()
        .await
        .map_err(|_| AuthError::Unauthorized)?;

    if !resp.status().is_success() {
        return Err(AuthError::Unauthorized);
    }

    let body: serde_json::Value = resp.json().await.map_err(|_| AuthError::Unauthorized)?;
    let subject = body
        .get("sub")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or(AuthError::Unauthorized)?;

    // Cache the fresh result; opportunistically purge expired entries so the map
    // can't grow without bound under a churn of distinct tokens.
    if let Ok(mut cache) = oidc_cache().lock() {
        cache.retain(|_, (_, inserted)| inserted.elapsed() < ttl);
        cache.insert(key, (subject.clone(), Instant::now()));
    }

    Ok(subject)
}

// ─── Offline / airgap OIDC (JWT-signature verification against a JWKS) ────────
//
// The `/userinfo` path above is ONLINE-only: it makes an outbound call, so it
// cannot serve a disconnected/airgapped deploy. This path verifies a bearer JWT
// like a PKI signature — validate the signature + `exp`/`iss`/`aud` against a
// **JWKS** — with zero network once the keys are in hand. Keys are resolved in
// order: (1) a fresh in-memory cache entry, (2) inline `jwks` in the RON config,
// (3) a pre-provisioned `jwks_path` file, (4) an online fetch from `jwks_uri`
// (write-through-persisted to `jwks_path` when set, so the next disconnected boot
// still verifies). (1)-(3) touch NO network — that is the airgap path.

/// How long a JWKS FETCHED ONLINE is trusted before it is refetched (to pick up
/// key rotation). Pre-provisioned keys (inline / file) never expire — they are
/// the operator's declared trust anchor. Override `HOLGER_JWKS_CACHE_TTL_SECS`.
/// `online`-only: only a fetched JWKS carries an expiry (pre-provisioned keys
/// never expire), so the TTL is dead code without the fetch path.
#[cfg(feature = "online")]
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);

#[cfg(feature = "online")]
fn jwks_cache_ttl() -> Duration {
    std::env::var("HOLGER_JWKS_CACHE_TTL_SECS")
        .ok()
        .and_then(|v| v.trim().parse::<u64>().ok())
        .map(Duration::from_secs)
        .unwrap_or(JWKS_CACHE_TTL)
}

/// A cached JWKS for one issuer. `expires` is `None` for pre-provisioned keys
/// (inline / file — never auto-expire) and `Some(instant)` for an online fetch.
struct CachedJwks {
    set: jsonwebtoken::jwk::JwkSet,
    expires: Option<Instant>,
}

impl CachedJwks {
    fn fresh(&self) -> bool {
        match self.expires {
            None => true,
            Some(deadline) => Instant::now() < deadline,
        }
    }
}

/// Process-global JWKS cache, keyed by issuer URL. Keys inside are looked up by
/// `kid` at verify time (`JwkSet::find`).
fn jwks_cache() -> &'static Mutex<HashMap<String, CachedJwks>> {
    static CACHE: OnceLock<Mutex<HashMap<String, CachedJwks>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

fn cache_jwks(issuer: &str, set: jsonwebtoken::jwk::JwkSet, expires: Option<Instant>) {
    if let Ok(mut cache) = jwks_cache().lock() {
        cache.insert(issuer.to_string(), CachedJwks { set, expires });
    }
}

/// Test-only: drop every cached JWKS so a test starts from a clean slate.
#[cfg(test)]
fn clear_jwks_cache() {
    if let Ok(mut c) = jwks_cache().lock() {
        c.clear();
    }
}

/// Parse a JWKS JSON document (`{"keys":[…]}`) into a `JwkSet`.
fn parse_jwks(json: &str) -> Result<jsonwebtoken::jwk::JwkSet, AuthError> {
    serde_json::from_str(json).map_err(|_| AuthError::Unauthorized)
}

/// Resolve the JWKS for an OIDC method, honouring the offline-first order above.
/// The online branch write-through-persists the fetched set to `jwks_path` (when
/// configured) so a later disconnected boot verifies against the same keys.
async fn resolve_jwks(method: &AuthMethodConfig) -> Result<jsonwebtoken::jwk::JwkSet, AuthError> {
    let AuthMethodConfig::Oidc { issuer_url, jwks, jwks_path, jwks_uri, .. } = method else {
        return Err(AuthError::Unauthorized);
    };

    // (1) fresh in-memory cache entry — the common hit; no work.
    if let Ok(cache) = jwks_cache().lock() {
        if let Some(entry) = cache.get(issuer_url) {
            if entry.fresh() {
                return Ok(entry.set.clone());
            }
        }
    }

    // (2) inline JWKS in the RON config — fully airgapped, no file, no fetch.
    if let Some(inline) = jwks.as_deref().filter(|s| !s.trim().is_empty()) {
        let set = parse_jwks(inline)?;
        cache_jwks(issuer_url, set.clone(), None);
        return Ok(set);
    }

    // (3) pre-provisioned JWKS file — airgap by path.
    if let Some(path) = jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
        if let Ok(bytes) = std::fs::read_to_string(path) {
            let set = parse_jwks(&bytes)?;
            cache_jwks(issuer_url, set.clone(), None);
            return Ok(set);
        }
        // file absent/unreadable → fall through to the online fetch, which then
        // write-through-persists to this path for next time.
    }

    // (4) online fetch — the ONLY branch that touches the network. Compiled ONLY
    // under the `online` feature; the airgap `mini` build stops after (1)-(3) and
    // fails closed if no pre-provisioned key resolved.
    #[cfg(feature = "online")]
    {
        let url = jwks_uri
            .as_deref()
            .filter(|s| !s.trim().is_empty())
            .map(|s| s.to_string())
            .unwrap_or_else(|| format!("{}/jwks", issuer_url.trim_end_matches('/')));
        let resp = oidc_client()
            .get(&url)
            .send()
            .await
            .map_err(|_| AuthError::Unauthorized)?;
        if !resp.status().is_success() {
            return Err(AuthError::Unauthorized);
        }
        let body = resp.text().await.map_err(|_| AuthError::Unauthorized)?;
        let set = parse_jwks(&body)?;

        // Write-through-persist so a later disconnected boot still verifies.
        if let Some(path) = jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
            if let Err(e) = std::fs::write(path, &body) {
                log::warn!("holger auth: failed to persist fetched JWKS to {path}: {e}");
            }
        }
        cache_jwks(issuer_url, set.clone(), Some(Instant::now() + jwks_cache_ttl()));
        Ok(set)
    }
    // Airgap (`mini`) build: no HTTP client linked. Only pre-provisioned keys
    // (in-memory cache / inline RON / file) verify — an unresolvable JWKS fails
    // closed rather than reaching for the network.
    #[cfg(not(feature = "online"))]
    {
        let _ = jwks_uri;
        Err(AuthError::Unauthorized)
    }
}

/// Minimal claims: only `sub` (the identity) is read out. `exp`/`iss`/`aud` are
/// validated by `jsonwebtoken` from the raw payload via the `Validation`, so
/// they need not appear here.
#[derive(serde::Deserialize)]
struct OidcClaims {
    sub: String,
}

/// Does this bearer token look like a JWT (three base64url `.`-separated
/// segments)? Opaque OIDC access tokens (the mannequin's UUIDs) are not JWTs and
/// go to `/userinfo`; only JWTs can be verified offline.
fn looks_like_jwt(token: &str) -> bool {
    token.split('.').count() == 3 && !token.starts_with('.')
}

/// Verify a bearer JWT OFFLINE against `method`'s JWKS. Checks the signature (key
/// selected by the header `kid`), `exp`, `iss` (must equal `issuer_url`), and
/// `aud` (when `audience` is configured). No network beyond the one-time JWKS
/// resolution — and that too is offline when the keys are pre-provisioned.
async fn verify_jwt_offline(method: &AuthMethodConfig, token: &str) -> Result<String, AuthError> {
    use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};

    let AuthMethodConfig::Oidc { issuer_url, audience, .. } = method else {
        return Err(AuthError::Unauthorized);
    };

    let header = decode_header(token).map_err(|_| AuthError::Unauthorized)?;
    let jwks = resolve_jwks(method).await?;

    // Select the key by `kid`; if the token carries none, accept the sole key.
    let jwk = match header.kid.as_deref() {
        Some(kid) => jwks.find(kid).ok_or(AuthError::Unauthorized)?,
        None => {
            if jwks.keys.len() == 1 {
                &jwks.keys[0]
            } else {
                return Err(AuthError::Unauthorized);
            }
        }
    };
    let key = DecodingKey::from_jwk(jwk).map_err(|_| AuthError::Unauthorized)?;

    let mut validation = Validation::new(header.alg);
    validation.set_issuer(&[issuer_url.as_str()]);
    match audience.as_deref() {
        Some(aud) => validation.set_audience(&[aud]),
        // No audience configured → don't require the claim (userinfo never did).
        None => validation.validate_aud = false,
    }
    // `exp` is validated by default (validation.validate_exp == true).

    let data =
        decode::<OidcClaims>(token, &key, &validation).map_err(|_| AuthError::Unauthorized)?;
    Ok(data.claims.sub)
}

/// Whether a JWKS is resolvable for this method WITHOUT a network call: a fresh
/// cache entry, inline config JWKS, or a readable `jwks_path` file. Decides
/// whether an offline-verify failure is authoritative (reject) or merely
/// "no keys — try `/userinfo`".
fn jwks_resolvable(method: &AuthMethodConfig) -> bool {
    let AuthMethodConfig::Oidc { issuer_url, jwks, jwks_path, .. } = method else {
        return false;
    };
    if let Ok(cache) = jwks_cache().lock() {
        if cache.get(issuer_url).map(|e| e.fresh()).unwrap_or(false) {
            return true;
        }
    }
    if jwks.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false) {
        return true;
    }
    if let Some(p) = jwks_path.as_deref().filter(|s| !s.trim().is_empty()) {
        return std::path::Path::new(p).is_file();
    }
    false
}

/// Validate an OIDC bearer token, **offline-first**.
///
/// A JWT is verified OFFLINE against the JWKS (the airgap path). An opaque token
/// — or a JWT for which no JWKS is resolvable at all — falls back to the ONLINE
/// `/userinfo` round-trip. This keeps the connected behaviour intact while making
/// disconnected token auth work.
async fn validate_oidc_bearer(method: &AuthMethodConfig, token: &str) -> Result<String, AuthError> {
    let AuthMethodConfig::Oidc { issuer_url, .. } = method else {
        return Err(AuthError::Unauthorized);
    };

    if looks_like_jwt(token) {
        match verify_jwt_offline(method, token).await {
            Ok(sub) => return Ok(sub),
            // A well-formed JWT that fails verification (bad signature / expired /
            // wrong iss/aud) is REJECTED — we do NOT fall back to `/userinfo` for
            // it (offline is authoritative once keys are resolvable). Only when NO
            // JWKS is resolvable (e.g. none configured and offline) do we fall
            // through to the online path.
            Err(_) if jwks_resolvable(method) => return Err(AuthError::Unauthorized),
            Err(_) => { /* no keys available — try the online userinfo path */ }
        }
    }

    // Opaque token, or a JWT with no resolvable JWKS: fall back to `/userinfo`.
    // That path is `online`-only; the airgap `mini` build has no HTTP client, so
    // it fails closed here (only offline JWT verification is available there).
    #[cfg(feature = "online")]
    {
        validate_oidc_token(issuer_url, token).await
    }
    #[cfg(not(feature = "online"))]
    {
        let _ = (issuer_url, token);
        Err(AuthError::Unauthorized)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    /// A throwaway `/userinfo` that always says `sub=alice` and counts how many
    /// TCP connections (i.e. real validations) it served. Returns the issuer URL.
    #[cfg(feature = "online")]
    async fn spawn_userinfo(hits: Arc<AtomicUsize>) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        tokio::spawn(async move {
            while let Ok((mut sock, _)) = listener.accept().await {
                hits.fetch_add(1, Ordering::SeqCst);
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf).await; // drain request (best-effort)
                let body = br#"{"sub":"alice"}"#;
                let head = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                    body.len()
                );
                let _ = sock.write_all(head.as_bytes()).await;
                let _ = sock.write_all(body).await;
                let _ = sock.flush().await;
            }
        });
        format!("http://{addr}")
    }

    /// W1: the second validation of the same `(issuer, token)` is served from the
    /// cache — the `/userinfo` endpoint is hit exactly once, not per write.
    #[cfg(feature = "online")]
    #[tokio::test]
    async fn oidc_validation_caches_and_skips_repeat_userinfo_call() {
        let hits = Arc::new(AtomicUsize::new(0));
        // Unique token so this test's cache entry can't be touched by others
        // sharing the process-global cache.
        let token = "tok-w1-cache-test-7f3a9c";
        let issuer = spawn_userinfo(hits.clone()).await;

        let s1 = validate_oidc_token(&issuer, token).await.expect("first validate ok");
        let s2 = validate_oidc_token(&issuer, token).await.expect("second validate ok");

        assert_eq!(s1, "alice");
        assert_eq!(s2, "alice");
        assert_eq!(
            hits.load(Ordering::SeqCst),
            1,
            "second validation must come from cache — /userinfo should be hit once, not twice"
        );
    }

    /// A token the cache has never seen still triggers a real `/userinfo` call
    /// (the cache is keyed per-token, so a different token is not a hit).
    #[cfg(feature = "online")]
    #[tokio::test]
    async fn oidc_distinct_token_is_not_a_cache_hit() {
        let hits = Arc::new(AtomicUsize::new(0));
        let issuer = spawn_userinfo(hits.clone()).await;

        let _ = validate_oidc_token(&issuer, "tok-w1-distinct-A-11").await.expect("A ok");
        let _ = validate_oidc_token(&issuer, "tok-w1-distinct-B-22").await.expect("B ok");

        assert_eq!(
            hits.load(Ordering::SeqCst),
            2,
            "two distinct tokens must each validate against /userinfo"
        );
    }

    // ── W2: role policy (authorization) ──────────────────────────────────────

    #[test]
    fn role_privilege_ordering_and_capabilities() {
        assert!(Role::Reader < Role::Writer && Role::Writer < Role::Admin);
        assert!(!Role::Reader.can_write());
        assert!(Role::Writer.can_write());
        assert!(Role::Admin.can_write());
        assert!(!Role::Writer.can_admin());
        assert!(Role::Admin.can_admin());
    }

    #[test]
    fn rbac_is_off_until_policy_is_configured() {
        // No roles, no default → RBAC inactive (authN-only, back-compat).
        let open = AuthConfig::default();
        assert!(!open.rbac_enabled());

        // A single mapping turns it on.
        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Writer);
        let cfg = AuthConfig { methods: vec![], roles, default_role: None, ..Default::default() };
        assert!(cfg.rbac_enabled());

        // A default role alone also turns it on.
        let cfg2 = AuthConfig { methods: vec![], roles: HashMap::new(), default_role: Some(Role::Reader), ..Default::default() };
        assert!(cfg2.rbac_enabled());

        // A per-repo ACL alone also turns it on.
        let mut repo_roles = HashMap::new();
        repo_roles.insert(
            "team-a-dev".to_string(),
            HashMap::from([("alice".to_string(), Role::Writer)]),
        );
        let cfg3 = AuthConfig { repo_roles, ..Default::default() };
        assert!(cfg3.rbac_enabled());
    }

    #[test]
    fn role_resolution_explicit_then_default_then_least_privilege() {
        let mut roles = HashMap::new();
        roles.insert("alice".to_string(), Role::Admin);
        roles.insert("bot".to_string(), Role::Writer);
        let cfg = AuthConfig { methods: vec![], roles, default_role: Some(Role::Reader), ..Default::default() };

        // Explicit mapping wins.
        assert_eq!(cfg.role_for("alice"), Role::Admin);
        assert_eq!(cfg.role_for("bot"), Role::Writer);
        // Unmapped identity falls back to default_role.
        assert_eq!(cfg.role_for("stranger"), Role::Reader);

        // With no default_role, an unmapped identity is least-privilege Reader.
        let cfg_nodef = AuthConfig {
            methods: vec![],
            roles: HashMap::from([("bot".to_string(), Role::Writer)]),
            default_role: None,
            ..Default::default()
        };
        assert_eq!(cfg_nodef.role_for("stranger"), Role::Reader);
        assert!(!cfg_nodef.role_for("stranger").can_write(), "unmapped identity cannot write");
    }

    #[test]
    fn per_repo_acl_overrides_global_role() {
        // Global: alice=Reader, bob=Writer, default Reader.
        let roles = HashMap::from([
            ("alice".to_string(), Role::Reader),
            ("bob".to_string(), Role::Writer),
        ]);
        // Per-repo: on `team-a-dev`, alice is a Writer and bob is only a Reader.
        let repo_roles = HashMap::from([(
            "team-a-dev".to_string(),
            HashMap::from([
                ("alice".to_string(), Role::Writer),
                ("bob".to_string(), Role::Reader),
            ]),
        )]);
        let cfg = AuthConfig {
            methods: vec![],
            roles,
            default_role: Some(Role::Reader),
            repo_roles,
        };

        // Per-repo mapping WINS on the scoped repo…
        assert_eq!(cfg.role_for_repo("team-a-dev", "alice"), Role::Writer);
        assert!(cfg.role_for_repo("team-a-dev", "alice").can_write());
        assert_eq!(cfg.role_for_repo("team-a-dev", "bob"), Role::Reader);
        assert!(!cfg.role_for_repo("team-a-dev", "bob").can_write(), "per-repo demotion holds");

        // …and the GLOBAL role applies on any other repo.
        assert_eq!(cfg.role_for_repo("other-repo", "alice"), Role::Reader);
        assert_eq!(cfg.role_for_repo("other-repo", "bob"), Role::Writer);

        // A subject with no per-repo entry falls back to global on the scoped repo.
        assert_eq!(cfg.role_for_repo("team-a-dev", "stranger"), Role::Reader);
    }
}

/// Shared test helpers for the offline-OIDC path — a throwaway RSA keypair, its
/// matching JWKS, and JWT minting — used by this crate's auth tests AND the gRPC
/// promotion tests (which need a signed admin/writer token to exercise the
/// RBAC-gated handler offline). `pub(crate)` + `#[cfg(test)]` so it's shared
/// within the crate's test build only, never in a release binary.
#[cfg(test)]
pub(crate) mod test_support {
    use super::*;
    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
    use std::time::{SystemTime, UNIX_EPOCH};

    // A throwaway RSA-2048 test keypair. The private PEM signs test JWTs; the
    // matching public modulus/exponent (`N`/`E`) go into the test JWKS. Generated
    // once with openssl; used only in tests — never a real credential.
    pub(crate) const TEST_KID: &str = "holger-test-key-1";
    pub(crate) const TEST_RSA_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCfm1N85WUhzBGy\nLW66oLq+FgMfJb4tmHyxBZAs4jn/f4s5PiEIOe0stDtJZdZGK55ZC/5KxLAOnFtO\nBdSnmWpGhCSPmRiYHXlTNV0MJfPkXseje6toPtA3tZx2TYEYKJjthIUGeh0fWNqa\ndmuLiTLTZ/yC9XOxJ8p9lcWyxiYe2afSQt8/a7fMZHlporbcao7BFbZ4MmfCFTmI\n6PWb4ctiJQF0RYlIl7hn3DyKSoAtZAudTO0vc+maaqXcd7yDAUOMMCAU+FHS3snE\n84jY7vs3Jo70yq1qvXq32Mkg1j0OQhKycPnTmc9/lRRbDI5T08TtPxkAyxy33bJN\nqZA2RhDhAgMBAAECggEAAreegYEXSm7Cu12WJcPAQiW2SmS2WujecJYXaQg19BFc\nEa6q2czAqrLr0nlTH1E1Si9P1j6If8suK8MiJnNQcaBkzfQNjONtkhrVuXP49cGe\nVPxxQ95T5aroHcR+huaECgEoUZ6pGcRFvnz3IkJE9P/vdtxOaqzyf9ZZrgw9EGfd\n0VYwj0rlH+gECY6V/3tXzaxcsSOqe0yIX2nCV09WOLR+HQmGwHTeJ3zNbkG5RKJK\nUoluDfyLzHFf8BWEx/gWJjYoKP5exSAUkLMuUMvunZA37Hh+4w1nY5zu4AcNmJBS\nPLbcfFWkdtghwmJJ/wr/KlYz4dQJ2/xb9BR5HpxeUQKBgQDbgpGJQJuo0Uqg/lZE\nczcRbaAGjO6YTD2rxdjPT1EqyrXKiyYzLEqfzb7ZU8CDfm6R8G6FZ1RRHk4ZfNSc\nDotAyIT0+7fBpwofDusdIokQK5OLmb9FJiXuPBonbLoFlInIL5PtmPbx+fU6JfJp\nmng7KzQhDM5hPLPmGg/OPGgJOQKBgQC6I4X+wQu7gnCHsjmta1wma8zKl2EJWFr8\nFCP+0bcb6DTORzgMwBhp6kHjOmdzR8EAHFHZ5BedTj2diKjzfLGjejv5ypqdtZZx\nvsCov85G8BROgwsYhprGPdYJl60GD63lqcnmnOw33irBez4xfyNkSEv3FefPYS+p\nkdxdZ18M6QKBgD1KcM6NDqmNfrQDMB0+umKQZH1PvpMhvpqGzKnd8MDtBuN+BPWG\n6XNDZIWUIA3XMJJpOsLe/ikEODRfqZHFcdZt5snsuvtEY8wWQ0nISdMNB86T6Fcr\ndq3VdEwLzMkgF3Sg7EIMUu9vpqxMyin4apfC0XRk+f8mjuix4DXqofXpAoGBAJVq\nNIJFdeSDlU7nurLBTkwtFku9bhAxU3+rryU6NHD7WYAH3S8m97H1cAQ1epsoCv3K\nvc1Y8be8+Wq+K1igTHu8f+5IOwrDm8scYsxSgwOgbbyEJmD+k7j0JB65GROXfHCx\ndTdf2aIS/WEoS8kxlDhIbGoftF9cVQUZ57k2ZC8ZAoGBALH1TvYihhjyPXffs6nV\nS4s36ZmPPnbI0qPq4sgp6iWuSeK4hnSHbxI6tLrV/4On5szLOAAzjXIE5Sq2xkcC\n8mkWFtlPWHKqTWIeYU1pwii2zFMhLh1yI5fZnXPza1FUVzbHsYk9k231T8R713R5\ns2E2/CtglGHTEyXP/WIIiWWV\n-----END PRIVATE KEY-----\n";
    pub(crate) const TEST_N: &str = "n5tTfOVlIcwRsi1uuqC6vhYDHyW-LZh8sQWQLOI5_3-LOT4hCDntLLQ7SWXWRiueWQv-SsSwDpxbTgXUp5lqRoQkj5kYmB15UzVdDCXz5F7Ho3uraD7QN7Wcdk2BGCiY7YSFBnodH1jamnZri4ky02f8gvVzsSfKfZXFssYmHtmn0kLfP2u3zGR5aaK23GqOwRW2eDJnwhU5iOj1m-HLYiUBdEWJSJe4Z9w8ikqALWQLnUztL3Ppmmql3He8gwFDjDAgFPhR0t7JxPOI2O77NyaO9Mqtar16t9jJINY9DkISsnD505nPf5UUWwyOU9PE7T8ZAMsct92yTamQNkYQ4Q";
    pub(crate) const TEST_E: &str = "AQAB";

    pub(crate) fn now() -> i64 {
        SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64
    }

    pub(crate) fn jwks_json() -> String {
        format!(
            r#"{{"keys":[{{"kty":"RSA","use":"sig","alg":"RS256","kid":"{kid}","n":"{n}","e":"{e}"}}]}}"#,
            kid = TEST_KID,
            n = TEST_N,
            e = TEST_E,
        )
    }

    /// Sign a JWT (RS256, header `kid` = the test key) with arbitrary claims.
    pub(crate) fn sign(claims: serde_json::Value) -> String {
        let mut header = Header::new(Algorithm::RS256);
        header.kid = Some(TEST_KID.to_string());
        let key = EncodingKey::from_rsa_pem(TEST_RSA_PEM.as_bytes()).expect("valid rsa pem");
        encode(&header, &claims, &key).expect("sign ok")
    }

    /// A standard-claims token for `issuer`/`aud`, expiring `exp` seconds from now
    /// (negative ⇒ already expired).
    pub(crate) fn token_for(issuer: &str, aud: &str, sub: &str, exp_offset: i64) -> String {
        sign(serde_json::json!({
            "sub": sub,
            "iss": issuer,
            "aud": aud,
            "exp": now() + exp_offset,
            "iat": now(),
        }))
    }

    /// An `AuthMethodConfig::Oidc` that verifies fully offline against the test
    /// JWKS (inline in the config) for `issuer`, audience `"holger"`.
    pub(crate) fn oidc_offline_method(issuer: &str) -> AuthMethodConfig {
        AuthMethodConfig::Oidc {
            issuer_url: issuer.to_string(),
            audience: Some("holger".into()),
            jwks: Some(jwks_json()),
            jwks_path: None,
            jwks_uri: None,
        }
    }
}

// ── W1-airgap: OFFLINE OIDC (JWT-signature verify against a cached JWKS) ──────
#[cfg(test)]
mod offline_oidc_tests {
    use super::test_support::*;
    use super::*;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    fn oidc_inline(issuer: &str) -> AuthMethodConfig {
        AuthMethodConfig::Oidc {
            issuer_url: issuer.to_string(),
            audience: Some("holger".into()),
            jwks: Some(jwks_json()),
            jwks_path: None,
            jwks_uri: None,
        }
    }

    /// A valid JWT verifies fully OFFLINE against an inline (RON-provisioned) JWKS
    /// — no network — and yields the `sub` as the identity.
    #[tokio::test]
    async fn valid_jwt_verifies_offline_against_inline_jwks() {
        let issuer = "https://idp.example/valid";
        let method = oidc_inline(issuer);
        let token = token_for(issuer, "holger", "alice@corp", 3600);
        let sub = validate_oidc_bearer(&method, &token).await.expect("valid token verifies");
        assert_eq!(sub, "alice@corp");
    }

    /// An expired token is rejected (offline `exp` check).
    #[tokio::test]
    async fn expired_jwt_rejected() {
        let issuer = "https://idp.example/expired";
        let method = oidc_inline(issuer);
        let token = token_for(issuer, "holger", "alice", -3600);
        assert!(validate_oidc_bearer(&method, &token).await.is_err(), "expired token must be rejected");
    }

    /// A token whose signature does not match the JWKS key is rejected.
    #[tokio::test]
    async fn wrong_signature_jwt_rejected() {
        let issuer = "https://idp.example/badsig";
        let method = oidc_inline(issuer);
        let good = token_for(issuer, "holger", "alice", 3600);
        // Tamper the signature segment: flip its last character.
        let mut parts: Vec<&str> = good.split('.').collect();
        let sig = parts[2].to_string();
        let last = sig.chars().last().unwrap();
        let repl = if last == 'A' { 'B' } else { 'A' };
        let tampered_sig = format!("{}{}", &sig[..sig.len() - 1], repl);
        parts[2] = &tampered_sig;
        let tampered = parts.join(".");
        assert!(validate_oidc_bearer(&method, &tampered).await.is_err(), "bad signature must be rejected");
    }

    /// A token from the wrong issuer is rejected (offline `iss` check).
    #[tokio::test]
    async fn wrong_issuer_jwt_rejected() {
        let issuer = "https://idp.example/goodiss";
        let method = oidc_inline(issuer);
        let token = token_for("https://evil.example/realm", "holger", "alice", 3600);
        assert!(validate_oidc_bearer(&method, &token).await.is_err(), "wrong issuer must be rejected");
    }

    /// A token for the wrong audience is rejected (offline `aud` check).
    #[tokio::test]
    async fn wrong_audience_jwt_rejected() {
        let issuer = "https://idp.example/goodaud";
        let method = oidc_inline(issuer);
        let token = token_for(issuer, "some-other-service", "alice", 3600);
        assert!(validate_oidc_bearer(&method, &token).await.is_err(), "wrong audience must be rejected");
    }

    /// FULLY OFFLINE: a JWKS pre-provisioned as a file verifies a token with no
    /// network at all. `jwks_uri` points at an unroutable address to prove the
    /// file path wins and no fetch is attempted.
    #[tokio::test]
    async fn fully_offline_preprovisioned_jwks_file_succeeds() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("jwks.json");
        std::fs::write(&path, jwks_json()).unwrap();

        let issuer = "https://idp.example/airgap";
        let method = AuthMethodConfig::Oidc {
            issuer_url: issuer.to_string(),
            audience: Some("holger".into()),
            jwks: None,
            jwks_path: Some(path.to_string_lossy().to_string()),
            // Would fail if ever contacted — but the file path is used first.
            jwks_uri: Some("http://127.0.0.1:1/never".into()),
        };
        let token = token_for(issuer, "holger", "airgap-bot", 3600);
        let sub = validate_oidc_bearer(&method, &token).await.expect("airgap file JWKS verifies");
        assert_eq!(sub, "airgap-bot");
    }

    /// A tiny JWKS HTTP endpoint that serves the test JWKS and counts how many
    /// times it is hit — to prove the JWKS is fetched ONCE then cached.
    #[cfg(feature = "online")]
    async fn spawn_jwks(hits: Arc<AtomicUsize>) -> String {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        tokio::spawn(async move {
            while let Ok((mut sock, _)) = listener.accept().await {
                hits.fetch_add(1, Ordering::SeqCst);
                let mut buf = [0u8; 1024];
                let _ = sock.read(&mut buf).await;
                let body = jwks_json();
                let head = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                    body.len()
                );
                let _ = sock.write_all(head.as_bytes()).await;
                let _ = sock.write_all(body.as_bytes()).await;
                let _ = sock.flush().await;
            }
        });
        format!("http://{addr}/jwks")
    }

    /// Cache miss → one fetch; subsequent verifies are cache HITS (zero further
    /// network). The online branch is exercised, and the cache proven.
    #[cfg(feature = "online")]
    #[tokio::test]
    async fn jwks_fetched_once_then_cached() {
        let hits = Arc::new(AtomicUsize::new(0));
        let jwks_uri = spawn_jwks(hits.clone()).await;
        // Unique issuer so this test owns its own process-global cache entry.
        let issuer = "https://idp.example/cache-test-unique";
        clear_jwks_cache();
        let method = AuthMethodConfig::Oidc {
            issuer_url: issuer.to_string(),
            audience: Some("holger".into()),
            jwks: None,
            jwks_path: None,
            jwks_uri: Some(jwks_uri),
        };

        // Miss: no cache entry yet.
        assert!(!jwks_resolvable(&method), "no keys resolvable before first fetch");

        let t1 = token_for(issuer, "holger", "u1", 3600);
        let t2 = token_for(issuer, "holger", "u2", 3600);
        assert_eq!(validate_oidc_bearer(&method, &t1).await.unwrap(), "u1");
        assert_eq!(validate_oidc_bearer(&method, &t2).await.unwrap(), "u2");

        // Hit: the second verify used the cache — the endpoint saw exactly one fetch.
        assert_eq!(hits.load(Ordering::SeqCst), 1, "JWKS must be fetched once then served from cache");
        assert!(jwks_resolvable(&method), "keys resolvable from cache after fetch");
    }
}