authentication-verifier 0.9.0

Offline PASETO v4.public token verification for the Main X Index federation: fetch the authentication-service Ed25519 public keys once, then verify peer-service bearer tokens locally — no shared secret, no per-request introspection hop.
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
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
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
//! Offline PASETO v4.public token verification against the
//! authentication-service's published Ed25519 keys.
//!
//! # The offline verification model
//!
//! The [`authentication-service`] is the federation's single auth
//! provider. A user session lives server-side (a Postgres-backed cookie
//! session); from that session the service mints short-lived **PASETO
//! v4.public** access tokens and publishes its **Ed25519 public keys** at
//! `/.well-known/paseto-keys`. Every other service verifies those tokens
//! *offline*: fetch the key set once at boot, build a [`Verifier`], then
//! call [`Verifier::verify`] per request. There is no shared secret and
//! no per-request introspection call.
//!
//! "Offline" is the key property and the reason this crate exists.
//! Because v4.public is *asymmetric* (Ed25519), the signer (auth-service)
//! holds the private key and the verifiers (every peer service) hold only
//! the public key. A peer can therefore confirm a token's authenticity
//! with no network round-trip on the hot path, no shared symmetric secret
//! to distribute, and no introspection endpoint to depend on for
//! availability. The only network interaction is the one-time (or rare)
//! key fetch, available out of band or via the optional
//! [`fetch`](crate#features) feature.
//!
//! This replaces the crate's previous RS256-JWT + JWKS design (≤ 0.1.x):
//! PASETO is a versioned, misuse-resistant token format with no algorithm
//! agility, so the "alg confusion" / `alg=none` class of JWT attacks does
//! not exist. See [`agents/share/authentication-sessions.md`] in the
//! monorepo for the family-wide design.
//!
//! # Authorization (ABAC)
//!
//! Since 0.3 the crate is also the family's shared **authorization**
//! foundation: verified [`Claims`] carry the subject's attributes in the
//! [`attrs`](Claims::attrs) claim, and the [`abac`] module provides the
//! pure policy engine ([`Policy`], [`Rule`], [`Action`],
//! [`Policy::evaluate`] → [`Decision`]) that the nine entity services
//! call from their blanket `/api/*` guards. See
//! [`agents/share/authorization-attributes.md`] for the design.
//!
//! # Security properties
//!
//! - **Asymmetric trust.** Verifiers never possess signing material, so a
//!   compromised peer cannot mint tokens — it can only verify them.
//! - **No algorithm agility.** The token header is the literal string
//!   `v4.public`; there is no `alg` field to downgrade and no `none`.
//! - **Key selection by `kid`.** The verifying key is chosen by the
//!   token's (authenticated) footer `kid`; a forged or stale `kid` simply
//!   matches no known key.
//! - **Issuer / audience / expiry enforcement.** Beyond a valid
//!   signature, every token must carry the expected `iss`, the expected
//!   `aud`, an unexpired `exp`, and (if present) a satisfied `nbf`.
//!
//! # Features
//!
//! - `fetch` (off by default) — adds [`Verifier::from_paseto_keys_url`],
//!   which pulls the key set over HTTPS via `reqwest` (rustls). With the
//!   feature off the crate does no I/O and the caller supplies the key set
//!   as a [`serde_json::Value`].
//!
//! # Example
//!
//! ```no_run
//! # use authentication_verifier::Verifier;
//! // Normally the keys come from the auth-service; an empty set here
//! // keeps the doctest offline and dependency-free.
//! let keys: serde_json::Value = serde_json::json!({ "keys": [] });
//! let verifier = Verifier::from_paseto_keys_value(&keys, "authentication-service", "main-x-service")?;
//! let claims = verifier.verify("v4.public...")?;
//! println!("authenticated subject: {}", claims.sub);
//! # Ok::<(), authentication_verifier::VerifyError>(())
//! ```
//!
//! [`authentication-service`]: https://github.com/sixarm/authentication-service-with-loco
//! [`agents/share/authentication-sessions.md`]: https://github.com/sixarm/main-x-service
//! [`agents/share/authorization-attributes.md`]: https://github.com/sixarm/main-x-service

// Reject `unsafe` outright: this crate touches only safe, allocation-light
// code paths and a security library has no business reaching for `unsafe`.
#![forbid(unsafe_code)]
// Opt into clippy's pedantic lints to keep the public-facing library tidy.
#![warn(clippy::pedantic)]
// A published library must document every public item; fail the build if
// any item lacks a doc comment.
#![deny(missing_docs)]

use std::collections::{BTreeMap, HashMap};
use std::time::{SystemTime, UNIX_EPOCH};

// base64url (no padding) decodes the published key's `x` component.
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
// The PASETO v4.public verify primitive plus the key / footer wrapper
// types. `UntrustedToken` lets us read the (authenticated) footer to
// select a key *before* verifying the signature.
use rusty_paseto::core::{
    Footer, ImplicitAssertion, Key, Paseto, PasetoAsymmetricPublicKey, Public, UntrustedToken, V4,
};
// `serde` derives let `Claims` deserialize from the token payload (and
// serialize again, which the tests rely on to mint tokens).
use serde::{Deserialize, Serialize};

pub mod abac;

// Re-export the ABAC engine types at the crate root so callers can use
// `authentication_verifier::{Policy, Action, ...}` alongside `Verifier`
// and `Claims` without spelling the module path.
pub use abac::{Action, ActionPattern, Decision, Effect, Policy, ReloadablePolicy, Rule};

