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,322 @@
1
+ //! The real fetch of the issuer's key set, on the HTTP client the host already uses for Google
2
+ //! (`reqwest`, as in the Google token exchange).
3
+ //!
4
+ //! It is bounded on every side: a five second timeout, no redirects (a key set that moves is
5
+ //! refused, not followed), a status of exactly 200, and a body cap checked while reading. It also
6
+ //! reports how long the issuer allows the answer to be cached, which the cache honours up to its
7
+ //! own cap.
8
+
9
+ use std::sync::atomic::{AtomicI64, Ordering};
10
+ use std::time::Duration;
11
+
12
+ use super::jwks::{FetchFailed, JwksFetcher};
13
+
14
+ /// How long a fetch may take, connection and body together.
15
+ pub const TIMEOUT: Duration = Duration::from_secs(5);
16
+ /// The largest key set body accepted, in bytes.
17
+ pub const MAX_BODY_BYTES: usize = 64 * 1024;
18
+
19
+ /// The stored value that means the last answer carried no cache hint.
20
+ const NO_HINT: i64 = -1;
21
+
22
+ /// Fetches a key set over HTTPS from a fixed address.
23
+ pub struct HttpJwksFetcher {
24
+ url: String,
25
+ timeout: Duration,
26
+ max_age: AtomicI64,
27
+ }
28
+
29
+ impl HttpJwksFetcher {
30
+ /// Builds a fetcher for `url` with the standard timeout.
31
+ pub fn new(url: &str) -> Self {
32
+ Self::with_timeout(url, TIMEOUT)
33
+ }
34
+
35
+ /// Builds a fetcher with an explicit timeout.
36
+ pub fn with_timeout(url: &str, timeout: Duration) -> Self {
37
+ Self {
38
+ url: url.to_string(),
39
+ timeout,
40
+ max_age: AtomicI64::new(NO_HINT),
41
+ }
42
+ }
43
+
44
+ async fn get(&self) -> Result<(String, Option<i64>), FetchFailed> {
45
+ let client = reqwest::Client::builder()
46
+ .redirect(reqwest::redirect::Policy::none())
47
+ .timeout(self.timeout)
48
+ .build()
49
+ .map_err(|_| FetchFailed)?;
50
+ let mut response = client
51
+ .get(&self.url)
52
+ .send()
53
+ .await
54
+ .map_err(|_| FetchFailed)?;
55
+ if response.status() != reqwest::StatusCode::OK || announced_too_large(&response) {
56
+ return Err(FetchFailed);
57
+ }
58
+ let hint = response
59
+ .headers()
60
+ .get(reqwest::header::CACHE_CONTROL)
61
+ .and_then(|value| value.to_str().ok())
62
+ .and_then(max_age_of);
63
+ let mut body = Vec::new();
64
+ while let Some(chunk) = response.chunk().await.map_err(|_| FetchFailed)? {
65
+ if body.len() + chunk.len() > MAX_BODY_BYTES {
66
+ return Err(FetchFailed);
67
+ }
68
+ body.extend_from_slice(&chunk);
69
+ }
70
+ Ok((String::from_utf8(body).map_err(|_| FetchFailed)?, hint))
71
+ }
72
+ }
73
+
74
+ fn announced_too_large(response: &reqwest::Response) -> bool {
75
+ response
76
+ .content_length()
77
+ .is_some_and(|length| length > MAX_BODY_BYTES as u64)
78
+ }
79
+
80
+ /// Reads `max-age` from a `Cache-Control` value; `no-store`, `no-cache` and absence give `None`.
81
+ pub fn max_age_of(cache_control: &str) -> Option<i64> {
82
+ let directives: Vec<String> = cache_control
83
+ .split(',')
84
+ .map(|d| d.trim().to_ascii_lowercase())
85
+ .collect();
86
+ if directives
87
+ .iter()
88
+ .any(|d| d == "no-store" || d == "no-cache")
89
+ {
90
+ return None;
91
+ }
92
+ directives
93
+ .iter()
94
+ .find_map(|d| d.strip_prefix("max-age="))
95
+ .and_then(|seconds| seconds.parse::<i64>().ok())
96
+ .filter(|seconds| *seconds >= 0)
97
+ }
98
+
99
+ impl JwksFetcher for HttpJwksFetcher {
100
+ /// Must be called from a blocking thread of the tokio runtime.
101
+ fn fetch(&self) -> Result<String, FetchFailed> {
102
+ let handle = tokio::runtime::Handle::try_current().map_err(|_| FetchFailed)?;
103
+ let outcome = handle.block_on(self.get());
104
+ let hint = outcome.as_ref().ok().and_then(|(_, hint)| *hint);
105
+ self.max_age
106
+ .store(hint.unwrap_or(NO_HINT), Ordering::SeqCst);
107
+ outcome.map(|(body, _)| body)
108
+ }
109
+
110
+ fn ttl_hint(&self) -> Option<i64> {
111
+ Some(self.max_age.load(Ordering::SeqCst)).filter(|hint| *hint >= 0)
112
+ }
113
+ }
114
+
115
+ #[cfg(test)]
116
+ mod tests {
117
+ use super::*;
118
+ use std::io::{Read, Write};
119
+ use std::net::TcpListener;
120
+
121
+ /// Serves `responses` in turn on a loopback port, one connection each, and returns the address.
122
+ fn serve(responses: Vec<Vec<u8>>) -> String {
123
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
124
+ let address = listener.local_addr().unwrap();
125
+ std::thread::spawn(move || {
126
+ for response in responses {
127
+ let Ok((mut stream, _)) = listener.accept() else {
128
+ return;
129
+ };
130
+ let mut buffer = [0u8; 2048];
131
+ let _ = stream.read(&mut buffer);
132
+ let _ = stream.write_all(&response);
133
+ std::thread::sleep(Duration::from_millis(300));
134
+ }
135
+ });
136
+ format!("http://{address}/jwks.json")
137
+ }
138
+
139
+ fn http(status: &str, headers: &str, body: &str) -> Vec<u8> {
140
+ format!(
141
+ "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}",
142
+ body.len()
143
+ )
144
+ .into_bytes()
145
+ }
146
+
147
+ async fn fetch(url: String) -> (Result<String, FetchFailed>, Option<i64>) {
148
+ tokio::task::spawn_blocking(move || {
149
+ let fetcher = HttpJwksFetcher::with_timeout(&url, Duration::from_millis(800));
150
+ let result = fetcher.fetch();
151
+ (result, fetcher.ttl_hint())
152
+ })
153
+ .await
154
+ .unwrap()
155
+ }
156
+
157
+ #[tokio::test(flavor = "multi_thread")]
158
+ async fn a_200_answer_is_returned_with_its_cache_hint() {
159
+ let url = serve(vec![http(
160
+ "200 OK",
161
+ "Cache-Control: public, max-age=120\r\n",
162
+ "{\"keys\":[]}",
163
+ )]);
164
+ let (body, hint) = fetch(url).await;
165
+ assert_eq!(body.unwrap(), "{\"keys\":[]}");
166
+ assert_eq!(hint, Some(120));
167
+ }
168
+
169
+ #[tokio::test(flavor = "multi_thread")]
170
+ async fn an_answer_without_a_cache_header_gives_no_hint() {
171
+ let (body, hint) = fetch(serve(vec![http("200 OK", "", "{}")])).await;
172
+ assert!(body.is_ok());
173
+ assert_eq!(hint, None);
174
+ }
175
+
176
+ #[tokio::test(flavor = "multi_thread")]
177
+ async fn a_redirect_is_refused_not_followed() {
178
+ let target = serve(vec![http("200 OK", "", "{\"keys\":[]}")]);
179
+ let url = serve(vec![http(
180
+ "302 Found",
181
+ &format!("Location: {target}\r\n"),
182
+ "",
183
+ )]);
184
+ assert_eq!(fetch(url).await.0, Err(FetchFailed));
185
+ }
186
+
187
+ #[tokio::test(flavor = "multi_thread")]
188
+ async fn any_status_but_200_is_a_failure() {
189
+ for status in [
190
+ "204 No Content",
191
+ "404 Not Found",
192
+ "500 Internal Server Error",
193
+ "206 Partial Content",
194
+ ] {
195
+ assert_eq!(
196
+ fetch(serve(vec![http(status, "", "{}")])).await.0,
197
+ Err(FetchFailed),
198
+ "{status}"
199
+ );
200
+ }
201
+ }
202
+
203
+ #[tokio::test(flavor = "multi_thread")]
204
+ async fn a_body_over_the_cap_is_a_failure_by_header_and_by_reading() {
205
+ let big = "a".repeat(MAX_BODY_BYTES + 1);
206
+ assert_eq!(
207
+ fetch(serve(vec![http("200 OK", "", &big)])).await.0,
208
+ Err(FetchFailed)
209
+ );
210
+ let chunked = format!("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n{:x}\r\n{big}\r\n0\r\n\r\n", big.len());
211
+ assert_eq!(
212
+ fetch(serve(vec![chunked.into_bytes()])).await.0,
213
+ Err(FetchFailed)
214
+ );
215
+ }
216
+
217
+ #[tokio::test(flavor = "multi_thread")]
218
+ async fn a_body_exactly_at_the_cap_is_accepted() {
219
+ let exact = "a".repeat(MAX_BODY_BYTES);
220
+ assert_eq!(
221
+ fetch(serve(vec![http("200 OK", "", &exact)]))
222
+ .await
223
+ .0
224
+ .unwrap()
225
+ .len(),
226
+ MAX_BODY_BYTES
227
+ );
228
+ }
229
+
230
+ #[tokio::test(flavor = "multi_thread")]
231
+ async fn a_server_that_never_answers_times_out() {
232
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
233
+ let url = format!("http://{}/jwks.json", listener.local_addr().unwrap());
234
+ let started = std::time::Instant::now();
235
+ assert_eq!(fetch(url).await.0, Err(FetchFailed));
236
+ assert!(started.elapsed() < Duration::from_secs(4));
237
+ drop(listener);
238
+ }
239
+
240
+ #[tokio::test(flavor = "multi_thread")]
241
+ async fn a_refused_connection_is_a_failure() {
242
+ let port = TcpListener::bind("127.0.0.1:0")
243
+ .unwrap()
244
+ .local_addr()
245
+ .unwrap()
246
+ .port();
247
+ assert_eq!(
248
+ fetch(format!("http://127.0.0.1:{port}/x")).await.0,
249
+ Err(FetchFailed)
250
+ );
251
+ }
252
+
253
+ #[tokio::test(flavor = "multi_thread")]
254
+ async fn the_real_fetcher_feeds_the_real_verifier_through_the_handler() {
255
+ use super::super::handler::SigninHandler;
256
+ use super::super::jti::JtiStore;
257
+ use super::super::jwks::JwksCache;
258
+ use super::super::verify::Verifier;
259
+ use super::super::wiring_support::*;
260
+ use std::sync::Arc;
261
+
262
+ let doc = vectors();
263
+ let document = doc["jwks"].to_string();
264
+ let url = serve(vec![http(
265
+ "200 OK",
266
+ "Cache-Control: max-age=300\r\n",
267
+ &document,
268
+ )]);
269
+ let pins = &doc["config"];
270
+ let pinned = super::super::config::PlatformSigninConfig::new(
271
+ pins["iss"].as_str().unwrap(),
272
+ &url,
273
+ pins["aud"].as_str().unwrap(),
274
+ pins["env"].as_str().unwrap(),
275
+ );
276
+ let keys = JwksCache::new(HttpJwksFetcher::with_timeout(&url, Duration::from_secs(2)));
277
+ let verifier = Verifier::new(pinned, keys, JtiStore::new());
278
+ let now = doc["now"].as_i64().unwrap();
279
+ let handler = SigninHandler::new(
280
+ settings("alongside"),
281
+ Arc::new(verifier),
282
+ Arc::new(Recorder::default()),
283
+ )
284
+ .with_clock(Box::new(move || now));
285
+ let members = FakeMembers::resolving(Person::new("person@example.test", Some("Admin")));
286
+ let response = handler
287
+ .callback(&request_for(&single(&doc, "valid")), &members)
288
+ .await;
289
+ assert_eq!(response["statusCode"], 302);
290
+ }
291
+
292
+ #[test]
293
+ fn the_standard_timeout_is_five_seconds_and_the_cap_64_kib() {
294
+ assert_eq!(TIMEOUT, Duration::from_secs(5));
295
+ assert_eq!(MAX_BODY_BYTES, 65536);
296
+ }
297
+
298
+ #[test]
299
+ fn max_age_is_read_from_cache_control() {
300
+ assert_eq!(max_age_of("max-age=300"), Some(300));
301
+ assert_eq!(max_age_of("public, max-age=60, must-revalidate"), Some(60));
302
+ assert_eq!(max_age_of("Max-Age=45"), Some(45));
303
+ assert_eq!(max_age_of("s-maxage=90, max-age=30"), Some(30));
304
+ }
305
+
306
+ #[test]
307
+ fn a_hint_that_forbids_caching_or_is_unreadable_is_none() {
308
+ for value in [
309
+ "",
310
+ "no-store",
311
+ "no-cache",
312
+ "max-age=300, no-store",
313
+ "max-age=abc",
314
+ "max-age=-5",
315
+ "max-age=",
316
+ "private",
317
+ "max-age=99999999999999999999",
318
+ ] {
319
+ assert_eq!(max_age_of(value), None, "{value:?}");
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,251 @@
1
+ //! The two halves of the sign-in flow: starting it, and finishing it on the callback.
2
+ //!
3
+ //! Starting makes a fresh nonce and state, stores them in the browser's `__Host-signin` cookie
4
+ //! and sends the browser to the issuer. Finishing reads that cookie and the callback's `token`
5
+ //! and `state`, verifies the token, maps it to a member and, only then, establishes a session.
6
+ //! Whatever happens, the sign-in cookie is deleted.
7
+ //!
8
+ //! ## What it never reads
9
+ //!
10
+ //! The issuer's address, the site's address and every redirect target come from the validated
11
+ //! settings. The handler is never given a `Host` or `X-Forwarded-*` header and reads no parameter
12
+ //! other than `token` and `state`, so a request cannot change where the browser is sent.
13
+ //!
14
+ //! ## What it never logs
15
+ //!
16
+ //! The token, the nonce, the state and the cookie are never written to a log, and an email only as
17
+ //! a short hash. A refusal logs its stable code.
18
+
19
+ use std::sync::Arc;
20
+
21
+ use serde_json::{json, Value};
22
+ use sha2::{Digest, Sha256};
23
+
24
+ use super::cookie;
25
+ use super::jwks::KeySource;
26
+ use super::pages::{self, Failure};
27
+ use super::query;
28
+ use super::session::{self, MemberDirectory};
29
+ use super::settings::SigninSettings;
30
+ use super::verify::{Reason, Refusal, SigninCookie, SigninRequest, VerifiedClaims, Verifier};
31
+ use crate::auth;
32
+
33
+ /// The verification the handler needs, so a test can stand in a verifier with fixed keys.
34
+ pub trait TokenVerifier: Send + Sync {
35
+ /// Decides whether a token may be used at `now` (Unix seconds).
36
+ fn verify(&self, request: &SigninRequest<'_>, now: i64) -> Result<VerifiedClaims, Refusal>;
37
+ }
38
+
39
+ impl<K: KeySource + Send + Sync> TokenVerifier for Verifier<K> {
40
+ fn verify(&self, request: &SigninRequest<'_>, now: i64) -> Result<VerifiedClaims, Refusal> {
41
+ Verifier::verify(self, request, now)
42
+ }
43
+ }
44
+
45
+ /// Where the flow records what happened. Fields never hold a secret.
46
+ pub trait SigninLog: Send + Sync {
47
+ /// Records one event with its identifying fields.
48
+ fn record(&self, event: &str, fields: Value);
49
+ }
50
+
51
+ /// The host's own structured log, one JSON line on stdout.
52
+ pub struct StdoutLog;
53
+
54
+ impl SigninLog for StdoutLog {
55
+ fn record(&self, event: &str, fields: Value) {
56
+ crate::log::info(event, fields);
57
+ }
58
+ }
59
+
60
+ /// Makes one fresh nonce or state value each time it is called.
61
+ pub type Random = Box<dyn Fn() -> String + Send + Sync>;
62
+ /// Returns the current time in Unix seconds.
63
+ pub type Clock = Box<dyn Fn() -> i64 + Send + Sync>;
64
+
65
+ /// Makes a value of 244 random bits as 43 base64url characters.
66
+ pub fn fresh_value() -> String {
67
+ let mut bytes = Vec::with_capacity(32);
68
+ bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
69
+ bytes.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
70
+ super::jws::encode_base64url(&bytes)
71
+ }
72
+
73
+ /// Reads the system clock.
74
+ pub fn system_clock() -> i64 {
75
+ std::time::SystemTime::now()
76
+ .duration_since(std::time::UNIX_EPOCH)
77
+ .map(|elapsed| elapsed.as_secs() as i64)
78
+ .unwrap_or(0)
79
+ }
80
+
81
+ /// What a callback request carried, already separated from any headers.
82
+ #[derive(Debug, Clone, Default)]
83
+ pub struct CallbackRequest {
84
+ /// The raw query string, without the leading `?`.
85
+ pub raw_query: String,
86
+ /// Every `name=value` cookie pair the request carried.
87
+ pub cookie_pairs: Vec<String>,
88
+ }
89
+
90
+ /// Starts and finishes sign-ins for one site against one issuer.
91
+ pub struct SigninHandler {
92
+ settings: SigninSettings,
93
+ verifier: Arc<dyn TokenVerifier>,
94
+ log: Arc<dyn SigninLog>,
95
+ random: Random,
96
+ clock: Clock,
97
+ }
98
+
99
+ impl SigninHandler {
100
+ /// Builds a handler that uses fresh randomness and the system clock.
101
+ pub fn new(
102
+ settings: SigninSettings,
103
+ verifier: Arc<dyn TokenVerifier>,
104
+ log: Arc<dyn SigninLog>,
105
+ ) -> Self {
106
+ Self {
107
+ settings,
108
+ verifier,
109
+ log,
110
+ random: Box::new(fresh_value),
111
+ clock: Box::new(system_clock),
112
+ }
113
+ }
114
+
115
+ /// Replaces the source of nonce and state values.
116
+ pub fn with_random(mut self, random: Random) -> Self {
117
+ self.random = random;
118
+ self
119
+ }
120
+
121
+ /// Replaces the clock.
122
+ pub fn with_clock(mut self, clock: Clock) -> Self {
123
+ self.clock = clock;
124
+ self
125
+ }
126
+
127
+ /// Returns the validated settings.
128
+ pub fn settings(&self) -> &SigninSettings {
129
+ &self.settings
130
+ }
131
+
132
+ /// Starts a sign-in: a redirect to the issuer and the browser's sign-in cookie.
133
+ pub fn start(&self) -> Value {
134
+ let cookie = SigninCookie {
135
+ nonce: (self.random)(),
136
+ state: (self.random)(),
137
+ };
138
+ let location = self.authorize_url(&cookie);
139
+ self.log.record("platform_signin_started", json!({}));
140
+ pages::redirect(&location, &[cookie::set_header(&cookie)])
141
+ }
142
+
143
+ fn authorize_url(&self, cookie: &SigninCookie) -> String {
144
+ let config = &self.settings.config;
145
+ format!(
146
+ "{}/sso/authorize?site={}&env={}&nonce={}&state={}",
147
+ config.issuer_url,
148
+ auth::urlencode(&config.site_id),
149
+ auth::urlencode(&config.environment),
150
+ auth::urlencode(&cookie.nonce),
151
+ auth::urlencode(&cookie.state),
152
+ )
153
+ }
154
+
155
+ /// Finishes a sign-in on the callback. Every outcome deletes the sign-in cookie.
156
+ pub async fn callback(
157
+ &self,
158
+ request: &CallbackRequest,
159
+ members: &dyn MemberDirectory,
160
+ ) -> Value {
161
+ match self.finish(request, members).await {
162
+ Ok(response) => response,
163
+ Err(failure) => pages::refusal(failure),
164
+ }
165
+ }
166
+
167
+ async fn finish(
168
+ &self,
169
+ request: &CallbackRequest,
170
+ members: &dyn MemberDirectory,
171
+ ) -> Result<Value, Failure> {
172
+ let cookie = cookie::read(&request.cookie_pairs)
173
+ .map_err(|fault| self.refused(Failure::Cookie, &format!("{fault:?}")))?;
174
+ let params = query::parse_callback(&request.raw_query)
175
+ .map_err(|fault| self.refused(Failure::Params, &format!("{fault:?}")))?;
176
+ let claims = self.verified(cookie, params).await?;
177
+ let session = match session::map_member(members, &claims).await {
178
+ Ok(Some(session)) => session,
179
+ Ok(None) => {
180
+ return Err(self.refused_with(Failure::Unlinked, "no_member", Some(&claims.email)))
181
+ }
182
+ Err(error) => {
183
+ return Err(self.refused(Failure::Unavailable, &format!("member_store: {error}")))
184
+ }
185
+ };
186
+ self.log_success(&claims, &session);
187
+ Ok(session::signed_in(&self.settings, &session))
188
+ }
189
+
190
+ /// Verifies on a blocking thread: the key set fetch waits on the network.
191
+ async fn verified(
192
+ &self,
193
+ cookie: SigninCookie,
194
+ params: query::CallbackParams,
195
+ ) -> Result<VerifiedClaims, Failure> {
196
+ let verifier = self.verifier.clone();
197
+ let now = (self.clock)();
198
+ let outcome = tokio::task::spawn_blocking(move || {
199
+ let request = SigninRequest {
200
+ token: Some(&params.token),
201
+ state: Some(&params.state),
202
+ cookie: Some(&cookie),
203
+ };
204
+ verifier.verify(&request, now)
205
+ })
206
+ .await
207
+ .map_err(|_| self.refused(Failure::Unavailable, "verification_did_not_finish"))?;
208
+ outcome.map_err(|refusal| self.refused(failure_for(refusal.reason), refusal.code()))
209
+ }
210
+
211
+ fn refused(&self, failure: Failure, reason: &str) -> Failure {
212
+ self.refused_with(failure, reason, None)
213
+ }
214
+
215
+ fn refused_with(&self, failure: Failure, reason: &str, email: Option<&str>) -> Failure {
216
+ let mut fields = json!({"code": failure.code(), "reason": reason});
217
+ if let Some(email) = email {
218
+ fields["who"] = json!(short_hash(email));
219
+ }
220
+ self.log.record("platform_signin_refused", fields);
221
+ failure
222
+ }
223
+
224
+ fn log_success(&self, claims: &VerifiedClaims, session: &auth::Session) {
225
+ if let Some(drift) = session::role_drift(claims, session) {
226
+ let fields = json!({"token_role": drift.token_role, "local_role": drift.local_role, "who": short_hash(&claims.email)});
227
+ self.log.record("platform_signin_role_drift", fields);
228
+ }
229
+ self.log.record(
230
+ "platform_signin_succeeded",
231
+ json!({"who": short_hash(&claims.email)}),
232
+ );
233
+ }
234
+ }
235
+
236
+ /// The page a verifier refusal earns: the browser's own cookie and state are told apart, the rest
237
+ /// share one answer so the page teaches nothing about which later check failed.
238
+ fn failure_for(reason: Reason) -> Failure {
239
+ match reason {
240
+ Reason::MissingCookie => Failure::Cookie,
241
+ Reason::StateMismatch => Failure::State,
242
+ _ => Failure::Token,
243
+ }
244
+ }
245
+
246
+ /// The first four bytes of the lowercased email's SHA-256, enough to tell people apart in a log.
247
+ fn short_hash(email: &str) -> String {
248
+ let digest = Sha256::digest(email.to_lowercase().as_bytes());
249
+ let hex: String = digest.iter().take(4).map(|b| format!("{b:02x}")).collect();
250
+ format!("h:{hex}")
251
+ }