ppoppo-token 0.3.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
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
//! Negative regression tests for the JWT verification engine.
//!
//! Each `#[test]` in this file pins one mitigation row from
//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §4. Test name
//! mirrors the matrix `Test name` column verbatim — the CI drift check
//! (matrix §2.4) greps for these names. Renaming a test without flipping
//! the matrix row is a regression.
//!
//! Phase 1 covers M01–M16a (algorithm + header attack surface). Subsequent
//! phases append modules; no test is ever deleted (a `[X]` → `[~]`
//! transition keeps the test and converts it to a "this is allowed by
//! policy" assertion per matrix §2.5).

#![allow(clippy::unwrap_used, clippy::expect_used)]

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};
use jsonwebtoken::{DecodingKey, EncodingKey, Header};
use ppoppo_token::access_token::{AuthError, VerifyConfig, verify};
#[allow(unused_imports)]
use ppoppo_token::SharedAuthError;
use ppoppo_token::{KeySet};
use serde_json::json;

// ── Forge primitives ─────────────────────────────────────────────────────
//
// Two forge styles coexist:
//
// 1. `forge_with_header` — structural-only forge with a literal `<sig>`
//    placeholder. Phase 1 negative tests (M01-M16a) reject before any
//    signature check, so the placeholder is fine and keeps tests cheap.
//
// 2. `forge_signed_token` — real Ed25519-signed Compact JWS using the
//    fixed test keypair below. Sub-cycle B M-row tests (M17-M30) need a
//    token that survives header validation so claim-level enforcement can
//    fire; they forge with this helper and verify against `test_keyset()`.

fn b64<T: serde::Serialize>(value: &T) -> String {
    B64.encode(serde_json::to_vec(value).unwrap())
}

fn forge_with_header(header: &serde_json::Value, payload: &serde_json::Value) -> String {
    format!("{}.{}.<sig>", b64(header), b64(payload))
}

/// `sub` is `ppnum_id` (ULID, 26-char Crockford base32) per RFC §6.5.
/// Phase 4 commit 4.1 (M39) starts enforcing this format on the verify
/// side. Pre-existing baseline kept an 11-digit string here from a much
/// earlier draft that conflated `ppnum` (digit display id) with `ppnum_id`
/// (ULID).
///
/// Crockford base32 excludes `I L O U` — using `01HSAB...` (S, A, B all
/// valid) instead of the spelled-out `01HSUB...` (`U` invalid) avoids the
/// look-alike footgun. Phase 3 round-trip fixtures used the broken form;
/// commit 4.1 fixes them in lockstep.
const TEST_SUB_ULID: &str = "01HSAB00000000000000000000";

fn default_payload() -> serde_json::Value {
    json!({
        "iss": "https://accounts.ppoppo.com",
        "sub": TEST_SUB_ULID,
        "aud": "ppoppo",
        "exp": 9999999999_i64,
        "iat": 1_700_000_000_i64,
        "jti": "01HABC00000000000000000000",
    })
}

fn cfg() -> VerifyConfig {
    VerifyConfig::access_token("https://accounts.ppoppo.com", "ppoppo")
}

// ── Signed-forge keypair (Ed25519, test-only) ────────────────────────────
//
// Generated once via `openssl genpkey -algorithm ed25519`. The private key
// has no production value and is checked in only so signed-forge tests are
// deterministic across CI runs and local replays. Production keys live in
// PAS's KMS and never appear in the repo.

const TEST_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIG+00IvEd4uv6IWtGFVUEBVdqnXiuI/ESQHu6rmcDvAs
-----END PRIVATE KEY-----
";

const TEST_PUBLIC_KEY_PEM: &[u8] = b"-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=
-----END PUBLIC KEY-----
";

const TEST_KID: &str = "k4.test.0";

fn test_keyset() -> KeySet {
    let mut ks = KeySet::new();
    let dec =
        DecodingKey::from_ed_pem(TEST_PUBLIC_KEY_PEM).expect("test public key PEM should parse");
    ks.insert(TEST_KID, dec);
    ks
}

/// Forge a real Ed25519-signed JWS Compact token with the test keypair.
///
/// The header is fixed at the values Phase 1 checks accept (`alg=EdDSA`,
/// `typ=at+jwt`, `kid=TEST_KID`); the caller only chooses the payload.
/// Sub-cycle B per-row tests start from `signed_default_payload()` and
/// mutate the field under enforcement.
fn forge_signed_token(payload: &serde_json::Value) -> String {
    let mut header = Header::new(jsonwebtoken::Algorithm::EdDSA);
    header.kid = Some(TEST_KID.to_string());
    header.typ = Some("at+jwt".to_string());
    let enc =
        EncodingKey::from_ed_pem(TEST_PRIVATE_KEY_PEM).expect("test private key PEM should parse");
    jsonwebtoken::encode(&header, payload, &enc).expect("encode forge should succeed")
}