/// Verified token claims. Mirrors the auth-service `Claims` exactly so a
/// token signed there round-trips here. `sub` carries the user `pid`.
///
/// The field set is a contract with the auth-service: the service defines
/// an identical struct, and changing one without the other breaks token
/// round-tripping. `exp` / `iat` / `nbf` are unix seconds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    /// Subject — the user `pid` (UUID string); the stable identifier a
    /// peer service keys its authorization on.
    pub sub: String,
    /// User email, surfaced for convenience at the edge; not used for
    /// authorization decisions.
    pub email: String,
    /// Human-readable display name carried alongside the subject.
    pub name: String,
    /// Issuer (`iss`) — the auth-service that minted the token. Checked
    /// against the verifier's configured issuer.
    pub iss: String,
    /// Audience (`aud`) — the intended recipient service. Checked against
    /// the verifier's configured audience so a token issued for one peer
    /// cannot be replayed against another.
    pub aud: String,
    /// Expiry (`exp`), unix seconds. Tokens at or past this instant are
    /// rejected. Issued ~5 minutes out (the session is the durable thing).
    pub exp: i64,
    /// Issued-at (`iat`), unix seconds — when the token was minted.
    pub iat: i64,
    /// Not-before (`nbf`), unix seconds. When present, tokens before this
    /// instant are rejected. Omitted from the wire form when `None`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub nbf: Option<i64>,
    /// Session id (`sid`) — the originating server-side session, so a
    /// token can be correlated back to (and revoked with) its session.
    pub sid: String,
    /// Granted scopes, if any. Empty when the token carries none.
    ///
    /// **Deprecated for authorization** (kept on the wire for
    /// compatibility; removal is a future major): the ABAC guard ignores
    /// `scope` and decides from [`attrs`](Self::attrs) instead. See
    /// `agents/share/authorization-attributes.md` §3.
    #[serde(default)]
    pub scope: Vec<String>,
    /// Granted roles, if any. Empty when the token carries none.
    ///
    /// **Deprecated for authorization** (kept on the wire for
    /// compatibility; removal is a future major): the ABAC guard ignores
    /// `roles` and decides from [`attrs`](Self::attrs) instead — a role,
    /// where one is wanted, is just another attribute (`role=editor`).
    /// See `agents/share/authorization-attributes.md` §3.
    #[serde(default)]
    pub roles: Vec<String>,
    /// Subject attributes for ABAC authorization — a string→strings map
    /// minted by the auth-service from the user's assigned attributes
    /// (e.g. `access: ["write"]`, `dept: ["cardiology"]`,
    /// `svc: ["true"]` for machine peers). Multi-valued keys mean "has
    /// each of these values"; policies match set-membership; unknown
    /// attributes are inert (forward-compatible). Absent on the wire
    /// (old tokens) ⇒ empty map — no re-issue needed. Evaluated by the
    /// [`abac`] engine per `agents/share/authorization-attributes.md`
    /// §2–§3, alongside the pseudo-attributes `sub` and `email`.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub attrs: BTreeMap<String, Vec<String>>,
}

/// Failure modes for key-set loading and token verification.
///
/// Every fallible entry point returns this type, and every variant is a
/// *handled* outcome — the crate never panics on bad input, so a malformed
/// key set and a forged token are both ordinary `Err` values.
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
    /// The key-set document was missing or structurally invalid (no `keys`
    /// array, a key missing `kid` / `x`, or an `x` that is not a 32-byte
    /// base64url Ed25519 public key). Raised at load time, not per token.
    #[error("malformed key set: {0}")]
    Keys(String),
    /// The token was not a structurally valid `v4.public` token, or its
    /// footer could not be decoded as `{ "kid": ... }`. Distinct from a
    /// signature failure ([`Paseto`](Self::Paseto)).
    #[error("malformed token: {0}")]
    Malformed(String),
    /// The token footer carried no `kid`, so no key could be selected.
    /// All auth-service tokens stamp a footer `kid`, so this indicates a
    /// hand-built or non-conforming token.
    #[error("token footer has no kid")]
    MissingKid,
    /// No verification key matched the token's footer `kid` (stale cache,
    /// wrong issuer, or forgery). The wrapped `String` is the unmatched
    /// `kid`; on a legitimate stale cache a caller may refetch and retry.
    #[error("no verification key for kid {0:?}")]
    UnknownKid(String),
    /// PASETO parsing or Ed25519 signature verification failed. Carries the
    /// stringified underlying `rusty_paseto` error.
    #[error("token verification failed: {0}")]
    Paseto(String),
    /// The signature was valid but a registered claim did not satisfy the
    /// policy: wrong `iss`, wrong `aud`, expired `exp`, or unmet `nbf`.
    #[error("claim rejected: {0}")]
    Claim(String),
    /// The token's `kid` selected a key whose algorithm this build does
    /// not implement.
    ///
    /// Distinct from [`UnknownKid`](VerifyError::UnknownKid) on purpose.
    /// Both reject the token, but they mean different things to whoever
    /// is on call: `UnknownKid` says "I hold no key for this signer" and
    /// invites a key-set refetch; this says "I hold the key and cannot
    /// use it", which a refetch will never fix. It is the expected error
    /// during a partial algorithm rollout — the issuer has moved ahead of
    /// this verifier, and this binary needs upgrading.
    #[error("key {kid:?} uses unsupported algorithm {algorithm:?}")]
    UnsupportedAlgorithm {
        /// The `kid` that selected the key.
        kid: String,
        /// The algorithm label as advertised in the key set.
        algorithm: String,
    },

    /// Fetching the key set over HTTP failed (only with the `fetch`
    /// feature): transport error, non-2xx status, or undecodable body.
    #[cfg(feature = "fetch")]
    #[error("key set fetch failed: {0}")]
    Fetch(String),
}

/// One published verification key, tagged with the algorithm it is for.
///
/// Modelled as an enum rather than a byte string plus an algorithm field
/// so that **verification cannot fall through to a default**. Adding a
/// variant forces every match to be revisited; an unrecognised key can
/// only ever land in [`Unsupported`](VerificationKey::Unsupported), which
/// has no key material and therefore no path to an accept.
#[derive(Debug, Clone)]
enum VerificationKey {
    /// Ed25519 raw public key — the algorithm PASETO `v4.public` uses.
    Ed25519(Box<[u8; 32]>),
    /// A key this build does not implement, retained only so the verifier
    /// can say *why* it is refusing rather than reporting the `kid` as
    /// unknown. Carries no key material.
    Unsupported {
        /// Algorithm label from the key set, for the error message.
        label: String,
    },
}

impl VerificationKey {
    /// Parse one JWK-shaped entry.
    ///
    /// Returns `None` when the entry cannot be indexed at all (no `kid`),
    /// since such a key could never be selected.
    fn from_jwk(jwk: &serde_json::Value) -> Result<Option<(String, Self)>, VerifyError> {
        let Some(kid) = jwk.get("kid").and_then(serde_json::Value::as_str) else {
            // An entry with no `kid` is unselectable. For a supported
            // algorithm that is a malformed key set; for one we do not
            // implement it is simply not our business.
            if is_ed25519(jwk) {
                return Err(VerifyError::Keys("ed25519 jwk missing \"kid\"".to_string()));
            }
            return Ok(None);
        };
        if !is_ed25519(jwk) {
            return Ok(Some((
                kid.to_string(),
                Self::Unsupported {
                    label: algorithm_label(jwk),
                },
            )));
        }
        let x = jwk
            .get("x")
            .and_then(serde_json::Value::as_str)
            .ok_or_else(|| VerifyError::Keys(format!("jwk {kid} missing \"x\"")))?;
        let bytes = URL_SAFE_NO_PAD
            .decode(x)
            .map_err(|err| VerifyError::Keys(format!("jwk {kid}: bad base64url x: {err}")))?;
        let key: [u8; 32] = bytes
            .as_slice()
            .try_into()
            .map_err(|_| VerifyError::Keys(format!("jwk {kid}: x is not 32 bytes")))?;
        Ok(Some((kid.to_string(), Self::Ed25519(Box::new(key)))))
    }
}

