hecks 3.11.0 → 3.12.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  3. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  4. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  5. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  6. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  7. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  8. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  9. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
  12. data/lib/hecks/gate/stages.yml +32 -0
  13. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  14. data/lib/hecks/hecks/hecks.world +3 -0
  15. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  16. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  18. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  19. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  20. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  22. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  23. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  24. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  25. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  26. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  27. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  28. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  29. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  30. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  31. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  32. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  33. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  34. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  35. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  36. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  37. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  38. data/lib/hecks/projections/deploy/box.rb +15 -3
  39. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  40. data/lib/hecks/projector/cli_audience.rb +20 -0
  41. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  42. data/lib/hecks/rust_build/host.rb +11 -4
  43. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  44. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  45. data/lib/hecks/tools/ci_gates.rb +20 -1
  46. data/lib/hecks/tools/lanes/files.rb +8 -0
  47. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  48. data/lib/hecks/tools/lanes.rb +1 -0
  49. data/lib/hecks/tools/release_only.rb +111 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +15 -1
  52. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  53. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  54. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
  55. data/rust/host/HECKS_RELEASE +1 -1
  56. data/rust/host/src/extension.rs +13 -1
  57. data/rust/host/src/lib.rs +1 -0
  58. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  59. data/rust/host/src/platform_signin/config.rs +512 -0
  60. data/rust/host/src/platform_signin/cookie.rs +237 -0
  61. data/rust/host/src/platform_signin/extension.rs +190 -0
  62. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  63. data/rust/host/src/platform_signin/handler.rs +251 -0
  64. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  65. data/rust/host/src/platform_signin/jti.rs +196 -0
  66. data/rust/host/src/platform_signin/jwks.rs +542 -0
  67. data/rust/host/src/platform_signin/jws.rs +249 -0
  68. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  69. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  70. data/rust/host/src/platform_signin/mod.rs +75 -0
  71. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  72. data/rust/host/src/platform_signin/pages.rs +204 -0
  73. data/rust/host/src/platform_signin/query.rs +233 -0
  74. data/rust/host/src/platform_signin/session.rs +151 -0
  75. data/rust/host/src/platform_signin/settings.rs +385 -0
  76. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  77. data/rust/host/src/platform_signin/test_support.rs +235 -0
  78. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  79. data/rust/host/src/platform_signin/verify.rs +604 -0
  80. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  81. data/rust/host/src/web.rs +50 -6
  82. data/rust/parser/src/parse/value_object.rs +22 -10
  83. metadata +42 -4
  84. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  85. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