/// Default payload for signed-forge tests. Carries every registered claim
/// Phase 2 will deserialize. Per-row tests deviate from this baseline only
/// in the field under enforcement.
///
/// Timestamps are computed at call time so M18 (exp > now) and M19
/// (exp - iat ≤ 24h) both pass for the baseline. Per-row tests that need
/// an expired or far-future timestamp override the relevant field.
#[allow(dead_code)] // wired up incrementally in Sub-cycle B (M17-M30)
fn signed_default_payload() -> serde_json::Value {
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    json!({
        "iss": "https://accounts.ppoppo.com",
        "sub": TEST_SUB_ULID,
        "aud": "ppoppo",
        "exp": now + 3600,       // 1h from now — within M19's 24h cap
        "iat": now - 60,          // 1m ago — within M24's 60s leeway
        "jti": "01HABC00000000000000000000",
        "client_id": "ppoppo-internal",
        "cat": "access",
    })
}

// ── C. Claims (M17–M30) ──────────────────────────────────────────────────

/// M17 — RFC 8725 §3.10: `exp` is mandatory on every JWT. A token without
/// an expiry contract has no admissibility window, so the engine refuses
/// it before any value check can apply. Per Decision 2 of Phase 2 design,
/// the missing-exp signal is its own variant (`ExpMissing`) rather than a
/// generic `ClaimMissing("exp")` — audit logs read the M-ID off the
/// variant name without a lookup table.
#[tokio::test]
async fn test_require_exp() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("exp");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::ExpMissing));
}

/// M18 — RFC 8725 §3.10: `exp` is enforced strictly (leeway = 0). A
/// token whose expiry timestamp precedes the current instant is rejected
/// regardless of how many other checks pass. Test uses a fixed past
/// timestamp (2023-11-14) so it stays expired no matter when CI runs.
#[tokio::test]
async fn test_reject_expired() {
    let mut payload = signed_default_payload();
    payload["exp"] = json!(1_700_000_000_i64); // 2023-11-14 — well in the past
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Expired));
}

/// M19 — RFC 8725 §3.10 / RFC 9068 access-token profile: an access token
/// MUST NOT be issued with `exp - iat > 24h`. A malicious or
/// misconfigured issuer that mints near-immortal credentials is bounded
/// by the engine even when every other claim looks valid. Phase 2 covers
/// the access-token cap (24h); the refresh cap (200d) is wired when
/// refresh issuance lands in Phase 4.
#[tokio::test]
async fn test_reject_far_future_exp() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["iat"] = json!(now - 60);
    payload["exp"] = json!(now + 25 * 3600); // 25 hours — exceeds the 24h cap
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::ExpUpperBound));
}

/// M20 — RFC 8725 §3.11: `aud` is mandatory. Without an audience binding
/// the engine cannot enforce the verifier-specific match (M21/M22). The
/// matrix splits "missing" (M20) from "value mismatch" (M21/M22) so audit
/// logs distinguish "issuer never bound this token to anyone" from
/// "issuer bound to the wrong resource server".
#[tokio::test]
async fn test_require_aud() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("aud");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::AudMissing));
}

/// M21 — RFC 8725 §3.11: `aud` (string form) must exactly match the
/// verifier's expected audience. A token bound to a different resource
/// server than the verifier protects is the canonical audience-confusion
/// vector — the verifier MUST refuse it even when the issuer's signature
/// is valid.
#[tokio::test]
async fn test_reject_aud_mismatch() {
    let mut payload = signed_default_payload();
    payload["aud"] = json!("some-other-resource-server");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::AudMismatch));
}

/// M22 — RFC 7519 §4.1.3 / RFC 8725 §3.11: `aud` MAY be an array. When
/// it is, the verifier's expected audience must match at least one
/// entry. The wire-shape variation (string vs array) is hidden inside
/// `engine::check_claims` per Phase 2 design memory — callers never see
/// `OneOrMany<String>` on the public surface.
#[tokio::test]
async fn test_aud_array_any_match() {
    let mut payload = signed_default_payload();
    payload["aud"] = json!(["some-other-resource", "ppoppo", "another-one"]);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert!(
        result.is_ok(),
        "aud array containing cfg.audience must be accepted; got {result:?}"
    );
}

/// M23 — RFC 7519 §4.1.1: `iss` MUST equal the pinned issuer
/// (`https://accounts.ppoppo.com` for ppoppo's PAS). Missing-iss and
/// wrong-iss collapse into the same variant because the audit signal is
/// identical — the token did not come from the trusted issuer.
#[tokio::test]
async fn test_require_iss_pinned() {
    let mut payload = signed_default_payload();
    payload["iss"] = json!("https://attacker.example.com");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::IssMismatch));
}

