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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
- data/lib/hecks/adapters/driven/script_runner.rb +93 -0
- data/lib/hecks/adapters/driven.rb +1 -0
- data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
- data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
- data/lib/hecks/adapters/driving/newcomer.rb +50 -0
- data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -15
- data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
- data/lib/hecks/bluebook/meta_validator.rb +28 -21
- data/lib/hecks/bluebook.rb +1 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
- data/lib/hecks/framework/bluebook/change.behaviors +30 -0
- data/lib/hecks/framework/bluebook/change.bluebook +162 -0
- data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
- data/lib/hecks/framework/change.oidc.json +34 -0
- data/lib/hecks/framework/console_settings.oidc.json +103 -0
- data/lib/hecks/framework/governance.oidc.json +28 -0
- data/lib/hecks/framework/identity.oidc.json +18 -0
- data/lib/hecks/framework/privacy.oidc.json +30 -0
- data/lib/hecks/gate/stages.yml +35 -0
- data/lib/hecks/grammar/translation.oidc.json +48 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
- data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
- data/lib/hecks/hecks/hecks.world +3 -0
- data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
- data/lib/hecks/language/bluebook/policy.behaviors +19 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
- data/lib/hecks/projection_files/expression_tables.rb +7 -2
- data/lib/hecks/projections/deploy/box/compose.rb +6 -4
- data/lib/hecks/projections/deploy/box/edge.rb +62 -0
- data/lib/hecks/projections/deploy/box/logging.rb +99 -0
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
- data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
- data/lib/hecks/projections/deploy/box/settings.rb +6 -2
- data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
- data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
- data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
- data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box.rb +15 -3
- data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
- data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
- data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
- data/lib/hecks/projections/site/edge/checks.rb +2 -0
- data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
- data/lib/hecks/projections/site/edge/function_code.rb +86 -0
- data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
- data/lib/hecks/projections/site/edge.rb +35 -9
- data/lib/hecks/projections/site/site_cdn.rb +21 -7
- data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
- data/lib/hecks/projections/site/table/row_builder.rb +12 -2
- data/lib/hecks/projections/site/table.rb +4 -2
- data/lib/hecks/projector/cli_audience.rb +20 -0
- data/lib/hecks/projector/cli_projector/usage.rb +15 -4
- data/lib/hecks/rust_build/host.rb +11 -4
- data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
- data/lib/hecks/site/bluebook/site.bluebook +16 -1
- data/lib/hecks/tools/ci_gate_decision.rb +35 -0
- data/lib/hecks/tools/ci_gates.rb +20 -1
- data/lib/hecks/tools/lanes/files.rb +8 -0
- data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
- data/lib/hecks/tools/lanes.rb +1 -0
- data/lib/hecks/tools/oidc_manifests.rb +44 -6
- data/lib/hecks/tools/release_only.rb +111 -0
- data/lib/hecks/tools/site_routes/templates.rb +13 -9
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +15 -1
- data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
- data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
- data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +58 -0
- data/rust/host/src/extension.rs +13 -1
- data/rust/host/src/lib.rs +1 -0
- data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
- data/rust/host/src/platform_signin/config.rs +512 -0
- data/rust/host/src/platform_signin/cookie.rs +237 -0
- data/rust/host/src/platform_signin/extension.rs +190 -0
- data/rust/host/src/platform_signin/fetcher.rs +322 -0
- data/rust/host/src/platform_signin/handler.rs +251 -0
- data/rust/host/src/platform_signin/handler_tests.rs +554 -0
- data/rust/host/src/platform_signin/jti.rs +196 -0
- data/rust/host/src/platform_signin/jwks.rs +542 -0
- data/rust/host/src/platform_signin/jws.rs +249 -0
- data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
- data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
- data/rust/host/src/platform_signin/mod.rs +75 -0
- data/rust/host/src/platform_signin/mode_tests.rs +382 -0
- data/rust/host/src/platform_signin/pages.rs +204 -0
- data/rust/host/src/platform_signin/query.rs +233 -0
- data/rust/host/src/platform_signin/session.rs +151 -0
- data/rust/host/src/platform_signin/settings.rs +385 -0
- data/rust/host/src/platform_signin/strict_json.rs +351 -0
- data/rust/host/src/platform_signin/test_support.rs +235 -0
- data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
- data/rust/host/src/platform_signin/verify.rs +604 -0
- data/rust/host/src/platform_signin/wiring_support.rs +319 -0
- data/rust/host/src/web.rs +50 -6
- data/rust/parser/src/parse/value_object.rs +22 -10
- metadata +59 -7
- data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
- data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
- /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
- /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +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
|
+
}
|