/// Whether a JWK entry declares the one algorithm this build implements.
fn is_ed25519(jwk: &serde_json::Value) -> bool {
    jwk.get("kty").and_then(serde_json::Value::as_str) == Some("OKP")
        && jwk.get("crv").and_then(serde_json::Value::as_str) == Some("Ed25519")
}

/// A human-readable label for an algorithm this build does not implement.
///
/// Deliberately assembled from whatever the entry advertises rather than
/// matched against a fixed list of future algorithms: the JOSE/COSE
/// registrations for post-quantum signatures were still settling when
/// this was written, and guessing at names here would age badly. The
/// label exists to be read in a log line, not to be matched on.
fn algorithm_label(jwk: &serde_json::Value) -> String {
    let field = |name: &str| {
        jwk.get(name)
            .and_then(serde_json::Value::as_str)
            .unwrap_or("?")
            .to_string()
    };
    match jwk.get("alg").and_then(serde_json::Value::as_str) {
        Some(alg) => format!("{}/{alg}", field("kty")),
        None => format!("{}/{}", field("kty"), field("crv")),
    }
}

/// A set of published verification keys (indexed by `kid`) plus the issuer /
/// audience policy applied to every token. Construct once at boot, then
/// share behind an `Arc` and call [`verify`](Verifier::verify) per
/// request — verification is read-only and allocation-light.
///
/// `kid` is an opaque string assigned by the auth-service and carried in
/// each token's footer; the verifier indexes its keys by exactly that
/// `kid`, so key selection at verify time is a direct map lookup.
pub struct Verifier {
    /// Published keys by `kid`, each tagged with its algorithm. Populated
    /// at construction; never mutated, so a `Verifier` is safe to share
    /// immutably across threads.
    ///
    /// Keys for algorithms this build does not implement are **kept**
    /// rather than dropped, so a token naming one is refused with a
    /// diagnosis instead of being reported as an unknown `kid`.
    keys: HashMap<String, VerificationKey>,
    /// Expected issuer (`iss`) enforced on every token.
    issuer: String,
    /// Expected audience (`aud`) enforced on every token.
    audience: String,
}

impl Verifier {
    /// Build a verifier from an in-memory key-set document, validating
    /// tokens against `issuer` (`iss`) and `audience` (`aud`).
    ///
    /// The document mirrors a JWK set restricted to Ed25519:
    /// `{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "kid": "...",
    /// "x": "<base64url 32-byte public key>" }, ... ] }`. Entries whose
    /// `kty`/`crv` are not `OKP`/`Ed25519` are skipped. An empty key set is
    /// permitted — it yields a verifier that rejects every token with
    /// [`VerifyError::UnknownKid`], so a service can boot before its key
    /// source is reachable without panicking.
    ///
    /// # Errors
    ///
    /// [`VerifyError::Keys`] when the document lacks a `keys` array, an
    /// Ed25519 key is missing `kid` / `x`, or `x` is not a 32-byte
    /// base64url value.
    ///
    /// # Examples
    ///
    /// ```
    /// # use authentication_verifier::Verifier;
    /// let keys = serde_json::json!({ "keys": [] });
    /// let verifier = Verifier::from_paseto_keys_value(&keys, "authentication-service", "main-x-service")?;
    /// assert_eq!(verifier.key_count(), 0);
    /// # Ok::<(), authentication_verifier::VerifyError>(())
    /// ```
    pub fn from_paseto_keys_value(
        keys_doc: &serde_json::Value,
        issuer: &str,
        audience: &str,
    ) -> Result<Self, VerifyError> {
        let entries = keys_doc
            .get("keys")
            .and_then(serde_json::Value::as_array)
            .ok_or_else(|| VerifyError::Keys("missing \"keys\" array".to_string()))?;

        let mut keys: HashMap<String, VerificationKey> = HashMap::new();
        for jwk in entries {
            let Some((kid, key)) = VerificationKey::from_jwk(jwk)? else {
                continue;
            };
            // A repeated `kid` is a malformed key set, not a last-wins
            // merge. Silently overwriting would let a key set that
            // advertises the same id twice — say, mid-rotation across two
            // algorithms — resolve differently depending on array order,
            // and a verifier whose answer depends on JSON ordering is not
            // one anybody should trust.
            if keys.insert(kid.clone(), key).is_some() {
                return Err(VerifyError::Keys(format!(
                    "duplicate kid {kid:?} in key set"
                )));
            }
        }

        Ok(Self {
            keys,
            issuer: issuer.to_string(),
            audience: audience.to_string(),
        })
    }

    /// Number of **usable** verification keys loaded.
    ///
    /// Counts only keys whose algorithm this build implements, so a
    /// health check reading this cannot be reassured by a key set full of
    /// keys it cannot verify with. A count of zero means no token can
    /// verify, which usually signals a key set that failed to load — or,
    /// now, an issuer that has moved entirely to an algorithm this binary
    /// does not support.
    #[must_use]
    pub fn key_count(&self) -> usize {
        self.keys
            .values()
            .filter(|k| matches!(k, VerificationKey::Ed25519(_)))
            .count()
    }

    /// Number of loaded keys whose algorithm this build does **not**
    /// implement.
    ///
    /// Non-zero means the issuer publishes keys this binary cannot use.
    /// That is normal and expected mid-rollout — the issuer adds the new
    /// algorithm before every verifier understands it — and is the signal
    /// to upgrade verifiers before the old keys are withdrawn. Worth
    /// exporting as a metric for exactly that reason.
    #[must_use]
    pub fn unsupported_key_count(&self) -> usize {
        self.keys
            .values()
            .filter(|k| matches!(k, VerificationKey::Unsupported { .. }))
            .count()
    }

    /// The algorithm labels this verifier holds keys for, usable or not,
    /// sorted and deduplicated — for logging what a key set actually
    /// advertises.
    #[must_use]
    pub fn algorithms(&self) -> Vec<String> {
        let mut out: Vec<String> = self
            .keys
            .values()
            .map(|k| match k {
                VerificationKey::Ed25519(_) => "OKP/Ed25519".to_string(),
                VerificationKey::Unsupported { label } => label.clone(),
            })
            .collect();
        out.sort_unstable();
        out.dedup();
        out
    }