/// M24 — RFC 7519 §4.1.6: `iat` must be in the past, with a 60s
/// clock-skew leeway to absorb minor desync between issuer and verifier.
/// A token with iat far in the future is either misconfigured or a
/// crude post-dating attempt.
#[tokio::test]
async fn test_require_iat_in_past() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["iat"] = json!(now + 120); // 2 minutes ahead — beyond 60s leeway
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::IatFuture));
}

/// M25 — RFC 7519 §4.1.6: `iat` upper bound. Distinct matrix row from
/// M24 but the engine enforces both via the same predicate
/// (`iat > now + 60s`) — Phase 2 design memory's documented-collapse.
/// The test exercises a far-future iat (well beyond M24's 60s leeway)
/// to make the upper-bound semantics explicit at the test layer; engine
/// behavior is identical.
#[tokio::test]
async fn test_reject_future_iat() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["iat"] = json!(now + 25 * 3600); // 25h ahead — clearly not desync
    payload["exp"] = json!(now + 26 * 3600); // keep exp > iat for M18
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::IatFuture));
}

/// M26 — RFC 7519 §4.1.5: `nbf` (not-before) is the issuer's explicit
/// "valid from" boundary. When present and in the future, the token has
/// not yet entered its admissibility window and MUST be refused. nbf is
/// optional in the spec — absence is not an error.
#[tokio::test]
async fn test_reject_premature_nbf() {
    let mut payload = signed_default_payload();
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    payload["nbf"] = json!(now + 600); // 10 minutes in the future
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::NotYetValid));
}

/// M27 — RFC 7519 §4.1.7: `jti` is the unique token identifier and the
/// replay-cache key (M35). Without it the engine cannot enforce
/// one-shot semantics on per-token operations.
#[tokio::test]
async fn test_require_jti() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("jti");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::JtiMissing));
}

/// M28 — RFC 7519 §4.1.2: `sub` identifies the principal the token is
/// about. No useful authorization decision can follow when sub is
/// absent.
#[tokio::test]
async fn test_require_sub() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("sub");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::SubMissing));
}

/// M28a — RFC 9068 §2.2 mandates `client_id` on every access JWT so the
/// resource server can identify the originating OAuth client (audit,
/// per-client rate limits, scope-narrowing).
#[tokio::test]
async fn test_require_client_id() {
    let mut payload = signed_default_payload();
    payload
        .as_object_mut()
        .expect("default payload is a JSON object")
        .remove("client_id");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::ClientIdMissing));
}

/// M29 — RFC 9068 §2.2 + ppoppo extension: `cat` (token category) is a
/// payload-level discriminator that lets the verifier refuse type
/// confusion. Phase 2 verifies access tokens only; a refresh-cat token
/// presented at an access endpoint must be refused even when every
/// other claim is well-formed.
#[tokio::test]
async fn test_reject_token_type_mismatch() {
    let mut payload = signed_default_payload();
    payload["cat"] = json!("refresh"); // wrong category for the access profile
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::TokenTypeMismatch));
}

/// M30 — RFC 8725 §2.4: numeric claims (`exp`/`iat`/`nbf`) MUST be JSON
/// integers, not strings. String-coerced numerics are a classic
/// substitution vector — a forger sets `"exp": "9999"` (string) hoping
/// the verifier parses it loosely as a far-future expiry. The engine
/// refuses non-integer numerics before any value-violation rule fires.
#[tokio::test]
async fn test_reject_string_numeric_claim() {
    let mut payload = signed_default_payload();
    payload["exp"] = json!("9999"); // string, not integer
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::InvalidNumericType));
}

// ── F. Domain (M39–M45) ──────────────────────────────────────────────────

/// M45 — RFC §6.5: payload claims MUST be in the engine's strict
/// allowlist. PII (`email`, `phone`, `name`) is the canonical thing to
/// keep OUT of the payload — base64-decoded JWT bodies are semi-public
/// (anyone holding the token can read them), so caching email there
/// would leak it to every downstream consumer that logs the token.
/// Forces the email lookup through PAS userinfo (where ACLs apply).
///
/// The allowlist also acts as a catch-net for forgery / smuggling: any
/// attacker-injected claim (`x_marker: ...`) gets rejected with the
/// claim name in the audit signal.
#[tokio::test]
async fn test_reject_pii_in_payload() {
    for pii_key in ["email", "phone", "name"] {
        let mut payload = signed_default_payload();
        payload[pii_key] = json!("alice@example.com");
        let token = forge_signed_token(&payload);
        let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(
            result,
            Err(AuthError::UnknownClaim(pii_key.to_string())),
            "expected UnknownClaim({pii_key:?}); got {result:?}",
        );
    }
}