@@ -0,0 +1,604 @@
1
+ //! The ordered checks that decide whether a platform sign-in token may be used.
2
+ //!
3
+ //! ## The order is the contract
4
+ //!
5
+ //! The checks run in a fixed order and the first failure is the verdict: the site's cookie, then
6
+ //! the
7
+ //! state, the size, the structure, the header, the key and signature, the claims, the nonce, the
8
+ //! single-use identifier, and last the identity. Three consequences matter:
9
+ //!
10
+ //! - A request that fails early learns nothing about later checks (a forged request with a bad
11
+ //! state
12
+ //! is not told whether its token was expired).
13
+ //! - The `jti` is spent only after every check before it passed, so an attacker who replays a
14
+ //! victim's token with the wrong nonce cannot burn it.
15
+ //! - Every refusal is one of the stable [`Reason`] codes, the same ones, in the same order, as the
16
+ //! issuer's reference verifier.
17
+ //!
18
+ //! ## What the caller supplies
19
+ //!
20
+ //! The query `token` and `state`, the pair of values from the site's own sign-in cookie, and the
21
+ //! time. State and nonce are compared through an injected comparison so a test can see that it is
22
+ //! the one in use; the default hashes both sides and compares fixed-length digests.
23
+
24
+ use jsonwebtoken::{Algorithm, DecodingKey};
25
+ use sha2::{Digest, Sha256};
26
+
27
+ use super::config::PlatformSigninConfig;
28
+ use super::jti::JtiStore;
29
+ use super::jwks::{KeySource, VerifyingKey};
30
+ use super::jws::{CompactJws, JwsFault};
31
+ use super::strict_json::{Json, JsonObject};
32
+
33
+ /// The only signature algorithm accepted, compared exactly.
34
+ const ALGORITHM: &str = "EdDSA";
35
+ /// The only token type accepted, compared exactly.
36
+ const TOKEN_TYPE: &str = "JWT";
37
+ /// The claims every token must carry, present and not `null`.
38
+ const REQUIRED_CLAIMS: [&str; 11] = [
39
+ "iss",
40
+ "aud",
41
+ "env",
42
+ "sub",
43
+ "email",
44
+ "email_verified",
45
+ "nonce",
46
+ "iat",
47
+ "nbf",
48
+ "exp",
49
+ "jti",
50
+ ];
51
+
52
+ /// Why a token was refused; the stable code is the contract with the issuer's reference verifier.
53
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54
+ pub enum Reason {
55
+ MissingCookie,
56
+ StateMismatch,
57
+ TooLarge,
58
+ Malformed,
59
+ BadAlg,
60
+ UnknownKid,
61
+ BadSignature,
62
+ BadIss,
63
+ BadAud,
64
+ BadEnv,
65
+ MissingClaim,
66
+ NotYetValid,
67
+ Expired,
68
+ Lifetime,
69
+ NonceMismatch,
70
+ Replay,
71
+ EmailUnverified,
72
+ SubjectMismatch,
73
+ }
74
+
75
+ impl Reason {
76
+ /// Every reason, in the order the checks run.
77
+ pub const ALL: [Reason; 18] = [
78
+ Reason::MissingCookie,
79
+ Reason::StateMismatch,
80
+ Reason::TooLarge,
81
+ Reason::Malformed,
82
+ Reason::BadAlg,
83
+ Reason::UnknownKid,
84
+ Reason::BadSignature,
85
+ Reason::BadIss,
86
+ Reason::BadAud,
87
+ Reason::BadEnv,
88
+ Reason::MissingClaim,
89
+ Reason::NotYetValid,
90
+ Reason::Expired,
91
+ Reason::Lifetime,
92
+ Reason::NonceMismatch,
93
+ Reason::Replay,
94
+ Reason::EmailUnverified,
95
+ Reason::SubjectMismatch,
96
+ ];
97
+
98
+ /// Returns the stable lowercase code, such as `"bad_aud"`.
99
+ pub fn code(self) -> &'static str {
100
+ match self {
101
+ Reason::MissingCookie => "missing_cookie",
102
+ Reason::StateMismatch => "state_mismatch",
103
+ Reason::TooLarge => "too_large",
104
+ Reason::Malformed => "malformed",
105
+ Reason::BadAlg => "bad_alg",
106
+ Reason::UnknownKid => "unknown_kid",
107
+ Reason::BadSignature => "bad_signature",
108
+ Reason::BadIss => "bad_iss",
109
+ Reason::BadAud => "bad_aud",
110
+ Reason::BadEnv => "bad_env",
111
+ Reason::MissingClaim => "missing_claim",
112
+ Reason::NotYetValid => "not_yet_valid",
113
+ Reason::Expired => "expired",
114
+ Reason::Lifetime => "lifetime",
115
+ Reason::NonceMismatch => "nonce_mismatch",
116
+ Reason::Replay => "replay",
117
+ Reason::EmailUnverified => "email_unverified",
118
+ Reason::SubjectMismatch => "subject_mismatch",
119
+ }
120
+ }
121
+
122
+ /// Finds the reason that has this code.
123
+ pub fn from_code(code: &str) -> Option<Reason> {
124
+ Self::ALL.into_iter().find(|reason| reason.code() == code)
125
+ }
126
+ }
127
+
128
+ /// A refused token, carrying the stable reason and nothing of the token.
129
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
130
+ pub struct Refusal {
131
+ pub reason: Reason,
132
+ }
133
+
134
+ impl Refusal {
135
+ /// Returns the stable reason code.
136
+ pub fn code(&self) -> &'static str {
137
+ self.reason.code()
138
+ }
139
+ }
140
+
141
+ /// The two values the site stored in the browser when it started the sign-in.
142
+ ///
143
+ /// An empty value counts as absent: a cookie missing either one is a cookie that was never set.
144
+ #[derive(Debug, Clone, PartialEq, Eq)]
145
+ pub struct SigninCookie {
146
+ pub nonce: String,
147
+ pub state: String,
148
+ }
149
+
150
+ /// What arrived on the callback and what the browser's cookie held.
151
+ #[derive(Clone, Copy)]
152
+ pub struct SigninRequest<'a> {
153
+ /// The `token` query parameter, `None` when absent.
154
+ pub token: Option<&'a str>,
155
+ /// The `state` query parameter, `None` when absent.
156
+ pub state: Option<&'a str>,
157
+ /// The parsed sign-in cookie, `None` when the browser sent none.
158
+ pub cookie: Option<&'a SigninCookie>,
159
+ }
160
+
161
+ /// The claims of a token that passed every check.
162
+ #[derive(Debug, Clone, PartialEq)]
163
+ pub struct VerifiedClaims {
164
+ pub issuer: String,
165
+ pub audience: String,
166
+ pub environment: String,
167
+ pub subject: String,
168
+ /// The email, lowercased.
169
+ pub email: String,
170
+ pub idp_issuer: Option<String>,
171
+ pub idp_subject: Option<String>,
172
+ /// The `roles` claim when it is an array; its string members only.
173
+ pub roles: Vec<String>,
174
+ pub nonce: String,
175
+ pub jti: String,
176
+ pub issued_at: i64,
177
+ pub not_before: i64,
178
+ pub expires_at: i64,
179
+ /// Every claim as signed.
180
+ pub all: JsonObject,
181
+ }
182
+
183
+ /// A comparison of two secrets for equality, injected so tests can observe its use.
184
+ pub type SecureCompare = Box<dyn Fn(&str, &str) -> bool + Send + Sync>;
185
+
186
+ /// Compares two strings without the timing depending on where they first differ or on their
187
+ /// lengths.
188
+ ///
189
+ /// Both sides are hashed first so the comparison runs over equal-length digests.
190
+ pub fn secure_compare(a: &str, b: &str) -> bool {
191
+ let (left, right) = (Sha256::digest(a.as_bytes()), Sha256::digest(b.as_bytes()));
192
+ left.iter()
193
+ .zip(right.iter())
194
+ .fold(0u8, |difference, (x, y)| difference | (x ^ y))
195
+ == 0
196
+ }
197
+
198
+ /// Runs the ordered checks against one issuer, site and environment.
199
+ pub struct Verifier<K: KeySource> {
200
+ config: PlatformSigninConfig,
201
+ keys: K,
202
+ jti_store: JtiStore,
203
+ compare: SecureCompare,
204
+ }
205
+
206
+ impl<K: KeySource> Verifier<K> {
207
+ /// Builds a verifier that uses [`secure_compare`] for state and nonce.
208
+ pub fn new(config: PlatformSigninConfig, keys: K, jti_store: JtiStore) -> Self {
209
+ Self {
210
+ config,
211
+ keys,
212
+ jti_store,
213
+ compare: Box::new(secure_compare),
214
+ }
215
+ }
216
+
217
+ /// Replaces the comparison used for state and nonce.
218
+ pub fn with_compare(mut self, compare: SecureCompare) -> Self {
219
+ self.compare = compare;
220
+ self
221
+ }
222
+
223
+ /// Exposes the single-use record, so a caller can share or inspect it.
224
+ pub fn jti_store(&self) -> &JtiStore {
225
+ &self.jti_store
226
+ }
227
+
228
+ /// Decides whether a token may be used at `now` (Unix seconds).
229
+ ///
230
+ /// # Errors
231
+ ///
232
+ /// Returns the [`Refusal`] of the first check that fails, in the order the module documents.
233
+ /// Never panics on hostile input.
234
+ pub fn verify(&self, request: &SigninRequest<'_>, now: i64) -> Result<VerifiedClaims, Refusal> {
235
+ self.run_checks(request, now)
236
+ .map_err(|reason| Refusal { reason })
237
+ }
238
+
239
+ fn run_checks(&self, request: &SigninRequest<'_>, now: i64) -> Result<VerifiedClaims, Reason> {
240
+ let cookie = self.check_cookie_and_state(request)?;
241
+ let jws = parse_token(request.token, self.config.max_token_bytes)?;
242
+ self.check_signature(&jws, now)?;
243
+ let claims = &jws.claims;
244
+ self.check_pinned_claims(claims)?;
245
+ self.check_times(claims, now)?;
246
+ self.check_nonce(claims, cookie)?;
247
+ self.spend_jti(claims, now)?;
248
+ check_identity(claims)?;
249
+ Ok(accepted(jws.claims))
250
+ }
251
+
252
+ /// The site's own cookie must be present, and its state must equal the query's.
253
+ fn check_cookie_and_state<'r>(
254
+ &self,
255
+ request: &SigninRequest<'r>,
256
+ ) -> Result<&'r SigninCookie, Reason> {
257
+ let cookie = request
258
+ .cookie
259
+ .filter(|c| !c.nonce.is_empty() && !c.state.is_empty())
260
+ .ok_or(Reason::MissingCookie)?;
261
+ let state = request
262
+ .state
263
+ .filter(|s| !s.is_empty())
264
+ .ok_or(Reason::StateMismatch)?;
265
+ if (self.compare)(state, &cookie.state) {
266
+ Ok(cookie)
267
+ } else {
268
+ Err(Reason::StateMismatch)
269
+ }
270
+ }
271
+
272
+ /// The algorithm is pinned before any key is looked up, so a header can never choose its own
273
+ /// check.
274
+ fn check_signature(&self, jws: &CompactJws, now: i64) -> Result<(), Reason> {
275
+ if jws.header.get_str("alg") != Some(ALGORITHM) {
276
+ return Err(Reason::BadAlg);
277
+ }
278
+ if jws.header.get_str("typ") != Some(TOKEN_TYPE) {
279
+ return Err(Reason::Malformed);
280
+ }
281
+ jws.signature_bytes().ok_or(Reason::Malformed)?;
282
+ let key = jws
283
+ .header
284
+ .get_str("kid")
285
+ .and_then(|kid| self.keys.key_for(kid, now))
286
+ .ok_or(Reason::UnknownKid)?;
287
+ if signature_verifies(&key, jws) {
288
+ Ok(())
289
+ } else {
290
+ Err(Reason::BadSignature)
291
+ }
292
+ }
293
+
294
+ /// Presence, then the exact issuer, site and environment, in that order.
295
+ fn check_pinned_claims(&self, claims: &JsonObject) -> Result<(), Reason> {
296
+ if REQUIRED_CLAIMS.iter().any(|name| !claims.is_present(name)) {
297
+ return Err(Reason::MissingClaim);
298
+ }
299
+ if claims.get_str("iss") != Some(self.config.issuer_url.as_str()) {
300
+ return Err(Reason::BadIss);
301
+ }
302
+ if claims.get_str("aud") != Some(self.config.site_id.as_str()) {
303
+ return Err(Reason::BadAud);
304
+ }
305
+ if claims.get_str("env") != Some(self.config.environment.as_str()) {
306
+ return Err(Reason::BadEnv);
307
+ }
308
+ Ok(())
309
+ }
310
+
311
+ /// Integer times only, then not-yet-valid, expired and lifetime, in that order.
312
+ fn check_times(&self, claims: &JsonObject, now: i64) -> Result<(), Reason> {
313
+ let (iat, nbf, exp) = (
314
+ integer(claims, "iat"),
315
+ integer(claims, "nbf"),
316
+ integer(claims, "exp"),
317
+ );
318
+ let (Some(iat), Some(nbf), Some(exp)) = (iat, nbf, exp) else {
319
+ return Err(Reason::Malformed);
320
+ };
321
+ let (now, skew) = (i128::from(now), i128::from(self.config.skew_seconds));
322
+ if nbf > now.saturating_add(skew) {
323
+ return Err(Reason::NotYetValid);
324
+ }
325
+ if exp <= now.saturating_sub(skew) {
326
+ return Err(Reason::Expired);
327
+ }
328
+ let lifetime = exp.saturating_sub(iat);
329
+ if (1..=i128::from(self.config.max_lifetime_seconds)).contains(&lifetime) {
330
+ Ok(())
331
+ } else {
332
+ Err(Reason::Lifetime)
333
+ }
334
+ }
335
+
336
+ fn check_nonce(&self, claims: &JsonObject, cookie: &SigninCookie) -> Result<(), Reason> {
337
+ match claims.get_str("nonce") {
338
+ Some(nonce) if (self.compare)(nonce, &cookie.nonce) => Ok(()),
339
+ _ => Err(Reason::NonceMismatch),
340
+ }
341
+ }
342
+
343
+ /// Spends the `jti`. Called only after every check before it passed, so a request that fails
344
+ /// earlier can never burn a token that belongs to someone else.
345
+ fn spend_jti(&self, claims: &JsonObject, now: i64) -> Result<(), Reason> {
346
+ let jti = claims
347
+ .get_str("jti")
348
+ .filter(|jti| !jti.is_empty())
349
+ .ok_or(Reason::Replay)?;
350
+ let remembered_until = clamp(
351
+ integer(claims, "exp")
352
+ .unwrap_or(0)
353
+ .saturating_add(i128::from(self.config.skew_seconds)),
354
+ );
355
+ if self.jti_store.claim(jti, remembered_until, now) {
356
+ Ok(())
357
+ } else {
358
+ Err(Reason::Replay)
359
+ }
360
+ }
361
+ }
362
+
363
+ fn parse_token(token: Option<&str>, max_bytes: usize) -> Result<CompactJws, Reason> {
364
+ let token = token.ok_or(Reason::Malformed)?;
365
+ CompactJws::parse(token, max_bytes).map_err(|fault| match fault {
366
+ JwsFault::TooLarge => Reason::TooLarge,
367
+ JwsFault::Malformed => Reason::Malformed,
368
+ })
369
+ }
370
+
371
+ fn signature_verifies(key: &VerifyingKey, jws: &CompactJws) -> bool {
372
+ let Ok(decoding_key) = DecodingKey::from_ed_components(key.x()) else {
373
+ return false;
374
+ };
375
+ jsonwebtoken::crypto::verify(
376
+ &jws.signature_segment,
377
+ jws.signing_input.as_bytes(),
378
+ &decoding_key,
379
+ Algorithm::EdDSA,
380
+ )
381
+ .unwrap_or(false)
382
+ }
383
+
384
+ fn integer(claims: &JsonObject, name: &str) -> Option<i128> {
385
+ match claims.get(name) {
386
+ Some(Json::Int(value)) => Some(*value),
387
+ _ => None,
388
+ }
389
+ }
390
+
391
+ fn clamp(value: i128) -> i64 {
392
+ i64::try_from(value).unwrap_or(if value < 0 { i64::MIN } else { i64::MAX })
393
+ }
394
+
395
+ /// A verified token must say the provider verified the email, and `sub` must be that email
396
+ /// lowercased.
397
+ fn check_identity(claims: &JsonObject) -> Result<(), Reason> {
398
+ if claims.get("email_verified") != Some(&Json::Bool(true)) {
399
+ return Err(Reason::EmailUnverified);
400
+ }
401
+ let (Some(email), Some(subject)) = (claims.get_str("email"), claims.get_str("sub")) else {
402
+ return Err(Reason::Malformed);
403
+ };
404
+ if subject == email.to_lowercase() {
405
+ Ok(())
406
+ } else {
407
+ Err(Reason::SubjectMismatch)
408
+ }
409
+ }
410
+
411
+ fn accepted(all: JsonObject) -> VerifiedClaims {
412
+ let text = |name: &str| all.get_str(name).unwrap_or_default().to_string();
413
+ let optional = |name: &str| all.get_str(name).map(str::to_string);
414
+ let time = |name: &str| clamp(integer(&all, name).unwrap_or(0));
415
+ VerifiedClaims {
416
+ issuer: text("iss"),
417
+ audience: text("aud"),
418
+ environment: text("env"),
419
+ subject: text("sub"),
420
+ email: text("email").to_lowercase(),
421
+ idp_issuer: optional("idp_iss"),
422
+ idp_subject: optional("idp_sub"),
423
+ roles: role_names(&all),
424
+ nonce: text("nonce"),
425
+ jti: text("jti"),
426
+ issued_at: time("iat"),
427
+ not_before: time("nbf"),
428
+ expires_at: time("exp"),
429
+ all,
430
+ }
431
+ }
432
+
433
+ fn role_names(claims: &JsonObject) -> Vec<String> {
434
+ match claims.get("roles") {
435
+ Some(Json::Array(items)) => items
436
+ .iter()
437
+ .filter_map(|item| {
438
+ if let Json::Str(role) = item {
439
+ Some(role.clone())
440
+ } else {
441
+ None
442
+ }
443
+ })
444
+ .collect(),
445
+ _ => Vec::new(),
446
+ }
447
+ }
448
+
449
+ #[cfg(test)]
450
+ mod tests {
451
+ use super::*;
452
+ use crate::platform_signin::test_support::*;
453
+ use std::sync::{Arc, Mutex};
454
+
455
+ const DOCUMENTED_ORDER: &str = "missing_cookie, state_mismatch, too_large, malformed, bad_alg, unknown_kid, bad_signature, \
456
+ bad_iss, bad_aud, bad_env, missing_claim, not_yet_valid, expired, lifetime, nonce_mismatch, replay, \
457
+ email_unverified, subject_mismatch";
458
+
459
+ #[test]
460
+ fn the_reason_codes_are_the_issuers_codes_in_the_issuers_order() {
461
+ let codes: Vec<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
462
+ assert_eq!(codes.join(", "), DOCUMENTED_ORDER);
463
+ }
464
+
465
+ #[test]
466
+ fn a_code_maps_back_to_its_reason_and_nothing_else_does() {
467
+ for reason in Reason::ALL {
468
+ assert_eq!(Reason::from_code(reason.code()), Some(reason));
469
+ }
470
+ for text in ["", "BadAud", "bad_aud ", "accept", "refuse", "unknown"] {
471
+ assert_eq!(Reason::from_code(text), None, "{text:?}");
472
+ }
473
+ assert_eq!(
474
+ Refusal {
475
+ reason: Reason::Replay
476
+ }
477
+ .code(),
478
+ "replay"
479
+ );
480
+ }
481
+
482
+ #[test]
483
+ fn secure_compare_is_plain_equality_for_equal_different_and_different_length_input() {
484
+ assert!(secure_compare("", ""));
485
+ assert!(secure_compare("abc", "abc"));
486
+ assert!(!secure_compare("abc", "abd"));
487
+ assert!(!secure_compare("abc", "ab"));
488
+ assert!(!secure_compare("ab", "abc"));
489
+ assert!(!secure_compare("", "a"));
490
+ assert!(!secure_compare("\u{e9}", "e"));
491
+ }
492
+
493
+ type Calls = Arc<Mutex<Vec<(String, String)>>>;
494
+
495
+ fn spy() -> (SecureCompare, Calls) {
496
+ let calls = Arc::new(Mutex::new(Vec::new()));
497
+ let seen = calls.clone();
498
+ let compare: SecureCompare = Box::new(move |a, b| {
499
+ seen.lock().unwrap().push((a.to_string(), b.to_string()));
500
+ a == b
501
+ });
502
+ (compare, calls)
503
+ }
504
+
505
+ #[test]
506
+ fn state_and_nonce_are_compared_through_the_injected_seam() {
507
+ let (compare, calls) = spy();
508
+ let verifier = verifier_for(&[&KEY_A]).with_compare(compare);
509
+ let scenario = Scenario::good();
510
+ assert!(scenario.run(&verifier).is_ok());
511
+ let calls = calls.lock().unwrap();
512
+ assert!(
513
+ calls.contains(&(STATE.to_string(), STATE.to_string())),
514
+ "state goes through the seam: {calls:?}"
515
+ );
516
+ assert!(
517
+ calls.contains(&(NONCE.to_string(), NONCE.to_string())),
518
+ "nonce goes through the seam: {calls:?}"
519
+ );
520
+ }
521
+
522
+ #[test]
523
+ fn a_seam_that_says_no_refuses_even_equal_state_and_equal_nonce() {
524
+ let state_refused = verifier_for(&[&KEY_A]).with_compare(Box::new(|a, _| a != STATE));
525
+ assert_eq!(
526
+ Scenario::good().run(&state_refused).unwrap_err().reason,
527
+ Reason::StateMismatch
528
+ );
529
+ let nonce_refused = verifier_for(&[&KEY_A]).with_compare(Box::new(|a, _| a != NONCE));
530
+ assert_eq!(
531
+ Scenario::good().run(&nonce_refused).unwrap_err().reason,
532
+ Reason::NonceMismatch
533
+ );
534
+ }
535
+
536
+ #[test]
537
+ fn a_seam_that_says_yes_accepts_a_different_state_and_a_different_nonce() {
538
+ let verifier = verifier_for(&[&KEY_A]).with_compare(Box::new(|_, _| true));
539
+ let mut scenario = Scenario::good();
540
+ scenario.state = Some("not-the-cookies-state".into());
541
+ scenario
542
+ .claims
543
+ .insert("nonce".into(), "not-the-cookies-nonce".into());
544
+ assert!(
545
+ scenario.run(&verifier).is_ok(),
546
+ "the verdict is the seam's, nothing else compares them"
547
+ );
548
+ }
549
+
550
+ #[test]
551
+ fn the_seam_is_not_consulted_for_a_nonce_before_the_earlier_checks_pass() {
552
+ let (compare, calls) = spy();
553
+ let verifier = verifier_for(&[&KEY_A]).with_compare(compare);
554
+ let mut scenario = Scenario::good();
555
+ scenario.flip_signature = true;
556
+ assert_eq!(
557
+ scenario.run(&verifier).unwrap_err().reason,
558
+ Reason::BadSignature
559
+ );
560
+ assert!(
561
+ calls.lock().unwrap().iter().all(|(a, _)| a == STATE),
562
+ "only the state was compared"
563
+ );
564
+ }
565
+
566
+ #[test]
567
+ fn a_good_token_yields_typed_claims_with_the_email_lowercased() {
568
+ let verifier = verifier_for(&[&KEY_A]);
569
+ let mut scenario = Scenario::good();
570
+ scenario
571
+ .claims
572
+ .insert("email".into(), "Person@Example.Test".into());
573
+ let claims = scenario.run(&verifier).expect("accepted");
574
+ assert_eq!(claims.email, "person@example.test");
575
+ assert_eq!(claims.subject, "person@example.test");
576
+ assert_eq!(
577
+ (
578
+ claims.issuer.as_str(),
579
+ claims.audience.as_str(),
580
+ claims.environment.as_str()
581
+ ),
582
+ (ISSUER, SITE, ENVIRONMENT)
583
+ );
584
+ assert_eq!(claims.roles, vec!["Admin".to_string()]);
585
+ assert_eq!(claims.idp_issuer.as_deref(), Some("https://idp.example"));
586
+ assert_eq!(claims.idp_subject.as_deref(), Some("idp-subject-0001"));
587
+ assert_eq!(
588
+ (claims.issued_at, claims.not_before, claims.expires_at),
589
+ (NOW, NOW, NOW + 60)
590
+ );
591
+ assert_eq!(claims.nonce, NONCE);
592
+ assert_eq!(claims.all.get_str("email"), Some("Person@Example.Test"));
593
+ }
594
+
595
+ #[test]
596
+ fn a_refusal_and_its_debug_text_never_hold_the_token() {
597
+ let verifier = verifier_for(&[&KEY_A]);
598
+ let mut scenario = Scenario::good();
599
+ scenario.flip_signature = true;
600
+ let token = scenario.token();
601
+ let refusal = scenario.run(&verifier).unwrap_err();
602
+ assert!(!format!("{refusal:?} {refusal:#?}").contains(&token[..20]));
603
+ }
604
+ }