    /// Verify a PASETO `v4.public` bearer token: select the key by the
    /// footer `kid`, check the Ed25519 signature, then enforce issuer,
    /// audience, expiry, and not-before.
    ///
    /// Steps run cheapest-rejection-first: confirm the `v4.public` header,
    /// read the (authenticated) footer for its `kid`, select the key, then
    /// perform the signature check and finally the claim policy.
    ///
    /// # Errors
    ///
    /// - [`VerifyError::Malformed`] if the token is not a structurally
    ///   valid `v4.public` token or its footer is not `{ "kid": ... }`.
    /// - [`VerifyError::MissingKid`] if the footer carries no `kid`.
    /// - [`VerifyError::UnknownKid`] if the `kid` matches no loaded key.
    /// - [`VerifyError::Paseto`] if the Ed25519 signature check fails.
    /// - [`VerifyError::Claim`] if `iss` / `aud` / `exp` / `nbf` do not
    ///   satisfy the policy.
    ///
    /// # Examples
    ///
    /// ```
    /// # use authentication_verifier::{Verifier, VerifyError};
    /// let keys = serde_json::json!({ "keys": [] });
    /// let verifier = Verifier::from_paseto_keys_value(&keys, "authentication-service", "main-x-service")?;
    /// assert!(verifier.verify("not.a.paseto").is_err());
    /// # Ok::<(), VerifyError>(())
    /// ```
    pub fn verify(&self, token: &str) -> Result<Claims, VerifyError> {
        // 1. Pin the version + purpose by the literal header. PASETO has no
        //    algorithm field, so this is the whole "alg" decision.
        if !token.starts_with("v4.public.") {
            return Err(VerifyError::Malformed("not a v4.public token".to_string()));
        }
        // 2. Parse the token without trusting it, and read its footer. The
        //    footer is authenticated (covered by the signature), so reading
        //    the `kid` here and feeding the same footer back to `try_verify`
        //    is safe: tampering with it fails the signature check in step 4.
        let untrusted =
            UntrustedToken::try_parse(token).map_err(|err| VerifyError::Paseto(err.to_string()))?;
        let footer = untrusted
            .footer_str()
            .map_err(|err| VerifyError::Paseto(err.to_string()))?
            .ok_or(VerifyError::MissingKid)?;
        let footer_json: serde_json::Value = serde_json::from_str(&footer)
            .map_err(|err| VerifyError::Malformed(format!("footer is not json: {err}")))?;
        let kid = footer_json
            .get("kid")
            .and_then(serde_json::Value::as_str)
            .ok_or(VerifyError::MissingKid)?;
        // 3. Select the published key for this `kid`. A miss means we hold
        //    no key for this signer (stale cache, wrong issuer, or forgery).
        let selected = self
            .keys
            .get(kid)
            .ok_or_else(|| VerifyError::UnknownKid(kid.to_string()))?;
        // Dispatch on the key's declared algorithm. The match is
        // exhaustive over `VerificationKey`, so a future algorithm cannot
        // silently reach the Ed25519 path: adding a variant breaks this
        // compile until it is handled deliberately.
        let key_bytes = match selected {
            VerificationKey::Ed25519(bytes) => bytes,
            VerificationKey::Unsupported { label } => {
                return Err(VerifyError::UnsupportedAlgorithm {
                    kid: kid.to_string(),
                    algorithm: label.clone(),
                });
            }
        };
        let key = Key::<32>::from(key_bytes.as_ref());
        let public_key = PasetoAsymmetricPublicKey::<V4, Public>::from(&key);
        // 4. Verify the Ed25519 signature over (header, payload, footer).
        let payload = Paseto::<V4, Public>::try_verify(
            token,
            &public_key,
            Footer::from(footer.as_str()),
            Option::<ImplicitAssertion>::None,
        )
        .map_err(|err| VerifyError::Paseto(err.to_string()))?;
        // 5. Reconstruct claims and apply the issuer/audience/expiry policy.
        let claims: Claims = serde_json::from_str(&payload)
            .map_err(|err| VerifyError::Malformed(format!("payload is not claims json: {err}")))?;
        self.check_claims(&claims)?;
        Ok(claims)
    }

    /// Apply the registered-claim policy: `iss`, `aud`, `exp`, and `nbf`.
    fn check_claims(&self, claims: &Claims) -> Result<(), VerifyError> {
        if claims.iss != self.issuer {
            return Err(VerifyError::Claim(format!(
                "issuer mismatch: expected {:?}, got {:?}",
                self.issuer, claims.iss
            )));
        }
        if claims.aud != self.audience {
            return Err(VerifyError::Claim(format!(
                "audience mismatch: expected {:?}, got {:?}",
                self.audience, claims.aud
            )));
        }
        let now = now_unix();
        if let Some(nbf) = claims.nbf
            && now < nbf
        {
            return Err(VerifyError::Claim("token not yet valid (nbf)".to_string()));
        }
        if now >= claims.exp {
            return Err(VerifyError::Claim("token expired (exp)".to_string()));
        }
        Ok(())
    }
}

/// Current unix time in seconds, saturating to `i64::MAX` if the clock is
/// before the epoch (which would make every token "expired").
fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .ok()
        .and_then(|d| i64::try_from(d.as_secs()).ok())
        .unwrap_or(i64::MAX)
}

/// HTTP-loading constructor, available only with the `fetch` feature.
///
/// Kept in its own `cfg`-gated `impl` so the default build pulls in no
/// HTTP stack and does no I/O whatsoever.
#[cfg(feature = "fetch")]
impl Verifier {
    /// Fetch the key set from `url` over HTTPS and build a verifier. Call
    /// once at boot; the auth-service rotates keys rarely, so a process can
    /// cache the result for its lifetime (or refetch on
    /// [`VerifyError::UnknownKid`] to pick up a rotation).
    ///
    /// # Errors
    ///
    /// [`VerifyError::Fetch`] on any transport / non-2xx / decode error, or
    /// [`VerifyError::Keys`] when the fetched body is not a valid key set.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # use authentication_verifier::Verifier;
    /// # async fn run() -> Result<(), authentication_verifier::VerifyError> {
    /// let verifier = Verifier::from_paseto_keys_url(
    ///     "https://auth.example.com/.well-known/paseto-keys",
    ///     "authentication-service",
    ///     "main-x-service",
    /// )
    /// .await?;
    /// # let _ = verifier;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn from_paseto_keys_url(
        url: &str,
        issuer: &str,
        audience: &str,
    ) -> Result<Self, VerifyError> {
        // SEC-V1: hard cap on the key-set body so a hostile endpoint can't
        // OOM the peer. A published key set is a few hundred bytes.
        const MAX_KEYS_BYTES: usize = 64 * 1024;
        // SEC-V1: only fetch the key set over TLS — a plaintext (`http://`)
        // or silently-downgraded fetch lets a network attacker inject their
        // own Ed25519 public key, which is full **token forgery**. The one
        // exception is a **loopback** host (`127.0.0.1` / `::1` / `localhost`),
        // which is not reachable by a network attacker and is where dev/CI
        // key servers run. Redirects are forbidden below so an `https` URL
        // can't be bounced to plaintext.
        if !url_scheme_is_permitted(url) {
            return Err(VerifyError::Fetch(format!(
                "key-set URL must be https:// (or http:// on loopback); refusing to fetch {url}"
            )));
        }
        let client = reqwest::Client::builder()
            // SEC-V1: bound boot time so a hung/slow key endpoint can't stall
            // startup indefinitely.
            .timeout(std::time::Duration::from_secs(10))
            // SEC-V1: no redirects, so an https→http (or cross-host) bounce
            // can't defeat the scheme check above.
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(|e| VerifyError::Fetch(e.to_string()))?;
        let mut response = client
            .get(url)
            .send()
            .await
            .map_err(|e| VerifyError::Fetch(e.to_string()))?
            .error_for_status()
            .map_err(|e| VerifyError::Fetch(e.to_string()))?;

        // SEC-V1: read the body with the hard size cap (above) so a hostile
        // endpoint can't OOM the peer with an unbounded response.
        let mut buf: Vec<u8> = Vec::new();
        while let Some(chunk) = response
            .chunk()
            .await
            .map_err(|e| VerifyError::Fetch(e.to_string()))?
        {
            if buf.len() + chunk.len() > MAX_KEYS_BYTES {
                return Err(VerifyError::Fetch(format!(
                    "key set exceeds the {MAX_KEYS_BYTES}-byte limit"
                )));
            }
            buf.extend_from_slice(&chunk);
        }
        let body: serde_json::Value =
            serde_json::from_slice(&buf).map_err(|e| VerifyError::Fetch(e.to_string()))?;
        Self::from_paseto_keys_value(&body, issuer, audience)
    }
}