/// M45 — attacker-injected smuggling claim. The allowlist refuses
/// arbitrary keys, not just PII names; any claim outside the
/// engine-known set is a forgery signal.
#[tokio::test]
async fn test_reject_smuggled_claim() {
    let mut payload = signed_default_payload();
    payload["x_attacker_marker"] = json!(1);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(
        result,
        Err(AuthError::UnknownClaim("x_attacker_marker".to_string())),
    );
}

/// M44 — RFC §6.5: a token with `admin: true` MUST carry an
/// `active_ppnum` whose first 3 digits fall in the admin allocation
/// band. Defense-in-depth on top of the DB-side `is_admin` lookup
/// (STANDARDS_AUTH_PPOPPO §3.2 — DB is the source of truth); the
/// band check narrows a stolen-key forgery surface from "any ppnum"
/// to "an admin-banded ppnum". Phase 4 hardcodes the band at
/// `[100, 109]`; Phase 5+ may load the allowlist from cfg.
#[tokio::test]
async fn test_admin_token_band_pinned() {
    // (a) admin=true with non-admin band — `200-...` is in user band.
    let mut payload = signed_default_payload();
    payload["admin"] = json!(true);
    payload["active_ppnum"] = json!("20012345678");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::AdminBandRejected));

    // (b) admin=true without active_ppnum at all — engine cannot
    //     prove the band, so refuse rather than silently admit.
    let mut payload = signed_default_payload();
    payload["admin"] = json!(true);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::AdminBandRejected));

    // (c) admin=true with active_ppnum in admin band — admitted.
    let mut payload = signed_default_payload();
    payload["admin"] = json!(true);
    payload["active_ppnum"] = json!("10012345678"); // first 3 digits = 100 ∈ [100,109]
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok());

    // (d) admin=false (default) — band check is skipped entirely;
    //     non-admin tokens can have any ppnum. Pinned alongside (c)
    //     so a future drift that accidentally checks band on every
    //     token (a "fail closed" overreach) fails loudly.
    let mut payload = signed_default_payload();
    payload["active_ppnum"] = json!("20012345678");
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok());
}

/// M43 — RFC §6.5: `dlg_depth` ∈ [0, 4]. Bounds the audit-trail
/// explosion of arbitrarily deep Token Exchange chains. Absence and
/// `0` both mean "no delegation" — the engine collapses them at the
/// wire (`0` is omitted on emission per the default-deny pattern).
/// Test pins the inclusive upper bound (4 admits, 5 rejects) and
/// the wire-shape forgery vector (string "5" rejected).
#[tokio::test]
async fn test_reject_deep_delegation() {
    // (a) value over the bound
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!(5);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::DlgDepthInvalid));

    // (b) negative value — an unsigned counter cannot be negative; a
    //     present-but-negative dlg_depth is a forgery / misconfiguration
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!(-1);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::DlgDepthInvalid));

    // (c) string-coerced numeric — same M30-style substitution attack
    //     as numeric claims; collapsed into M43's variant because the
    //     rejection happens after registered-claim checks pass.
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!("3");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::DlgDepthInvalid));
}

/// M43 boundary — `dlg_depth = 4` is admitted (inclusive bound).
#[tokio::test]
async fn test_dlg_depth_at_bound_admitted() {
    let mut payload = signed_default_payload();
    payload["dlg_depth"] = json!(4);
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok());
}

/// M42 (length) — RFC §6.5: `scopes` capped at ≤ 256 entries. Bounds
/// the audit-surface explosion of arbitrarily-large scope arrays and
/// caps the per-request scope-check cost. The boundary value (256) is
/// tested as positive; 257 is the canonical failing case.
#[tokio::test]
async fn test_reject_scopes_over_256() {
    let mut payload = signed_default_payload();
    let too_many: Vec<String> = (0..257).map(|i| format!("scope-{i}")).collect();
    payload["scopes"] = json!(too_many);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::ScopesTooLong));
}

/// M42 (boundary) — exactly 256 entries is admitted (the bound is
/// inclusive). 0-255 also pass; the negative test pins the exclusive
/// upper edge.
#[tokio::test]
async fn test_scopes_at_256_admitted() {
    let mut payload = signed_default_payload();
    let exactly_max: Vec<String> = (0..256).map(|i| format!("scope-{i}")).collect();
    payload["scopes"] = json!(exactly_max);
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok());
}

/// M42 (shape) — `scopes` MUST be a JSON array of strings. Mirrors M41
/// but with its own variant; audit signal is "scope confusion attack"
/// vs "capability confusion attack".
#[tokio::test]
async fn test_reject_scopes_non_array() {
    let mut payload = signed_default_payload();
    payload["scopes"] = json!("read write"); // OAuth-spec space-delim — wrong for ppoppo
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::ScopesShapeInvalid));
}

