hecks 3.11.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  3. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  4. data/lib/hecks/adapters/driven.rb +1 -0
  5. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  6. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  7. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  8. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  9. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  10. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  11. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  12. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  13. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  14. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  15. data/lib/hecks/bluebook.rb +1 -0
  16. data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
  17. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  18. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  19. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  20. data/lib/hecks/framework/change.oidc.json +34 -0
  21. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  22. data/lib/hecks/framework/governance.oidc.json +28 -0
  23. data/lib/hecks/framework/identity.oidc.json +18 -0
  24. data/lib/hecks/framework/privacy.oidc.json +30 -0
  25. data/lib/hecks/gate/stages.yml +35 -0
  26. data/lib/hecks/grammar/translation.oidc.json +48 -0
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  28. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  29. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  30. data/lib/hecks/hecks/hecks.world +3 -0
  31. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  32. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  33. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  34. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  35. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  36. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  37. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  38. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  39. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  40. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  41. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  42. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  43. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  44. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  45. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  46. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  47. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  48. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  49. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  50. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  51. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  52. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  53. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  54. data/lib/hecks/projections/deploy/box.rb +15 -3
  55. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  56. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  57. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  58. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  59. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  60. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  61. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  62. data/lib/hecks/projections/site/edge.rb +35 -9
  63. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  64. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  65. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  66. data/lib/hecks/projections/site/table.rb +4 -2
  67. data/lib/hecks/projector/cli_audience.rb +20 -0
  68. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  69. data/lib/hecks/rust_build/host.rb +11 -4
  70. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  71. data/lib/hecks/site/bluebook/site.bluebook +16 -1
  72. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  73. data/lib/hecks/tools/ci_gates.rb +20 -1
  74. data/lib/hecks/tools/lanes/files.rb +8 -0
  75. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  76. data/lib/hecks/tools/lanes.rb +1 -0
  77. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  78. data/lib/hecks/tools/release_only.rb +111 -0
  79. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks/vocabulary.rb +15 -1
  82. data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
  83. data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
  84. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
  85. data/rust/host/HECKS_RELEASE +1 -1
  86. data/rust/host/src/auth.rs +58 -0
  87. data/rust/host/src/extension.rs +13 -1
  88. data/rust/host/src/lib.rs +1 -0
  89. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  90. data/rust/host/src/platform_signin/config.rs +512 -0
  91. data/rust/host/src/platform_signin/cookie.rs +237 -0
  92. data/rust/host/src/platform_signin/extension.rs +190 -0
  93. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  94. data/rust/host/src/platform_signin/handler.rs +251 -0
  95. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  96. data/rust/host/src/platform_signin/jti.rs +196 -0
  97. data/rust/host/src/platform_signin/jwks.rs +542 -0
  98. data/rust/host/src/platform_signin/jws.rs +249 -0
  99. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  100. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  101. data/rust/host/src/platform_signin/mod.rs +75 -0
  102. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  103. data/rust/host/src/platform_signin/pages.rs +204 -0
  104. data/rust/host/src/platform_signin/query.rs +233 -0
  105. data/rust/host/src/platform_signin/session.rs +151 -0
  106. data/rust/host/src/platform_signin/settings.rs +385 -0
  107. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  108. data/rust/host/src/platform_signin/test_support.rs +235 -0
  109. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  110. data/rust/host/src/platform_signin/verify.rs +604 -0
  111. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  112. data/rust/host/src/web.rs +50 -6
  113. data/rust/parser/src/parse/value_object.rs +22 -10
  114. metadata +59 -7
  115. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  116. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  117. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  118. /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