/// A **hot-reloadable** [`Verifier`] holder for **key rotation**: the
/// active verifier (its published Ed25519 key set) can be swapped at
/// runtime — e.g. by a periodic re-fetch of `/.well-known/paseto-keys` —
/// **without a restart**, while the per-request verify path stays
/// lock-light.
///
/// It wraps an `Arc<Verifier>` behind an `RwLock` (the same shape as
/// [`ReloadablePolicy`](crate::ReloadablePolicy)). Per request a guard
/// calls [`current`](Self::current) — a brief read-lock returning a cheap
/// `Arc` clone it verifies against; a refresh calls
/// [`store`](Self::store) — a brief write-lock swapping the `Arc`. A
/// verification in flight during a refresh finishes against its snapshot.
/// Poison-safe: a panic elsewhere never makes `current`/`store` panic.
///
/// The **refresh trigger** (a periodic timer, a signal) is the service's
/// concern — this type only holds and swaps the value. A refresh should
/// keep the current verifier on a fetch failure (never swap to an empty
/// key set), so a transient auth-service outage cannot lock everyone out.
///
/// (No `Debug` — [`Verifier`] deliberately does not derive it, so its key
/// material never lands in a debug log.)
pub struct ReloadableVerifier {
    inner: std::sync::RwLock<std::sync::Arc<Verifier>>,
}

impl ReloadableVerifier {
    /// Wrap an initial verifier (e.g. the one built/fetched at boot).
    #[must_use]
    pub fn new(verifier: Verifier) -> Self {
        Self {
            inner: std::sync::RwLock::new(std::sync::Arc::new(verifier)),
        }
    }

    /// The currently active verifier — a cheap `Arc` clone taken under a
    /// brief read-lock. Verify against the returned snapshot; a
    /// concurrent [`store`](Self::store) does not affect it.
    #[must_use]
    pub fn current(&self) -> std::sync::Arc<Verifier> {
        self.inner
            .read()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .clone()
    }

    /// Atomically replace the active verifier (a brief write-lock) — e.g.
    /// after re-fetching a rotated key set. New requests verify against
    /// the new key set; in-flight ones finish against their snapshot.
    pub fn store(&self, verifier: Verifier) {
        *self
            .inner
            .write()
            .unwrap_or_else(std::sync::PoisonError::into_inner) = std::sync::Arc::new(verifier);
    }
}

/// Whether a key-set URL may be fetched (SEC-V1): `https` to any host, or
/// `http` only to a **loopback** host (`127.0.0.1` / `::1` / `localhost`),
/// which a network attacker cannot intercept and where dev/CI key servers
/// run. Any other scheme, a non-loopback `http` host, or an unparseable URL
/// is refused. Pure, so it is unit-tested without network access.
#[cfg(feature = "fetch")]
fn url_scheme_is_permitted(url: &str) -> bool {
    let Ok(parsed) = reqwest::Url::parse(url.trim()) else {
        return false;
    };
    match parsed.scheme() {
        "https" => true,
        "http" => matches!(
            parsed.host_str(),
            Some("127.0.0.1" | "::1" | "[::1]" | "localhost")
        ),
        _ => false,
    }
}