/// M41 — RFC §6.5: `caps` MUST be a JSON array of strings when present.
/// Absence and empty array both mean "no capabilities" (default-deny).
/// The engine validates only the wire shape; semantic enforcement of
/// individual capability strings is per-surface (PAS / PCS / RCW each
/// own their capability vocabulary). Rejecting `caps: "admin"`
/// (string) defends against the "verifier reads as one-element list"
/// confusion vector.
#[tokio::test]
async fn test_reject_caps_non_array() {
    // (a) string instead of array — canonical forgery shape
    let mut payload = signed_default_payload();
    payload["caps"] = json!("admin");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::CapsShapeInvalid));

    // (b) array but with a non-string element — would let a forger
    //     embed numbers / objects / nested arrays
    let mut payload = signed_default_payload();
    payload["caps"] = json!(["read", 1, "write"]);
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::CapsShapeInvalid));

    // (c) object — rejected as not an array
    let mut payload = signed_default_payload();
    payload["caps"] = json!({"admin": true});
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::CapsShapeInvalid));
}

/// M41 positive — empty array is admitted (default-deny semantic).
/// Absence is also admitted (already covered by the default fixture).
#[tokio::test]
async fn test_caps_empty_array_admitted() {
    let mut payload = signed_default_payload();
    payload["caps"] = json!([]);
    let token = forge_signed_token(&payload);
    assert!(verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok());
}

/// M40 — RFC §6.5: `account_type` ∈ `{"human", "ai_agent"}` when present.
/// The claim is optional (legacy tokens minted before the field existed
/// are admitted); but if a token CLAIMS to belong to a recognized
/// principal class and uses an unknown value, the engine refuses — the
/// issuer never emits free-form strings here, so any non-whitelist value
/// is a forgery signal. Three negative cases (typo / new-class / empty)
/// share the same variant; audit logs disambiguate via the rejected value.
#[tokio::test]
async fn test_reject_invalid_account_type() {
    for bad in ["superuser", "Human" /* case-sensitive */, ""] {
        let mut payload = signed_default_payload();
        payload["account_type"] = json!(bad);
        let token = forge_signed_token(&payload);
        let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(
            result,
            Err(AuthError::AccountTypeInvalid),
            "expected AccountTypeInvalid for value {bad:?}; got {result:?}",
        );
    }
}

/// M40 positive — both whitelist values + claim absence are admitted.
/// Pinned alongside the negative test so a future drift that
/// accidentally narrows the whitelist (e.g. dropping `ai_agent`) fails
/// loudly rather than silently breaking AI-agent tokens.
#[tokio::test]
async fn test_account_type_whitelist_accepts_human_and_ai_agent() {
    for good in ["human", "ai_agent"] {
        let mut payload = signed_default_payload();
        payload["account_type"] = json!(good);
        let token = forge_signed_token(&payload);
        assert!(
            verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok(),
            "expected {good:?} to pass M40",
        );
    }
    // Absence is also OK (legacy admit).
    let payload = signed_default_payload();
    let token = forge_signed_token(&payload);
    assert!(
        verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await.is_ok(),
        "absent account_type must pass M40 (legacy admit)",
    );
}

/// M39 — RFC §6.5: `sub` MUST be a 26-character Crockford-base32 ULID
/// (`^[0-9A-HJKMNP-TV-Z]{26}$`). PAS-issued tokens carry `ppnum_id`
/// (Human) or an AI-agent ULID; any other shape is either issuer drift
/// or forgery. The check is unconditional — even legacy tokens minted
/// with the 11-digit `ppnum` (display) string get rejected, forcing the
/// migration to `ppnum_id` to actually happen.
#[tokio::test]
async fn test_reject_non_ulid_sub() {
    // (a) too-short sub — 11 digit ppnum-style (legacy mistake)
    let mut payload = signed_default_payload();
    payload["sub"] = json!("00000000000");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::SubFormatInvalid));

    // (b) right length but invalid Crockford alphabet (`I`, `L`, `O`, `U`
    //     are excluded). 26-char string with `I` at the start exercises
    //     the alphabet check independently of the length check.
    let mut payload = signed_default_payload();
    payload["sub"] = json!("I1HSUB00000000000000000000"); // 'I' not in Crockford
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::SubFormatInvalid));

    // (c) free-form garbage — neither ppnum nor ULID
    let mut payload = signed_default_payload();
    payload["sub"] = json!("not-a-ulid");
    let token = forge_signed_token(&payload);
    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::SubFormatInvalid));
}

// ── D. Serialization (M31–M34) ───────────────────────────────────────────