@@ -0,0 +1,1100 @@
1
+ //! Attacks on the verifier, each named for what the attacker tries.
2
+ //!
3
+ //! The first test is the positive control: a token this suite signed by hand must be accepted, so
4
+ //! every refusal below can only come from a verifier that tells the attack from a good token. The
5
+ //! examples mirror the issuer's own attack suite for the reference verifier.
6
+
7
+ use std::sync::atomic::{AtomicUsize, Ordering};
8
+ use std::sync::Arc;
9
+
10
+ use hmac::{Hmac, Mac};
11
+ use serde_json::{json, Value};
12
+ use sha2::Sha256;
13
+
14
+ use super::config::PlatformSigninConfig;
15
+ use super::jti::JtiStore;
16
+ use super::jwks::{JwksCache, KeySource};
17
+ use super::jws::{decode_base64url, encode_base64url};
18
+ use super::test_support::*;
19
+ use super::verify::{Reason, SigninRequest, Verifier};
20
+
21
+ fn refused_with(mutate: impl FnOnce(&mut Scenario)) -> Reason {
22
+ let mut scenario = Scenario::good();
23
+ mutate(&mut scenario);
24
+ scenario
25
+ .run(&verifier_for(&[&KEY_A]))
26
+ .expect_err("must be refused")
27
+ .reason
28
+ }
29
+
30
+ fn accepted_with(mutate: impl FnOnce(&mut Scenario)) {
31
+ let mut scenario = Scenario::good();
32
+ mutate(&mut scenario);
33
+ scenario
34
+ .run(&verifier_for(&[&KEY_A]))
35
+ .expect("must be accepted");
36
+ }
37
+
38
+ fn set(scenario: &mut Scenario, claim: &str, value: Value) {
39
+ scenario.claims.insert(claim.into(), value);
40
+ }
41
+
42
+ fn raw_token_reason(token: Option<&str>) -> Reason {
43
+ let verifier = verifier_for(&[&KEY_A]);
44
+ let cookie = good_cookie();
45
+ let request = SigninRequest {
46
+ token,
47
+ state: Some(STATE),
48
+ cookie: Some(&cookie),
49
+ };
50
+ verifier
51
+ .verify(&request, NOW)
52
+ .expect_err("must be refused")
53
+ .reason
54
+ }
55
+
56
+ // --- positive controls --------------------------------------------------------------------------
57
+
58
+ #[test]
59
+ fn accepts_a_valid_token() {
60
+ assert!(Scenario::good().run(&verifier_for(&[&KEY_A])).is_ok());
61
+ }
62
+
63
+ #[test]
64
+ fn accepts_a_token_exactly_at_the_edges_of_the_window() {
65
+ accepted_with(|s| set(s, "nbf", json!(NOW + 30)));
66
+ accepted_with(|s| {
67
+ set(s, "iat", json!(NOW - 89));
68
+ set(s, "nbf", json!(NOW - 89));
69
+ set(s, "exp", json!(NOW - 29));
70
+ });
71
+ }
72
+
73
+ // --- algorithm attacks -----------------------------------------------------------------------
74
+
75
+ #[test]
76
+ fn rejects_alg_none_with_and_without_a_signature_segment() {
77
+ let header = json!({"alg": "none", "typ": "JWT", "kid": KEY_A.kid()}).to_string();
78
+ let claims = Scenario::good().claims;
79
+ let body = encode_base64url(Value::Object(claims).to_string().as_bytes());
80
+ let head = encode_base64url(header.as_bytes());
81
+ for token in [
82
+ format!("{head}.{body}."),
83
+ format!("{head}.{body}.{}", encode_base64url(&[7u8; 64])),
84
+ ] {
85
+ assert_eq!(raw_token_reason(Some(&token)), Reason::BadAlg, "{token}");
86
+ }
87
+ for alg in ["None", "NONE", "nOnE", ""] {
88
+ let head = encode_base64url(
89
+ json!({"alg": alg, "typ": "JWT", "kid": KEY_A.kid()})
90
+ .to_string()
91
+ .as_bytes(),
92
+ );
93
+ assert_eq!(
94
+ raw_token_reason(Some(&format!("{head}.{body}."))),
95
+ Reason::BadAlg,
96
+ "{alg:?}"
97
+ );
98
+ }
99
+ }
100
+
101
+ #[test]
102
+ fn rejects_hs256_signed_with_the_public_key_as_the_hmac_secret() {
103
+ let public_raw = decode_base64url(X_A).unwrap();
104
+ let secrets: Vec<Vec<u8>> = vec![
105
+ public_raw,
106
+ X_A.as_bytes().to_vec(),
107
+ format!(r#"{{"kty":"OKP","crv":"Ed25519","x":"{X_A}"}}"#).into_bytes(),
108
+ ];
109
+ for secret in secrets {
110
+ let head = encode_base64url(
111
+ json!({"alg": "HS256", "typ": "JWT", "kid": KEY_A.kid()})
112
+ .to_string()
113
+ .as_bytes(),
114
+ );
115
+ let body = encode_base64url(
116
+ Value::Object(Scenario::good().claims)
117
+ .to_string()
118
+ .as_bytes(),
119
+ );
120
+ let input = format!("{head}.{body}");
121
+ let mut mac = Hmac::<Sha256>::new_from_slice(&secret).unwrap();
122
+ mac.update(input.as_bytes());
123
+ let token = format!("{input}.{}", encode_base64url(&mac.finalize().into_bytes()));
124
+ assert_eq!(raw_token_reason(Some(&token)), Reason::BadAlg);
125
+ }
126
+ }
127
+
128
+ #[test]
129
+ fn rejects_every_alg_other_than_eddsa_even_with_a_valid_eddsa_signature_over_that_header() {
130
+ for alg in [
131
+ "HS256", "HS384", "HS512", "RS256", "RS512", "ES256", "ES384", "PS256", "none", "eddsa",
132
+ "EDDSA", "Ed25519", "EdDSA ", "ES256K",
133
+ ] {
134
+ assert_eq!(
135
+ refused_with(|s| {
136
+ s.header.insert("alg".into(), json!(alg));
137
+ }),
138
+ Reason::BadAlg,
139
+ "{alg:?}"
140
+ );
141
+ }
142
+ assert_eq!(
143
+ refused_with(|s| {
144
+ s.header.remove("alg");
145
+ }),
146
+ Reason::BadAlg
147
+ );
148
+ assert_eq!(
149
+ refused_with(|s| {
150
+ s.header.insert("alg".into(), json!(["EdDSA"]));
151
+ }),
152
+ Reason::BadAlg
153
+ );
154
+ assert_eq!(
155
+ refused_with(|s| {
156
+ s.header.insert("alg".into(), Value::Null);
157
+ }),
158
+ Reason::BadAlg
159
+ );
160
+ }
161
+
162
+ #[test]
163
+ fn rejects_a_typ_that_is_not_jwt() {
164
+ for typ in [json!("jwt"), json!("JWS"), json!(""), json!(1), Value::Null] {
165
+ assert_eq!(
166
+ refused_with(|s| {
167
+ s.header.insert("typ".into(), typ.clone());
168
+ }),
169
+ Reason::Malformed,
170
+ "{typ}"
171
+ );
172
+ }
173
+ assert_eq!(
174
+ refused_with(|s| {
175
+ s.header.remove("typ");
176
+ }),
177
+ Reason::Malformed
178
+ );
179
+ }
180
+
181
+ #[test]
182
+ fn ignores_an_embedded_jwk_and_jku_and_never_uses_them() {
183
+ let attacker_jwk = json!({"kty": "OKP", "crv": "Ed25519", "x": X_B, "kid": KEY_B.kid()});
184
+ let reason = refused_with(|s| {
185
+ s.signer = KEY_B;
186
+ s.header.insert("jwk".into(), attacker_jwk.clone());
187
+ s.header
188
+ .insert("jku".into(), json!("https://evil.example/jwks.json"));
189
+ });
190
+ assert_eq!(
191
+ reason,
192
+ Reason::BadSignature,
193
+ "signed by the attacker, kid names the real key"
194
+ );
195
+ let reason = refused_with(|s| {
196
+ s.signer = KEY_B;
197
+ s.header.insert("kid".into(), json!(KEY_B.kid()));
198
+ s.header.insert("jwk".into(), attacker_jwk.clone());
199
+ });
200
+ assert_eq!(
201
+ reason,
202
+ Reason::UnknownKid,
203
+ "the attacker's own key is not in the key set"
204
+ );
205
+ }
206
+
207
+ #[test]
208
+ fn rejects_an_unknown_kid_a_missing_kid_and_a_kid_of_the_wrong_type() {
209
+ assert_eq!(
210
+ refused_with(|s| {
211
+ s.header.insert("kid".into(), json!("nope"));
212
+ }),
213
+ Reason::UnknownKid
214
+ );
215
+ assert_eq!(
216
+ refused_with(|s| {
217
+ s.header.remove("kid");
218
+ }),
219
+ Reason::UnknownKid
220
+ );
221
+ for kid in [json!(7), json!(["x"]), Value::Null, json!({"a": 1})] {
222
+ assert_eq!(
223
+ refused_with(|s| {
224
+ s.header.insert("kid".into(), kid.clone());
225
+ }),
226
+ Reason::UnknownKid
227
+ );
228
+ }
229
+ assert_eq!(
230
+ refused_with(|s| {
231
+ s.header.insert("kid".into(), json!(KID_A.to_uppercase()));
232
+ }),
233
+ Reason::UnknownKid
234
+ );
235
+ }
236
+
237
+ #[test]
238
+ fn rejects_a_token_signed_by_another_key_that_claims_the_real_kid() {
239
+ assert_eq!(refused_with(|s| s.signer = KEY_B), Reason::BadSignature);
240
+ }
241
+
242
+ // --- tampering -------------------------------------------------------------------------------
243
+
244
+ fn parts(scenario: &Scenario) -> (String, String, String) {
245
+ let token = scenario.token();
246
+ let mut it = token.split('.');
247
+ (
248
+ it.next().unwrap().into(),
249
+ it.next().unwrap().into(),
250
+ it.next().unwrap().into(),
251
+ )
252
+ }
253
+
254
+ #[test]
255
+ fn rejects_a_tampered_payload() {
256
+ let (head, _, signature) = parts(&Scenario::good());
257
+ let mut evil = Scenario::good().claims;
258
+ evil.insert("email".into(), json!("admin@example.test"));
259
+ evil.insert("sub".into(), json!("admin@example.test"));
260
+ let tampered = format!(
261
+ "{head}.{}.{signature}",
262
+ encode_base64url(Value::Object(evil).to_string().as_bytes())
263
+ );
264
+ assert_eq!(raw_token_reason(Some(&tampered)), Reason::BadSignature);
265
+ }
266
+
267
+ #[test]
268
+ fn rejects_a_tampered_header_with_the_kid_swapped_to_another_known_kid() {
269
+ let verifier = verifier_for(&[&KEY_A, &KEY_B]);
270
+ let (_, body, signature) = parts(&Scenario::good());
271
+ let head = encode_base64url(
272
+ json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_B.kid()})
273
+ .to_string()
274
+ .as_bytes(),
275
+ );
276
+ let cookie = good_cookie();
277
+ let token = format!("{head}.{body}.{signature}");
278
+ let request = SigninRequest {
279
+ token: Some(&token),
280
+ state: Some(STATE),
281
+ cookie: Some(&cookie),
282
+ };
283
+ assert_eq!(
284
+ verifier.verify(&request, NOW).unwrap_err().reason,
285
+ Reason::BadSignature
286
+ );
287
+ }
288
+
289
+ #[test]
290
+ fn rejects_a_tampered_signature_one_bit_flipped_and_every_wrong_length() {
291
+ assert_eq!(
292
+ refused_with(|s| s.flip_signature = true),
293
+ Reason::BadSignature
294
+ );
295
+ let (head, body, signature) = parts(&Scenario::good());
296
+ let bytes = decode_base64url(&signature).unwrap();
297
+ for wrong in [&bytes[..63], &bytes[..32], &bytes[..1], &[][..]] {
298
+ let token = format!("{head}.{body}.{}", encode_base64url(wrong));
299
+ assert_eq!(
300
+ raw_token_reason(Some(&token)),
301
+ Reason::BadSignature,
302
+ "{} bytes",
303
+ wrong.len()
304
+ );
305
+ }
306
+ let mut longer = bytes.clone();
307
+ longer.extend_from_slice(&[0, 0]);
308
+ assert_eq!(
309
+ raw_token_reason(Some(&format!(
310
+ "{head}.{body}.{}",
311
+ encode_base64url(&longer)
312
+ ))),
313
+ Reason::BadSignature
314
+ );
315
+ }
316
+
317
+ // --- structure -------------------------------------------------------------------------------
318
+
319
+ #[test]
320
+ fn rejects_truncated_extra_segment_empty_and_missing_tokens() {
321
+ let token = Scenario::good().token();
322
+ let (head, body, signature) = parts(&Scenario::good());
323
+ assert_eq!(
324
+ raw_token_reason(Some(&token[..token.len() - 10])),
325
+ Reason::BadSignature,
326
+ "a shorter signature still parses"
327
+ );
328
+ assert_eq!(
329
+ raw_token_reason(Some(&format!("{head}.{body}"))),
330
+ Reason::Malformed
331
+ );
332
+ assert_eq!(
333
+ raw_token_reason(Some(&format!("{token}.{signature}"))),
334
+ Reason::Malformed
335
+ );
336
+ assert_eq!(
337
+ raw_token_reason(Some(&format!("{token}."))),
338
+ Reason::Malformed
339
+ );
340
+ assert_eq!(raw_token_reason(Some("")), Reason::Malformed);
341
+ assert_eq!(raw_token_reason(Some("..")), Reason::Malformed);
342
+ assert_eq!(raw_token_reason(None), Reason::Malformed);
343
+ }
344
+
345
+ #[test]
346
+ fn rejects_non_base64url_segments_and_non_object_json() {
347
+ let (head, body, signature) = parts(&Scenario::good());
348
+ let cases = [
349
+ format!("{head}=.{body}.{signature}"),
350
+ format!("{head}.{}+.{signature}", &body[..body.len() - 1]),
351
+ format!("{head}./{body}.{signature}"),
352
+ format!("{head}.{} {}.{signature}", &body[..4], &body[4..]),
353
+ format!("{head}.{body}\n.{signature}"),
354
+ format!("{}.{body}.{signature}", encode_base64url(b"not json")),
355
+ format!("{head}.{}.{signature}", encode_base64url(b"[1,2]")),
356
+ format!("{head}.{}.{signature}", encode_base64url(b"\"s\"")),
357
+ format!("{head}.{}.{signature}", encode_base64url(b"")),
358
+ format!("{head}.{body}.{}", signature.replace(&signature[..1], "!")),
359
+ ];
360
+ for token in cases {
361
+ let reason = raw_token_reason(Some(&token));
362
+ assert_eq!(reason, Reason::Malformed, "{token}");
363
+ }
364
+ }
365
+
366
+ #[test]
367
+ fn rejects_a_header_or_payload_that_repeats_a_key() {
368
+ let kid = KEY_A.kid();
369
+ let claims = Value::Object(Scenario::good().claims).to_string();
370
+ let header_dup = format!(r#"{{"alg":"none","alg":"EdDSA","typ":"JWT","kid":"{kid}"}}"#);
371
+ let payload_dup = format!(r#"{{"aud":"other",{}"#, &claims[1..]);
372
+ assert_eq!(
373
+ raw_token_reason(Some(&assemble(&header_dup, &claims, &KEY_A))),
374
+ Reason::Malformed
375
+ );
376
+ let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": kid}).to_string();
377
+ assert_eq!(
378
+ raw_token_reason(Some(&assemble(&header, &payload_dup, &KEY_A))),
379
+ Reason::Malformed
380
+ );
381
+ }
382
+
383
+ #[test]
384
+ fn rejects_a_non_canonical_signature_spelling() {
385
+ let (head, body, signature) = parts(&Scenario::good());
386
+ let last = signature.chars().last().unwrap();
387
+ let spare = if last == 'A' { 'B' } else { 'A' };
388
+ let respelled = format!("{head}.{body}.{}{spare}", &signature[..signature.len() - 1]);
389
+ assert_eq!(raw_token_reason(Some(&respelled)), Reason::Malformed);
390
+ assert_eq!(
391
+ raw_token_reason(Some(&format!("{head}.{body}.{signature}="))),
392
+ Reason::Malformed
393
+ );
394
+ }
395
+
396
+ fn token_of_exactly(len: usize) -> String {
397
+ for pad in 0..len {
398
+ for jti in 0..4 {
399
+ let mut scenario = Scenario::good();
400
+ set(&mut scenario, "pad", json!("x".repeat(pad)));
401
+ set(
402
+ &mut scenario,
403
+ "jti",
404
+ json!(format!("jti-size-{}", "j".repeat(jti))),
405
+ );
406
+ let token = scenario.token();
407
+ if token.len() == len {
408
+ return token;
409
+ }
410
+ }
411
+ }
412
+ panic!("no token of {len} bytes");
413
+ }
414
+
415
+ #[test]
416
+ fn rejects_an_oversized_token_before_parsing_it_and_accepts_one_at_the_cap() {
417
+ assert_eq!(
418
+ raw_token_reason(Some(&token_of_exactly(4097))),
419
+ Reason::TooLarge
420
+ );
421
+ assert_eq!(
422
+ raw_token_reason(Some(&"x".repeat(100_000))),
423
+ Reason::TooLarge
424
+ );
425
+ let at_cap = token_of_exactly(4096);
426
+ let cookie = good_cookie();
427
+ let request = SigninRequest {
428
+ token: Some(&at_cap),
429
+ state: Some(STATE),
430
+ cookie: Some(&cookie),
431
+ };
432
+ assert!(verifier_for(&[&KEY_A]).verify(&request, NOW).is_ok());
433
+ }
434
+
435
+ // --- claims ----------------------------------------------------------------------------------
436
+
437
+ #[test]
438
+ fn rejects_claims_of_the_wrong_json_type() {
439
+ assert_eq!(
440
+ refused_with(|s| set(s, "exp", json!("1800000060"))),
441
+ Reason::Malformed
442
+ );
443
+ assert_eq!(
444
+ refused_with(|s| set(s, "exp", json!(1800000060.0))),
445
+ Reason::Malformed
446
+ );
447
+ assert_eq!(
448
+ refused_with(|s| set(s, "iat", json!(1800000000.5))),
449
+ Reason::Malformed
450
+ );
451
+ assert_eq!(
452
+ refused_with(|s| set(s, "nbf", json!(true))),
453
+ Reason::Malformed
454
+ );
455
+ assert_eq!(
456
+ refused_with(|s| set(s, "aud", json!([SITE]))),
457
+ Reason::BadAud
458
+ );
459
+ assert_eq!(
460
+ refused_with(|s| set(s, "env", json!([ENVIRONMENT]))),
461
+ Reason::BadEnv
462
+ );
463
+ assert_eq!(
464
+ refused_with(|s| set(s, "iss", json!([ISSUER]))),
465
+ Reason::BadIss
466
+ );
467
+ }
468
+
469
+ #[test]
470
+ fn rejects_the_wrong_iss_including_lookalikes() {
471
+ for iss in [
472
+ "https://issuer.example/",
473
+ "http://issuer.example",
474
+ "https://ISSUER.example",
475
+ "https://issuer.example.evil.example",
476
+ "https://evil.example",
477
+ "issuer.example",
478
+ "",
479
+ ] {
480
+ assert_eq!(
481
+ refused_with(|s| set(s, "iss", json!(iss))),
482
+ Reason::BadIss,
483
+ "{iss:?}"
484
+ );
485
+ }
486
+ }
487
+
488
+ #[test]
489
+ fn rejects_the_wrong_aud_and_env() {
490
+ for aud in ["site-b", "SITE-A", "site-a ", "site", "platform", ""] {
491
+ assert_eq!(
492
+ refused_with(|s| set(s, "aud", json!(aud))),
493
+ Reason::BadAud,
494
+ "{aud:?}"
495
+ );
496
+ }
497
+ for env in ["staging", "Production", "prod", "production ", ""] {
498
+ assert_eq!(
499
+ refused_with(|s| set(s, "env", json!(env))),
500
+ Reason::BadEnv,
501
+ "{env:?}"
502
+ );
503
+ }
504
+ }
505
+
506
+ #[test]
507
+ fn rejects_an_expired_token_at_exp_minus_skew_and_not_a_second_sooner() {
508
+ assert_eq!(
509
+ refused_with(|s| {
510
+ set(s, "iat", json!(NOW - 90));
511
+ set(s, "nbf", json!(NOW - 90));
512
+ set(s, "exp", json!(NOW - 30));
513
+ }),
514
+ Reason::Expired
515
+ );
516
+ assert_eq!(
517
+ refused_with(|s| {
518
+ set(s, "iat", json!(NOW - 9000));
519
+ set(s, "nbf", json!(NOW - 9000));
520
+ set(s, "exp", json!(NOW - 8940));
521
+ }),
522
+ Reason::Expired
523
+ );
524
+ }
525
+
526
+ #[test]
527
+ fn rejects_a_not_yet_valid_token_beyond_the_skew() {
528
+ assert_eq!(
529
+ refused_with(|s| set(s, "nbf", json!(NOW + 31))),
530
+ Reason::NotYetValid
531
+ );
532
+ assert_eq!(
533
+ refused_with(|s| {
534
+ set(s, "nbf", json!(NOW + 3600));
535
+ set(s, "iat", json!(NOW + 3600));
536
+ set(s, "exp", json!(NOW + 3660));
537
+ }),
538
+ Reason::NotYetValid
539
+ );
540
+ }
541
+
542
+ #[test]
543
+ fn rejects_a_lifetime_over_sixty_seconds_even_when_currently_valid() {
544
+ assert_eq!(
545
+ refused_with(|s| set(s, "exp", json!(NOW + 61))),
546
+ Reason::Lifetime
547
+ );
548
+ assert_eq!(
549
+ refused_with(|s| set(s, "iat", json!(NOW - 1))),
550
+ Reason::Lifetime
551
+ );
552
+ assert_eq!(
553
+ refused_with(|s| set(s, "exp", json!(NOW))),
554
+ Reason::Lifetime,
555
+ "zero lifetime"
556
+ );
557
+ assert_eq!(
558
+ refused_with(|s| set(s, "exp", json!(NOW - 1))),
559
+ Reason::Lifetime,
560
+ "negative lifetime"
561
+ );
562
+ accepted_with(|s| set(s, "exp", json!(NOW + 1)));
563
+ }
564
+
565
+ #[test]
566
+ fn rejects_a_token_missing_any_required_claim_or_holding_null() {
567
+ for claim in [
568
+ "iss",
569
+ "aud",
570
+ "env",
571
+ "sub",
572
+ "email",
573
+ "email_verified",
574
+ "nonce",
575
+ "iat",
576
+ "nbf",
577
+ "exp",
578
+ "jti",
579
+ ] {
580
+ assert_eq!(
581
+ refused_with(|s| {
582
+ s.claims.remove(claim);
583
+ }),
584
+ Reason::MissingClaim,
585
+ "{claim}"
586
+ );
587
+ assert_eq!(
588
+ refused_with(|s| set(s, claim, Value::Null)),
589
+ Reason::MissingClaim,
590
+ "null {claim}"
591
+ );
592
+ }
593
+ accepted_with(|s| {
594
+ s.claims.remove("roles");
595
+ s.claims.remove("idp_iss");
596
+ s.claims.remove("idp_sub");
597
+ });
598
+ }
599
+
600
+ // --- single use, nonce, state ----------------------------------------------------------------
601
+
602
+ #[test]
603
+ fn accepts_a_jti_once_and_rejects_the_replay() {
604
+ let verifier = verifier_for(&[&KEY_A]);
605
+ let scenario = Scenario::good();
606
+ assert!(scenario.run(&verifier).is_ok());
607
+ assert_eq!(scenario.run(&verifier).unwrap_err().reason, Reason::Replay);
608
+ assert_eq!(scenario.run(&verifier).unwrap_err().reason, Reason::Replay);
609
+ }
610
+
611
+ #[test]
612
+ fn does_not_burn_the_jti_when_an_earlier_check_fails() {
613
+ let verifier = verifier_for(&[&KEY_A]);
614
+ let mut attacker = Scenario::good();
615
+ attacker.cookie = Some(super::verify::SigninCookie {
616
+ nonce: "attackers-own-nonce-value".into(),
617
+ state: STATE.into(),
618
+ });
619
+ assert_eq!(
620
+ attacker.run(&verifier).unwrap_err().reason,
621
+ Reason::NonceMismatch
622
+ );
623
+ assert!(
624
+ Scenario::good().run(&verifier).is_ok(),
625
+ "the genuine browser still redeems it"
626
+ );
627
+ }
628
+
629
+ #[test]
630
+ fn records_the_jti_until_exp_plus_skew_and_not_before() {
631
+ let verifier = verifier_for(&[&KEY_A]);
632
+ Scenario::good().run(&verifier).unwrap();
633
+ let store = verifier.jti_store();
634
+ assert!(!store.claim("jti-scenario-0000000000", NOW + 200, NOW + 89));
635
+ assert!(store.claim("jti-scenario-0000000000", NOW + 200, NOW + 90));
636
+ }
637
+
638
+ #[test]
639
+ fn a_jti_that_is_not_a_string_is_a_replay_not_a_pass() {
640
+ assert_eq!(refused_with(|s| set(s, "jti", json!(5))), Reason::Replay);
641
+ assert_eq!(refused_with(|s| set(s, "jti", json!(""))), Reason::Replay);
642
+ }
643
+
644
+ #[test]
645
+ fn rejects_a_nonce_that_differs_from_the_cookie() {
646
+ let mut scenario = Scenario::good();
647
+ scenario.cookie.as_mut().unwrap().nonce = "x".repeat(20);
648
+ assert_eq!(
649
+ scenario.run(&verifier_for(&[&KEY_A])).unwrap_err().reason,
650
+ Reason::NonceMismatch
651
+ );
652
+ assert_eq!(
653
+ refused_with(|s| set(s, "nonce", json!(NONCE.to_lowercase()))),
654
+ Reason::NonceMismatch
655
+ );
656
+ assert_eq!(
657
+ refused_with(|s| set(s, "nonce", json!(7))),
658
+ Reason::NonceMismatch
659
+ );
660
+ }
661
+
662
+ #[test]
663
+ fn rejects_when_the_cookie_is_absent_or_lacks_a_value() {
664
+ assert_eq!(refused_with(|s| s.cookie = None), Reason::MissingCookie);
665
+ assert_eq!(
666
+ refused_with(|s| s.cookie.as_mut().unwrap().state = String::new()),
667
+ Reason::MissingCookie
668
+ );
669
+ assert_eq!(
670
+ refused_with(|s| s.cookie.as_mut().unwrap().nonce = String::new()),
671
+ Reason::MissingCookie
672
+ );
673
+ }
674
+
675
+ #[test]
676
+ fn rejects_a_state_that_differs_or_is_missing_or_empty() {
677
+ assert_eq!(
678
+ refused_with(|s| s.state = Some("other".into())),
679
+ Reason::StateMismatch
680
+ );
681
+ assert_eq!(refused_with(|s| s.state = None), Reason::StateMismatch);
682
+ assert_eq!(
683
+ refused_with(|s| s.state = Some(String::new())),
684
+ Reason::StateMismatch
685
+ );
686
+ assert_eq!(
687
+ refused_with(|s| s.state = Some(format!("{STATE} "))),
688
+ Reason::StateMismatch
689
+ );
690
+ }
691
+
692
+ #[test]
693
+ fn checks_state_before_it_looks_at_the_token_at_all() {
694
+ let verifier = verifier_for(&[&KEY_A]);
695
+ let cookie = good_cookie();
696
+ for token in [Some("garbage"), None, Some("")] {
697
+ let request = SigninRequest {
698
+ token,
699
+ state: Some("wrong"),
700
+ cookie: Some(&cookie),
701
+ };
702
+ assert_eq!(
703
+ verifier.verify(&request, NOW).unwrap_err().reason,
704
+ Reason::StateMismatch
705
+ );
706
+ }
707
+ }
708
+
709
+ // --- identity --------------------------------------------------------------------------------
710
+
711
+ #[test]
712
+ fn rejects_email_verified_false_and_a_non_boolean() {
713
+ for value in [
714
+ json!(false),
715
+ json!("true"),
716
+ json!(1),
717
+ json!([true]),
718
+ json!(""),
719
+ ] {
720
+ assert_eq!(
721
+ refused_with(|s| set(s, "email_verified", value.clone())),
722
+ Reason::EmailUnverified,
723
+ "{value}"
724
+ );
725
+ }
726
+ }
727
+
728
+ #[test]
729
+ fn handles_a_mixed_case_email_consistently() {
730
+ accepted_with(|s| set(s, "email", json!("Person@Example.TEST")));
731
+ assert_eq!(
732
+ refused_with(|s| {
733
+ set(s, "email", json!("Person@Example.TEST"));
734
+ set(s, "sub", json!("Person@Example.TEST"));
735
+ }),
736
+ Reason::SubjectMismatch
737
+ );
738
+ assert_eq!(
739
+ refused_with(|s| set(s, "sub", json!("other@example.test"))),
740
+ Reason::SubjectMismatch
741
+ );
742
+ }
743
+
744
+ #[test]
745
+ fn rejects_an_email_or_sub_that_is_not_a_string() {
746
+ assert_eq!(
747
+ refused_with(|s| set(s, "email", json!(["person@example.test"]))),
748
+ Reason::Malformed
749
+ );
750
+ assert_eq!(refused_with(|s| set(s, "sub", json!(7))), Reason::Malformed);
751
+ }
752
+
753
+ // --- another site, another environment ----------------------------------------------------------
754
+
755
+ fn verifier_pinned(site: &str, environment: &str) -> Verifier<StaticKeys> {
756
+ let config = PlatformSigninConfig::new(ISSUER, "https://issuer.example/k", site, environment);
757
+ Verifier::new(config, static_keys(&[&KEY_A]), JtiStore::new())
758
+ }
759
+
760
+ #[test]
761
+ fn a_token_for_site_a_presented_to_site_b_is_refused() {
762
+ assert_eq!(
763
+ Scenario::good()
764
+ .run(&verifier_pinned("site-b", ENVIRONMENT))
765
+ .unwrap_err()
766
+ .reason,
767
+ Reason::BadAud
768
+ );
769
+ }
770
+
771
+ #[test]
772
+ fn a_production_token_on_a_staging_site_is_refused() {
773
+ assert_eq!(
774
+ Scenario::good()
775
+ .run(&verifier_pinned(SITE, "staging"))
776
+ .unwrap_err()
777
+ .reason,
778
+ Reason::BadEnv
779
+ );
780
+ }
781
+
782
+ #[test]
783
+ fn a_verifier_pinned_to_another_issuer_refuses_this_issuers_tokens() {
784
+ let config = PlatformSigninConfig::new(
785
+ "https://other-issuer.example",
786
+ "https://other-issuer.example/k",
787
+ SITE,
788
+ ENVIRONMENT,
789
+ );
790
+ let verifier = Verifier::new(config, static_keys(&[&KEY_A]), JtiStore::new());
791
+ assert_eq!(
792
+ Scenario::good().run(&verifier).unwrap_err().reason,
793
+ Reason::BadIss
794
+ );
795
+ }
796
+
797
+ // --- keys through the cache ------------------------------------------------------------------
798
+
799
+ fn cached_verifier(documents: Vec<String>) -> (Verifier<JwksCache<FnFetcher>>, Arc<AtomicUsize>) {
800
+ let calls = Arc::new(AtomicUsize::new(0));
801
+ let seen = calls.clone();
802
+ let fetcher = FnFetcher(Box::new(move || {
803
+ let n = seen.fetch_add(1, Ordering::SeqCst);
804
+ Ok(documents[n.min(documents.len() - 1)].clone())
805
+ }));
806
+ (
807
+ Verifier::new(config(), JwksCache::new(fetcher), JtiStore::new()),
808
+ calls,
809
+ )
810
+ }
811
+
812
+ fn run_with<K: KeySource>(verifier: &Verifier<K>, scenario: &Scenario) -> Result<(), Reason> {
813
+ let token = scenario.token();
814
+ let request = SigninRequest {
815
+ token: Some(&token),
816
+ state: scenario.state.as_deref(),
817
+ cookie: scenario.cookie.as_ref(),
818
+ };
819
+ verifier
820
+ .verify(&request, scenario.now)
821
+ .map(|_| ())
822
+ .map_err(|r| r.reason)
823
+ }
824
+
825
+ #[test]
826
+ fn a_flood_of_unknown_kids_causes_one_fetch_not_one_per_token() {
827
+ let (verifier, calls) = cached_verifier(vec![jwks_document(&[&KEY_A])]);
828
+ for i in 0..100 {
829
+ let mut scenario = Scenario::good();
830
+ scenario
831
+ .header
832
+ .insert("kid".into(), json!(format!("random-kid-{i}")));
833
+ scenario.now = NOW + (i % 25);
834
+ assert_eq!(run_with(&verifier, &scenario), Err(Reason::UnknownKid));
835
+ }
836
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
837
+ }
838
+
839
+ #[test]
840
+ fn a_known_kid_does_not_refetch() {
841
+ let (verifier, calls) = cached_verifier(vec![jwks_document(&[&KEY_A])]);
842
+ for i in 0..5 {
843
+ let mut scenario = Scenario::good();
844
+ set(&mut scenario, "jti", json!(format!("jti-known-{i}")));
845
+ assert_eq!(run_with(&verifier, &scenario), Ok(()));
846
+ }
847
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
848
+ }
849
+
850
+ #[test]
851
+ fn a_previous_key_verifies_while_listed_and_is_refused_once_removed() {
852
+ let (verifier, _) = cached_verifier(vec![
853
+ jwks_document(&[&KEY_A, &KEY_B]),
854
+ jwks_document(&[&KEY_B]),
855
+ ]);
856
+ let mut old = Scenario::good();
857
+ old.signer = KEY_A;
858
+ assert_eq!(run_with(&verifier, &old), Ok(()));
859
+ let mut newer = Scenario::good();
860
+ newer.signer = KEY_B;
861
+ newer.header.insert("kid".into(), json!(KEY_B.kid()));
862
+ set(&mut newer, "jti", json!("jti-rotated-key-0001"));
863
+ assert_eq!(run_with(&verifier, &newer), Ok(()));
864
+ let mut later = Scenario::good();
865
+ later.now = NOW + 300;
866
+ set(&mut later, "iat", json!(NOW + 300));
867
+ set(&mut later, "nbf", json!(NOW + 300));
868
+ set(&mut later, "exp", json!(NOW + 360));
869
+ set(&mut later, "jti", json!("jti-after-removal-001"));
870
+ assert_eq!(run_with(&verifier, &later), Err(Reason::UnknownKid));
871
+ }
872
+
873
+ // --- the order of the checks -----------------------------------------------------------------
874
+
875
+ type Fault = fn(&mut Scenario);
876
+
877
+ fn missing_cookie(s: &mut Scenario) {
878
+ s.cookie = None;
879
+ }
880
+ fn state_mismatch(s: &mut Scenario) {
881
+ s.state = Some("wrong-state".into());
882
+ }
883
+ fn garbage_token(s: &mut Scenario) {
884
+ s.replace_token = Some("not.a.token".into());
885
+ }
886
+ fn too_large(s: &mut Scenario) {
887
+ set(s, "pad", json!("x".repeat(5000)));
888
+ }
889
+ fn bad_alg(s: &mut Scenario) {
890
+ s.header.insert("alg".into(), json!("none"));
891
+ }
892
+ fn bad_typ(s: &mut Scenario) {
893
+ s.header.insert("typ".into(), json!("JWS"));
894
+ }
895
+ fn unknown_kid(s: &mut Scenario) {
896
+ s.header.insert("kid".into(), json!("nope"));
897
+ }
898
+ fn bad_signature(s: &mut Scenario) {
899
+ s.flip_signature = true;
900
+ }
901
+ fn missing_claim(s: &mut Scenario) {
902
+ s.claims.remove("jti");
903
+ }
904
+ fn bad_iss(s: &mut Scenario) {
905
+ set(s, "iss", json!("https://evil.example"));
906
+ }
907
+ fn bad_aud(s: &mut Scenario) {
908
+ set(s, "aud", json!("site-b"));
909
+ }
910
+ fn bad_env(s: &mut Scenario) {
911
+ set(s, "env", json!("staging"));
912
+ }
913
+ fn float_exp(s: &mut Scenario) {
914
+ set(s, "exp", json!(1800000060.5));
915
+ }
916
+ fn not_yet_valid(s: &mut Scenario) {
917
+ set(s, "nbf", json!(NOW + 100));
918
+ }
919
+ fn expired(s: &mut Scenario) {
920
+ set(s, "iat", json!(NOW - 160));
921
+ set(s, "exp", json!(NOW - 100));
922
+ }
923
+ fn long_lifetime(s: &mut Scenario) {
924
+ set(s, "iat", json!(NOW - 100));
925
+ }
926
+ fn nonce_mismatch(s: &mut Scenario) {
927
+ set(s, "nonce", json!("someone-elses-nonce"));
928
+ }
929
+ fn replayed(s: &mut Scenario) {
930
+ s.spend_first = true;
931
+ }
932
+ fn unverified(s: &mut Scenario) {
933
+ set(s, "email_verified", json!(false));
934
+ }
935
+ fn wrong_subject(s: &mut Scenario) {
936
+ set(s, "sub", json!("other@example.test"));
937
+ }
938
+
939
+ fn with_both(first: Fault, second: Fault) -> (Reason, Reason) {
940
+ let run = |a: Fault, b: Fault| {
941
+ let mut scenario = Scenario::good();
942
+ a(&mut scenario);
943
+ b(&mut scenario);
944
+ scenario
945
+ .run(&verifier_for(&[&KEY_A]))
946
+ .expect_err("two faults")
947
+ .reason
948
+ };
949
+ (run(first, second), run(second, first))
950
+ }
951
+
952
+ #[test]
953
+ fn a_token_with_two_faults_reports_the_first_in_the_references_order() {
954
+ let pairs: [(Fault, Fault, Reason); 20] = [
955
+ (missing_cookie, state_mismatch, Reason::MissingCookie),
956
+ (missing_cookie, garbage_token, Reason::MissingCookie),
957
+ (state_mismatch, garbage_token, Reason::StateMismatch),
958
+ (state_mismatch, bad_signature, Reason::StateMismatch),
959
+ (too_large, bad_alg, Reason::TooLarge),
960
+ (bad_alg, bad_typ, Reason::BadAlg),
961
+ (bad_alg, unknown_kid, Reason::BadAlg),
962
+ (bad_typ, unknown_kid, Reason::Malformed),
963
+ (unknown_kid, bad_aud, Reason::UnknownKid),
964
+ (bad_signature, bad_aud, Reason::BadSignature),
965
+ (bad_signature, missing_claim, Reason::BadSignature),
966
+ (missing_claim, bad_iss, Reason::MissingClaim),
967
+ (bad_iss, bad_aud, Reason::BadIss),
968
+ (bad_aud, bad_env, Reason::BadAud),
969
+ (bad_env, float_exp, Reason::BadEnv),
970
+ (float_exp, not_yet_valid, Reason::Malformed),
971
+ (not_yet_valid, expired, Reason::NotYetValid),
972
+ (expired, long_lifetime, Reason::Expired),
973
+ (long_lifetime, nonce_mismatch, Reason::Lifetime),
974
+ (nonce_mismatch, replayed, Reason::NonceMismatch),
975
+ ];
976
+ for (first, second, expected) in pairs {
977
+ assert_eq!(
978
+ with_both(first, second),
979
+ (expected, expected),
980
+ "{expected:?} must win in either application order"
981
+ );
982
+ }
983
+ }
984
+
985
+ #[test]
986
+ fn the_identity_checks_come_last() {
987
+ assert_eq!(
988
+ with_both(replayed, unverified),
989
+ (Reason::Replay, Reason::Replay)
990
+ );
991
+ assert_eq!(
992
+ with_both(unverified, wrong_subject),
993
+ (Reason::EmailUnverified, Reason::EmailUnverified)
994
+ );
995
+ assert_eq!(
996
+ with_both(nonce_mismatch, unverified),
997
+ (Reason::NonceMismatch, Reason::NonceMismatch)
998
+ );
999
+ }
1000
+
1001
+ #[test]
1002
+ fn the_replay_check_spends_nothing_when_an_earlier_check_fails() {
1003
+ for fault in [
1004
+ state_mismatch,
1005
+ bad_alg,
1006
+ bad_signature,
1007
+ bad_aud,
1008
+ expired,
1009
+ nonce_mismatch,
1010
+ ] {
1011
+ let verifier = verifier_for(&[&KEY_A]);
1012
+ let mut scenario = Scenario::good();
1013
+ fault(&mut scenario);
1014
+ assert!(scenario.run(&verifier).is_err());
1015
+ assert!(
1016
+ verifier.jti_store().is_empty(NOW),
1017
+ "a refused request must not leave a spent jti"
1018
+ );
1019
+ }
1020
+ }
1021
+
1022
+ #[test]
1023
+ fn identity_failures_come_after_the_jti_is_spent() {
1024
+ let verifier = verifier_for(&[&KEY_A]);
1025
+ let mut scenario = Scenario::good();
1026
+ unverified(&mut scenario);
1027
+ assert_eq!(
1028
+ scenario.run(&verifier).unwrap_err().reason,
1029
+ Reason::EmailUnverified
1030
+ );
1031
+ assert_eq!(
1032
+ verifier.jti_store().len(NOW),
1033
+ 1,
1034
+ "the reference spends the jti before the identity checks"
1035
+ );
1036
+ }
1037
+
1038
+ // --- hostile input never panics --------------------------------------------------------------
1039
+
1040
+ #[test]
1041
+ fn every_single_character_mutation_of_a_good_token_is_answered_without_panicking() {
1042
+ let good = Scenario::good().token();
1043
+ let cookie = good_cookie();
1044
+ let replacements = ['A', '.', '=', '-', '_', ' ', '\u{0}', '\u{e9}', '\u{1f600}'];
1045
+ for position in (0..good.len()).step_by(7) {
1046
+ for replacement in replacements {
1047
+ if !good.is_char_boundary(position) {
1048
+ continue;
1049
+ }
1050
+ let mut mutated = good.clone();
1051
+ mutated.replace_range(position..position + 1, &replacement.to_string());
1052
+ let request = SigninRequest {
1053
+ token: Some(&mutated),
1054
+ state: Some(STATE),
1055
+ cookie: Some(&cookie),
1056
+ };
1057
+ let verdict = verifier_for(&[&KEY_A]).verify(&request, NOW);
1058
+ if mutated != good {
1059
+ assert!(
1060
+ verdict.is_err(),
1061
+ "a mutated token must never be accepted: position {position}"
1062
+ );
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn extreme_numbers_in_time_claims_are_refused_without_overflow() {
1070
+ let big = "99999999999999999999999999999999999999999999999";
1071
+ for claim in ["iat", "nbf", "exp"] {
1072
+ let claims = Scenario::good().claims;
1073
+ let mut text = Value::Object(claims).to_string();
1074
+ let needle = format!("\"{claim}\":");
1075
+ let start = text.find(&needle).unwrap() + needle.len();
1076
+ let end = start + text[start..].find([',', '}']).unwrap();
1077
+ text.replace_range(start..end, big);
1078
+ let header = json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_A.kid()}).to_string();
1079
+ let token = assemble(&header, &text, &KEY_A);
1080
+ assert!(
1081
+ matches!(
1082
+ raw_token_reason(Some(&token)),
1083
+ Reason::NotYetValid | Reason::Lifetime | Reason::Expired
1084
+ ),
1085
+ "{claim}"
1086
+ );
1087
+ if claim == "nbf" {
1088
+ continue; // a far-past `nbf` is simply valid; only the other two have a negative extreme to refuse
1089
+ }
1090
+ let negative = text.replace(big, &format!("-{big}"));
1091
+ let token = assemble(&header, &negative, &KEY_A);
1092
+ assert!(
1093
+ matches!(
1094
+ raw_token_reason(Some(&token)),
1095
+ Reason::Expired | Reason::Lifetime
1096
+ ),
1097
+ "{claim}"
1098
+ );
1099
+ }
1100
+ }