/// Offline unit tests.
///
/// The whole suite runs without network access: a fixed Ed25519 keypair
/// plays the auth-service's signing role, a key set is derived from its
/// public half exactly as the service would publish it, and tokens are
/// minted locally so each verification path is exercised deterministically.
#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::SigningKey;
    use rusty_paseto::core::{PasetoAsymmetricPrivateKey, Payload};

    // A fixed 32-byte Ed25519 seed → deterministic keypair, used only to
    // exercise the verifier offline. Not used anywhere in production.
    const TEST_SEED: [u8; 32] = [
        7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
        7, 7,
    ];
    const ISSUER: &str = "authentication-service";
    const AUDIENCE: &str = "main-x-service";
    const KID: &str = "test-key-1";

    fn signing_key() -> SigningKey {
        SigningKey::from_bytes(&TEST_SEED)
    }

    // Build a key-set document from the test public key, mirroring exactly
    // how the auth-service publishes (kty, crv, kid, x).
    fn test_keys() -> serde_json::Value {
        let public = signing_key().verifying_key().to_bytes();
        let x = URL_SAFE_NO_PAD.encode(public);
        serde_json::json!({
            "keys": [{ "kty": "OKP", "crv": "Ed25519", "use": "sig", "kid": KID, "x": x }]
        })
    }

    // Mint a v4.public token with the given footer kid and claims, using
    // the test private key — the inverse of what the verifier does.
    fn sign(kid: &str, claims: &Claims) -> String {
        let payload = serde_json::to_string(claims).expect("serialize claims");
        sign_payload(kid, &payload)
    }

    // Mint a v4.public token from a raw JSON payload string, so tests can
    // exercise wire forms `Claims` itself would not serialize (e.g. a
    // pre-0.3 token with no `attrs` member at all).
    fn sign_payload(kid: &str, payload: &str) -> String {
        let keypair = signing_key().to_keypair_bytes(); // [u8; 64]
        let key = Key::<64>::from(keypair);
        let private = PasetoAsymmetricPrivateKey::<V4, Public>::from(&key);
        let footer = format!(r#"{{"kid":"{kid}"}}"#);
        let mut builder = Paseto::<V4, Public>::builder();
        builder.set_payload(Payload::from(payload));
        builder.set_footer(Footer::from(footer.as_str()));
        builder.try_sign(&private).expect("sign")
    }

    // Claims whose `exp` is `exp_offset` seconds from a fixed reference
    // "now" comfortably in the future, so well-formed tokens are unexpired
    // regardless of the real clock; large negative offsets expire them.
    fn claims(exp_offset: i64) -> Claims {
        let now = 1_900_000_000; // year 2030
        Claims {
            sub: "11111111-1111-1111-1111-111111111111".to_string(),
            email: "alice@example.com".to_string(),
            name: "Alice".to_string(),
            iss: ISSUER.to_string(),
            aud: AUDIENCE.to_string(),
            exp: now + exp_offset,
            iat: now,
            nbf: None,
            sid: "22222222-2222-2222-2222-222222222222".to_string(),
            scope: vec![],
            roles: vec![],
            attrs: BTreeMap::new(),
        }
    }

    #[test]
    fn valid_token_round_trips_claims() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        assert_eq!(verifier.key_count(), 1);
        let token = sign(KID, &claims(3600));
        let got = verifier.verify(&token).expect("verify");
        assert_eq!(got.sub, "11111111-1111-1111-1111-111111111111");
        assert_eq!(got.email, "alice@example.com");
        assert_eq!(got.iss, ISSUER);
        assert_eq!(got.aud, AUDIENCE);
        assert_eq!(got.sid, "22222222-2222-2222-2222-222222222222");
    }

    #[test]
    fn attrs_claim_round_trips_mint_to_verify() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let mut c = claims(3600);
        c.attrs.insert(
            "access".to_string(),
            vec!["write".to_string(), "admin".to_string()],
        );
        c.attrs
            .insert("dept".to_string(), vec!["cardiology".to_string()]);
        let token = sign(KID, &c);
        let got = verifier.verify(&token).expect("verify");
        assert_eq!(got.attrs, c.attrs);
    }

    #[test]
    fn absent_attrs_claim_verifies_to_empty_map() {
        // A pre-0.3 token carries no `attrs` member at all; it must verify
        // and land as an empty map — no re-issue needed.
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let now = 1_900_000_000_i64;
        let payload = serde_json::json!({
            "sub": "11111111-1111-1111-1111-111111111111",
            "email": "alice@example.com",
            "name": "Alice",
            "iss": ISSUER,
            "aud": AUDIENCE,
            "exp": now + 3600,
            "iat": now,
            "sid": "22222222-2222-2222-2222-222222222222",
        })
        .to_string();
        let token = sign_payload(KID, &payload);
        let got = verifier.verify(&token).expect("verify");
        assert!(got.attrs.is_empty());
    }

    #[test]
    fn expired_token_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let token = sign(KID, &claims(-10_000_000_000));
        assert!(matches!(
            verifier.verify(&token),
            Err(VerifyError::Claim(_))
        ));
    }

    #[test]
    fn not_yet_valid_token_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let mut c = claims(3600);
        c.nbf = Some(1_900_000_000); // year 2030, after the real clock
        let token = sign(KID, &c);
        assert!(matches!(
            verifier.verify(&token),
            Err(VerifyError::Claim(_))
        ));
    }

    #[test]
    fn wrong_audience_is_rejected() {
        let verifier =
            Verifier::from_paseto_keys_value(&test_keys(), ISSUER, "some-other-service").unwrap();
        let token = sign(KID, &claims(3600));
        assert!(matches!(
            verifier.verify(&token),
            Err(VerifyError::Claim(_))
        ));
    }

    #[test]
    fn wrong_issuer_is_rejected() {
        let verifier =
            Verifier::from_paseto_keys_value(&test_keys(), "some-other-issuer", AUDIENCE).unwrap();
        let token = sign(KID, &claims(3600));
        assert!(matches!(
            verifier.verify(&token),
            Err(VerifyError::Claim(_))
        ));
    }

    #[test]
    fn unknown_kid_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let token = sign("not-a-known-kid", &claims(3600));
        assert!(matches!(
            verifier.verify(&token),
            Err(VerifyError::UnknownKid(_))
        ));
    }

    #[test]
    fn tampered_payload_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let token = sign(KID, &claims(3600));
        // Flip a character in the payload segment (index 2 of v4.public.X.Y).
        let mut parts: Vec<&str> = token.split('.').collect();
        let mut payload = parts[2].to_string();
        let last = payload.len() - 1;
        let swapped = if &payload[last..] == "A" { "B" } else { "A" };
        payload.replace_range(last.., swapped);
        parts[2] = &payload;
        let tampered = parts.join(".");
        assert!(verifier.verify(&tampered).is_err());
    }

    #[test]
    fn garbage_token_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        assert!(verifier.verify("not.a.paseto").is_err());
        assert!(verifier.verify("").is_err());
        // A wrong-version token is rejected on the header check.
        assert!(matches!(
            verifier.verify("v2.public.aaaa"),
            Err(VerifyError::Malformed(_))
        ));
    }

    #[test]
    fn empty_key_set_builds_but_rejects_everything() {
        let keys = serde_json::json!({ "keys": [] });
        let verifier = Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE).unwrap();
        assert_eq!(verifier.key_count(), 0);
        let token = sign(KID, &claims(3600));
        assert!(matches!(
            verifier.verify(&token),
            Err(VerifyError::UnknownKid(_))
        ));
    }

    #[test]
    fn key_set_without_keys_array_errors() {
        let keys = serde_json::json!({ "not_keys": [] });
        assert!(matches!(
            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
            Err(VerifyError::Keys(_))
        ));
    }

    #[test]
    fn non_ed25519_keys_are_skipped() {
        let keys = serde_json::json!({
            "keys": [{ "kty": "RSA", "kid": "rsa-1", "n": "a", "e": "b" }]
        });
        let verifier = Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE).unwrap();
        assert_eq!(verifier.key_count(), 0);
    }

    #[test]
    fn ed25519_key_missing_kid_errors() {
        let public = signing_key().verifying_key().to_bytes();
        let x = URL_SAFE_NO_PAD.encode(public);
        let keys = serde_json::json!({
            "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x }]
        });
        assert!(matches!(
            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
            Err(VerifyError::Keys(_))
        ));
    }

    #[test]
    fn ed25519_key_with_bad_x_errors() {
        let keys = serde_json::json!({
            "keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "bad-1", "x": "!!!not-base64!!!" }]
        });
        assert!(matches!(
            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
            Err(VerifyError::Keys(_))
        ));
    }

    #[test]
    fn ed25519_key_with_wrong_length_x_errors() {
        // Valid base64url, but only 3 bytes — not a 32-byte Ed25519 key.
        let keys = serde_json::json!({
            "keys": [{ "kty": "OKP", "crv": "Ed25519", "kid": "short-1", "x": URL_SAFE_NO_PAD.encode([1, 2, 3]) }]
        });
        assert!(matches!(
            Verifier::from_paseto_keys_value(&keys, ISSUER, AUDIENCE),
            Err(VerifyError::Keys(_))
        ));
    }

    #[cfg(feature = "fetch")]
    #[tokio::test]
    async fn from_paseto_keys_url_maps_transport_error_to_fetch() {
        let result = Verifier::from_paseto_keys_url("not-a-url://nowhere", ISSUER, AUDIENCE).await;
        assert!(matches!(result, Err(VerifyError::Fetch(_))));
    }

    #[test]
    fn reloadable_verifier_swaps_the_key_set_for_rotation() {
        // Start with an empty key set (rejects every token), then
        // hot-swap to the real published key set — simulating a key
        // rotation picked up by a periodic re-fetch.
        let empty = serde_json::json!({ "keys": [] });
        let holder = ReloadableVerifier::new(
            Verifier::from_paseto_keys_value(&empty, ISSUER, AUDIENCE).unwrap(),
        );
        let token = sign(KID, &claims(3600));
        assert!(
            holder.current().verify(&token).is_err(),
            "before rotation: the empty key set rejects the token"
        );

        holder.store(Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap());
        assert!(
            holder.current().verify(&token).is_ok(),
            "after rotation: the token verifies against the new key set"
        );

        // A snapshot taken before a swap keeps the key set it captured.
        let snapshot = holder.current();
        holder.store(Verifier::from_paseto_keys_value(&empty, ISSUER, AUDIENCE).unwrap());
        assert!(
            snapshot.verify(&token).is_ok(),
            "an in-flight verification keeps the key set it snapshotted"
        );
        assert!(
            holder.current().verify(&token).is_err(),
            "new requests see the latest key set"
        );
    }

    // A DIFFERENT (attacker) seed → a keypair the published key set does NOT
    // contain. Signing with it while stamping the honest `kid` is the forgery
    // attempt the SEC-V4 test below must reject.
    const ATTACKER_SEED: [u8; 32] = [
        9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
        9, 9,
    ];

    fn attacker_sign_payload(kid: &str, payload: &str) -> String {
        let keypair = SigningKey::from_bytes(&ATTACKER_SEED).to_keypair_bytes();
        let key = Key::<64>::from(keypair);
        let private = PasetoAsymmetricPrivateKey::<V4, Public>::from(&key);
        let footer = format!(r#"{{"kid":"{kid}"}}"#);
        let mut builder = Paseto::<V4, Public>::builder();
        builder.set_payload(Payload::from(payload));
        builder.set_footer(Footer::from(footer.as_str()));
        builder.try_sign(&private).expect("attacker sign")
    }

    /// SEC-V4 (the previously-missing forgery path): a token **validly signed
    /// by an attacker key** but stamped with the *honest* published `kid`
    /// must be rejected. The verifier selects the honest public key by `kid`,
    /// then the Ed25519 signature check fails — proving `kid` selection can't
    /// be abused to verify a token the honest key never signed.
    #[test]
    fn cross_key_forgery_with_honest_kid_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let payload = serde_json::to_string(&claims(3600)).unwrap();
        let forged = attacker_sign_payload(KID, &payload);
        assert!(matches!(
            verifier.verify(&forged),
            Err(VerifyError::Paseto(_))
        ));
    }

    /// SEC-V4: a token whose payload omits the required `exp` claim must be
    /// rejected (not treated as never-expiring) — `exp` is a non-`Option`
    /// field, so deserialization fails after the signature verifies.
    #[test]
    fn token_missing_exp_is_rejected() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let now = 1_900_000_000_i64;
        let payload = serde_json::json!({
            "sub": "11111111-1111-1111-1111-111111111111",
            "email": "alice@example.com",
            "name": "Alice",
            "iss": ISSUER,
            "aud": AUDIENCE,
            // no "exp"
            "iat": now,
            "sid": "22222222-2222-2222-2222-222222222222",
        })
        .to_string();
        let token = sign_payload(KID, &payload);
        assert!(verifier.verify(&token).is_err(), "missing exp must reject");
    }

    /// SEC-V4 (parser robustness / fuzz-lite): the verifier must only ever
    /// return `Err` — never panic — on arbitrary / malformed / truncated
    /// input. Pairs with `#![forbid(unsafe_code)]`.
    #[test]
    fn malformed_tokens_never_panic() {
        let verifier = Verifier::from_paseto_keys_value(&test_keys(), ISSUER, AUDIENCE).unwrap();
        let valid = sign(KID, &claims(3600));
        let mut cases: Vec<String> = vec![
            String::new(),
            ".".into(),
            "....".into(),
            "v4".into(),
            "v4.public".into(),
            "v4.public.".into(),
            "v4.public.!!!!".into(),
            "v4.local.deadbeef".into(),
            "v3.public.deadbeef".into(),
            "v4.public.YWJj.YWJj".into(),
            format!("v4.public.{}", "A".repeat(10_000)),
            format!("{valid}.extrasegment"),
            valid[..valid.len() / 2].to_string(),
            "🔥.🔥.🔥".into(),
        ];
        // A valid token with a wildly oversized footer.
        cases.push(sign_payload(
            &"k".repeat(5_000),
            &serde_json::to_string(&claims(3600)).unwrap(),
        ));
        for c in cases {
            // The contract: an `Err`, and above all no panic / no unwind.
            let _ = verifier.verify(&c);
        }
    }

    /// SEC-V1: the `fetch` path refuses a non-`https` key-set URL outright
    /// (before any network I/O), so a plaintext / downgraded fetch can't
    /// inject attacker keys. No network needed — the scheme check fails fast.
    #[cfg(feature = "fetch")]
    #[tokio::test]
    async fn non_https_keys_url_is_refused() {
        for url in [
            "http://auth.example.com/.well-known/paseto-keys",
            "ftp://x",
            "//x",
            "auth",
        ] {
            let r = Verifier::from_paseto_keys_url(url, ISSUER, AUDIENCE).await;
            assert!(
                matches!(r, Err(VerifyError::Fetch(_))),
                "non-https URL {url:?} must be refused"
            );
        }
    }

    /// SEC-V1 scheme policy (pure): `https` anywhere is permitted; `http` is
    /// permitted only to a loopback host (where dev/CI key servers run);
    /// everything else — non-loopback `http`, other schemes, garbage — is
    /// refused. This loopback exception is why the services' own
    /// `http://127.0.0.1` key-fetch tests keep working.
    #[cfg(feature = "fetch")]
    #[test]
    fn url_scheme_policy_allows_https_and_loopback_http_only() {
        assert!(url_scheme_is_permitted("https://auth.example.com/keys"));
        assert!(url_scheme_is_permitted("http://127.0.0.1:8080/keys"));
        assert!(url_scheme_is_permitted("http://localhost:3000/keys"));
        assert!(url_scheme_is_permitted("http://[::1]:9000/keys"));
        assert!(!url_scheme_is_permitted("http://auth.example.com/keys"));
        assert!(!url_scheme_is_permitted("http://10.0.0.5/keys"));
        assert!(!url_scheme_is_permitted("ftp://x"));
        assert!(!url_scheme_is_permitted("not a url"));
    }

    // ─── Algorithm agility ──────────────────────────────────────────────
    //
    // The system's only Shor-vulnerable component is this signature: the
    // audit digests are hash-based, and sessions are opaque ids. When the
    // issuer eventually adds a post-quantum algorithm it will publish both
    // key types for a while, so a verifier must (a) keep working off the
    // Ed25519 keys, and (b) refuse a token naming the new algorithm with
    // an error that tells an operator to upgrade rather than to refetch.

    // A key set advertising a post-quantum key alongside the Ed25519 one.
    // The exact `kty` / `alg` spelling is invented: the JOSE registrations
    // were still settling when this was written, and the verifier is built
    // not to care — it matches only what it supports and labels the rest.
    fn mixed_keys() -> serde_json::Value {
        let public = signing_key().verifying_key().to_bytes();
        let x = URL_SAFE_NO_PAD.encode(public);
        serde_json::json!({
            "keys": [
                { "kty": "OKP", "crv": "Ed25519", "use": "sig", "kid": KID, "x": x },
                { "kty": "AKP", "alg": "ML-DSA-44", "use": "sig", "kid": "pq-1",
                  "pub": "irrelevant-to-this-build" }
            ]
        })
    }

    /// A key set carrying an algorithm this build does not implement still
    /// verifies tokens signed with the one it does. This is the property
    /// that lets an issuer roll a new algorithm out ahead of its verifiers.
    #[test]
    fn unknown_algorithm_in_the_key_set_does_not_break_ed25519() {
        let verifier =
            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
        assert_eq!(verifier.key_count(), 1, "one usable key");
        assert_eq!(verifier.unsupported_key_count(), 1);
        let claims = verifier.verify(&sign(KID, &claims(3600))).expect("verify");
        assert_eq!(claims.iss, ISSUER);
    }

    /// A token naming a key this build cannot use is refused **as such** —
    /// not as an unknown `kid`.
    ///
    /// Both reject, so this is not a security fix; it is a diagnosis fix,
    /// and the distinction is the point. `UnknownKid` invites an operator
    /// to refetch the key set, which during an algorithm rollout will
    /// cheerfully return the same key and the same failure forever. The
    /// error has to say "upgrade this binary" instead.
    #[test]
    fn token_naming_an_unsupported_algorithm_reports_the_algorithm() {
        let verifier =
            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
        // Signed with the Ed25519 test key but footered with the PQ kid:
        // enough to select the key, which is all this test needs.
        let err = verifier
            .verify(&sign("pq-1", &claims(3600)))
            .expect_err("must refuse");
        match err {
            VerifyError::UnsupportedAlgorithm { kid, algorithm } => {
                assert_eq!(kid, "pq-1");
                assert_eq!(algorithm, "AKP/ML-DSA-44", "the label must be actionable");
            }
            other => panic!("expected UnsupportedAlgorithm, got {other:?}"),
        }
    }

    /// **Fail closed.** An unsupported key carries no material and cannot
    /// reach the signature check, so a token cannot be accepted by
    /// selecting it — even when signed with a key the verifier does hold.
    ///
    /// Without the enum this is exactly the bug that would appear: store a
    /// byte string plus an algorithm tag, forget one branch, and a
    /// "post-quantum" key verifies as Ed25519.
    #[test]
    fn an_unsupported_key_can_never_produce_an_accept() {
        let verifier =
            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
        for offset in [3600, -3600] {
            assert!(
                verifier.verify(&sign("pq-1", &claims(offset))).is_err(),
                "no token selecting an unsupported key may verify"
            );
        }
    }

    /// A repeated `kid` is a malformed key set, not a last-wins merge.
    ///
    /// Silently overwriting would make the verifier's answer depend on
    /// JSON array order — the failure would surface as intermittent auth
    /// errors after a rotation, which is close to undiagnosable.
    #[test]
    fn duplicate_kid_is_rejected_rather_than_resolved_by_order() {
        let public = signing_key().verifying_key().to_bytes();
        let x = URL_SAFE_NO_PAD.encode(public);
        let doc = serde_json::json!({
            "keys": [
                { "kty": "OKP", "crv": "Ed25519", "kid": KID, "x": x },
                { "kty": "AKP", "alg": "ML-DSA-44", "kid": KID }
            ]
        });
        let result = Verifier::from_paseto_keys_value(&doc, ISSUER, AUDIENCE);
        let Err(err) = result else {
            panic!("duplicate kid must fail");
        };
        assert!(matches!(err, VerifyError::Keys(m) if m.contains("duplicate kid")));
    }

    /// An unsupported entry with no `kid` is skipped rather than fatal: it
    /// could never be selected, so it is not this verifier's problem. A
    /// *supported* entry missing its `kid` is still a malformed key set.
    #[test]
    fn unselectable_unsupported_entry_is_skipped_not_fatal() {
        let doc = serde_json::json!({
            "keys": [{ "kty": "AKP", "alg": "ML-DSA-44" }]
        });
        let verifier = Verifier::from_paseto_keys_value(&doc, ISSUER, AUDIENCE).expect("keys");
        assert_eq!(verifier.key_count(), 0);
        assert_eq!(verifier.unsupported_key_count(), 0);

        let bad = serde_json::json!({
            "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "AAAA" }]
        });
        assert!(Verifier::from_paseto_keys_value(&bad, ISSUER, AUDIENCE).is_err());
    }

    /// `algorithms()` reports what the key set actually advertises, so a
    /// service can log it at boot and an operator can see a rollout
    /// arriving before it breaks anything.
    #[test]
    fn algorithms_reports_what_is_published() {
        let verifier =
            Verifier::from_paseto_keys_value(&mixed_keys(), ISSUER, AUDIENCE).expect("keys");
        assert_eq!(verifier.algorithms(), vec!["AKP/ML-DSA-44", "OKP/Ed25519"]);
    }
}