/// M31 — RFC 8725 §2.4: the profile accepts JWS Compact only. Tokens
/// presented in JWS JSON serialization (a JSON object, not a 3-segment
/// dotted string) are rejected before any segment parser runs. The
/// JSON form expands the implementation surface and has historically
/// carried polyglot-payload attacks.
#[tokio::test]
async fn test_reject_jws_json_serialization() {
    let json_form = r#"{"payload":"eyJpc3MiOiJodHRwczovL2FjY291bnRzLnBwb3Bwby5jb20ifQ","signatures":[{"protected":"eyJhbGciOiJFZERTQSJ9","signature":"c2ln"}]}"#;
    let result = verify(json_form, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::JwsJsonRejected)));
}

/// M32 — RFC 7515 §3: a JSON object with duplicate keys MUST be
/// rejected. serde_json's default behavior silently keeps the last
/// occurrence, hiding the smuggling case where a forger duplicates a
/// claim hoping the verifier reads one value while a downstream
/// consumer reads another. The forge here builds the payload bytes by
/// hand because `serde_json::json!` deduplicates at construction time.
#[tokio::test]
async fn test_reject_duplicate_json_keys() {
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    let header_b64 = b64(&json!({"alg":"EdDSA","typ":"at+jwt","kid":TEST_KID}));
    let raw_payload = format!(
        r#"{{"iss":"https://accounts.ppoppo.com","sub":"a","sub":"b","aud":"ppoppo","exp":{},"iat":{},"jti":"01HABC00000000000000000000","client_id":"x","cat":"access"}}"#,
        now + 3600,
        now - 60
    );
    let payload_b64 = B64.encode(raw_payload.as_bytes());
    let token = format!("{header_b64}.{payload_b64}.<sig>");

    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::DuplicateJsonKeys)));
}

/// M33 — RFC 8725 §2.4: segments MUST use strict base64url
/// (URL_SAFE_NO_PAD; only `A-Z a-z 0-9 - _`). Standard-base64 chars
/// (`+`, `/`, `=`) are refused with their own variant so audit logs
/// distinguish intentional injection from generic decode failure.
#[tokio::test]
async fn test_reject_lax_base64() {
    let header_b64 = b64(&json!({"alg":"EdDSA","typ":"at+jwt","kid":TEST_KID}));
    let bad_header = format!("{header_b64}+"); // '+' is not in URL_SAFE_NO_PAD
    let token = format!("{bad_header}.payload.<sig>");

    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::LaxBase64)));
}

/// M34 — total token length cap. The access-token profile uses 8 KB by
/// default (`cfg.max_token_size`). An oversized token is either a
/// misconfigured issuer or a parser-amplification DoS vector; the
/// engine refuses it before any segment parsing.
#[tokio::test]
async fn test_reject_oversized_token() {
    let mut payload = signed_default_payload();
    payload["padding"] = json!("a".repeat(10_000)); // pushes the token > 8 KB
    let token = forge_signed_token(&payload);
    assert!(token.len() > 8 * 1024, "padded token must exceed 8 KB");

    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::OversizedToken)));
}

/// Smoke test for the signed-forge primitive — pins the contract that
/// Sub-cycle B relies on: a token forged with the test keypair, valid
/// `at+jwt` typ, and a kid registered in `test_keyset()` survives every
/// Phase 1 checkpoint (alg + header). Signature verification itself lands
/// when Sub-cycle C wires `jsonwebtoken::decode`; until then this test
/// asserts the *structural* contract only.
#[tokio::test]
async fn signed_forge_passes_phase1_skeleton() {
    let token = forge_signed_token(&signed_default_payload());

    let parts: Vec<&str> = token.split('.').collect();
    assert_eq!(parts.len(), 3, "JWS Compact MUST be 3 segments");
    assert!(!parts[2].is_empty(), "signature segment must be non-empty");

    let result = verify(&token, &cfg(), &test_keyset(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert!(
        result.is_ok(),
        "forged signed token must pass Phase 1 verify; got {result:?}"
    );
}

// ── A. Algorithm (M01–M06) ───────────────────────────────────────────────

/// M01 — RFC 8725 §3.1: a token with `alg: none` is the canonical JWT
/// disaster. The library can't even parse it (no `Algorithm::None` variant
/// in jsonwebtoken 9.3.1), but the engine maps the failure to a *specific*
/// `AuthError::Jose(SharedAuthError::AlgNone)` so audit logs distinguish "alg=none attack" from
/// "alg field garbled".
#[tokio::test]
async fn test_reject_alg_none() {
    let token = forge_with_header(
        &json!({ "alg": "none", "typ": "at+jwt" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::AlgNone)));
}

/// M02 — RFC 8725 §3.1: only algorithms in the per-request whitelist are
/// accepted. Even values the JOSE library happily parses (here: an
/// unknown identifier `FOO`) are rejected because the *configuration* is
/// the source of truth, not the header.
#[tokio::test]
async fn test_reject_alg_outside_whitelist() {
    let token = forge_with_header(
        &json!({ "alg": "FOO", "typ": "at+jwt" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::AlgNotWhitelisted)));
}

/// M03 — RFC 8725 §3.1: HMAC algorithms (`HS256`/`HS384`/`HS512`) are the
/// classic alg-confusion attack vector. An attacker who knows the public
/// key signs with HMAC; a naive verifier that picks the alg from the
/// header would treat the public key as a shared secret. The engine
/// rejects the entire family before any key resolution happens.
#[tokio::test]
async fn test_reject_hs_family_with_pubkey() {
    for alg in ["HS256", "HS384", "HS512"] {
        let token = forge_with_header(&json!({ "alg": alg, "typ": "at+jwt" }), &default_payload());
        let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(result, Err(AuthError::Jose(SharedAuthError::AlgHmacRejected)), "alg={alg}");
    }
}

/// M04 — RFC 8725 §3.1: RSA family (`RS*` PKCS#1 v1.5, `PS*` PSS). EdDSA-
/// only profile excludes both — an issuer rotating off RSA must not have
/// any verifier silently accept legacy tokens.
#[tokio::test]
async fn test_reject_rs_family() {
    for alg in ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"] {
        let token = forge_with_header(&json!({ "alg": alg, "typ": "at+jwt" }), &default_payload());
        let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(result, Err(AuthError::Jose(SharedAuthError::AlgRsaRejected)), "alg={alg}");
    }
}

/// M05 — RFC 8725 §3.1: ECDSA family (`ES256`/`ES384`/`ES512`). Same
/// rationale as M04 — EdDSA-only profile.
#[tokio::test]
async fn test_reject_es_family() {
    for alg in ["ES256", "ES384", "ES512"] {
        let token = forge_with_header(&json!({ "alg": alg, "typ": "at+jwt" }), &default_payload());
        let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(result, Err(AuthError::Jose(SharedAuthError::AlgEcdsaRejected)), "alg={alg}");
    }
}

// ── B. Header (M07–M16a) ─────────────────────────────────────────────────

/// M07 — RFC 8725 §3.5: `jku` (JWK Set URL) header tells a verifier to
/// fetch keys over the network. Honoring it would let an attacker host a
/// JWK Set on any controlled URL and have the verifier sign their forgery
/// with attacker-supplied keys. Engine rejects unconditionally.
#[tokio::test]
async fn test_reject_jku_header() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "jku": "https://attacker.example/keys" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderJku)));
}

/// M08 — RFC 8725 §3.5: `x5u` is the X.509 analogue of `jku`. Same
/// remote-loading attack vector, same flat rejection.
#[tokio::test]
async fn test_reject_x5u_header() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "x5u": "https://attacker.example/cert.pem" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderX5u)));
}

/// M09 — RFC 8725 §3.6: an inline `jwk` header lets a token bring its own
/// validation key. Trivially forgeable; engine rejects on presence alone.
#[tokio::test]
async fn test_reject_jwk_header() {
    let token = forge_with_header(
        &json!({
            "alg": "EdDSA", "typ": "at+jwt",
            "jwk": { "kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }
        }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderJwk)));
}

/// M10 — RFC 8725 §3.6: `x5c` carries an inline X.509 chain. Same
/// self-validating-key risk as `jwk`; engine rejects on presence alone.
#[tokio::test]
async fn test_reject_x5c_header() {
    let token = forge_with_header(
        &json!({
            "alg": "EdDSA", "typ": "at+jwt",
            "x5c": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"]
        }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderX5c)));
}

/// M11 — RFC 7515 §4.1.11: `crit` lists header extensions the verifier
/// MUST understand. The profile understands none, so any `crit` value is
/// a flat rejection.
#[tokio::test]
async fn test_reject_unknown_crit() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "crit": ["b64"] }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderCrit)));
}

/// M15 — JWE entirely outside profile (signing-only). JWE compact form
/// has 5 segments where JWS has 3, so the structural shape is enough to
/// reject. Engine fires before parsing so a JWE never reaches the
/// JWS-shaped header parser.
#[tokio::test]
async fn test_reject_jwe_payload() {
    // Fake JWE: 5 segments. Content irrelevant — only the segment count
    // matters at this stage.
    let jwe = "header.encrypted_key.iv.ciphertext.tag";
    let result = verify(jwe, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::JwePayload)));
}

/// M16 — RFC 8725 §3.6: header parameter allow-list = {typ, alg, kid}.
/// Any other key — registered JOSE params the profile chose not to
/// support, or attacker-injected extras — gets rejected with
/// `HeaderExtraParam`. Specific checks (M07-M14) fire first, so audit
/// logs carry the precise variant when one applies.
#[tokio::test]
async fn test_reject_extra_header_params() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "kid": "k4.pid.x", "x_attacker_marker": 1 }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderExtraParam)));
}

/// M16a — RFC 7797: `b64: false` enables an unencoded payload form. The
/// profile requires base64url-encoded payloads; `b64=false` would break
/// the signature input construction the engine assumes. Engine maps to
/// `HeaderB64False` so audit logs distinguish "tried RFC 7797" from a
/// generic extra-param probe (M16).
#[tokio::test]
async fn test_reject_b64_false_header() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "b64": false }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::HeaderB64False)));
}

/// M14 — RFC 8725 §3.13: a `cty` (content type) of `JWT` or `JOSE`
/// declares the payload is itself a JWT/JWS. The profile is signing-only
/// and single-layer; nested signatures expand the audit surface and have
/// historically carried downgrade attacks. Both `JWT` and `JOSE` (and
/// case variants) trip the rejection.
#[tokio::test]
async fn test_reject_nested_jws() {
    for cty in ["JWT", "jwt", "JOSE", "jose"] {
        let token = forge_with_header(
            &json!({ "alg": "EdDSA", "typ": "at+jwt", "cty": cty }),
            &default_payload(),
        );
        let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(result, Err(AuthError::Jose(SharedAuthError::NestedJws)), "cty={cty}");
    }
}

/// M13 + M13a — RFC 9068 §2.1 + RFC 8725 §3.11: access tokens MUST carry
/// `typ: "at+jwt"` (strict — even the legacy `"JWT"` is rejected). The
/// strict pin is what defends against a receiver accepting an id_token
/// where it expected an access token, and vice versa.
#[tokio::test]
async fn test_reject_arbitrary_typ() {
    for bad_typ in ["JWT", "jwt", "application/jwt", "id_token+jwt", ""] {
        let token = forge_with_header(
            &json!({ "alg": "EdDSA", "typ": bad_typ }),
            &default_payload(),
        );
        let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
        assert_eq!(result, Err(AuthError::Jose(SharedAuthError::TypMismatch)), "typ={bad_typ:?}");
    }

    // Absent typ also rejected.
    let no_typ = forge_with_header(&json!({ "alg": "EdDSA" }), &default_payload());
    assert_eq!(
        verify(&no_typ, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
        Err(AuthError::Jose(SharedAuthError::TypMismatch)),
    );
}

/// M13a — explicit subcase of M13: pinning `typ` to `at+jwt` is the
/// receiver-side defense against `id_token` substitution at an
/// access-token endpoint. Tested separately so a future change to
/// id-token typ ("id_token+jwt") doesn't accidentally break the
/// regression guard.
#[tokio::test]
async fn test_reject_id_token_at_access_endpoint() {
    let token = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "id_token+jwt" }),
        &default_payload(),
    );
    let result = verify(&token, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(result, Err(AuthError::Jose(SharedAuthError::TypMismatch)));
}

/// M12 — RFC 8725 §3.6 + RFC 7517: the verifier resolves `kid` against a
/// server-pinned set, never a network lookup (M07/M08 already block URL
/// loaders). Missing kid and unknown kid both yield `KidUnknown` — an
/// attacker probing without `kid` and one guessing a wrong `kid` look
/// the same to the audit log.
#[tokio::test]
async fn test_reject_unknown_kid() {
    // (a) kid absent
    let no_kid = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt" }),
        &default_payload(),
    );
    assert_eq!(
        verify(&no_kid, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
        Err(AuthError::Jose(SharedAuthError::KidUnknown)),
    );

    // (b) kid present but not pinned
    let unknown_kid = forge_with_header(
        &json!({ "alg": "EdDSA", "typ": "at+jwt", "kid": "k4.pid.attacker" }),
        &default_payload(),
    );
    assert_eq!(
        verify(&unknown_kid, &cfg(), &KeySet::new(), time::OffsetDateTime::now_utc().unix_timestamp()).await,
        Err(AuthError::Jose(SharedAuthError::KidUnknown)),
    );
}

// M06 — RFC 8725 §3.2 ("use the algorithm sent by the application, not the
// one sent in the header"). Phase 7 §6.8: enforced *structurally*. The
// sealed `pub enum Algorithm { EdDSA }` makes a `cfg.algorithms` whitelist
// without EdDSA unconstructable — the misconfiguration this test guarded
// against cannot be expressed in the type system. The previous test
// (`test_alg_pinned_ignores_header`) constructed `with_algorithms(
// vec![Algorithm::ES256])` and asserted the EdDSA-headered token was
// rejected; with sealed `Algorithm`, the test body itself fails to
// compile. STANDARDS_JWT_DETAILS_MITIGATION §B M06 row reflects the
// `[~] (structural)` status.