1use std::{fmt, ops::Range};
10
11use reqwest::header::HeaderValue;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15use url::{Host, Url};
16use zeroize::Zeroizing;
17
18pub(crate) mod oauth_authorization;
19pub(crate) mod oauth_code;
20pub(crate) mod oauth_refresh;
21pub use oauth_code::{OAuthCodeInputError, SuppliedSessionOAuthGrant};
22
23pub const SUPPLIED_SESSION_POLICY_SCHEMA: &str = "security.supplied-session-policy/v1";
25pub const SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA: &str = "security.supplied-session-policy/v2";
27pub const SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA: &str = "security.supplied-session-policy/v3";
29pub const SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA: &str = "security.supplied-session-policy/v4";
31pub const SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA: &str = "security.supplied-session-policy/v5";
33pub const SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA: &str =
35 "security.supplied-session-policy/v6";
36pub const SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA: &str =
38 "security.supplied-session-policy/v7";
39pub const HARD_MAX_SUPPLIED_SESSION_POLICY_BYTES: usize = 64 * 1024;
41pub const HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES: usize = 16 * 1024;
43pub const HARD_MAX_SUPPLIED_SESSION_FORM_SECRET_BYTES: usize = 8 * 1024;
45pub const HARD_MAX_SUPPLIED_SESSION_OAUTH_CODE_SECRET_BYTES: usize = 16 * 1024;
47pub(crate) const MAX_SUPPLIED_SESSION_OAUTH_TOKEN_RESPONSE_BYTES: usize = 32 * 1024;
48pub const MAX_SUPPLIED_SESSION_COOKIES: usize = 8;
50pub const MAX_SUPPLIED_SESSION_COOKIE_HEADER_BYTES: usize = 8 * 1024;
52pub const MAX_SUPPLIED_SESSION_RESOURCES: usize = 4;
54pub const MAX_SUPPLIED_SESSION_CHECKPOINTS: usize = MAX_SUPPLIED_SESSION_RESOURCES + 1;
56pub const MAX_SUPPLIED_SESSION_REQUESTS: u8 = 9;
58pub const MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES: u64 = 256 * 1024;
60pub const MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES: usize = 64 * 1024;
62pub const MAX_SUPPLIED_SESSION_WALL_TIME_MS: u64 = 10_000;
64
65pub(crate) const SUPPLIED_SESSION_HEALTH_ACTION_ID: &str = "web.review.supplied-session.health";
66pub(crate) const SUPPLIED_SESSION_RESOURCE_ACTION_ID: &str = "web.review.supplied-session.resource";
67pub(crate) const SUPPLIED_SESSION_LOGIN_PAGE_ACTION_ID: &str =
68 "web.review.supplied-session.login-page";
69pub(crate) const SUPPLIED_SESSION_LOGIN_SUBMIT_ACTION_ID: &str =
70 "web.review.supplied-session.login-submit";
71pub(crate) const SUPPLIED_SESSION_RENEWAL_ACTION_ID: &str = "web.review.supplied-session.renewal";
72pub(crate) const SUPPLIED_SESSION_OAUTH_CODE_ACTION_ID: &str =
73 "web.review.supplied-session.oauth-code-exchange";
74pub(crate) const SUPPLIED_SESSION_OAUTH_REFRESH_ACTION_ID: &str =
75 "web.review.supplied-session.oauth-refresh";
76
77const MAX_PRINCIPAL_ALIAS_BYTES: usize = 128;
78const MAX_HEALTH_FIELD_BYTES: usize = 128;
79const MAX_POLICY_PATH_BYTES: usize = 2 * 1024;
80const MAX_AUTHORIZATION_HEADER_BYTES: usize = 8 * 1024;
81const MAX_COOKIE_ID_BYTES: usize = 128;
82const MAX_COOKIE_NAME_BYTES: usize = 128;
83const MAX_COOKIE_VALUE_BYTES: usize = 4 * 1024;
84const MAX_LOGIN_USERNAME_BYTES: usize = 256;
85const MAX_LOGIN_PASSWORD_BYTES: usize = 4 * 1024;
86const MAX_LOGIN_FIELD_BYTES: usize = 128;
87const MAX_LOGIN_CSRF_VALUE_BYTES: usize = 2 * 1024;
88const MAX_LOGIN_FORM_BODY_BYTES: usize = (MAX_LOGIN_FIELD_BYTES * 3
91 + MAX_LOGIN_USERNAME_BYTES
92 + MAX_LOGIN_PASSWORD_BYTES
93 + MAX_LOGIN_CSRF_VALUE_BYTES)
94 * 3
95 + 5;
96const POLICY_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-policy.reference.v1\0";
97const COOKIE_POLICY_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-policy.reference.v2\0";
98const FORM_LOGIN_POLICY_REFERENCE_DOMAIN: &[u8] =
99 b"security.supplied-session-policy.reference.v3\0";
100const APPLICATION_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-application.reference.v1\0";
101const HEALTH_FIELD_REFERENCE_DOMAIN: &[u8] =
102 b"security.supplied-session-health-field.reference.v1\0";
103const RESOURCE_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-resource.reference.v1\0";
104const LOGIN_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-login.reference.v1\0";
105const REQUEST_DESCRIPTOR_REFERENCE_DOMAIN: &[u8] =
106 b"security.supplied-session-request-descriptor.reference.v1\0";
107const SUPPLIED_SESSION_PRINCIPAL_REFERENCE: &str = "supplied-session-principal-0001";
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
111#[serde(rename_all = "snake_case")]
112#[non_exhaustive]
113pub enum SuppliedSessionPolicyVersion {
114 V1,
116 V2,
118 V3,
120 V4,
122 V5,
124 V6,
126 V7,
128}
129
130impl SuppliedSessionPolicyVersion {
131 pub const fn schema(self) -> &'static str {
133 match self {
134 Self::V1 => SUPPLIED_SESSION_POLICY_SCHEMA,
135 Self::V2 => SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA,
136 Self::V3 => SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA,
137 Self::V4 => SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA,
138 Self::V5 => SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA,
139 Self::V6 => SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA,
140 Self::V7 => SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum SuppliedSessionCredentialAcquisition {
150 SuppliedAuthorization,
152 SuppliedCookieJar,
154 BoundedFormLogin,
156 #[serde(rename = "imported_oauth_code_pkce")]
158 ImportedOAuthCodePkce,
159 #[serde(rename = "client_initiated_oauth_pkce")]
161 ClientInitiatedOAuthPkce,
162}
163
164impl SuppliedSessionCredentialAcquisition {
165 pub const fn as_str(self) -> &'static str {
167 match self {
168 Self::SuppliedAuthorization => "supplied_authorization",
169 Self::SuppliedCookieJar => "supplied_cookie_jar",
170 Self::BoundedFormLogin => "bounded_form_login",
171 Self::ImportedOAuthCodePkce => "imported_oauth_code_pkce",
172 Self::ClientInitiatedOAuthPkce => "client_initiated_oauth_pkce",
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
179#[serde(rename_all = "snake_case")]
180#[non_exhaustive]
181pub enum SuppliedSessionCredentialMechanism {
182 AuthorizationHeader,
184 CookieJar,
186}
187
188impl SuppliedSessionCredentialMechanism {
189 pub const fn as_str(self) -> &'static str {
191 match self {
192 Self::AuthorizationHeader => "authorization_header",
193 Self::CookieJar => "cookie_jar",
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
200#[serde(rename_all = "snake_case")]
201#[non_exhaustive]
202pub enum SuppliedSessionCookieUpdatePolicy {
203 StopOnSelectedCookie,
205}
206
207impl SuppliedSessionCookieUpdatePolicy {
208 pub const fn as_str(self) -> &'static str {
210 match self {
211 Self::StopOnSelectedCookie => "stop_on_selected_cookie",
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
218#[serde(rename_all = "snake_case")]
219#[non_exhaustive]
220pub enum SuppliedSessionCookieSameSite {
221 Missing,
223 Strict,
225 Lax,
227 None,
229}
230
231impl SuppliedSessionCookieSameSite {
232 pub const fn as_str(self) -> &'static str {
234 match self {
235 Self::Missing => "missing",
236 Self::Strict => "strict",
237 Self::Lax => "lax",
238 Self::None => "none",
239 }
240 }
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
245#[non_exhaustive]
246pub enum SuppliedSessionPolicyError {
247 #[error("supplied-session policy exceeds its compiled byte limit")]
249 PolicyTooLarge,
250 #[error("supplied-session policy is malformed")]
252 MalformedPolicy,
253 #[error("supplied-session policy schema is unsupported")]
255 UnsupportedSchema,
256 #[error("supplied-session credential mechanism is unsupported")]
258 UnsupportedCredentialMechanism,
259 #[error("supplied-session cookie update policy is unsupported")]
261 UnsupportedCookieUpdatePolicy,
262 #[error("supplied-session application is invalid")]
264 InvalidApplication,
265 #[error("supplied-session principal alias is invalid")]
267 InvalidPrincipalAlias,
268 #[error("supplied-session health JSON field is invalid")]
270 InvalidHealthField,
271 #[error("supplied-session path is invalid or outside the selected application")]
273 InvalidPath,
274 #[error("supplied-session resource count is invalid")]
276 InvalidResourceCount,
277 #[error("supplied-session cookie count is invalid")]
279 InvalidCookieCount,
280 #[error("supplied-session cookie metadata is invalid")]
282 InvalidCookieMetadata,
283 #[error("supplied-session form-login policy is invalid")]
285 InvalidFormLogin,
286 #[error("supplied-session OAuth code policy is invalid or unsupported")]
288 InvalidOAuthCode,
289 #[error("supplied-session OAuth refresh policy is invalid or unsupported")]
291 InvalidOAuthRefresh,
292 #[error("supplied-session OAuth authorization policy is invalid or unsupported")]
294 InvalidOAuthAuthorization,
295 #[error("supplied-session limits are invalid")]
297 InvalidLimits,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
302#[non_exhaustive]
303pub enum SuppliedSessionAuthorizationError {
304 #[error("supplied-session authorization is not a bounded safe HTTP header value")]
306 InvalidValue,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
311#[non_exhaustive]
312pub enum SuppliedSessionCookieError {
313 #[error("supplied-session cookie secret exceeds its compiled byte limit")]
315 SecretTooLarge,
316 #[error("supplied-session cookie secret is malformed")]
318 MalformedSecret,
319 #[error("supplied-session cookie credential does not match the selected policy")]
321 PolicyMismatch,
322 #[error("supplied-session cookie credential does not cover every selected request")]
324 IncompleteInitialCoverage,
325 #[error("supplied-session cookie target is not admitted by the selected policy")]
327 TargetNotAdmitted,
328 #[error("supplied-session cookie credential is unavailable for this request")]
330 NoApplicableCookie,
331 #[error("supplied-session cookie header exceeds its compiled byte limit")]
333 HeaderTooLarge,
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
338#[non_exhaustive]
339pub enum SuppliedSessionFormCredentialError {
340 #[error("supplied-session form credential exceeds its compiled byte limit")]
342 SecretTooLarge,
343 #[error("supplied-session form credential is malformed")]
345 MalformedSecret,
346 #[error("supplied-session form credential does not match the selected policy")]
348 PolicyMismatch,
349 #[error("supplied-session login form token is invalid")]
351 InvalidCsrfToken,
352}
353
354pub struct SuppliedSessionAuthorization {
356 value: Zeroizing<String>,
357}
358
359impl SuppliedSessionAuthorization {
360 pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, SuppliedSessionAuthorizationError> {
362 let mut value = Zeroizing::new(value.into());
363 if value.is_empty()
364 || value.len() > MAX_AUTHORIZATION_HEADER_BYTES
365 || value.iter().any(|byte| !matches!(*byte, 0x20..=0x7e))
366 {
367 return Err(SuppliedSessionAuthorizationError::InvalidValue);
368 }
369 let parsed = HeaderValue::from_bytes(value.as_slice())
370 .map_err(|_| SuppliedSessionAuthorizationError::InvalidValue)?;
371 let text = parsed
372 .to_str()
373 .map_err(|_| SuppliedSessionAuthorizationError::InvalidValue)?;
374 let Some((scheme, credentials)) = text.split_once(' ') else {
375 return Err(SuppliedSessionAuthorizationError::InvalidValue);
376 };
377 if scheme.is_empty()
378 || credentials.is_empty()
379 || credentials.starts_with(' ')
380 || credentials.ends_with(' ')
381 || !scheme.bytes().all(http_token_byte)
382 {
383 return Err(SuppliedSessionAuthorizationError::InvalidValue);
384 }
385 let owned = String::from_utf8(std::mem::take(&mut *value))
386 .map_err(|_| SuppliedSessionAuthorizationError::InvalidValue)?;
387 Ok(Self {
388 value: Zeroizing::new(owned),
389 })
390 }
391
392 pub(crate) fn as_str(&self) -> &str {
393 self.value.as_str()
394 }
395}
396
397impl fmt::Debug for SuppliedSessionAuthorization {
398 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399 formatter.write_str("SuppliedSessionAuthorization(<redacted>)")
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
405pub struct SuppliedSessionCookieSummary {
406 cookie_count: usize,
407 host_only_count: usize,
408 domain_count: usize,
409 secure_count: usize,
410 http_only_count: usize,
411 session_count: usize,
412 persistent_count: usize,
413 same_site_missing_count: usize,
414 same_site_strict_count: usize,
415 same_site_lax_count: usize,
416 same_site_none_count: usize,
417}
418
419impl SuppliedSessionCookieSummary {
420 pub const fn cookie_count(self) -> usize {
422 self.cookie_count
423 }
424
425 pub const fn host_only_count(self) -> usize {
427 self.host_only_count
428 }
429
430 pub const fn domain_count(self) -> usize {
432 self.domain_count
433 }
434
435 pub const fn secure_count(self) -> usize {
437 self.secure_count
438 }
439
440 pub const fn http_only_count(self) -> usize {
442 self.http_only_count
443 }
444
445 pub const fn session_count(self) -> usize {
447 self.session_count
448 }
449
450 pub const fn persistent_count(self) -> usize {
452 self.persistent_count
453 }
454
455 pub const fn same_site_missing_count(self) -> usize {
457 self.same_site_missing_count
458 }
459
460 pub const fn same_site_strict_count(self) -> usize {
462 self.same_site_strict_count
463 }
464
465 pub const fn same_site_lax_count(self) -> usize {
467 self.same_site_lax_count
468 }
469
470 pub const fn same_site_none_count(self) -> usize {
472 self.same_site_none_count
473 }
474}
475
476struct SuppliedSessionCookieMetadata {
477 id: String,
478 name: String,
479 domain: String,
480 host_only: bool,
481 path: String,
482 secure: bool,
483 http_only: bool,
484 same_site: SuppliedSessionCookieSameSite,
485 expires_unix_seconds: Option<i64>,
486}
487
488struct SuppliedSessionCookieValue {
489 metadata_index: usize,
490 range: Range<usize>,
491}
492
493pub struct SuppliedSessionCookies {
498 backing: Zeroizing<Vec<u8>>,
499 values: Vec<SuppliedSessionCookieValue>,
500 policy_reference: String,
501 principal_alias: String,
502}
503
504impl SuppliedSessionCookies {
505 pub fn parse_tsv(
511 policy: &SuppliedSessionPolicy,
512 source: impl Into<Vec<u8>>,
513 now_unix_seconds: i64,
514 ) -> Result<Self, SuppliedSessionCookieError> {
515 if policy.credential_mechanism != SuppliedSessionCredentialMechanism::CookieJar
516 || !matches!(
517 policy.version,
518 SuppliedSessionPolicyVersion::V2 | SuppliedSessionPolicyVersion::V4
519 )
520 || policy.cookies.is_empty()
521 {
522 return Err(SuppliedSessionCookieError::PolicyMismatch);
523 }
524 let backing = Zeroizing::new(source.into());
525 if backing.is_empty() {
526 return Err(SuppliedSessionCookieError::MalformedSecret);
527 }
528 if backing.len() > HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES {
529 return Err(SuppliedSessionCookieError::SecretTooLarge);
530 }
531
532 let mut ranges: Vec<Option<Range<usize>>> =
533 (0..policy.cookies.len()).map(|_| None).collect();
534 let mut cursor = 0_usize;
535 while cursor < backing.len() {
536 let physical_end = backing[cursor..]
537 .iter()
538 .position(|byte| *byte == b'\n')
539 .map_or(backing.len(), |offset| cursor + offset);
540 let mut line_end = physical_end;
541 if line_end > cursor && backing[line_end - 1] == b'\r' {
542 line_end -= 1;
543 }
544 if line_end == cursor {
545 return Err(SuppliedSessionCookieError::MalformedSecret);
546 }
547 let line = &backing[cursor..line_end];
548 let Some(tab_offset) = line.iter().position(|byte| *byte == b'\t') else {
549 return Err(SuppliedSessionCookieError::MalformedSecret);
550 };
551 if line[tab_offset + 1..].contains(&b'\t') {
552 return Err(SuppliedSessionCookieError::MalformedSecret);
553 }
554 let id = std::str::from_utf8(&line[..tab_offset])
555 .map_err(|_| SuppliedSessionCookieError::MalformedSecret)?;
556 let Some(metadata_index) = policy.cookies.iter().position(|cookie| cookie.id == id)
557 else {
558 return Err(SuppliedSessionCookieError::MalformedSecret);
559 };
560 if ranges[metadata_index].is_some() {
561 return Err(SuppliedSessionCookieError::MalformedSecret);
562 }
563 let value_start = cursor
564 .checked_add(tab_offset)
565 .and_then(|value| value.checked_add(1))
566 .ok_or(SuppliedSessionCookieError::MalformedSecret)?;
567 let value = &backing[value_start..line_end];
568 if value.is_empty()
569 || value.len() > MAX_COOKIE_VALUE_BYTES
570 || !value.iter().copied().all(cookie_octet)
571 {
572 return Err(SuppliedSessionCookieError::MalformedSecret);
573 }
574 ranges[metadata_index] = Some(value_start..line_end);
575 cursor = if physical_end == backing.len() {
576 backing.len()
577 } else {
578 physical_end + 1
579 };
580 }
581 if ranges.iter().any(Option::is_none) {
582 return Err(SuppliedSessionCookieError::MalformedSecret);
583 }
584 if policy.cookies.iter().any(|cookie| {
585 cookie
586 .expires_unix_seconds
587 .is_some_and(|expires| expires <= now_unix_seconds)
588 }) {
589 return Err(SuppliedSessionCookieError::IncompleteInitialCoverage);
590 }
591 let values = ranges
592 .into_iter()
593 .enumerate()
594 .map(|(metadata_index, range)| SuppliedSessionCookieValue {
595 metadata_index,
596 range: range.expect("the exact cookie id set was checked above"),
597 })
598 .collect::<Vec<_>>();
599 let cookies = Self {
600 backing,
601 values,
602 policy_reference: policy.policy_reference.clone(),
603 principal_alias: policy.principal_alias.clone(),
604 };
605 if !policy
606 .execution_targets()
607 .all(|target| cookies.has_applicable_cookie(policy, target, now_unix_seconds))
608 {
609 return Err(SuppliedSessionCookieError::IncompleteInitialCoverage);
610 }
611 Ok(cookies)
612 }
613
614 pub(crate) fn from_form_login_set_cookie_fields<'a, I>(
620 policy: &SuppliedSessionPolicy,
621 fields: I,
622 now_unix_seconds: i64,
623 ) -> Result<Self, SuppliedSessionCookieError>
624 where
625 I: IntoIterator<Item = &'a [u8]>,
626 {
627 if !((policy.version == SuppliedSessionPolicyVersion::V3
628 && policy.credential_acquisition
629 == SuppliedSessionCredentialAcquisition::BoundedFormLogin)
630 || (policy.version == SuppliedSessionPolicyVersion::V4 && policy.renewal.is_some()))
631 || policy.cookies.is_empty()
632 {
633 return Err(SuppliedSessionCookieError::PolicyMismatch);
634 }
635 let mut backing = Zeroizing::new(Vec::new());
636 let mut ranges: Vec<Option<Range<usize>>> =
637 (0..policy.cookies.len()).map(|_| None).collect();
638 let mut field_count = 0_usize;
639 let mut total_bytes = 0_usize;
640 for raw in fields {
641 field_count = field_count.saturating_add(1);
642 total_bytes = total_bytes.saturating_add(raw.len());
643 if field_count > MAX_SUPPLIED_SESSION_COOKIES
644 || raw.is_empty()
645 || raw.len() > MAX_COOKIE_VALUE_BYTES.saturating_add(1024)
646 || total_bytes > HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES
647 {
648 return Err(SuppliedSessionCookieError::MalformedSecret);
649 }
650 let parsed = parse_form_login_set_cookie(raw, policy)?;
651 if ranges[parsed.metadata_index].is_some() {
652 return Err(SuppliedSessionCookieError::MalformedSecret);
653 }
654 let start = backing.len();
655 backing.extend_from_slice(parsed.value);
656 let end = backing.len();
657 ranges[parsed.metadata_index] = Some(start..end);
658 }
659 if field_count != policy.cookies.len() || ranges.iter().any(Option::is_none) {
660 return Err(SuppliedSessionCookieError::MalformedSecret);
661 }
662 let values = ranges
663 .into_iter()
664 .enumerate()
665 .map(|(metadata_index, range)| SuppliedSessionCookieValue {
666 metadata_index,
667 range: range.expect("the exact V3 cookie set was checked above"),
668 })
669 .collect::<Vec<_>>();
670 let cookies = Self {
671 backing,
672 values,
673 policy_reference: policy.policy_reference.clone(),
674 principal_alias: policy.principal_alias.clone(),
675 };
676 if !policy
677 .execution_targets()
678 .all(|target| cookies.has_applicable_cookie(policy, target, now_unix_seconds))
679 {
680 return Err(SuppliedSessionCookieError::IncompleteInitialCoverage);
681 }
682 Ok(cookies)
683 }
684
685 pub fn cookie_count(&self) -> usize {
687 self.values.len()
688 }
689
690 pub(crate) fn sensitive_header_for_target(
691 &self,
692 policy: &SuppliedSessionPolicy,
693 target: &Url,
694 now_unix_seconds: i64,
695 ) -> Result<HeaderValue, SuppliedSessionCookieError> {
696 if !self.matches_policy(policy) {
697 return Err(SuppliedSessionCookieError::PolicyMismatch);
698 }
699 if !policy.admits_execution_target(target) {
700 return Err(SuppliedSessionCookieError::TargetNotAdmitted);
701 }
702 if policy.cookies.iter().any(|cookie| {
706 cookie
707 .expires_unix_seconds
708 .is_some_and(|expires| expires <= now_unix_seconds)
709 }) {
710 return Err(SuppliedSessionCookieError::NoApplicableCookie);
711 }
712 let mut selected = self
713 .values
714 .iter()
715 .filter(|value| {
716 cookie_applies(
717 &policy.cookies[value.metadata_index],
718 target,
719 now_unix_seconds,
720 )
721 })
722 .collect::<Vec<_>>();
723 selected.sort_by(|left, right| {
724 policy.cookies[right.metadata_index]
725 .path
726 .len()
727 .cmp(&policy.cookies[left.metadata_index].path.len())
728 .then_with(|| left.metadata_index.cmp(&right.metadata_index))
729 });
730 if selected.is_empty() {
731 return Err(SuppliedSessionCookieError::NoApplicableCookie);
732 }
733
734 let mut composed = Zeroizing::new(Vec::new());
735 for (position, value) in selected.into_iter().enumerate() {
736 let metadata = &policy.cookies[value.metadata_index];
737 let separator_bytes = usize::from(position > 0) * 2;
738 let next_len = composed
739 .len()
740 .checked_add(separator_bytes)
741 .and_then(|length| length.checked_add(metadata.name.len()))
742 .and_then(|length| length.checked_add(1))
743 .and_then(|length| length.checked_add(value.range.len()))
744 .ok_or(SuppliedSessionCookieError::HeaderTooLarge)?;
745 if next_len > MAX_SUPPLIED_SESSION_COOKIE_HEADER_BYTES {
746 return Err(SuppliedSessionCookieError::HeaderTooLarge);
747 }
748 if position > 0 {
749 composed.extend_from_slice(b"; ");
750 }
751 composed.extend_from_slice(metadata.name.as_bytes());
752 composed.push(b'=');
753 composed.extend_from_slice(&self.backing[value.range.clone()]);
754 }
755 let mut header = HeaderValue::from_bytes(composed.as_slice())
756 .map_err(|_| SuppliedSessionCookieError::MalformedSecret)?;
757 header.set_sensitive(true);
758 Ok(header)
759 }
760
761 fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
762 matches!(
763 policy.version,
764 SuppliedSessionPolicyVersion::V2
765 | SuppliedSessionPolicyVersion::V3
766 | SuppliedSessionPolicyVersion::V4
767 ) && policy.credential_mechanism == SuppliedSessionCredentialMechanism::CookieJar
768 && self.policy_reference == policy.policy_reference
769 && self.principal_alias == policy.principal_alias
770 && self.values.len() == policy.cookies.len()
771 }
772
773 fn has_applicable_cookie(
774 &self,
775 policy: &SuppliedSessionPolicy,
776 target: &Url,
777 now_unix_seconds: i64,
778 ) -> bool {
779 self.values.iter().any(|value| {
780 cookie_applies(
781 &policy.cookies[value.metadata_index],
782 target,
783 now_unix_seconds,
784 )
785 })
786 }
787}
788
789struct ParsedFormLoginCookie<'a> {
790 metadata_index: usize,
791 value: &'a [u8],
792}
793
794fn parse_form_login_set_cookie<'a>(
795 raw: &'a [u8],
796 policy: &SuppliedSessionPolicy,
797) -> Result<ParsedFormLoginCookie<'a>, SuppliedSessionCookieError> {
798 let mut segments = raw.split(|byte| *byte == b';');
799 let pair = trim_cookie_segment(
800 segments
801 .next()
802 .ok_or(SuppliedSessionCookieError::MalformedSecret)?,
803 );
804 let equals = pair
805 .iter()
806 .position(|byte| *byte == b'=')
807 .ok_or(SuppliedSessionCookieError::MalformedSecret)?;
808 let name = std::str::from_utf8(&pair[..equals])
809 .map_err(|_| SuppliedSessionCookieError::MalformedSecret)?;
810 let value = &pair[equals + 1..];
811 if name.is_empty()
812 || value.is_empty()
813 || value.len() > MAX_COOKIE_VALUE_BYTES
814 || !value.iter().copied().all(cookie_octet)
815 {
816 return Err(SuppliedSessionCookieError::MalformedSecret);
817 }
818 let mut path = None;
819 let mut secure = false;
820 let mut http_only = false;
821 let mut same_site = None;
822 for raw_attribute in segments {
823 let attribute = trim_cookie_segment(raw_attribute);
824 if attribute.is_empty() {
825 return Err(SuppliedSessionCookieError::MalformedSecret);
826 }
827 let (attribute_name, attribute_value) = attribute
828 .iter()
829 .position(|byte| *byte == b'=')
830 .map_or((attribute, None), |offset| {
831 (&attribute[..offset], Some(&attribute[offset + 1..]))
832 });
833 if attribute_name.eq_ignore_ascii_case(b"path") {
834 let candidate = attribute_value.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
835 if path.replace(candidate).is_some() {
836 return Err(SuppliedSessionCookieError::MalformedSecret);
837 }
838 } else if attribute_name.eq_ignore_ascii_case(b"secure") {
839 if attribute_value.is_some() || secure {
840 return Err(SuppliedSessionCookieError::MalformedSecret);
841 }
842 secure = true;
843 } else if attribute_name.eq_ignore_ascii_case(b"httponly") {
844 if attribute_value.is_some() || http_only {
845 return Err(SuppliedSessionCookieError::MalformedSecret);
846 }
847 http_only = true;
848 } else if attribute_name.eq_ignore_ascii_case(b"samesite") {
849 let candidate = attribute_value.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
850 if same_site.replace(candidate).is_some() {
851 return Err(SuppliedSessionCookieError::MalformedSecret);
852 }
853 } else {
854 return Err(SuppliedSessionCookieError::MalformedSecret);
855 }
856 }
857 let path = path.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
858 let same_site = same_site.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
859 let mut matching_metadata = policy.cookies.iter().enumerate().filter(|(_, cookie)| {
860 cookie.name == name
861 && path == cookie.path.as_bytes()
862 && secure == cookie.secure
863 && http_only == cookie.http_only
864 && same_site.eq_ignore_ascii_case(cookie.same_site.as_str().as_bytes())
865 });
866 let metadata_index = matching_metadata
867 .next()
868 .map(|(index, _)| index)
869 .ok_or(SuppliedSessionCookieError::MalformedSecret)?;
870 if matching_metadata.next().is_some() {
871 return Err(SuppliedSessionCookieError::MalformedSecret);
872 }
873 Ok(ParsedFormLoginCookie {
874 metadata_index,
875 value,
876 })
877}
878
879fn trim_cookie_segment(mut value: &[u8]) -> &[u8] {
880 while value
881 .first()
882 .is_some_and(|byte| matches!(*byte, b' ' | b'\t'))
883 {
884 value = &value[1..];
885 }
886 while value
887 .last()
888 .is_some_and(|byte| matches!(*byte, b' ' | b'\t'))
889 {
890 value = &value[..value.len() - 1];
891 }
892 value
893}
894
895impl fmt::Debug for SuppliedSessionCookies {
896 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
897 formatter
898 .debug_struct("SuppliedSessionCookies")
899 .field("values", &"<redacted>")
900 .field("cookie_count", &self.values.len())
901 .finish()
902 }
903}
904
905pub enum SuppliedSessionCredential {
907 Authorization(SuppliedSessionAuthorization),
909 SuppliedCookies(SuppliedSessionCookies),
911}
912
913impl SuppliedSessionCredential {
914 pub const fn mechanism(&self) -> SuppliedSessionCredentialMechanism {
916 match self {
917 Self::Authorization(_) => SuppliedSessionCredentialMechanism::AuthorizationHeader,
918 Self::SuppliedCookies(_) => SuppliedSessionCredentialMechanism::CookieJar,
919 }
920 }
921
922 pub(crate) const fn authorization(&self) -> Option<&SuppliedSessionAuthorization> {
923 match self {
924 Self::Authorization(value) => Some(value),
925 Self::SuppliedCookies(_) => None,
926 }
927 }
928
929 pub(crate) const fn cookies(&self) -> Option<&SuppliedSessionCookies> {
930 match self {
931 Self::Authorization(_) => None,
932 Self::SuppliedCookies(value) => Some(value),
933 }
934 }
935}
936
937impl From<SuppliedSessionAuthorization> for SuppliedSessionCredential {
938 fn from(value: SuppliedSessionAuthorization) -> Self {
939 Self::Authorization(value)
940 }
941}
942
943impl From<SuppliedSessionCookies> for SuppliedSessionCredential {
944 fn from(value: SuppliedSessionCookies) -> Self {
945 Self::SuppliedCookies(value)
946 }
947}
948
949impl fmt::Debug for SuppliedSessionCredential {
950 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
951 match self {
952 Self::Authorization(_) => {
953 formatter.write_str("SuppliedSessionCredential::Authorization(<redacted>)")
954 },
955 Self::SuppliedCookies(_) => {
956 formatter.write_str("SuppliedSessionCredential::SuppliedCookies(<redacted>)")
957 },
958 }
959 }
960}
961
962pub struct SuppliedSessionPolicy {
964 version: SuppliedSessionPolicyVersion,
965 application_url: Url,
966 principal_alias: String,
967 health_url: Url,
968 health_json_field: String,
969 resources: Vec<SuppliedSessionResource>,
970 credential_mechanism: SuppliedSessionCredentialMechanism,
971 credential_acquisition: SuppliedSessionCredentialAcquisition,
972 cookie_update_policy: Option<SuppliedSessionCookieUpdatePolicy>,
973 cookies: Vec<SuppliedSessionCookieMetadata>,
974 login: Option<SuppliedSessionLoginPolicy>,
975 renewal: Option<SuppliedSessionRenewalPolicy>,
976 oauth_code: Option<Box<oauth_code::OAuthCodePolicy>>,
978 oauth_refresh: Option<Box<oauth_refresh::OAuthRefreshPolicy>>,
979 oauth_authorization: Option<Box<oauth_authorization::OAuthAuthorizationPolicy>>,
980 policy_reference: String,
981 application_reference: String,
982 principal_reference: String,
983 health_field_reference: String,
984 max_session_requests: u8,
985 max_total_response_bytes: u64,
986 max_response_body_bytes: usize,
987 max_wall_time_ms: u64,
988}
989
990struct SuppliedSessionResource {
991 url: Url,
992 reference: String,
993}
994
995pub enum SuppliedSessionRuntimeInput {
998 Ready(SuppliedSessionCredential),
1000 FormLogin(SuppliedSessionFormCredential),
1002 OAuthCode(SuppliedSessionOAuthGrant),
1004 OAuthAuthorization(Box<SuppliedSessionOAuthAuthorization>),
1006}
1007
1008impl SuppliedSessionRuntimeInput {
1009 pub fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
1011 match self {
1012 Self::Ready(credential) => {
1013 policy.version() != SuppliedSessionPolicyVersion::V3
1014 && policy.version() != SuppliedSessionPolicyVersion::V5
1015 && policy.version() != SuppliedSessionPolicyVersion::V6
1016 && policy.version() != SuppliedSessionPolicyVersion::V7
1017 && policy.credential_mechanism() == credential.mechanism()
1018 },
1019 Self::FormLogin(credential) => credential.matches_policy(policy),
1020 Self::OAuthCode(grant) => grant.matches_policy(policy),
1021 Self::OAuthAuthorization(authorization) => authorization.matches_policy(policy),
1022 }
1023 }
1024}
1025
1026impl From<SuppliedSessionCredential> for SuppliedSessionRuntimeInput {
1027 fn from(value: SuppliedSessionCredential) -> Self {
1028 Self::Ready(value)
1029 }
1030}
1031
1032impl From<SuppliedSessionAuthorization> for SuppliedSessionRuntimeInput {
1033 fn from(value: SuppliedSessionAuthorization) -> Self {
1034 Self::Ready(SuppliedSessionCredential::Authorization(value))
1035 }
1036}
1037
1038impl From<SuppliedSessionCookies> for SuppliedSessionRuntimeInput {
1039 fn from(value: SuppliedSessionCookies) -> Self {
1040 Self::Ready(SuppliedSessionCredential::SuppliedCookies(value))
1041 }
1042}
1043
1044impl From<SuppliedSessionFormCredential> for SuppliedSessionRuntimeInput {
1045 fn from(value: SuppliedSessionFormCredential) -> Self {
1046 Self::FormLogin(value)
1047 }
1048}
1049
1050impl From<SuppliedSessionOAuthGrant> for SuppliedSessionRuntimeInput {
1051 fn from(value: SuppliedSessionOAuthGrant) -> Self {
1052 Self::OAuthCode(value)
1053 }
1054}
1055
1056impl From<SuppliedSessionOAuthAuthorization> for SuppliedSessionRuntimeInput {
1057 fn from(value: SuppliedSessionOAuthAuthorization) -> Self {
1058 Self::OAuthAuthorization(Box::new(value))
1059 }
1060}
1061
1062impl fmt::Debug for SuppliedSessionRuntimeInput {
1063 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1064 match self {
1065 Self::Ready(_) => formatter.write_str("SuppliedSessionRuntimeInput::Ready(<redacted>)"),
1066 Self::FormLogin(_) => {
1067 formatter.write_str("SuppliedSessionRuntimeInput::FormLogin(<redacted>)")
1068 },
1069 Self::OAuthCode(_) => {
1070 formatter.write_str("SuppliedSessionRuntimeInput::OAuthCode(<redacted>)")
1071 },
1072 Self::OAuthAuthorization(_) => {
1073 formatter.write_str("SuppliedSessionRuntimeInput::OAuthAuthorization(<redacted>)")
1074 },
1075 }
1076 }
1077}
1078
1079#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
1081#[non_exhaustive]
1082pub enum OAuthAuthorizationInputError {
1083 #[error("OAuth authorization handoff does not match the selected policy")]
1085 PolicyMismatch,
1086 #[error("OAuth authorization handoff was already attempted")]
1088 AlreadyAttempted,
1089 #[error("OAuth authorization handoff URI is invalid")]
1091 InvalidUrl,
1092 #[error("OAuth authorization handoff could not be written")]
1094 WriteFailed,
1095}
1096
1097pub struct SuppliedSessionOAuthAuthorization {
1104 writer: Box<dyn std::io::Write + Send + Sync>,
1105 policy_reference: String,
1106 authorization_prefix: String,
1107 attempted: bool,
1108}
1109
1110impl SuppliedSessionOAuthAuthorization {
1111 pub fn new(
1113 policy: &SuppliedSessionPolicy,
1114 writer: impl std::io::Write + Send + Sync + 'static,
1115 ) -> Result<Self, OAuthAuthorizationInputError> {
1116 let authorization = policy
1117 .oauth_authorization_policy()
1118 .filter(|_| {
1119 policy.version() == SuppliedSessionPolicyVersion::V7
1120 && policy.credential_acquisition()
1121 == SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
1122 && policy.oauth_refresh_policy().is_none()
1123 })
1124 .ok_or(OAuthAuthorizationInputError::PolicyMismatch)?;
1125 Ok(Self {
1126 writer: Box::new(writer),
1127 policy_reference: policy.policy_reference().to_owned(),
1128 authorization_prefix: format!("{}?", authorization.authorization_endpoint()),
1129 attempted: false,
1130 })
1131 }
1132
1133 pub(crate) fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
1134 !self.attempted
1135 && policy.version() == SuppliedSessionPolicyVersion::V7
1136 && policy.credential_acquisition()
1137 == SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
1138 && self.policy_reference == policy.policy_reference()
1139 && policy
1140 .oauth_authorization_policy()
1141 .is_some_and(|authorization| {
1142 self.authorization_prefix
1143 == format!("{}?", authorization.authorization_endpoint())
1144 })
1145 }
1146
1147 pub(crate) fn write_authorization_url(
1150 &mut self,
1151 url: &str,
1152 ) -> Result<(), OAuthAuthorizationInputError> {
1153 if std::mem::replace(&mut self.attempted, true) {
1154 return Err(OAuthAuthorizationInputError::AlreadyAttempted);
1155 }
1156 if url.len() > 4_096
1157 || !url.starts_with(&self.authorization_prefix)
1158 || url.len() == self.authorization_prefix.len()
1159 || !url.bytes().all(|byte| matches!(byte, 0x21..=0x7e))
1160 {
1161 return Err(OAuthAuthorizationInputError::InvalidUrl);
1162 }
1163 let mut output = Zeroizing::new(Vec::with_capacity(url.len() + 1));
1164 output.extend_from_slice(url.as_bytes());
1165 output.push(b'\n');
1166 self.writer
1167 .write_all(&output)
1168 .and_then(|()| self.writer.flush())
1169 .map_err(|_| OAuthAuthorizationInputError::WriteFailed)
1170 }
1171}
1172
1173impl fmt::Debug for SuppliedSessionOAuthAuthorization {
1174 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1175 formatter.write_str("SuppliedSessionOAuthAuthorization(<redacted>)")
1176 }
1177}
1178
1179pub struct SuppliedSessionFormCredential {
1181 backing: Zeroizing<Vec<u8>>,
1182 username: Range<usize>,
1183 password: Range<usize>,
1184 policy_reference: String,
1185 principal_alias: String,
1186}
1187
1188impl SuppliedSessionFormCredential {
1189 pub fn parse_tsv(
1191 policy: &SuppliedSessionPolicy,
1192 source: impl Into<Vec<u8>>,
1193 ) -> Result<Self, SuppliedSessionFormCredentialError> {
1194 if policy.version != SuppliedSessionPolicyVersion::V3
1195 || policy.credential_acquisition
1196 != SuppliedSessionCredentialAcquisition::BoundedFormLogin
1197 {
1198 return Err(SuppliedSessionFormCredentialError::PolicyMismatch);
1199 }
1200 let backing = Zeroizing::new(source.into());
1201 if backing.is_empty() || backing.starts_with(&[0xef, 0xbb, 0xbf]) {
1202 return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1203 }
1204 if backing.len() > HARD_MAX_SUPPLIED_SESSION_FORM_SECRET_BYTES {
1205 return Err(SuppliedSessionFormCredentialError::SecretTooLarge);
1206 }
1207 let mut username = None;
1208 let mut password = None;
1209 let mut cursor = 0_usize;
1210 while cursor < backing.len() {
1211 let physical_end = backing[cursor..]
1212 .iter()
1213 .position(|byte| *byte == b'\n')
1214 .map_or(backing.len(), |offset| cursor + offset);
1215 let mut line_end = physical_end;
1216 if line_end > cursor && backing[line_end - 1] == b'\r' {
1217 line_end -= 1;
1218 }
1219 if line_end == cursor {
1220 return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1221 }
1222 let line = &backing[cursor..line_end];
1223 let Some(tab_offset) = line.iter().position(|byte| *byte == b'\t') else {
1224 return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1225 };
1226 if line[tab_offset + 1..].contains(&b'\t') {
1227 return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1228 }
1229 let label = &line[..tab_offset];
1230 let value_start = cursor
1231 .checked_add(tab_offset)
1232 .and_then(|value| value.checked_add(1))
1233 .ok_or(SuppliedSessionFormCredentialError::MalformedSecret)?;
1234 let value = &backing[value_start..line_end];
1235 let maximum = match label {
1236 b"username" if username.is_none() => MAX_LOGIN_USERNAME_BYTES,
1237 b"password" if password.is_none() => MAX_LOGIN_PASSWORD_BYTES,
1238 _ => return Err(SuppliedSessionFormCredentialError::MalformedSecret),
1239 };
1240 if value.is_empty()
1241 || value.len() > maximum
1242 || value
1243 .iter()
1244 .any(|byte| matches!(*byte, b'\0' | b'\r' | b'\n' | b'\t'))
1245 || std::str::from_utf8(value).is_err()
1246 {
1247 return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1248 }
1249 let range = value_start..line_end;
1250 match label {
1251 b"username" => username = Some(range),
1252 b"password" => password = Some(range),
1253 _ => unreachable!("the exact labels were matched above"),
1254 }
1255 cursor = if physical_end == backing.len() {
1256 backing.len()
1257 } else {
1258 physical_end + 1
1259 };
1260 }
1261 let (Some(username), Some(password)) = (username, password) else {
1262 return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1263 };
1264 Ok(Self {
1265 backing,
1266 username,
1267 password,
1268 policy_reference: policy.policy_reference.clone(),
1269 principal_alias: policy.principal_alias.clone(),
1270 })
1271 }
1272
1273 fn value(&self, range: Range<usize>) -> &str {
1274 std::str::from_utf8(&self.backing[range])
1275 .expect("form credential UTF-8 was validated at construction")
1276 }
1277
1278 pub(crate) fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
1279 policy.version == SuppliedSessionPolicyVersion::V3
1280 && policy.credential_acquisition
1281 == SuppliedSessionCredentialAcquisition::BoundedFormLogin
1282 && self.policy_reference == policy.policy_reference
1283 && self.principal_alias == policy.principal_alias
1284 }
1285
1286 pub(crate) fn compose_form_body(
1287 &self,
1288 policy: &SuppliedSessionPolicy,
1289 csrf: &str,
1290 ) -> Result<SuppliedSessionFormBody, SuppliedSessionFormCredentialError> {
1291 if !self.matches_policy(policy)
1292 || csrf.is_empty()
1293 || csrf.len() > MAX_LOGIN_CSRF_VALUE_BYTES
1294 || csrf
1295 .bytes()
1296 .any(|byte| matches!(byte, b'\0' | b'\r' | b'\n'))
1297 {
1298 return Err(SuppliedSessionFormCredentialError::InvalidCsrfToken);
1299 }
1300 let (Some(csrf_field), Some(username_field), Some(password_field)) = (
1301 policy.login_csrf_field(),
1302 policy.login_username_field(),
1303 policy.login_password_field(),
1304 ) else {
1305 return Err(SuppliedSessionFormCredentialError::PolicyMismatch);
1306 };
1307 let mut serializer = url::form_urlencoded::Serializer::new(String::new());
1308 serializer.append_pair(csrf_field, csrf);
1309 serializer.append_pair(username_field, self.value(self.username.clone()));
1310 serializer.append_pair(password_field, self.value(self.password.clone()));
1311 let body = serializer.finish();
1312 if body.len() > MAX_LOGIN_FORM_BODY_BYTES {
1313 return Err(SuppliedSessionFormCredentialError::SecretTooLarge);
1314 }
1315 Ok(SuppliedSessionFormBody {
1316 value: Zeroizing::new(body),
1317 })
1318 }
1319}
1320
1321impl fmt::Debug for SuppliedSessionFormCredential {
1322 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1323 formatter.write_str("SuppliedSessionFormCredential(<redacted>)")
1324 }
1325}
1326
1327pub(crate) struct SuppliedSessionFormBody {
1329 value: Zeroizing<String>,
1330}
1331
1332impl SuppliedSessionFormBody {
1333 pub(crate) fn as_bytes(&self) -> &[u8] {
1334 self.value.as_bytes()
1335 }
1336}
1337
1338impl fmt::Debug for SuppliedSessionFormBody {
1339 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1340 formatter.write_str("SuppliedSessionFormBody(<redacted>)")
1341 }
1342}
1343
1344struct SuppliedSessionLoginPolicy {
1345 url: Url,
1346 username_field: String,
1347 password_field: String,
1348 csrf_field: String,
1349 max_attempts: u8,
1350}
1351
1352struct SuppliedSessionRenewalPolicy {
1353 url: Url,
1354 after_resource_count: u8,
1355}
1356
1357impl SuppliedSessionPolicy {
1358 pub fn parse_toml(
1360 application_url: &Url,
1361 source: &[u8],
1362 ) -> Result<Self, SuppliedSessionPolicyError> {
1363 if source.len() > HARD_MAX_SUPPLIED_SESSION_POLICY_BYTES {
1364 return Err(SuppliedSessionPolicyError::PolicyTooLarge);
1365 }
1366 validate_application_url(application_url)?;
1367 let source =
1368 std::str::from_utf8(source).map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)?;
1369 let value: toml::Value =
1370 toml::from_str(source).map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)?;
1371 let schema = value
1372 .get("schema")
1373 .and_then(toml::Value::as_str)
1374 .ok_or(SuppliedSessionPolicyError::MalformedPolicy)?;
1375 match schema {
1376 SUPPLIED_SESSION_POLICY_SCHEMA => toml::from_str::<WirePolicy>(source)
1377 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1378 .and_then(|wire| Self::from_v1_wire(application_url, wire)),
1379 SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA => toml::from_str::<WireCookiePolicy>(source)
1380 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1381 .and_then(|wire| Self::from_v2_wire(application_url, wire)),
1382 SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA => {
1383 toml::from_str::<WireFormLoginPolicy>(source)
1384 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1385 .and_then(|wire| Self::from_v3_wire(application_url, wire))
1386 },
1387 SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA => toml::from_str::<WireRenewalPolicy>(source)
1388 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1389 .and_then(|wire| Self::from_v4_wire(application_url, wire)),
1390 SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA => {
1391 toml::from_str::<WireOAuthCodePolicy>(source)
1392 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1393 .and_then(|wire| Self::from_v5_wire(application_url, wire))
1394 },
1395 SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA => {
1396 toml::from_str::<WireOAuthRefreshPolicy>(source)
1397 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1398 .and_then(|wire| Self::from_v6_wire(application_url, wire))
1399 },
1400 SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA => {
1401 toml::from_str::<WireOAuthAuthorizationPolicy>(source)
1402 .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1403 .and_then(|wire| Self::from_v7_wire(application_url, wire))
1404 },
1405 _ => Err(SuppliedSessionPolicyError::UnsupportedSchema),
1406 }
1407 }
1408
1409 fn from_v1_wire(
1410 application_url: &Url,
1411 wire: WirePolicy,
1412 ) -> Result<Self, SuppliedSessionPolicyError> {
1413 if wire.schema != SUPPLIED_SESSION_POLICY_SCHEMA {
1414 return Err(SuppliedSessionPolicyError::UnsupportedSchema);
1415 }
1416 let credential_mechanism = match wire.credential_mechanism.as_str() {
1417 "authorization_header" => SuppliedSessionCredentialMechanism::AuthorizationHeader,
1418 _ => return Err(SuppliedSessionPolicyError::UnsupportedCredentialMechanism),
1419 };
1420 validate_token(&wire.principal_alias, MAX_PRINCIPAL_ALIAS_BYTES)
1421 .map_err(|_| SuppliedSessionPolicyError::InvalidPrincipalAlias)?;
1422 validate_token(&wire.health_json_field, MAX_HEALTH_FIELD_BYTES)
1423 .map_err(|_| SuppliedSessionPolicyError::InvalidHealthField)?;
1424 if wire.resources.is_empty() || wire.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES {
1425 return Err(SuppliedSessionPolicyError::InvalidResourceCount);
1426 }
1427 let minimum_requests = 1_usize
1428 .checked_add(
1429 wire.resources
1430 .len()
1431 .checked_mul(2)
1432 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?,
1433 )
1434 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
1435 if wire.max_session_requests == 0
1436 || usize::from(wire.max_session_requests) < minimum_requests
1437 || wire.max_session_requests > MAX_SUPPLIED_SESSION_REQUESTS
1438 || wire.max_total_response_bytes == 0
1439 || wire.max_total_response_bytes > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
1440 || wire.max_response_body_bytes == 0
1441 || wire.max_response_body_bytes > MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES
1442 || u64::try_from(wire.max_response_body_bytes).unwrap_or(u64::MAX)
1443 > wire.max_total_response_bytes
1444 || wire.max_wall_time_ms == 0
1445 || wire.max_wall_time_ms > MAX_SUPPLIED_SESSION_WALL_TIME_MS
1446 {
1447 return Err(SuppliedSessionPolicyError::InvalidLimits);
1448 }
1449
1450 let health_url = resolve_safe_path(application_url, &wire.health_path)?;
1451 let mut seen = std::collections::BTreeSet::new();
1452 let mut resources = Vec::with_capacity(wire.resources.len());
1453 for raw in &wire.resources {
1454 let url = resolve_safe_path(application_url, raw)?;
1455 if url == health_url || !seen.insert(url.as_str().to_owned()) {
1456 return Err(SuppliedSessionPolicyError::InvalidPath);
1457 }
1458 resources.push(SuppliedSessionResource {
1459 reference: reference(
1460 RESOURCE_REFERENCE_DOMAIN,
1461 url.as_str(),
1462 "supplied-session-resource-sha256",
1463 ),
1464 url,
1465 });
1466 }
1467 let policy_reference = policy_reference(application_url, &wire, credential_mechanism);
1468 Ok(Self {
1469 version: SuppliedSessionPolicyVersion::V1,
1470 application_url: application_url.clone(),
1471 principal_alias: wire.principal_alias,
1472 health_url,
1473 health_json_field: wire.health_json_field.clone(),
1474 resources,
1475 credential_mechanism,
1476 credential_acquisition: SuppliedSessionCredentialAcquisition::SuppliedAuthorization,
1477 cookie_update_policy: None,
1478 cookies: Vec::new(),
1479 login: None,
1480 renewal: None,
1481 oauth_code: None,
1482 oauth_refresh: None,
1483 oauth_authorization: None,
1484 policy_reference,
1485 application_reference: reference(
1486 APPLICATION_REFERENCE_DOMAIN,
1487 application_url.as_str(),
1488 "supplied-session-application-sha256",
1489 ),
1490 principal_reference: SUPPLIED_SESSION_PRINCIPAL_REFERENCE.to_owned(),
1494 health_field_reference: reference(
1495 HEALTH_FIELD_REFERENCE_DOMAIN,
1496 &wire.health_json_field,
1497 "supplied-session-health-field-sha256",
1498 ),
1499 max_session_requests: wire.max_session_requests,
1500 max_total_response_bytes: wire.max_total_response_bytes,
1501 max_response_body_bytes: wire.max_response_body_bytes,
1502 max_wall_time_ms: wire.max_wall_time_ms,
1503 })
1504 }
1505
1506 fn from_v2_wire(
1507 application_url: &Url,
1508 wire: WireCookiePolicy,
1509 ) -> Result<Self, SuppliedSessionPolicyError> {
1510 if wire.schema != SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA {
1511 return Err(SuppliedSessionPolicyError::UnsupportedSchema);
1512 }
1513 let credential_mechanism = match wire.credential_mechanism.as_str() {
1514 "cookie_jar" => SuppliedSessionCredentialMechanism::CookieJar,
1515 _ => return Err(SuppliedSessionPolicyError::UnsupportedCredentialMechanism),
1516 };
1517 let cookie_update_policy = match wire.cookie_update_policy.as_str() {
1518 "stop_on_selected_cookie" => SuppliedSessionCookieUpdatePolicy::StopOnSelectedCookie,
1519 _ => return Err(SuppliedSessionPolicyError::UnsupportedCookieUpdatePolicy),
1520 };
1521 validate_token(&wire.principal_alias, MAX_PRINCIPAL_ALIAS_BYTES)
1522 .map_err(|_| SuppliedSessionPolicyError::InvalidPrincipalAlias)?;
1523 validate_token(&wire.health_json_field, MAX_HEALTH_FIELD_BYTES)
1524 .map_err(|_| SuppliedSessionPolicyError::InvalidHealthField)?;
1525 if wire.resources.is_empty() || wire.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES {
1526 return Err(SuppliedSessionPolicyError::InvalidResourceCount);
1527 }
1528 validate_policy_limits(
1529 wire.resources.len(),
1530 wire.max_session_requests,
1531 wire.max_total_response_bytes,
1532 wire.max_response_body_bytes,
1533 wire.max_wall_time_ms,
1534 )?;
1535
1536 let health_url = resolve_safe_path(application_url, &wire.health_path)?;
1537 let mut seen_resources = std::collections::BTreeSet::new();
1538 let mut resources = Vec::with_capacity(wire.resources.len());
1539 for raw in &wire.resources {
1540 let url = resolve_safe_path(application_url, raw)?;
1541 if url == health_url || !seen_resources.insert(url.as_str().to_owned()) {
1542 return Err(SuppliedSessionPolicyError::InvalidPath);
1543 }
1544 resources.push(SuppliedSessionResource {
1545 reference: reference(
1546 RESOURCE_REFERENCE_DOMAIN,
1547 url.as_str(),
1548 "supplied-session-resource-sha256",
1549 ),
1550 url,
1551 });
1552 }
1553 if wire.cookies.is_empty() || wire.cookies.len() > MAX_SUPPLIED_SESSION_COOKIES {
1554 return Err(SuppliedSessionPolicyError::InvalidCookieCount);
1555 }
1556 let canonical_host = application_url
1557 .host_str()
1558 .ok_or(SuppliedSessionPolicyError::InvalidApplication)?;
1559 let application_host_is_domain = matches!(application_url.host(), Some(Host::Domain(_)));
1560 let mut seen_ids = std::collections::BTreeSet::new();
1561 let mut seen_tuples = std::collections::BTreeSet::new();
1562 let mut cookies = Vec::with_capacity(wire.cookies.len());
1563 for cookie in &wire.cookies {
1564 validate_token(&cookie.id, MAX_COOKIE_ID_BYTES)
1565 .map_err(|_| SuppliedSessionPolicyError::InvalidCookieMetadata)?;
1566 if cookie.name.is_empty()
1567 || cookie.name.len() > MAX_COOKIE_NAME_BYTES
1568 || !cookie.name.bytes().all(http_token_byte)
1569 || cookie.name.starts_with('$')
1570 || cookie.domain != canonical_host
1571 || (!cookie.host_only && !application_host_is_domain)
1572 || (cookie.secure && application_url.scheme() != "https")
1573 {
1574 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1575 }
1576 validate_cookie_path(&cookie.path)?;
1577 let same_site = parse_cookie_same_site(&cookie.same_site)?;
1578 if same_site == SuppliedSessionCookieSameSite::None && !cookie.secure {
1579 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1580 }
1581 if !cookie_path_matches(&cookie.path, health_url.path())
1582 && !resources
1583 .iter()
1584 .any(|resource| cookie_path_matches(&cookie.path, resource.url.path()))
1585 {
1586 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1587 }
1588 if !seen_ids.insert(cookie.id.clone())
1589 || !seen_tuples.insert((
1590 cookie.name.clone(),
1591 cookie.domain.clone(),
1592 cookie.path.clone(),
1593 ))
1594 {
1595 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1596 }
1597 cookies.push(SuppliedSessionCookieMetadata {
1598 id: cookie.id.clone(),
1599 name: cookie.name.clone(),
1600 domain: cookie.domain.clone(),
1601 host_only: cookie.host_only,
1602 path: cookie.path.clone(),
1603 secure: cookie.secure,
1604 http_only: cookie.http_only,
1605 same_site,
1606 expires_unix_seconds: cookie.expires_unix_seconds,
1607 });
1608 }
1609
1610 let policy_reference = cookie_policy_reference(
1611 application_url,
1612 &wire,
1613 credential_mechanism,
1614 cookie_update_policy,
1615 );
1616 Ok(Self {
1617 version: SuppliedSessionPolicyVersion::V2,
1618 application_url: application_url.clone(),
1619 principal_alias: wire.principal_alias,
1620 health_url,
1621 health_json_field: wire.health_json_field.clone(),
1622 resources,
1623 credential_mechanism,
1624 credential_acquisition: SuppliedSessionCredentialAcquisition::SuppliedCookieJar,
1625 cookie_update_policy: Some(cookie_update_policy),
1626 cookies,
1627 login: None,
1628 renewal: None,
1629 oauth_code: None,
1630 oauth_refresh: None,
1631 oauth_authorization: None,
1632 policy_reference,
1633 application_reference: reference(
1634 APPLICATION_REFERENCE_DOMAIN,
1635 application_url.as_str(),
1636 "supplied-session-application-sha256",
1637 ),
1638 principal_reference: SUPPLIED_SESSION_PRINCIPAL_REFERENCE.to_owned(),
1639 health_field_reference: reference(
1640 HEALTH_FIELD_REFERENCE_DOMAIN,
1641 &wire.health_json_field,
1642 "supplied-session-health-field-sha256",
1643 ),
1644 max_session_requests: wire.max_session_requests,
1645 max_total_response_bytes: wire.max_total_response_bytes,
1646 max_response_body_bytes: wire.max_response_body_bytes,
1647 max_wall_time_ms: wire.max_wall_time_ms,
1648 })
1649 }
1650
1651 fn from_v3_wire(
1652 application_url: &Url,
1653 wire: WireFormLoginPolicy,
1654 ) -> Result<Self, SuppliedSessionPolicyError> {
1655 if wire.schema != SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA
1656 || wire.credential_mechanism != "cookie_jar"
1657 || wire.credential_acquisition != "bounded_form_login"
1658 || wire.max_login_attempts != 1
1659 {
1660 return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1661 }
1662 let cookie_update_policy = match wire.cookie_update_policy.as_str() {
1663 "stop_on_selected_cookie" => SuppliedSessionCookieUpdatePolicy::StopOnSelectedCookie,
1664 _ => return Err(SuppliedSessionPolicyError::UnsupportedCookieUpdatePolicy),
1665 };
1666 validate_token(&wire.principal_alias, MAX_PRINCIPAL_ALIAS_BYTES)
1667 .map_err(|_| SuppliedSessionPolicyError::InvalidPrincipalAlias)?;
1668 validate_token(&wire.health_json_field, MAX_HEALTH_FIELD_BYTES)
1669 .map_err(|_| SuppliedSessionPolicyError::InvalidHealthField)?;
1670 for field in [&wire.username_field, &wire.password_field, &wire.csrf_field] {
1671 validate_html_field_name(field)?;
1672 }
1673 if wire.username_field == wire.password_field
1674 || wire.username_field == wire.csrf_field
1675 || wire.password_field == wire.csrf_field
1676 || wire.resources.is_empty()
1677 || wire.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES.saturating_sub(1)
1678 {
1679 return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1680 }
1681 validate_form_login_policy_limits(
1682 wire.resources.len(),
1683 wire.max_session_requests,
1684 wire.max_total_response_bytes,
1685 wire.max_response_body_bytes,
1686 wire.max_wall_time_ms,
1687 )?;
1688 if wire.login_method != "post" || wire.login_encoding != "application/x-www-form-urlencoded"
1689 {
1690 return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1691 }
1692 let login_url = resolve_login_path(application_url, &wire.login_path)?;
1693 let health_url = resolve_safe_path(application_url, &wire.health_path)?;
1694 let mut seen_resources = std::collections::BTreeSet::new();
1695 let mut resources = Vec::with_capacity(wire.resources.len());
1696 for raw in &wire.resources {
1697 let url = resolve_safe_path(application_url, raw)?;
1698 if url == health_url
1699 || url == login_url
1700 || !seen_resources.insert(url.as_str().to_owned())
1701 {
1702 return Err(SuppliedSessionPolicyError::InvalidPath);
1703 }
1704 resources.push(SuppliedSessionResource {
1705 reference: reference(
1706 RESOURCE_REFERENCE_DOMAIN,
1707 url.as_str(),
1708 "supplied-session-resource-sha256",
1709 ),
1710 url,
1711 });
1712 }
1713 let cookies = validate_v3_cookie_metadata(application_url, &health_url, &resources, &wire)?;
1714 let policy_reference =
1715 form_login_policy_reference(application_url, &wire, cookie_update_policy);
1716 Ok(Self {
1717 version: SuppliedSessionPolicyVersion::V3,
1718 application_url: application_url.clone(),
1719 principal_alias: wire.principal_alias,
1720 health_url,
1721 health_json_field: wire.health_json_field.clone(),
1722 resources,
1723 credential_mechanism: SuppliedSessionCredentialMechanism::CookieJar,
1724 credential_acquisition: SuppliedSessionCredentialAcquisition::BoundedFormLogin,
1725 cookie_update_policy: Some(cookie_update_policy),
1726 cookies,
1727 login: Some(SuppliedSessionLoginPolicy {
1728 url: login_url,
1729 username_field: wire.username_field,
1730 password_field: wire.password_field,
1731 csrf_field: wire.csrf_field,
1732 max_attempts: wire.max_login_attempts,
1733 }),
1734 renewal: None,
1735 oauth_code: None,
1736 oauth_refresh: None,
1737 oauth_authorization: None,
1738 policy_reference,
1739 application_reference: reference(
1740 APPLICATION_REFERENCE_DOMAIN,
1741 application_url.as_str(),
1742 "supplied-session-application-sha256",
1743 ),
1744 principal_reference: SUPPLIED_SESSION_PRINCIPAL_REFERENCE.to_owned(),
1745 health_field_reference: reference(
1746 HEALTH_FIELD_REFERENCE_DOMAIN,
1747 &wire.health_json_field,
1748 "supplied-session-health-field-sha256",
1749 ),
1750 max_session_requests: wire.max_session_requests,
1751 max_total_response_bytes: wire.max_total_response_bytes,
1752 max_response_body_bytes: wire.max_response_body_bytes,
1753 max_wall_time_ms: wire.max_wall_time_ms,
1754 })
1755 }
1756
1757 fn from_v4_wire(
1758 application_url: &Url,
1759 wire: WireRenewalPolicy,
1760 ) -> Result<Self, SuppliedSessionPolicyError> {
1761 if wire.schema != SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA
1762 || wire.renewal_method != "post"
1763 || wire.max_renewal_attempts != 1
1764 || wire.renewal_after_resource_count == 0
1765 || usize::from(wire.renewal_after_resource_count) >= wire.resources.len()
1766 || wire.resources.len() > 3
1767 || usize::from(wire.max_session_requests) != 3 + 2 * wire.resources.len()
1768 {
1769 return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1770 }
1771 let renewal_url = resolve_safe_path(application_url, &wire.renewal_path)?;
1772 let after_resource_count = wire.renewal_after_resource_count;
1773 let mut policy = Self::from_v2_wire(
1774 application_url,
1775 WireCookiePolicy {
1776 schema: SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA.to_owned(),
1777 principal_alias: wire.principal_alias,
1778 credential_mechanism: wire.credential_mechanism,
1779 cookie_update_policy: wire.cookie_update_policy,
1780 health_path: wire.health_path,
1781 health_json_field: wire.health_json_field,
1782 resources: wire.resources,
1783 cookies: wire.cookies,
1784 max_session_requests: wire.max_session_requests,
1785 max_total_response_bytes: wire.max_total_response_bytes,
1786 max_response_body_bytes: wire.max_response_body_bytes,
1787 max_wall_time_ms: wire.max_wall_time_ms,
1788 },
1789 )?;
1790 if policy.admits_execution_target(&renewal_url)
1791 || policy.cookies.iter().any(|cookie| {
1792 !cookie.host_only
1793 || cookie.expires_unix_seconds.is_some()
1794 || cookie.secure != (application_url.scheme() == "https")
1795 || !cookie.http_only
1796 || !matches!(
1797 cookie.same_site,
1798 SuppliedSessionCookieSameSite::Strict | SuppliedSessionCookieSameSite::Lax
1799 )
1800 || !cookie_path_matches(&cookie.path, renewal_url.path())
1801 || !policy
1802 .execution_targets()
1803 .all(|target| cookie_path_matches(&cookie.path, target.path()))
1804 })
1805 {
1806 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1807 }
1808 let mut digest = Sha256::new();
1809 digest.update(b"security.supplied-session-policy.reference.v4\0");
1810 hash_field(&mut digest, policy.policy_reference.as_bytes());
1811 hash_field(&mut digest, renewal_url.as_str().as_bytes());
1812 hash_field(&mut digest, &[after_resource_count]);
1813 hash_field(&mut digest, b"post;max_attempts=1");
1814 policy.policy_reference =
1815 format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
1816 policy.version = SuppliedSessionPolicyVersion::V4;
1817 policy.renewal = Some(SuppliedSessionRenewalPolicy {
1818 url: renewal_url,
1819 after_resource_count,
1820 });
1821 Ok(policy)
1822 }
1823
1824 fn from_v5_wire(
1825 application_url: &Url,
1826 wire: WireOAuthCodePolicy,
1827 ) -> Result<Self, SuppliedSessionPolicyError> {
1828 let minimum_requests = wire
1829 .resources
1830 .len()
1831 .checked_mul(2)
1832 .and_then(|count| count.checked_add(2))
1833 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
1834 if wire.schema != SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA
1835 || wire.credential_acquisition != "imported_oauth_code_pkce"
1836 || wire.resources.is_empty()
1837 || wire.resources.len() > 3
1838 || usize::from(wire.max_session_requests) != minimum_requests
1839 {
1840 return Err(SuppliedSessionPolicyError::InvalidOAuthCode);
1841 }
1842 let oauth_code = oauth_code::OAuthCodePolicy::from_wire(wire.oauth_code)?;
1843 if !oauth_code.is_valid_for_application(application_url)
1844 || oauth_code.max_token_response_bytes() > wire.max_response_body_bytes
1845 || u64::try_from(oauth_code.max_token_response_bytes()).unwrap_or(u64::MAX)
1846 > wire.max_total_response_bytes
1847 {
1848 return Err(SuppliedSessionPolicyError::InvalidOAuthCode);
1849 }
1850 let mut policy = Self::from_v1_wire(
1853 application_url,
1854 WirePolicy {
1855 schema: SUPPLIED_SESSION_POLICY_SCHEMA.to_owned(),
1856 principal_alias: wire.principal_alias,
1857 credential_mechanism: wire.credential_mechanism,
1858 health_path: wire.health_path,
1859 health_json_field: wire.health_json_field,
1860 resources: wire.resources,
1861 max_session_requests: wire.max_session_requests,
1862 max_total_response_bytes: wire.max_total_response_bytes,
1863 max_response_body_bytes: wire.max_response_body_bytes,
1864 max_wall_time_ms: wire.max_wall_time_ms,
1865 },
1866 )?;
1867 let mut digest = Sha256::new();
1868 digest.update(b"security.supplied-session-policy.reference.v5\0");
1869 hash_field(&mut digest, policy.policy_reference.as_bytes());
1870 hash_field(&mut digest, oauth_code.binding_reference().as_bytes());
1871 hash_field(
1872 &mut digest,
1873 b"imported_oauth_code_pkce;one_exchange;external_context_asserted",
1874 );
1875 policy.policy_reference =
1876 format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
1877 policy.version = SuppliedSessionPolicyVersion::V5;
1878 policy.credential_acquisition = SuppliedSessionCredentialAcquisition::ImportedOAuthCodePkce;
1879 policy.oauth_code = Some(Box::new(oauth_code));
1880 Ok(policy)
1881 }
1882
1883 fn from_v6_wire(
1884 application_url: &Url,
1885 wire: WireOAuthRefreshPolicy,
1886 ) -> Result<Self, SuppliedSessionPolicyError> {
1887 if wire.schema != SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA
1890 || wire.credential_acquisition != "imported_oauth_code_pkce"
1891 || wire.resources.len() != 2
1892 || wire.max_session_requests != 8
1893 {
1894 return Err(SuppliedSessionPolicyError::InvalidOAuthRefresh);
1895 }
1896 let oauth_code = oauth_code::OAuthCodePolicy::from_wire(wire.oauth_code)?;
1897 let oauth_refresh = oauth_refresh::OAuthRefreshPolicy::from_wire(wire.oauth_refresh)?;
1898 if !oauth_code.is_valid_for_application(application_url)
1899 || oauth_code.max_token_response_bytes() > wire.max_response_body_bytes
1900 || u64::try_from(oauth_code.max_token_response_bytes()).unwrap_or(u64::MAX)
1901 > wire.max_total_response_bytes
1902 || oauth_refresh.after_resource_count() != 1
1903 || oauth_refresh.max_attempts() != 1
1904 {
1905 return Err(SuppliedSessionPolicyError::InvalidOAuthRefresh);
1906 }
1907 let mut policy = Self::from_v1_wire(
1910 application_url,
1911 WirePolicy {
1912 schema: SUPPLIED_SESSION_POLICY_SCHEMA.to_owned(),
1913 principal_alias: wire.principal_alias,
1914 credential_mechanism: wire.credential_mechanism,
1915 health_path: wire.health_path,
1916 health_json_field: wire.health_json_field,
1917 resources: wire.resources,
1918 max_session_requests: wire.max_session_requests,
1919 max_total_response_bytes: wire.max_total_response_bytes,
1920 max_response_body_bytes: wire.max_response_body_bytes,
1921 max_wall_time_ms: wire.max_wall_time_ms,
1922 },
1923 )?;
1924 let mut digest = Sha256::new();
1925 digest.update(b"security.supplied-session-policy.reference.v6\0");
1926 hash_field(&mut digest, policy.policy_reference.as_bytes());
1927 hash_field(&mut digest, oauth_code.binding_reference().as_bytes());
1928 hash_field(&mut digest, oauth_refresh.binding_reference().as_bytes());
1929 hash_field(
1930 &mut digest,
1931 b"external_code;one_scheduled_refresh;new_epoch_health",
1932 );
1933 policy.policy_reference =
1934 format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
1935 policy.version = SuppliedSessionPolicyVersion::V6;
1936 policy.credential_acquisition = SuppliedSessionCredentialAcquisition::ImportedOAuthCodePkce;
1937 policy.oauth_code = Some(Box::new(oauth_code));
1938 policy.oauth_refresh = Some(Box::new(oauth_refresh));
1939 Ok(policy)
1940 }
1941
1942 fn from_v7_wire(
1943 application_url: &Url,
1944 wire: WireOAuthAuthorizationPolicy,
1945 ) -> Result<Self, SuppliedSessionPolicyError> {
1946 let outbound_requests = wire
1947 .resources
1948 .len()
1949 .checked_mul(2)
1950 .and_then(|count| count.checked_add(2))
1951 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
1952 if wire.schema != SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA
1957 || wire.credential_acquisition != "client_initiated_oauth_pkce"
1958 || wire.resources.is_empty()
1959 || wire.resources.len() > 3
1960 || usize::from(wire.max_session_requests) != outbound_requests
1961 || outbound_requests
1962 .checked_add(1)
1963 .is_none_or(|count| count > usize::from(MAX_SUPPLIED_SESSION_REQUESTS))
1964 {
1965 return Err(SuppliedSessionPolicyError::InvalidOAuthAuthorization);
1966 }
1967 let oauth_code = oauth_code::OAuthCodePolicy::from_authorization_wire(wire.oauth_code)?;
1968 let oauth_authorization = oauth_authorization::OAuthAuthorizationPolicy::from_wire(
1969 wire.oauth_authorization,
1970 &oauth_code,
1971 )?;
1972 if !oauth_code.is_valid_for_application(application_url)
1973 || oauth_code.max_token_response_bytes() > wire.max_response_body_bytes
1974 || u64::try_from(oauth_code.max_token_response_bytes()).unwrap_or(u64::MAX)
1975 > wire.max_total_response_bytes
1976 {
1977 return Err(SuppliedSessionPolicyError::InvalidOAuthAuthorization);
1978 }
1979 let mut policy = Self::from_v1_wire(
1980 application_url,
1981 WirePolicy {
1982 schema: SUPPLIED_SESSION_POLICY_SCHEMA.to_owned(),
1983 principal_alias: wire.principal_alias,
1984 credential_mechanism: wire.credential_mechanism,
1985 health_path: wire.health_path,
1986 health_json_field: wire.health_json_field,
1987 resources: wire.resources,
1988 max_session_requests: wire.max_session_requests,
1989 max_total_response_bytes: wire.max_total_response_bytes,
1990 max_response_body_bytes: wire.max_response_body_bytes,
1991 max_wall_time_ms: wire.max_wall_time_ms,
1992 },
1993 )?;
1994 let mut digest = Sha256::new();
1995 digest.update(b"security.supplied-session-policy.reference.v7\0");
1996 hash_field(&mut digest, policy.policy_reference.as_bytes());
1997 hash_field(&mut digest, oauth_code.binding_reference().as_bytes());
1998 hash_field(
1999 &mut digest,
2000 oauth_authorization.binding_reference().as_bytes(),
2001 );
2002 hash_field(
2003 &mut digest,
2004 b"client_initiated_oauth_pkce;one_loopback_form_post;one_exchange;no_oidc",
2005 );
2006 policy.policy_reference =
2007 format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
2008 policy.version = SuppliedSessionPolicyVersion::V7;
2009 policy.credential_acquisition =
2010 SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce;
2011 policy.oauth_code = Some(Box::new(oauth_code));
2012 policy.oauth_authorization = Some(Box::new(oauth_authorization));
2013 Ok(policy)
2014 }
2015
2016 pub const fn version(&self) -> SuppliedSessionPolicyVersion {
2018 self.version
2019 }
2020
2021 pub const fn schema(&self) -> &'static str {
2023 self.version.schema()
2024 }
2025
2026 pub fn policy_reference(&self) -> &str {
2028 &self.policy_reference
2029 }
2030
2031 pub fn application_reference(&self) -> &str {
2033 &self.application_reference
2034 }
2035
2036 pub fn principal_reference(&self) -> &str {
2038 &self.principal_reference
2039 }
2040
2041 pub fn principal_alias(&self) -> &str {
2046 &self.principal_alias
2047 }
2048
2049 pub fn health_field_reference(&self) -> &str {
2051 &self.health_field_reference
2052 }
2053
2054 pub const fn credential_mechanism(&self) -> SuppliedSessionCredentialMechanism {
2056 self.credential_mechanism
2057 }
2058
2059 pub const fn credential_acquisition(&self) -> SuppliedSessionCredentialAcquisition {
2061 self.credential_acquisition
2062 }
2063
2064 pub const fn cookie_update_policy(&self) -> Option<SuppliedSessionCookieUpdatePolicy> {
2066 self.cookie_update_policy
2067 }
2068
2069 pub fn cookie_summary(&self) -> Option<SuppliedSessionCookieSummary> {
2071 (!self.cookies.is_empty()).then(|| summarize_cookies(&self.cookies))
2072 }
2073
2074 pub fn resource_count(&self) -> usize {
2076 self.resources.len()
2077 }
2078
2079 pub const fn max_session_requests(&self) -> u8 {
2080 self.max_session_requests
2081 }
2082
2083 pub const fn max_total_response_bytes(&self) -> u64 {
2084 self.max_total_response_bytes
2085 }
2086
2087 pub const fn max_response_body_bytes(&self) -> usize {
2088 self.max_response_body_bytes
2089 }
2090
2091 pub const fn max_wall_time_ms(&self) -> u64 {
2092 self.max_wall_time_ms
2093 }
2094
2095 pub(crate) const fn application_url(&self) -> &Url {
2096 &self.application_url
2097 }
2098
2099 pub(crate) fn oauth_code_policy(&self) -> Option<&oauth_code::OAuthCodePolicy> {
2100 self.oauth_code.as_deref()
2101 }
2102
2103 pub(crate) fn oauth_refresh_policy(&self) -> Option<&oauth_refresh::OAuthRefreshPolicy> {
2104 self.oauth_refresh.as_deref()
2105 }
2106
2107 pub(crate) fn oauth_authorization_policy(
2108 &self,
2109 ) -> Option<&oauth_authorization::OAuthAuthorizationPolicy> {
2110 self.oauth_authorization.as_deref()
2111 }
2112
2113 pub(crate) fn health_json_field(&self) -> &str {
2114 &self.health_json_field
2115 }
2116
2117 pub(crate) fn login_url(&self) -> Option<&Url> {
2118 self.login.as_ref().map(|login| &login.url)
2119 }
2120
2121 pub(crate) fn login_username_field(&self) -> Option<&str> {
2122 self.login
2123 .as_ref()
2124 .map(|login| login.username_field.as_str())
2125 }
2126
2127 pub(crate) fn login_password_field(&self) -> Option<&str> {
2128 self.login
2129 .as_ref()
2130 .map(|login| login.password_field.as_str())
2131 }
2132
2133 pub(crate) fn login_csrf_field(&self) -> Option<&str> {
2134 self.login.as_ref().map(|login| login.csrf_field.as_str())
2135 }
2136
2137 pub(crate) fn max_login_attempts(&self) -> Option<u8> {
2138 self.login.as_ref().map(|login| login.max_attempts)
2139 }
2140
2141 pub(crate) fn execution_resources(&self) -> impl ExactSizeIterator<Item = (&Url, &str)> {
2142 self.resources
2143 .iter()
2144 .map(|resource| (&resource.url, resource.reference.as_str()))
2145 }
2146
2147 fn execution_targets(&self) -> impl Iterator<Item = &Url> {
2148 std::iter::once(&self.health_url)
2149 .chain(self.resources.iter().map(|resource| &resource.url))
2150 .chain(self.renewal.iter().map(|renewal| &renewal.url))
2151 }
2152
2153 fn admits_execution_target(&self, target: &Url) -> bool {
2154 self.health_url == *target
2155 || self
2156 .renewal
2157 .as_ref()
2158 .is_some_and(|renewal| renewal.url == *target)
2159 || self
2160 .resources
2161 .iter()
2162 .any(|resource| resource.url == *target)
2163 }
2164
2165 pub(crate) fn is_selected_cookie_name(&self, name: &str) -> bool {
2166 self.cookies.iter().any(|cookie| cookie.name == name)
2167 }
2168
2169 pub(crate) fn renewal_after_resource_count(&self) -> Option<u8> {
2170 self.renewal
2171 .as_ref()
2172 .map(|renewal| renewal.after_resource_count)
2173 }
2174}
2175
2176impl fmt::Debug for SuppliedSessionPolicy {
2177 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2178 formatter
2179 .debug_struct("SuppliedSessionPolicy")
2180 .field("application", &"<redacted>")
2181 .field("health", &"<redacted>")
2182 .field("health_json_field", &"<redacted>")
2183 .field("principal_alias", &"<operator-declared>")
2184 .field("version", &self.version)
2185 .field("resource_count", &self.resources.len())
2186 .field("credential_mechanism", &self.credential_mechanism)
2187 .field("credential_acquisition", &self.credential_acquisition)
2188 .field("cookie_update_policy", &self.cookie_update_policy)
2189 .field("cookie_count", &self.cookies.len())
2190 .field("login", &self.login.as_ref().map(|_| "<validated>"))
2191 .field(
2192 "oauth_code",
2193 &self.oauth_code.as_ref().map(|_| "<validated>"),
2194 )
2195 .field(
2196 "oauth_refresh",
2197 &self.oauth_refresh.as_ref().map(|_| "<validated>"),
2198 )
2199 .field(
2200 "oauth_authorization",
2201 &self.oauth_authorization.as_ref().map(|_| "<validated>"),
2202 )
2203 .field("policy_reference", &self.policy_reference)
2204 .field("application_reference", &self.application_reference)
2205 .field("principal_reference", &self.principal_reference)
2206 .field("max_session_requests", &self.max_session_requests)
2207 .field("max_total_response_bytes", &self.max_total_response_bytes)
2208 .field("max_response_body_bytes", &self.max_response_body_bytes)
2209 .field("max_wall_time_ms", &self.max_wall_time_ms)
2210 .finish()
2211 }
2212}
2213
2214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2216pub(crate) enum SuppliedSessionRequestPurpose {
2217 LoginPage,
2218 LoginSubmit,
2219 Renewal,
2220 Health,
2221 Resource,
2222}
2223
2224#[derive(Clone, PartialEq, Eq)]
2230pub(crate) struct SuppliedSessionRequestDescriptor {
2231 application_url: Url,
2232 target: Url,
2233 target_reference: String,
2234 purpose: SuppliedSessionRequestPurpose,
2235 credential_mechanism: SuppliedSessionCredentialMechanism,
2236 policy_reference: String,
2237 purpose_binding: String,
2238}
2239
2240impl SuppliedSessionRequestDescriptor {
2241 pub(crate) fn renewal(policy: &SuppliedSessionPolicy) -> Option<Self> {
2242 let target = policy.renewal.as_ref()?.url.clone();
2243 let purpose = SuppliedSessionRequestPurpose::Renewal;
2244 let descriptor = Self {
2245 application_url: policy.application_url.clone(),
2246 target_reference: reference(
2247 RESOURCE_REFERENCE_DOMAIN,
2248 target.as_str(),
2249 "supplied-session-resource-sha256",
2250 ),
2251 purpose_binding: request_descriptor_reference(
2252 &policy.application_url,
2253 &target,
2254 purpose,
2255 policy.credential_mechanism,
2256 &policy.policy_reference,
2257 ),
2258 target,
2259 purpose,
2260 credential_mechanism: policy.credential_mechanism,
2261 policy_reference: policy.policy_reference.clone(),
2262 };
2263 descriptor.validate_against(policy).then_some(descriptor)
2264 }
2265 pub(crate) fn login_page(policy: &SuppliedSessionPolicy) -> Option<Self> {
2266 Self::login(policy, SuppliedSessionRequestPurpose::LoginPage)
2267 }
2268
2269 pub(crate) fn login_submit(policy: &SuppliedSessionPolicy) -> Option<Self> {
2270 Self::login(policy, SuppliedSessionRequestPurpose::LoginSubmit)
2271 }
2272
2273 fn login(
2274 policy: &SuppliedSessionPolicy,
2275 purpose: SuppliedSessionRequestPurpose,
2276 ) -> Option<Self> {
2277 let target = policy.login_url()?.clone();
2278 let target_reference = reference(
2279 LOGIN_REFERENCE_DOMAIN,
2280 target.as_str(),
2281 "supplied-session-login-sha256",
2282 );
2283 let descriptor = Self {
2284 application_url: policy.application_url.clone(),
2285 target: target.clone(),
2286 target_reference,
2287 purpose,
2288 credential_mechanism: policy.credential_mechanism,
2289 policy_reference: policy.policy_reference.clone(),
2290 purpose_binding: request_descriptor_reference(
2291 &policy.application_url,
2292 &target,
2293 purpose,
2294 policy.credential_mechanism,
2295 &policy.policy_reference,
2296 ),
2297 };
2298 descriptor.validate_against(policy).then_some(descriptor)
2299 }
2300
2301 pub(crate) fn health(policy: &SuppliedSessionPolicy) -> Self {
2302 let purpose = SuppliedSessionRequestPurpose::Health;
2303 let descriptor = Self {
2304 application_url: policy.application_url.clone(),
2305 target: policy.health_url.clone(),
2306 target_reference: reference(
2307 RESOURCE_REFERENCE_DOMAIN,
2308 policy.health_url.as_str(),
2309 "supplied-session-resource-sha256",
2310 ),
2311 purpose,
2312 credential_mechanism: policy.credential_mechanism,
2313 policy_reference: policy.policy_reference.clone(),
2314 purpose_binding: request_descriptor_reference(
2315 &policy.application_url,
2316 &policy.health_url,
2317 purpose,
2318 policy.credential_mechanism,
2319 &policy.policy_reference,
2320 ),
2321 };
2322 debug_assert!(descriptor.validate_against(policy));
2323 descriptor
2324 }
2325
2326 pub(crate) fn resource(
2327 policy: &SuppliedSessionPolicy,
2328 target: &Url,
2329 target_reference: &str,
2330 ) -> Option<Self> {
2331 if !policy
2332 .resources
2333 .iter()
2334 .any(|resource| resource.url == *target && resource.reference == target_reference)
2335 {
2336 return None;
2337 }
2338 let purpose = SuppliedSessionRequestPurpose::Resource;
2339 let descriptor = Self {
2340 application_url: policy.application_url.clone(),
2341 target: target.clone(),
2342 target_reference: target_reference.to_owned(),
2343 purpose,
2344 credential_mechanism: policy.credential_mechanism,
2345 policy_reference: policy.policy_reference.clone(),
2346 purpose_binding: request_descriptor_reference(
2347 &policy.application_url,
2348 target,
2349 purpose,
2350 policy.credential_mechanism,
2351 &policy.policy_reference,
2352 ),
2353 };
2354 descriptor.validate_against(policy).then_some(descriptor)
2355 }
2356
2357 pub(crate) fn validate(&self) -> bool {
2358 validate_application_url(&self.application_url).is_ok()
2359 && self.target.origin() == self.application_url.origin()
2360 && self.target.query().is_none()
2361 && self.target.fragment().is_none()
2362 && self.target.username().is_empty()
2363 && self.target.password().is_none()
2364 && path_within_application(&self.application_url, &self.target)
2365 && !self
2366 .target
2367 .path()
2368 .bytes()
2369 .any(|byte| matches!(byte, b'%' | b'\\'))
2370 && (matches!(
2371 self.purpose,
2372 SuppliedSessionRequestPurpose::LoginPage
2373 | SuppliedSessionRequestPurpose::LoginSubmit
2374 ) || !self.target.path().split('/').any(action_or_admin_segment))
2375 && self.target_reference
2376 == if matches!(
2377 self.purpose,
2378 SuppliedSessionRequestPurpose::LoginPage
2379 | SuppliedSessionRequestPurpose::LoginSubmit
2380 ) {
2381 reference(
2382 LOGIN_REFERENCE_DOMAIN,
2383 self.target.as_str(),
2384 "supplied-session-login-sha256",
2385 )
2386 } else {
2387 reference(
2388 RESOURCE_REFERENCE_DOMAIN,
2389 self.target.as_str(),
2390 "supplied-session-resource-sha256",
2391 )
2392 }
2393 && self.purpose_binding
2394 == request_descriptor_reference(
2395 &self.application_url,
2396 &self.target,
2397 self.purpose,
2398 self.credential_mechanism,
2399 &self.policy_reference,
2400 )
2401 }
2402
2403 pub(crate) fn validate_against(&self, policy: &SuppliedSessionPolicy) -> bool {
2404 self.validate()
2405 && self.policy_reference == policy.policy_reference
2406 && self.application_url == policy.application_url
2407 && self.credential_mechanism == policy.credential_mechanism
2408 && match self.purpose {
2409 SuppliedSessionRequestPurpose::Renewal => {
2410 policy.version == SuppliedSessionPolicyVersion::V4
2411 && policy
2412 .renewal
2413 .as_ref()
2414 .is_some_and(|renewal| renewal.url == self.target)
2415 },
2416 SuppliedSessionRequestPurpose::LoginPage
2417 | SuppliedSessionRequestPurpose::LoginSubmit => {
2418 policy.version == SuppliedSessionPolicyVersion::V3
2419 && policy.login_url() == Some(&self.target)
2420 },
2421 SuppliedSessionRequestPurpose::Health => {
2422 self.target == policy.health_url
2423 && self.target_reference
2424 == reference(
2425 RESOURCE_REFERENCE_DOMAIN,
2426 policy.health_url.as_str(),
2427 "supplied-session-resource-sha256",
2428 )
2429 },
2430 SuppliedSessionRequestPurpose::Resource => {
2431 policy.resources.iter().any(|resource| {
2432 resource.url == self.target && resource.reference == self.target_reference
2433 })
2434 },
2435 }
2436 }
2437
2438 pub(crate) const fn target(&self) -> &Url {
2439 &self.target
2440 }
2441
2442 pub(crate) fn target_reference(&self) -> &str {
2443 &self.target_reference
2444 }
2445
2446 pub(crate) const fn purpose(&self) -> SuppliedSessionRequestPurpose {
2447 self.purpose
2448 }
2449
2450 pub(crate) const fn credential_mechanism(&self) -> SuppliedSessionCredentialMechanism {
2451 self.credential_mechanism
2452 }
2453}
2454
2455impl fmt::Debug for SuppliedSessionRequestDescriptor {
2456 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2457 formatter
2458 .debug_struct("SuppliedSessionRequestDescriptor")
2459 .field("application", &"<redacted>")
2460 .field("target", &"<redacted>")
2461 .field("purpose", &self.purpose)
2462 .field("credential_mechanism", &self.credential_mechanism)
2463 .finish()
2464 }
2465}
2466
2467#[derive(Deserialize)]
2468#[serde(deny_unknown_fields)]
2469struct WirePolicy {
2470 schema: String,
2471 principal_alias: String,
2472 credential_mechanism: String,
2473 health_path: String,
2474 health_json_field: String,
2475 resources: Vec<String>,
2476 max_session_requests: u8,
2477 max_total_response_bytes: u64,
2478 max_response_body_bytes: usize,
2479 max_wall_time_ms: u64,
2480}
2481
2482#[derive(Deserialize)]
2483#[serde(deny_unknown_fields)]
2484struct WireOAuthCodePolicy {
2485 schema: String,
2486 principal_alias: String,
2487 credential_mechanism: String,
2488 credential_acquisition: String,
2489 health_path: String,
2490 health_json_field: String,
2491 resources: Vec<String>,
2492 max_session_requests: u8,
2493 max_total_response_bytes: u64,
2494 max_response_body_bytes: usize,
2495 max_wall_time_ms: u64,
2496 oauth_code: oauth_code::WireOAuthCodePolicy,
2497}
2498
2499#[derive(Deserialize)]
2500#[serde(deny_unknown_fields)]
2501struct WireOAuthRefreshPolicy {
2502 schema: String,
2503 principal_alias: String,
2504 credential_mechanism: String,
2505 credential_acquisition: String,
2506 health_path: String,
2507 health_json_field: String,
2508 resources: Vec<String>,
2509 max_session_requests: u8,
2510 max_total_response_bytes: u64,
2511 max_response_body_bytes: usize,
2512 max_wall_time_ms: u64,
2513 oauth_code: oauth_code::WireOAuthCodePolicy,
2514 oauth_refresh: oauth_refresh::WireOAuthRefreshPolicy,
2515}
2516
2517#[derive(Deserialize)]
2518#[serde(deny_unknown_fields)]
2519struct WireOAuthAuthorizationPolicy {
2520 schema: String,
2521 principal_alias: String,
2522 credential_mechanism: String,
2523 credential_acquisition: String,
2524 health_path: String,
2525 health_json_field: String,
2526 resources: Vec<String>,
2527 max_session_requests: u8,
2528 max_total_response_bytes: u64,
2529 max_response_body_bytes: usize,
2530 max_wall_time_ms: u64,
2531 oauth_code: oauth_code::WireOAuthCodePolicy,
2532 oauth_authorization: oauth_authorization::WireOAuthAuthorizationPolicy,
2533}
2534
2535#[derive(Deserialize)]
2536#[serde(deny_unknown_fields)]
2537struct WireCookiePolicy {
2538 schema: String,
2539 principal_alias: String,
2540 credential_mechanism: String,
2541 cookie_update_policy: String,
2542 health_path: String,
2543 health_json_field: String,
2544 resources: Vec<String>,
2545 cookies: Vec<WireCookieMetadata>,
2546 max_session_requests: u8,
2547 max_total_response_bytes: u64,
2548 max_response_body_bytes: usize,
2549 max_wall_time_ms: u64,
2550}
2551
2552#[derive(Deserialize)]
2553#[serde(deny_unknown_fields)]
2554struct WireRenewalPolicy {
2555 schema: String,
2556 principal_alias: String,
2557 credential_mechanism: String,
2558 cookie_update_policy: String,
2559 renewal_path: String,
2560 renewal_method: String,
2561 renewal_after_resource_count: u8,
2562 max_renewal_attempts: u8,
2563 health_path: String,
2564 health_json_field: String,
2565 resources: Vec<String>,
2566 cookies: Vec<WireCookieMetadata>,
2567 max_session_requests: u8,
2568 max_total_response_bytes: u64,
2569 max_response_body_bytes: usize,
2570 max_wall_time_ms: u64,
2571}
2572
2573#[derive(Deserialize)]
2574#[serde(deny_unknown_fields)]
2575struct WireFormLoginPolicy {
2576 schema: String,
2577 principal_alias: String,
2578 credential_mechanism: String,
2579 credential_acquisition: String,
2580 cookie_update_policy: String,
2581 login_path: String,
2582 login_method: String,
2583 login_encoding: String,
2584 username_field: String,
2585 password_field: String,
2586 csrf_field: String,
2587 max_login_attempts: u8,
2588 health_path: String,
2589 health_json_field: String,
2590 resources: Vec<String>,
2591 cookies: Vec<WireCookieMetadata>,
2592 max_session_requests: u8,
2593 max_total_response_bytes: u64,
2594 max_response_body_bytes: usize,
2595 max_wall_time_ms: u64,
2596}
2597
2598#[derive(Deserialize)]
2599#[serde(deny_unknown_fields)]
2600struct WireCookieMetadata {
2601 id: String,
2602 name: String,
2603 domain: String,
2604 host_only: bool,
2605 path: String,
2606 secure: bool,
2607 http_only: bool,
2608 same_site: String,
2609 expires_unix_seconds: Option<i64>,
2610}
2611
2612fn validate_policy_limits(
2613 resource_count: usize,
2614 max_session_requests: u8,
2615 max_total_response_bytes: u64,
2616 max_response_body_bytes: usize,
2617 max_wall_time_ms: u64,
2618) -> Result<(), SuppliedSessionPolicyError> {
2619 let minimum_requests = 1_usize
2620 .checked_add(
2621 resource_count
2622 .checked_mul(2)
2623 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?,
2624 )
2625 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
2626 if max_session_requests == 0
2627 || usize::from(max_session_requests) < minimum_requests
2628 || max_session_requests > MAX_SUPPLIED_SESSION_REQUESTS
2629 || max_total_response_bytes == 0
2630 || max_total_response_bytes > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
2631 || max_response_body_bytes == 0
2632 || max_response_body_bytes > MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES
2633 || u64::try_from(max_response_body_bytes).unwrap_or(u64::MAX) > max_total_response_bytes
2634 || max_wall_time_ms == 0
2635 || max_wall_time_ms > MAX_SUPPLIED_SESSION_WALL_TIME_MS
2636 {
2637 Err(SuppliedSessionPolicyError::InvalidLimits)
2638 } else {
2639 Ok(())
2640 }
2641}
2642
2643fn validate_form_login_policy_limits(
2644 resource_count: usize,
2645 max_session_requests: u8,
2646 max_total_response_bytes: u64,
2647 max_response_body_bytes: usize,
2648 max_wall_time_ms: u64,
2649) -> Result<(), SuppliedSessionPolicyError> {
2650 let minimum_requests = 3_usize
2651 .checked_add(
2652 resource_count
2653 .checked_mul(2)
2654 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?,
2655 )
2656 .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
2657 if usize::from(max_session_requests) != minimum_requests
2658 || max_session_requests > MAX_SUPPLIED_SESSION_REQUESTS
2659 || max_total_response_bytes == 0
2660 || max_total_response_bytes > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
2661 || max_response_body_bytes == 0
2662 || max_response_body_bytes > MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES
2663 || u64::try_from(max_response_body_bytes).unwrap_or(u64::MAX) > max_total_response_bytes
2664 || max_wall_time_ms == 0
2665 || max_wall_time_ms > MAX_SUPPLIED_SESSION_WALL_TIME_MS
2666 {
2667 Err(SuppliedSessionPolicyError::InvalidLimits)
2668 } else {
2669 Ok(())
2670 }
2671}
2672
2673fn validate_html_field_name(value: &str) -> Result<(), SuppliedSessionPolicyError> {
2674 if value.is_empty()
2675 || value.len() > MAX_LOGIN_FIELD_BYTES
2676 || !value.bytes().all(|byte| {
2677 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'[' | b']')
2678 })
2679 {
2680 Err(SuppliedSessionPolicyError::InvalidFormLogin)
2681 } else {
2682 Ok(())
2683 }
2684}
2685
2686fn validate_v3_cookie_metadata(
2687 application_url: &Url,
2688 health_url: &Url,
2689 resources: &[SuppliedSessionResource],
2690 wire: &WireFormLoginPolicy,
2691) -> Result<Vec<SuppliedSessionCookieMetadata>, SuppliedSessionPolicyError> {
2692 if wire.cookies.is_empty() || wire.cookies.len() > MAX_SUPPLIED_SESSION_COOKIES {
2693 return Err(SuppliedSessionPolicyError::InvalidCookieCount);
2694 }
2695 let canonical_host = application_url
2696 .host_str()
2697 .ok_or(SuppliedSessionPolicyError::InvalidApplication)?;
2698 let expected_secure = application_url.scheme() == "https";
2699 let mut seen_ids = std::collections::BTreeSet::new();
2700 let mut seen_tuples = std::collections::BTreeSet::new();
2701 let mut cookies = Vec::with_capacity(wire.cookies.len());
2702 for cookie in &wire.cookies {
2703 validate_token(&cookie.id, MAX_COOKIE_ID_BYTES)
2704 .map_err(|_| SuppliedSessionPolicyError::InvalidCookieMetadata)?;
2705 if cookie.name.is_empty()
2706 || cookie.name.len() > MAX_COOKIE_NAME_BYTES
2707 || !cookie.name.bytes().all(http_token_byte)
2708 || cookie.name.starts_with('$')
2709 || cookie.domain != canonical_host
2710 || !cookie.host_only
2711 || cookie.secure != expected_secure
2712 || !cookie.http_only
2713 || cookie.expires_unix_seconds.is_some()
2714 {
2715 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
2716 }
2717 validate_cookie_path(&cookie.path)?;
2718 let same_site = parse_cookie_same_site(&cookie.same_site)?;
2719 if !matches!(
2720 same_site,
2721 SuppliedSessionCookieSameSite::Strict | SuppliedSessionCookieSameSite::Lax
2722 ) || !cookie_path_matches(&cookie.path, health_url.path())
2723 || !resources
2724 .iter()
2725 .all(|resource| cookie_path_matches(&cookie.path, resource.url.path()))
2726 || !seen_ids.insert(cookie.id.clone())
2727 || !seen_tuples.insert((
2728 cookie.name.clone(),
2729 cookie.domain.clone(),
2730 cookie.path.clone(),
2731 ))
2732 {
2733 return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
2734 }
2735 cookies.push(SuppliedSessionCookieMetadata {
2736 id: cookie.id.clone(),
2737 name: cookie.name.clone(),
2738 domain: cookie.domain.clone(),
2739 host_only: true,
2740 path: cookie.path.clone(),
2741 secure: cookie.secure,
2742 http_only: true,
2743 same_site,
2744 expires_unix_seconds: None,
2745 });
2746 }
2747 Ok(cookies)
2748}
2749
2750fn validate_cookie_path(path: &str) -> Result<(), SuppliedSessionPolicyError> {
2751 if path.is_empty()
2752 || path.len() > MAX_POLICY_PATH_BYTES
2753 || !path.starts_with('/')
2754 || path.contains("//")
2755 || path
2756 .bytes()
2757 .any(|byte| matches!(byte, b'?' | b'#' | b'%' | b'\\'))
2758 || path.bytes().any(|byte| {
2759 !(byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~'))
2760 })
2761 || path
2762 .split('/')
2763 .skip(1)
2764 .filter(|segment| !segment.is_empty())
2765 .any(|segment| matches!(segment, "." | ".."))
2766 {
2767 Err(SuppliedSessionPolicyError::InvalidCookieMetadata)
2768 } else {
2769 Ok(())
2770 }
2771}
2772
2773fn parse_cookie_same_site(
2774 value: &str,
2775) -> Result<SuppliedSessionCookieSameSite, SuppliedSessionPolicyError> {
2776 match value {
2777 "missing" => Ok(SuppliedSessionCookieSameSite::Missing),
2778 "strict" => Ok(SuppliedSessionCookieSameSite::Strict),
2779 "lax" => Ok(SuppliedSessionCookieSameSite::Lax),
2780 "none" => Ok(SuppliedSessionCookieSameSite::None),
2781 _ => Err(SuppliedSessionPolicyError::InvalidCookieMetadata),
2782 }
2783}
2784
2785fn cookie_path_matches(cookie_path: &str, request_path: &str) -> bool {
2786 request_path == cookie_path
2787 || request_path
2788 .strip_prefix(cookie_path)
2789 .is_some_and(|suffix| {
2790 cookie_path.ends_with('/') || suffix.as_bytes().first() == Some(&b'/')
2791 })
2792}
2793
2794fn cookie_applies(
2795 cookie: &SuppliedSessionCookieMetadata,
2796 target: &Url,
2797 now_unix_seconds: i64,
2798) -> bool {
2799 let Some(target_host) = target.host_str() else {
2800 return false;
2801 };
2802 let host_matches = if cookie.host_only {
2803 target_host == cookie.domain
2804 } else {
2805 target_host == cookie.domain
2806 || (target_host.ends_with(&cookie.domain)
2807 && target_host
2808 .as_bytes()
2809 .get(target_host.len().saturating_sub(cookie.domain.len() + 1))
2810 == Some(&b'.'))
2811 };
2812 host_matches
2813 && (!cookie.secure || target.scheme() == "https")
2814 && cookie_path_matches(&cookie.path, target.path())
2815 && cookie
2816 .expires_unix_seconds
2817 .is_none_or(|expires| expires > now_unix_seconds)
2818}
2819
2820fn cookie_octet(byte: u8) -> bool {
2821 matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
2822}
2823
2824fn summarize_cookies(cookies: &[SuppliedSessionCookieMetadata]) -> SuppliedSessionCookieSummary {
2825 let mut summary = SuppliedSessionCookieSummary {
2826 cookie_count: cookies.len(),
2827 host_only_count: 0,
2828 domain_count: 0,
2829 secure_count: 0,
2830 http_only_count: 0,
2831 session_count: 0,
2832 persistent_count: 0,
2833 same_site_missing_count: 0,
2834 same_site_strict_count: 0,
2835 same_site_lax_count: 0,
2836 same_site_none_count: 0,
2837 };
2838 for cookie in cookies {
2839 if cookie.host_only {
2840 summary.host_only_count += 1;
2841 } else {
2842 summary.domain_count += 1;
2843 }
2844 summary.secure_count += usize::from(cookie.secure);
2845 summary.http_only_count += usize::from(cookie.http_only);
2846 if cookie.expires_unix_seconds.is_some() {
2847 summary.persistent_count += 1;
2848 } else {
2849 summary.session_count += 1;
2850 }
2851 match cookie.same_site {
2852 SuppliedSessionCookieSameSite::Missing => summary.same_site_missing_count += 1,
2853 SuppliedSessionCookieSameSite::Strict => summary.same_site_strict_count += 1,
2854 SuppliedSessionCookieSameSite::Lax => summary.same_site_lax_count += 1,
2855 SuppliedSessionCookieSameSite::None => summary.same_site_none_count += 1,
2856 }
2857 }
2858 summary
2859}
2860
2861fn validate_application_url(application: &Url) -> Result<(), SuppliedSessionPolicyError> {
2862 if !matches!(application.scheme(), "http" | "https")
2863 || application.host().is_none()
2864 || !application.username().is_empty()
2865 || application.password().is_some()
2866 || application.query().is_some()
2867 || application.fragment().is_some()
2868 || !application.path().starts_with('/')
2869 || !application.path().ends_with('/')
2870 || application.path().contains('%')
2871 || application.path().contains('\\')
2872 {
2873 return Err(SuppliedSessionPolicyError::InvalidApplication);
2874 }
2875 Ok(())
2876}
2877
2878fn resolve_safe_path(application: &Url, raw: &str) -> Result<Url, SuppliedSessionPolicyError> {
2879 resolve_bounded_path(application, raw, false)
2880}
2881
2882fn resolve_login_path(application: &Url, raw: &str) -> Result<Url, SuppliedSessionPolicyError> {
2883 let target = resolve_bounded_path(application, raw, true)?;
2884 if raw
2885 .split('/')
2886 .skip(1)
2887 .filter(|segment| !segment.is_empty())
2888 .any(login_disallowed_action_segment)
2889 {
2890 return Err(SuppliedSessionPolicyError::InvalidPath);
2891 }
2892 Ok(target)
2893}
2894
2895fn resolve_bounded_path(
2896 application: &Url,
2897 raw: &str,
2898 allow_action_segment: bool,
2899) -> Result<Url, SuppliedSessionPolicyError> {
2900 if raw.is_empty()
2901 || raw.len() > MAX_POLICY_PATH_BYTES
2902 || !raw.starts_with('/')
2903 || raw.starts_with("//")
2904 || raw == "/"
2905 || raw.contains("//")
2906 || raw
2907 .bytes()
2908 .any(|byte| matches!(byte, b'?' | b'#' | b'%' | b'\\'))
2909 || raw.bytes().any(|byte| {
2910 !(byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~'))
2911 })
2912 || raw
2913 .split('/')
2914 .skip(1)
2915 .filter(|segment| !segment.is_empty())
2916 .any(|segment| matches!(segment, "." | ".."))
2917 {
2918 return Err(SuppliedSessionPolicyError::InvalidPath);
2919 }
2920 if !allow_action_segment
2921 && raw
2922 .split('/')
2923 .skip(1)
2924 .filter(|segment| !segment.is_empty())
2925 .any(action_or_admin_segment)
2926 {
2927 return Err(SuppliedSessionPolicyError::InvalidPath);
2928 }
2929 let mut target = application.clone();
2930 target.set_path(raw);
2931 target.set_query(None);
2932 target.set_fragment(None);
2933 if target.origin() != application.origin() || !path_within_application(application, &target) {
2934 return Err(SuppliedSessionPolicyError::InvalidPath);
2935 }
2936 Ok(target)
2937}
2938
2939fn path_within_application(application: &Url, target: &Url) -> bool {
2940 target.path().starts_with(application.path())
2941}
2942
2943fn action_or_admin_segment(segment: &str) -> bool {
2944 segment
2945 .split(['-', '_', '.'])
2946 .filter(|token| !token.is_empty())
2947 .any(|token| {
2948 matches!(
2949 token.to_ascii_lowercase().as_str(),
2950 "admin"
2951 | "login"
2952 | "logout"
2953 | "action"
2954 | "actions"
2955 | "delete"
2956 | "remove"
2957 | "update"
2958 | "create"
2959 | "edit"
2960 | "upload"
2961 | "download"
2962 )
2963 })
2964}
2965
2966fn login_disallowed_action_segment(segment: &str) -> bool {
2967 segment
2968 .split(['-', '_', '.'])
2969 .filter(|token| !token.is_empty())
2970 .any(|token| {
2971 matches!(
2972 token.to_ascii_lowercase().as_str(),
2973 "logout"
2974 | "action"
2975 | "actions"
2976 | "delete"
2977 | "remove"
2978 | "update"
2979 | "create"
2980 | "edit"
2981 | "upload"
2982 | "download"
2983 )
2984 })
2985}
2986
2987fn request_descriptor_reference(
2988 application: &Url,
2989 target: &Url,
2990 purpose: SuppliedSessionRequestPurpose,
2991 credential_mechanism: SuppliedSessionCredentialMechanism,
2992 policy_reference: &str,
2993) -> String {
2994 let mut hasher = Sha256::new();
2995 hasher.update(REQUEST_DESCRIPTOR_REFERENCE_DOMAIN);
2996 hash_field(&mut hasher, application.as_str().as_bytes());
2997 hash_field(&mut hasher, target.as_str().as_bytes());
2998 hash_field(
2999 &mut hasher,
3000 match purpose {
3001 SuppliedSessionRequestPurpose::LoginPage => b"login_page",
3002 SuppliedSessionRequestPurpose::LoginSubmit => b"login_submit",
3003 SuppliedSessionRequestPurpose::Renewal => b"renewal",
3004 SuppliedSessionRequestPurpose::Health => b"health",
3005 SuppliedSessionRequestPurpose::Resource => b"resource",
3006 },
3007 );
3008 hash_field(&mut hasher, credential_mechanism.as_str().as_bytes());
3009 hash_field(&mut hasher, policy_reference.as_bytes());
3010 hex(hasher.finalize().as_slice())
3011}
3012
3013fn validate_token(value: &str, maximum: usize) -> Result<(), ()> {
3014 if value.is_empty()
3015 || value.len() > maximum
3016 || !value.bytes().all(|byte| {
3017 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
3018 })
3019 {
3020 Err(())
3021 } else {
3022 Ok(())
3023 }
3024}
3025
3026fn policy_reference(
3027 application: &Url,
3028 wire: &WirePolicy,
3029 mechanism: SuppliedSessionCredentialMechanism,
3030) -> String {
3031 let mut hasher = Sha256::new();
3032 hasher.update(POLICY_REFERENCE_DOMAIN);
3033 hash_field(&mut hasher, SUPPLIED_SESSION_POLICY_SCHEMA.as_bytes());
3034 hash_field(&mut hasher, application.as_str().as_bytes());
3035 hash_field(
3039 &mut hasher,
3040 match mechanism {
3041 SuppliedSessionCredentialMechanism::AuthorizationHeader => b"authorization_header",
3042 SuppliedSessionCredentialMechanism::CookieJar => b"cookie_jar",
3043 },
3044 );
3045 hash_field(&mut hasher, wire.health_path.as_bytes());
3046 hash_field(&mut hasher, wire.health_json_field.as_bytes());
3047 for resource in &wire.resources {
3048 hash_field(&mut hasher, resource.as_bytes());
3049 }
3050 hash_field(&mut hasher, &[wire.max_session_requests]);
3051 hash_field(&mut hasher, &wire.max_total_response_bytes.to_be_bytes());
3052 hash_field(
3053 &mut hasher,
3054 &u64::try_from(wire.max_response_body_bytes)
3055 .unwrap_or(u64::MAX)
3056 .to_be_bytes(),
3057 );
3058 hash_field(&mut hasher, &wire.max_wall_time_ms.to_be_bytes());
3059 format!("supplied-session-policy-sha256:{}", hex(hasher.finalize()))
3060}
3061
3062fn cookie_policy_reference(
3063 application: &Url,
3064 wire: &WireCookiePolicy,
3065 mechanism: SuppliedSessionCredentialMechanism,
3066 update_policy: SuppliedSessionCookieUpdatePolicy,
3067) -> String {
3068 let mut hasher = Sha256::new();
3069 hasher.update(COOKIE_POLICY_REFERENCE_DOMAIN);
3070 hash_field(
3071 &mut hasher,
3072 SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA.as_bytes(),
3073 );
3074 hash_field(&mut hasher, application.as_str().as_bytes());
3075 hash_field(&mut hasher, mechanism.as_str().as_bytes());
3076 hash_field(&mut hasher, update_policy.as_str().as_bytes());
3077 hash_field(&mut hasher, wire.health_path.as_bytes());
3078 hash_field(&mut hasher, wire.health_json_field.as_bytes());
3079 for resource in &wire.resources {
3080 hash_field(&mut hasher, resource.as_bytes());
3081 }
3082 for cookie in &wire.cookies {
3083 hash_field(&mut hasher, cookie.id.as_bytes());
3084 hash_field(&mut hasher, cookie.name.as_bytes());
3085 hash_field(&mut hasher, cookie.domain.as_bytes());
3086 hash_field(&mut hasher, &[u8::from(cookie.host_only)]);
3087 hash_field(&mut hasher, cookie.path.as_bytes());
3088 hash_field(&mut hasher, &[u8::from(cookie.secure)]);
3089 hash_field(&mut hasher, &[u8::from(cookie.http_only)]);
3090 hash_field(&mut hasher, cookie.same_site.as_bytes());
3091 match cookie.expires_unix_seconds {
3092 Some(expires) => {
3093 hash_field(&mut hasher, b"persistent");
3094 hash_field(&mut hasher, &expires.to_be_bytes());
3095 },
3096 None => hash_field(&mut hasher, b"session"),
3097 }
3098 }
3099 hash_field(&mut hasher, &[wire.max_session_requests]);
3100 hash_field(&mut hasher, &wire.max_total_response_bytes.to_be_bytes());
3101 hash_field(
3102 &mut hasher,
3103 &u64::try_from(wire.max_response_body_bytes)
3104 .unwrap_or(u64::MAX)
3105 .to_be_bytes(),
3106 );
3107 hash_field(&mut hasher, &wire.max_wall_time_ms.to_be_bytes());
3108 format!("supplied-session-policy-sha256:{}", hex(hasher.finalize()))
3109}
3110
3111fn form_login_policy_reference(
3112 application: &Url,
3113 wire: &WireFormLoginPolicy,
3114 update_policy: SuppliedSessionCookieUpdatePolicy,
3115) -> String {
3116 let mut hasher = Sha256::new();
3117 hasher.update(FORM_LOGIN_POLICY_REFERENCE_DOMAIN);
3118 for value in [
3119 SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA,
3120 application.as_str(),
3121 "cookie_jar",
3122 "bounded_form_login",
3123 update_policy.as_str(),
3124 wire.login_path.as_str(),
3125 wire.login_method.as_str(),
3126 wire.login_encoding.as_str(),
3127 wire.username_field.as_str(),
3128 wire.password_field.as_str(),
3129 wire.csrf_field.as_str(),
3130 wire.health_path.as_str(),
3131 wire.health_json_field.as_str(),
3132 ] {
3133 hash_field(&mut hasher, value.as_bytes());
3134 }
3135 hash_field(&mut hasher, &[wire.max_login_attempts]);
3136 for resource in &wire.resources {
3137 hash_field(&mut hasher, resource.as_bytes());
3138 }
3139 for cookie in &wire.cookies {
3140 hash_field(&mut hasher, cookie.id.as_bytes());
3141 hash_field(&mut hasher, cookie.name.as_bytes());
3142 hash_field(&mut hasher, cookie.domain.as_bytes());
3143 hash_field(&mut hasher, &[u8::from(cookie.host_only)]);
3144 hash_field(&mut hasher, cookie.path.as_bytes());
3145 hash_field(&mut hasher, &[u8::from(cookie.secure)]);
3146 hash_field(&mut hasher, &[u8::from(cookie.http_only)]);
3147 hash_field(&mut hasher, cookie.same_site.as_bytes());
3148 match cookie.expires_unix_seconds {
3149 Some(expires) => hash_field(&mut hasher, &expires.to_be_bytes()),
3150 None => hash_field(&mut hasher, b"session"),
3151 }
3152 }
3153 hash_field(&mut hasher, &[wire.max_session_requests]);
3154 hash_field(&mut hasher, &wire.max_total_response_bytes.to_be_bytes());
3155 hash_field(
3156 &mut hasher,
3157 &u64::try_from(wire.max_response_body_bytes)
3158 .unwrap_or(u64::MAX)
3159 .to_be_bytes(),
3160 );
3161 hash_field(&mut hasher, &wire.max_wall_time_ms.to_be_bytes());
3162 format!("supplied-session-policy-sha256:{}", hex(hasher.finalize()))
3163}
3164
3165fn reference(domain: &[u8], value: &str, prefix: &str) -> String {
3166 let mut hasher = Sha256::new();
3167 hasher.update(domain);
3168 hash_field(&mut hasher, value.as_bytes());
3169 format!("{prefix}:{}", hex(hasher.finalize()))
3170}
3171
3172fn hash_field(hasher: &mut Sha256, value: &[u8]) {
3173 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
3174 hasher.update(value);
3175}
3176
3177fn hex(bytes: impl AsRef<[u8]>) -> String {
3178 use std::fmt::Write as _;
3179 let bytes = bytes.as_ref();
3180 let mut output = String::with_capacity(bytes.len() * 2);
3181 for byte in bytes {
3182 write!(output, "{byte:02x}").expect("writing into String cannot fail");
3183 }
3184 output
3185}
3186
3187fn http_token_byte(byte: u8) -> bool {
3188 byte.is_ascii_alphanumeric()
3189 || matches!(
3190 byte,
3191 b'!' | b'#'
3192 | b'$'
3193 | b'%'
3194 | b'&'
3195 | b'\''
3196 | b'*'
3197 | b'+'
3198 | b'-'
3199 | b'.'
3200 | b'^'
3201 | b'_'
3202 | b'`'
3203 | b'|'
3204 | b'~'
3205 )
3206}
3207
3208#[cfg(test)]
3209mod tests {
3210 use super::*;
3211
3212 fn authorization_source() -> String {
3215 r#"schema = "security.supplied-session-policy/v7"
3216principal_alias = "authorization-canary-a"
3217credential_mechanism = "authorization_header"
3218credential_acquisition = "client_initiated_oauth_pkce"
3219health_path = "/app/health"
3220health_json_field = "authenticated"
3221resources = ["/app/private/one", "/app/private/two"]
3222max_session_requests = 6
3223max_total_response_bytes = 65536
3224max_response_body_bytes = 8192
3225max_wall_time_ms = 10000
3226[oauth_code]
3227profile = "client-initiated-public-code-s256-bearer/v1"
3228transport_profile = "verified-https/v1"
3229issuer = "https://issuer.test/realm"
3230token_endpoint = "https://issuer.test/realm/token"
3231client_id = "termivar-owned-public"
3232redirect_uri = "http://127.0.0.1:18443/termivar/oauth/callback"
3233scopes = ["canary-read"]
3234max_token_response_bytes = 8192
3235[oauth_authorization]
3236profile = "loopback-form-post-state/v1"
3237authorization_endpoint = "https://issuer.test/realm/authorize"
3238max_wait_ms = 20000
3239"#
3240 .to_owned()
3241 }
3242
3243 fn authorization_policy() -> SuppliedSessionPolicy {
3244 SuppliedSessionPolicy::parse_toml(
3245 &Url::parse("https://application.test/app/").unwrap(),
3246 authorization_source().as_bytes(),
3247 )
3248 .unwrap()
3249 }
3250
3251 #[test]
3252 fn oauth_authorization_v7_has_separate_explicit_acquisition_and_finite_phases() {
3253 let policy = authorization_policy();
3254 assert_eq!(policy.version(), SuppliedSessionPolicyVersion::V7);
3255 assert_eq!(policy.schema(), "security.supplied-session-policy/v7");
3256 assert_eq!(
3257 policy.credential_acquisition(),
3258 SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
3259 );
3260 assert_eq!(
3261 policy.credential_acquisition().as_str(),
3262 "client_initiated_oauth_pkce"
3263 );
3264 assert_eq!(policy.resource_count(), 2);
3265 assert_eq!(policy.max_session_requests(), 6);
3266 assert_eq!(policy.max_wall_time_ms(), 10_000);
3267 assert_eq!(
3268 policy.oauth_authorization_policy().unwrap().max_wait_ms(),
3269 20_000
3270 );
3271 assert!(policy.oauth_refresh_policy().is_none());
3272 assert!(policy.oauth_code_policy().unwrap().is_client_initiated());
3273 assert!(SuppliedSessionOAuthGrant::parse_tsv(&policy, Vec::new(), 0).is_err());
3274 let ready = SuppliedSessionRuntimeInput::from(
3275 SuppliedSessionAuthorization::new(b"Bearer SYNTHETIC".to_vec()).unwrap(),
3276 );
3277 assert!(!ready.matches_policy(&policy));
3278 for (resource_list, outgoing) in [
3279 (r#"["/app/private/one"]"#, 4),
3280 (r#"["/app/private/one", "/app/private/two"]"#, 6),
3281 (
3282 r#"["/app/private/one", "/app/private/two", "/app/private/three"]"#,
3283 8,
3284 ),
3285 ] {
3286 let source = authorization_source()
3287 .replace(r#"["/app/private/one", "/app/private/two"]"#, resource_list)
3288 .replace(
3289 "max_session_requests = 6",
3290 &format!("max_session_requests = {outgoing}"),
3291 );
3292 let selected = SuppliedSessionPolicy::parse_toml(
3293 &Url::parse("https://application.test/app/").unwrap(),
3294 source.as_bytes(),
3295 )
3296 .unwrap();
3297 assert_eq!(selected.max_session_requests(), outgoing);
3298 assert!(selected.max_session_requests() < 9);
3299 }
3300 }
3301
3302 #[test]
3303 fn oauth_authorization_v7_rejects_cross_profile_scope_and_limit_mutations() {
3304 let source = authorization_source();
3305 for (from, to) in [
3306 ("client_initiated_oauth_pkce", "imported_oauth_code_pkce"),
3307 ("authorization_header", "cookie_jar"),
3308 (
3309 "client-initiated-public-code-s256-bearer/v1",
3310 "external-public-code-s256-bearer/v1",
3311 ),
3312 ("loopback-form-post-state/v1", "automatic-browser-login/v1"),
3313 ("max_wait_ms = 20000", "max_wait_ms = 0"),
3314 ("max_wait_ms = 20000", "max_wait_ms = 30001"),
3315 ("max_wait_ms = 20000", "max_wait_ms = true"),
3316 ("max_session_requests = 6", "max_session_requests = 5"),
3317 ("max_session_requests = 6", "max_session_requests = 7"),
3318 ("max_wall_time_ms = 10000", "max_wall_time_ms = 10001"),
3319 (
3320 "max_response_body_bytes = 8192",
3321 "max_response_body_bytes = 8191",
3322 ),
3323 (
3324 "https://issuer.test/realm/authorize",
3325 "https://foreign.test/realm/authorize",
3326 ),
3327 (
3328 "http://127.0.0.1:18443/termivar/oauth/callback",
3329 "http://localhost:18443/termivar/oauth/callback",
3330 ),
3331 (
3332 "http://127.0.0.1:18443/termivar/oauth/callback",
3333 "http://127.0.0.2:18443/termivar/oauth/callback",
3334 ),
3335 (
3336 "http://127.0.0.1:18443/termivar/oauth/callback",
3337 "http://127.0.0.1:18443/other",
3338 ),
3339 ("scopes = [\"canary-read\"]", "scopes = [\"openid\"]"),
3340 ] {
3341 let changed = source.replace(from, to);
3342 assert_ne!(changed, source, "mutation must change its literal input");
3343 assert!(
3344 SuppliedSessionPolicy::parse_toml(
3345 &Url::parse("https://application.test/app/").unwrap(),
3346 changed.as_bytes(),
3347 )
3348 .is_err(),
3349 "invalid V7 policy admitted"
3350 );
3351 }
3352 for extra in ["unknown = true", "max_wait_ms = 1"] {
3353 assert!(SuppliedSessionPolicy::parse_toml(
3354 &Url::parse("https://application.test/app/").unwrap(),
3355 format!("{source}{extra}\n").as_bytes(),
3356 )
3357 .is_err());
3358 }
3359 let missing = source.split("[oauth_authorization]").next().unwrap();
3360 assert!(SuppliedSessionPolicy::parse_toml(
3361 &Url::parse("https://application.test/app/").unwrap(),
3362 missing.as_bytes(),
3363 )
3364 .is_err());
3365 assert!(
3366 SuppliedSessionPolicy::parse_toml(
3367 &Url::parse("http://127.0.0.1:8080/app/").unwrap(),
3368 source.as_bytes(),
3369 )
3370 .is_err(),
3371 "callback HTTP exception must not authorize an HTTP application"
3372 );
3373 }
3374
3375 #[test]
3376 fn oauth_authorization_binding_preserves_application_identity_and_changes_with_policy() {
3377 let first = authorization_policy();
3378 let second = SuppliedSessionPolicy::parse_toml(
3379 &Url::parse("https://application.test/app/").unwrap(),
3380 authorization_source()
3381 .replace("max_wait_ms = 20000", "max_wait_ms = 19999")
3382 .as_bytes(),
3383 )
3384 .unwrap();
3385 assert_ne!(first.policy_reference(), second.policy_reference());
3386 assert_eq!(
3387 first.application_reference(),
3388 second.application_reference()
3389 );
3390 assert_ne!(
3391 first
3392 .oauth_authorization_policy()
3393 .unwrap()
3394 .binding_reference(),
3395 second
3396 .oauth_authorization_policy()
3397 .unwrap()
3398 .binding_reference(),
3399 );
3400 let legacy = renewal_policy();
3401 assert!(legacy.oauth_authorization_policy().is_none());
3402 assert!(SuppliedSessionOAuthAuthorization::new(&legacy, std::io::sink()).is_err());
3403 }
3404
3405 #[derive(Clone)]
3406 struct AuthorizationTestWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
3407
3408 impl std::io::Write for AuthorizationTestWriter {
3409 fn write(&mut self, source: &[u8]) -> std::io::Result<usize> {
3410 self.0.lock().unwrap().extend_from_slice(source);
3411 Ok(source.len())
3412 }
3413
3414 fn flush(&mut self) -> std::io::Result<()> {
3415 Ok(())
3416 }
3417 }
3418
3419 #[test]
3420 fn oauth_authorization_writer_is_policy_bound_move_only_and_one_attempt() {
3421 let selected = authorization_policy();
3422 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3423 let mut sink = SuppliedSessionOAuthAuthorization::new(
3424 &selected,
3425 AuthorizationTestWriter(captured.clone()),
3426 )
3427 .unwrap();
3428 assert!(sink.matches_policy(&selected));
3429 let changed = SuppliedSessionPolicy::parse_toml(
3430 &Url::parse("https://application.test/app/").unwrap(),
3431 authorization_source()
3432 .replace("max_wait_ms = 20000", "max_wait_ms = 19999")
3433 .as_bytes(),
3434 )
3435 .unwrap();
3436 assert!(!sink.matches_policy(&changed));
3437 let now = std::time::Instant::now();
3438 let transaction = oauth_authorization::PendingOAuthAuthorization::generate(
3439 selected.oauth_code_policy().unwrap(),
3440 selected.oauth_authorization_policy().unwrap(),
3441 selected.policy_reference(),
3442 selected.principal_alias(),
3443 now + std::time::Duration::from_secs(1),
3444 )
3445 .unwrap();
3446 let url = transaction.authorization_url().unwrap();
3447 assert_eq!(
3448 format!("{sink:?}"),
3449 "SuppliedSessionOAuthAuthorization(<redacted>)"
3450 );
3451 sink.write_authorization_url(&url).unwrap();
3452 assert!(!sink.matches_policy(&selected));
3453 assert_eq!(
3454 sink.write_authorization_url(&url).unwrap_err(),
3455 OAuthAuthorizationInputError::AlreadyAttempted
3456 );
3457 let bytes = captured.lock().unwrap();
3458 assert_eq!(&bytes[..bytes.len() - 1], url.as_bytes());
3459 assert_eq!(bytes.last(), Some(&b'\n'));
3460 assert!(bytes.len() <= 4_097);
3461 let input = SuppliedSessionRuntimeInput::from(
3462 SuppliedSessionOAuthAuthorization::new(&selected, std::io::sink()).unwrap(),
3463 );
3464 assert!(input.matches_policy(&selected));
3465 assert!(!input.matches_policy(&renewal_policy()));
3466 assert_eq!(
3467 format!("{input:?}"),
3468 "SuppliedSessionRuntimeInput::OAuthAuthorization(<redacted>)"
3469 );
3470 }
3471
3472 #[test]
3473 fn oauth_authorization_writer_burns_invalid_or_failed_attempts_without_error_text() {
3474 struct FailedWriter;
3475 impl std::io::Write for FailedWriter {
3476 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
3477 Err(std::io::Error::other("PRIVATE_WRITER_ERROR_SENTINEL"))
3478 }
3479 fn flush(&mut self) -> std::io::Result<()> {
3480 Ok(())
3481 }
3482 }
3483 let selected = authorization_policy();
3484 let url = "https://issuer.test/realm/authorize?state=SYNTHETIC-HANDOFF";
3485 for invalid in [
3486 "https://foreign.test/realm/authorize?state=SYNTHETIC",
3487 "https://issuer.test/realm/authorize?",
3488 "https://issuer.test/realm/authorize?state=SYNTHETIC\nINJECTION",
3489 ] {
3490 let mut sink =
3491 SuppliedSessionOAuthAuthorization::new(&selected, std::io::sink()).unwrap();
3492 assert_eq!(
3493 sink.write_authorization_url(invalid).unwrap_err(),
3494 OAuthAuthorizationInputError::InvalidUrl
3495 );
3496 assert_eq!(
3497 sink.write_authorization_url(url).unwrap_err(),
3498 OAuthAuthorizationInputError::AlreadyAttempted
3499 );
3500 }
3501 let mut sink = SuppliedSessionOAuthAuthorization::new(&selected, FailedWriter).unwrap();
3502 let error = sink.write_authorization_url(url).unwrap_err();
3503 assert_eq!(error, OAuthAuthorizationInputError::WriteFailed);
3504 assert!(!format!("{error:?}: {error}").contains("PRIVATE_WRITER_ERROR_SENTINEL"));
3505 assert_eq!(
3506 sink.write_authorization_url(url).unwrap_err(),
3507 OAuthAuthorizationInputError::AlreadyAttempted
3508 );
3509 }
3510
3511 #[test]
3512 fn oauth_optional_policy_storage_is_indirect_for_legacy_contexts() {
3513 fn optional_payload(
3514 policy: &SuppliedSessionPolicy,
3515 ) -> &Option<Box<oauth_code::OAuthCodePolicy>> {
3516 &policy.oauth_code
3517 }
3518 let legacy = renewal_policy();
3519 assert!(optional_payload(&legacy).is_none());
3520 assert!(legacy.oauth_code_policy().is_none());
3521 assert_eq!(
3522 std::mem::size_of_val(optional_payload(&legacy)),
3523 std::mem::size_of::<usize>()
3524 );
3525 }
3526
3527 #[test]
3530 fn imported_oauth_code_policy_is_a_distinct_bounded_acquisition() {
3531 let source = br#"
3532schema = "security.supplied-session-policy/v5"
3533principal_alias = "canary-a"
3534credential_mechanism = "authorization_header"
3535credential_acquisition = "imported_oauth_code_pkce"
3536health_path = "/app/health"
3537health_json_field = "authenticated"
3538resources = ["/app/private/one", "/app/private/two"]
3539max_session_requests = 6
3540max_total_response_bytes = 65536
3541max_response_body_bytes = 8192
3542max_wall_time_ms = 10000
3543
3544[oauth_code]
3545profile = "external-public-code-s256-bearer/v1"
3546transport_profile = "verified-https/v1"
3547issuer = "https://issuer.test/realm"
3548token_endpoint = "https://issuer.test/realm/token"
3549client_id = "termivar-owned-public"
3550redirect_uri = "https://client.test/callback"
3551scopes = ["canary-read"]
3552max_token_response_bytes = 8192
3553"#;
3554 let application = Url::parse("https://application.test/app/").unwrap();
3555 let parsed = SuppliedSessionPolicy::parse_toml(&application, source)
3556 .expect("the imported-code slice must have its own strict policy");
3557 assert_eq!(
3558 parsed.version().schema(),
3559 "security.supplied-session-policy/v5"
3560 );
3561 assert_eq!(parsed.resource_count(), 2);
3562 assert_eq!(parsed.max_session_requests(), 6);
3563 }
3564
3565 pub(crate) fn renewal_policy() -> SuppliedSessionPolicy {
3566 SuppliedSessionPolicy::parse_toml(
3567 &Url::parse("https://example.test/app/").unwrap(),
3568 renewal_source().as_bytes(),
3569 )
3570 .unwrap()
3571 }
3572
3573 fn renewal_source() -> String {
3574 r#"schema = "security.supplied-session-policy/v4"
3575principal_alias = "renewal-reader"
3576credential_mechanism = "cookie_jar"
3577cookie_update_policy = "stop_on_selected_cookie"
3578renewal_path = "/app/renew"
3579renewal_method = "post"
3580renewal_after_resource_count = 1
3581max_renewal_attempts = 1
3582health_path = "/app/health"
3583health_json_field = "authenticated"
3584resources = ["/app/before", "/app/after"]
3585max_session_requests = 7
3586max_total_response_bytes = 131072
3587max_response_body_bytes = 32768
3588max_wall_time_ms = 5000
3589[[cookies]]
3590id = "session"
3591name = "session"
3592domain = "example.test"
3593host_only = true
3594path = "/app/"
3595secure = true
3596http_only = true
3597same_site = "lax"
3598"#
3599 .to_owned()
3600 }
3601
3602 #[test]
3603 fn renewal_policy_is_closed_bounded_and_retains_private_cookie_scope() {
3604 let policy = renewal_policy();
3605 assert_eq!(policy.version(), SuppliedSessionPolicyVersion::V4);
3606 assert_eq!(policy.max_session_requests(), 7);
3607 let descriptor = SuppliedSessionRequestDescriptor::renewal(&policy).unwrap();
3608 assert!(descriptor.validate_against(&policy));
3609 assert_eq!(
3610 descriptor.target().as_str(),
3611 "https://example.test/app/renew"
3612 );
3613 let initial =
3614 SuppliedSessionCookies::parse_tsv(&policy, b"session\tinitial-canary".to_vec(), 0)
3615 .unwrap();
3616 assert!(initial
3617 .sensitive_header_for_target(&policy, descriptor.target(), 0)
3618 .is_ok());
3619 let replacement = SuppliedSessionCookies::from_form_login_set_cookie_fields(
3620 &policy,
3621 [
3622 b"session=replacement-canary; Path=/app/; Secure; HttpOnly; SameSite=Lax"
3623 .as_slice(),
3624 ],
3625 0,
3626 )
3627 .unwrap();
3628 assert_eq!(replacement.cookie_count(), 1);
3629 for (from, to) in [
3630 ("max_session_requests = 7", "max_session_requests = 9"),
3631 ("renewal_method = \"post\"", "renewal_method = \"get\""),
3632 ("max_renewal_attempts = 1", "max_renewal_attempts = 2"),
3633 (
3634 "renewal_after_resource_count = 1",
3635 "renewal_after_resource_count = 0",
3636 ),
3637 (
3638 "renewal_after_resource_count = 1",
3639 "renewal_after_resource_count = 2",
3640 ),
3641 (
3642 "renewal_path = \"/app/renew\"",
3643 "renewal_path = \"/app-evil/renew\"",
3644 ),
3645 (
3646 "renewal_path = \"/app/renew\"",
3647 "renewal_path = \"/app/%2e%2e/renew\"",
3648 ),
3649 (
3650 "renewal_path = \"/app/renew\"",
3651 "renewal_path = \"/app/health\"",
3652 ),
3653 ("host_only = true", "host_only = false"),
3654 ("secure = true", "secure = false"),
3655 ("http_only = true", "http_only = false"),
3656 ("same_site = \"lax\"", "same_site = \"none\""),
3657 ("path = \"/app/\"", "path = \"/app/before\""),
3658 ] {
3659 assert!(
3660 SuppliedSessionPolicy::parse_toml(
3661 &Url::parse("https://example.test/app/").unwrap(),
3662 renewal_source().replace(from, to).as_bytes()
3663 )
3664 .is_err(),
3665 "accepted mutation {from}"
3666 );
3667 }
3668 for field in [
3669 b"session=rotated; Path=/app/; HttpOnly; SameSite=Lax".as_slice(),
3670 b"session=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax; Domain=example.test",
3671 b"session=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax; Max-Age=10",
3672 b"session=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax; Secure",
3673 b"other=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax",
3674 ] {
3675 assert!(
3676 SuppliedSessionCookies::from_form_login_set_cookie_fields(&policy, [field], 0)
3677 .is_err()
3678 );
3679 }
3680 }
3681
3682 fn policy() -> &'static [u8] {
3683 br#"schema = "security.supplied-session-policy/v1"
3684principal_alias = "fixture-user"
3685credential_mechanism = "authorization_header"
3686health_path = "/app/session-health"
3687health_json_field = "authenticated"
3688resources = ["/app/private-one", "/app/private-two"]
3689max_session_requests = 5
3690max_total_response_bytes = 262144
3691max_response_body_bytes = 65536
3692max_wall_time_ms = 10000
3693"#
3694 }
3695
3696 fn cookie_policy() -> &'static [u8] {
3697 br#"schema = "security.supplied-session-policy/v2"
3698principal_alias = "fixture-user"
3699credential_mechanism = "cookie_jar"
3700cookie_update_policy = "stop_on_selected_cookie"
3701health_path = "/app/session-health"
3702health_json_field = "authenticated"
3703resources = ["/app/private-one", "/app/private-two"]
3704max_session_requests = 5
3705max_total_response_bytes = 262144
3706max_response_body_bytes = 65536
3707max_wall_time_ms = 10000
3708
3709[[cookies]]
3710id = "broad-session"
3711name = "session"
3712domain = "example.test"
3713host_only = true
3714path = "/app/"
3715secure = true
3716http_only = true
3717same_site = "lax"
3718
3719[[cookies]]
3720id = "narrow-session"
3721name = "session"
3722domain = "example.test"
3723host_only = false
3724path = "/app/private-one"
3725secure = true
3726http_only = true
3727same_site = "strict"
3728expires_unix_seconds = 4102444800
3729"#
3730 }
3731
3732 fn form_login_policy() -> &'static [u8] {
3733 br#"schema = "security.supplied-session-policy/v3"
3734principal_alias = "fixture-user"
3735credential_mechanism = "cookie_jar"
3736credential_acquisition = "bounded_form_login"
3737cookie_update_policy = "stop_on_selected_cookie"
3738login_path = "/app/login"
3739login_method = "post"
3740login_encoding = "application/x-www-form-urlencoded"
3741username_field = "user"
3742password_field = "pass"
3743csrf_field = "csrf"
3744max_login_attempts = 1
3745health_path = "/app/session-health"
3746health_json_field = "authenticated"
3747resources = ["/app/private-one", "/app/private-two"]
3748max_session_requests = 7
3749max_total_response_bytes = 262144
3750max_response_body_bytes = 65536
3751max_wall_time_ms = 10000
3752
3753[[cookies]]
3754id = "generated-session"
3755name = "session"
3756domain = "example.test"
3757host_only = true
3758path = "/app/"
3759secure = true
3760http_only = true
3761same_site = "lax"
3762"#
3763 }
3764
3765 #[test]
3766 fn policy_is_strict_bounded_and_path_scoped() {
3767 let application = Url::parse("https://example.test/app/").unwrap();
3768 let parsed = SuppliedSessionPolicy::parse_toml(&application, policy()).unwrap();
3769 assert_eq!(parsed.resource_count(), 2);
3770 assert_eq!(parsed.max_session_requests(), 5);
3771 assert!(parsed
3772 .policy_reference()
3773 .starts_with("supplied-session-policy-sha256:"));
3774 assert_eq!(
3775 parsed.policy_reference(),
3776 "supplied-session-policy-sha256:502b4044585b1cb18b9d4692f3c864d4611bb51e4aef0663188951de15a018c0"
3777 );
3778 assert_eq!(parsed.version(), SuppliedSessionPolicyVersion::V1);
3779 assert_eq!(parsed.schema(), SUPPLIED_SESSION_POLICY_SCHEMA);
3780 assert_eq!(parsed.cookie_update_policy(), None);
3781 assert_eq!(parsed.cookie_summary(), None);
3782 assert!(parsed
3783 .application_reference()
3784 .starts_with("supplied-session-application-sha256:"));
3785 assert_eq!(
3786 parsed.principal_reference(),
3787 "supplied-session-principal-0001"
3788 );
3789 assert_eq!(parsed.principal_alias(), "fixture-user");
3790 assert!(!format!("{parsed:?}").contains("/app/private"));
3791 assert!(!format!("{parsed:?}").contains("fixture-user"));
3792 let renamed = std::str::from_utf8(policy())
3793 .unwrap()
3794 .replace("fixture-user", "another-low-entropy-label");
3795 let renamed = SuppliedSessionPolicy::parse_toml(&application, renamed.as_bytes()).unwrap();
3796 assert_eq!(parsed.policy_reference(), renamed.policy_reference());
3797 assert_eq!(parsed.principal_reference(), renamed.principal_reference());
3798 assert_ne!(parsed.principal_alias(), renamed.principal_alias());
3799
3800 for invalid in [
3801 "/app/../admin",
3802 "/app/%2e%2e/admin",
3803 "/app",
3804 "/application/private",
3805 "/app/logout",
3806 "/app/account/logout.php",
3807 "/app/delete.php",
3808 "/app/admin-panel/",
3809 "https://example.test/app/private",
3810 "/app/private?x=1",
3811 "/app/privaté",
3812 "/app/private[0]",
3813 ] {
3814 let text = std::str::from_utf8(policy()).unwrap().replace(
3815 "resources = [\"/app/private-one\", \"/app/private-two\"]",
3816 &format!("resources = [\"{invalid}\"]"),
3817 );
3818 assert!(SuppliedSessionPolicy::parse_toml(&application, text.as_bytes()).is_err());
3819 }
3820
3821 let ordinary = std::str::from_utf8(policy()).unwrap().replace(
3822 "resources = [\"/app/private-one\", \"/app/private-two\"]",
3823 "resources = [\"/app/administrator-guide\"]",
3824 );
3825 let ordinary = ordinary.replace("max_session_requests = 5", "max_session_requests = 3");
3826 assert!(SuppliedSessionPolicy::parse_toml(&application, ordinary.as_bytes()).is_ok());
3827 }
3828
3829 #[test]
3830 fn policy_rejects_duplicates_unknowns_and_inconsistent_limits() {
3831 let application = Url::parse("https://example.test/app/").unwrap();
3832 let duplicate = std::str::from_utf8(policy()).unwrap().replace(
3833 "resources = [\"/app/private-one\", \"/app/private-two\"]",
3834 "resources = [\"/app/private-one\", \"/app/private-one\"]",
3835 );
3836 assert_eq!(
3837 SuppliedSessionPolicy::parse_toml(&application, duplicate.as_bytes()).unwrap_err(),
3838 SuppliedSessionPolicyError::InvalidPath
3839 );
3840 let unknown = [policy(), b"unknown = true\n"].concat();
3841 assert_eq!(
3842 SuppliedSessionPolicy::parse_toml(&application, &unknown).unwrap_err(),
3843 SuppliedSessionPolicyError::MalformedPolicy
3844 );
3845 let too_few = std::str::from_utf8(policy())
3846 .unwrap()
3847 .replace("max_session_requests = 5", "max_session_requests = 4");
3848 assert_eq!(
3849 SuppliedSessionPolicy::parse_toml(&application, too_few.as_bytes()).unwrap_err(),
3850 SuppliedSessionPolicyError::InvalidLimits
3851 );
3852 }
3853
3854 #[test]
3855 fn form_login_policy_preserves_a_closed_one_attempt_transition() {
3856 let application = Url::parse("https://example.test/app/").unwrap();
3857 let parsed = SuppliedSessionPolicy::parse_toml(&application, form_login_policy()).unwrap();
3858 assert_eq!(parsed.version(), SuppliedSessionPolicyVersion::V3);
3859 assert_eq!(parsed.schema(), SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA);
3860 assert_eq!(
3861 parsed.credential_mechanism(),
3862 SuppliedSessionCredentialMechanism::CookieJar
3863 );
3864 assert_eq!(
3865 parsed.credential_acquisition(),
3866 SuppliedSessionCredentialAcquisition::BoundedFormLogin
3867 );
3868 assert_eq!(parsed.max_login_attempts(), Some(1));
3869 assert_eq!(parsed.resource_count(), 2);
3870 assert_eq!(parsed.max_session_requests(), 7);
3871 assert_eq!(
3872 parsed.login_url().unwrap().as_str(),
3873 "https://example.test/app/login"
3874 );
3875 assert_eq!(parsed.login_username_field(), Some("user"));
3876 assert_eq!(parsed.login_password_field(), Some("pass"));
3877 assert_eq!(parsed.login_csrf_field(), Some("csrf"));
3878 assert_eq!(parsed.cookie_summary().unwrap().cookie_count(), 1);
3879 assert!(parsed
3880 .policy_reference()
3881 .starts_with("supplied-session-policy-sha256:"));
3882
3883 let page = SuppliedSessionRequestDescriptor::login_page(&parsed).unwrap();
3884 let submit = SuppliedSessionRequestDescriptor::login_submit(&parsed).unwrap();
3885 assert_eq!(page.purpose(), SuppliedSessionRequestPurpose::LoginPage);
3886 assert_eq!(submit.purpose(), SuppliedSessionRequestPurpose::LoginSubmit);
3887 assert!(page.validate_against(&parsed));
3888 assert!(submit.validate_against(&parsed));
3889 assert_eq!(page.target(), submit.target());
3890 assert_eq!(page.target_reference(), submit.target_reference());
3891
3892 for (needle, replacement) in [
3893 ("max_login_attempts = 1", "max_login_attempts = 2"),
3894 ("login_method = \"post\"", "login_method = \"get\""),
3895 (
3896 "login_encoding = \"application/x-www-form-urlencoded\"",
3897 "login_encoding = \"multipart/form-data\"",
3898 ),
3899 (
3900 "login_path = \"/app/login\"",
3901 "login_path = \"/app/logout\"",
3902 ),
3903 ("host_only = true", "host_only = false"),
3904 ("secure = true", "secure = false"),
3905 ("http_only = true", "http_only = false"),
3906 ] {
3907 let changed =
3908 std::str::from_utf8(form_login_policy())
3909 .unwrap()
3910 .replacen(needle, replacement, 1);
3911 assert!(
3912 SuppliedSessionPolicy::parse_toml(&application, changed.as_bytes()).is_err(),
3913 "mutation unexpectedly accepted: {needle} -> {replacement}"
3914 );
3915 }
3916 }
3917
3918 #[test]
3919 fn form_login_secret_and_generated_cookie_are_bounded_and_redacted() {
3920 let application = Url::parse("https://example.test/app/").unwrap();
3921 let parsed = SuppliedSessionPolicy::parse_toml(&application, form_login_policy()).unwrap();
3922 let credential = SuppliedSessionFormCredential::parse_tsv(
3923 &parsed,
3924 b"username\tUSER-CANARY\npassword\tPASSWORD-CANARY\n".to_vec(),
3925 )
3926 .unwrap();
3927 assert_eq!(
3928 format!("{credential:?}"),
3929 "SuppliedSessionFormCredential(<redacted>)"
3930 );
3931 assert!(!format!("{credential:?}").contains("CANARY"));
3932 assert!(SuppliedSessionRuntimeInput::from(credential).matches_policy(&parsed));
3933
3934 let credential = SuppliedSessionFormCredential::parse_tsv(
3935 &parsed,
3936 b"username\tUSER CANARY\npassword\tPASSWORD&CANARY\n".to_vec(),
3937 )
3938 .unwrap();
3939 let body = credential
3940 .compose_form_body(&parsed, "CSRF+/=CANARY")
3941 .unwrap();
3942 assert_eq!(
3943 std::str::from_utf8(body.as_bytes()).unwrap(),
3944 "csrf=CSRF%2B%2F%3DCANARY&user=USER+CANARY&pass=PASSWORD%26CANARY"
3945 );
3946 assert!(!format!("{body:?}").contains("CANARY"));
3947
3948 let worst_case_credential = SuppliedSessionFormCredential::parse_tsv(
3949 &parsed,
3950 format!(
3951 "username\t{}\npassword\t{}\n",
3952 "%".repeat(MAX_LOGIN_USERNAME_BYTES),
3953 "%".repeat(MAX_LOGIN_PASSWORD_BYTES)
3954 )
3955 .into_bytes(),
3956 )
3957 .unwrap();
3958 let worst_case_body = worst_case_credential
3959 .compose_form_body(&parsed, &"%".repeat(MAX_LOGIN_CSRF_VALUE_BYTES))
3960 .unwrap();
3961 assert!(worst_case_body.as_bytes().len() > 10_624);
3962 assert!(worst_case_body.as_bytes().len() <= MAX_LOGIN_FORM_BODY_BYTES);
3963
3964 for invalid in [
3965 b"username\tONLY\n".as_slice(),
3966 b"password\tPASS\nusername\tUSER\nextra\tVALUE\n".as_slice(),
3967 b"username\tONE\nusername\tTWO\npassword\tPASS\n".as_slice(),
3968 b"username\tUSER\npassword\tBAD\tVALUE\n".as_slice(),
3969 b"\xef\xbb\xbfusername\tUSER\npassword\tPASS\n".as_slice(),
3970 ] {
3971 assert!(SuppliedSessionFormCredential::parse_tsv(&parsed, invalid.to_vec()).is_err());
3972 }
3973
3974 let cookies = SuppliedSessionCookies::from_form_login_set_cookie_fields(
3975 &parsed,
3976 [b"session=SESSION-CANARY; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice()],
3977 2_000_000_000,
3978 )
3979 .unwrap();
3980 assert_eq!(cookies.cookie_count(), 1);
3981 assert!(!format!("{cookies:?}").contains("CANARY"));
3982 let health = SuppliedSessionRequestDescriptor::health(&parsed);
3983 assert_eq!(
3984 cookies
3985 .sensitive_header_for_target(&parsed, health.target(), 2_000_000_000)
3986 .unwrap()
3987 .to_str()
3988 .unwrap(),
3989 "session=SESSION-CANARY"
3990 );
3991
3992 for invalid in [
3993 b"session=VALUE; Path=/app/; Secure; SameSite=Lax".as_slice(),
3994 b"session=VALUE; Path=/app/; Secure; HttpOnly; SameSite=None".as_slice(),
3995 b"session=VALUE; Domain=example.test; Path=/app/; Secure; HttpOnly; SameSite=Lax"
3996 .as_slice(),
3997 b"session=VALUE; Path=/app/; Secure; HttpOnly; SameSite=Lax; Max-Age=10".as_slice(),
3998 b"other=VALUE; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
3999 ] {
4000 assert!(SuppliedSessionCookies::from_form_login_set_cookie_fields(
4001 &parsed,
4002 [invalid],
4003 2_000_000_000,
4004 )
4005 .is_err());
4006 }
4007 }
4008
4009 #[test]
4010 fn form_login_generated_cookies_with_one_name_are_bound_by_exact_path() {
4011 let application = Url::parse("https://example.test/app/").unwrap();
4012 let two_cookie_policy = [
4013 form_login_policy(),
4014 br#"
4015[[cookies]]
4016id = "generated-root-session"
4017name = "session"
4018domain = "example.test"
4019host_only = true
4020path = "/"
4021secure = true
4022http_only = true
4023same_site = "lax"
4024"#,
4025 ]
4026 .concat();
4027 let parsed = SuppliedSessionPolicy::parse_toml(&application, &two_cookie_policy).unwrap();
4028 assert_eq!(parsed.cookie_summary().unwrap().cookie_count(), 2);
4029
4030 let cookies = SuppliedSessionCookies::from_form_login_set_cookie_fields(
4034 &parsed,
4035 [
4036 b"session=ROOT-CANARY; Path=/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4037 b"session=APP-CANARY; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4038 ],
4039 2_000_000_000,
4040 )
4041 .unwrap();
4042 assert_eq!(cookies.cookie_count(), 2);
4043 let health = SuppliedSessionRequestDescriptor::health(&parsed);
4044 assert_eq!(
4045 cookies
4046 .sensitive_header_for_target(&parsed, health.target(), 2_000_000_000)
4047 .unwrap()
4048 .to_str()
4049 .unwrap(),
4050 "session=APP-CANARY; session=ROOT-CANARY"
4051 );
4052 assert!(SuppliedSessionCookies::from_form_login_set_cookie_fields(
4053 &parsed,
4054 [
4055 b"session=ONE; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4056 b"session=TWO; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4057 ],
4058 2_000_000_000,
4059 )
4060 .is_err());
4061 }
4062
4063 #[test]
4064 fn authorization_is_move_only_bounded_and_redacted() {
4065 let authorization = SuppliedSessionAuthorization::new("Bearer SECRET-CANARY").unwrap();
4066 assert_eq!(
4067 format!("{authorization:?}"),
4068 "SuppliedSessionAuthorization(<redacted>)"
4069 );
4070 assert!(!format!("{authorization:?}").contains("SECRET-CANARY"));
4071 for invalid in [
4072 "",
4073 "Bearer",
4074 "Bearer spaced",
4075 "Bearer trailing ",
4076 "Bad\r\n injected",
4077 ] {
4078 assert!(SuppliedSessionAuthorization::new(invalid).is_err());
4079 }
4080 }
4081
4082 #[test]
4083 fn cookie_policy_and_secret_preserve_metadata_and_rfc_path_order() {
4084 let application = Url::parse("https://example.test/app/").unwrap();
4085 let parsed = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4086 assert_eq!(parsed.version(), SuppliedSessionPolicyVersion::V2);
4087 assert_eq!(parsed.schema(), SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA);
4088 assert_eq!(
4089 parsed.credential_mechanism(),
4090 SuppliedSessionCredentialMechanism::CookieJar
4091 );
4092 assert_eq!(
4093 parsed.cookie_update_policy(),
4094 Some(SuppliedSessionCookieUpdatePolicy::StopOnSelectedCookie)
4095 );
4096 let summary = parsed.cookie_summary().unwrap();
4097 assert_eq!(summary.cookie_count(), 2);
4098 assert_eq!(summary.host_only_count(), 1);
4099 assert_eq!(summary.domain_count(), 1);
4100 assert_eq!(summary.secure_count(), 2);
4101 assert_eq!(summary.http_only_count(), 2);
4102 assert_eq!(summary.session_count(), 1);
4103 assert_eq!(summary.persistent_count(), 1);
4104 assert_eq!(summary.same_site_lax_count(), 1);
4105 assert_eq!(summary.same_site_strict_count(), 1);
4106 assert_eq!(summary.same_site_missing_count(), 0);
4107 assert_eq!(summary.same_site_none_count(), 0);
4108 assert!(parsed.is_selected_cookie_name("session"));
4109 assert!(!parsed.is_selected_cookie_name("Session"));
4110
4111 let cookies = SuppliedSessionCookies::parse_tsv(
4112 &parsed,
4113 b"narrow-session\tNARROW-CANARY\r\nbroad-session\tBROAD-CANARY\n".to_vec(),
4114 2_000_000_000,
4115 )
4116 .unwrap();
4117 assert_eq!(cookies.cookie_count(), 2);
4118 assert_eq!(
4119 format!("{cookies:?}"),
4120 "SuppliedSessionCookies { values: \"<redacted>\", cookie_count: 2 }"
4121 );
4122 assert!(!format!("{cookies:?}").contains("CANARY"));
4123
4124 let target = Url::parse("https://example.test/app/private-one").unwrap();
4125 let header = cookies
4126 .sensitive_header_for_target(&parsed, &target, 2_000_000_000)
4127 .unwrap();
4128 assert!(header.is_sensitive());
4129 assert_eq!(
4130 header.to_str().unwrap(),
4131 "session=NARROW-CANARY; session=BROAD-CANARY"
4132 );
4133
4134 let second = Url::parse("https://example.test/app/private-two").unwrap();
4135 assert_eq!(
4136 cookies
4137 .sensitive_header_for_target(&parsed, &second, 2_000_000_000)
4138 .unwrap()
4139 .to_str()
4140 .unwrap(),
4141 "session=BROAD-CANARY"
4142 );
4143 assert_eq!(
4144 cookies
4145 .sensitive_header_for_target(
4146 &parsed,
4147 &Url::parse("https://example.test/app/unselected").unwrap(),
4148 2_000_000_000,
4149 )
4150 .unwrap_err(),
4151 SuppliedSessionCookieError::TargetNotAdmitted
4152 );
4153 }
4154
4155 #[test]
4156 fn cookie_policy_rejects_unsafe_scope_duplicates_and_wrong_contracts() {
4157 let application = Url::parse("https://example.test/app/").unwrap();
4158 for (needle, replacement) in [
4159 ("domain = \"example.test\"", "domain = \"parent.test\""),
4160 ("path = \"/app/\"", "path = \"/application/\""),
4161 ("same_site = \"lax\"", "same_site = \"future\""),
4162 (
4163 "cookie_update_policy = \"stop_on_selected_cookie\"",
4164 "cookie_update_policy = \"apply_automatically\"",
4165 ),
4166 (
4167 "credential_mechanism = \"cookie_jar\"",
4168 "credential_mechanism = \"authorization_header\"",
4169 ),
4170 ] {
4171 let changed =
4172 std::str::from_utf8(cookie_policy())
4173 .unwrap()
4174 .replacen(needle, replacement, 1);
4175 assert!(SuppliedSessionPolicy::parse_toml(&application, changed.as_bytes()).is_err());
4176 }
4177
4178 let duplicate_id = std::str::from_utf8(cookie_policy())
4179 .unwrap()
4180 .replace("id = \"narrow-session\"", "id = \"broad-session\"");
4181 assert_eq!(
4182 SuppliedSessionPolicy::parse_toml(&application, duplicate_id.as_bytes()).unwrap_err(),
4183 SuppliedSessionPolicyError::InvalidCookieMetadata
4184 );
4185 let duplicate_tuple = std::str::from_utf8(cookie_policy())
4186 .unwrap()
4187 .replace("path = \"/app/private-one\"", "path = \"/app/\"");
4188 assert_eq!(
4189 SuppliedSessionPolicy::parse_toml(&application, duplicate_tuple.as_bytes())
4190 .unwrap_err(),
4191 SuppliedSessionPolicyError::InvalidCookieMetadata
4192 );
4193 let insecure_none = std::str::from_utf8(cookie_policy())
4194 .unwrap()
4195 .replacen("secure = true", "secure = false", 1)
4196 .replacen("same_site = \"lax\"", "same_site = \"none\"", 1);
4197 assert_eq!(
4198 SuppliedSessionPolicy::parse_toml(&application, insecure_none.as_bytes()).unwrap_err(),
4199 SuppliedSessionPolicyError::InvalidCookieMetadata
4200 );
4201
4202 let ip_application = Url::parse("https://127.0.0.1/app/").unwrap();
4203 let ip_domain = std::str::from_utf8(cookie_policy())
4204 .unwrap()
4205 .replace("example.test", "127.0.0.1")
4206 .replace("host_only = true", "host_only = false");
4207 assert_eq!(
4208 SuppliedSessionPolicy::parse_toml(&ip_application, ip_domain.as_bytes()).unwrap_err(),
4209 SuppliedSessionPolicyError::InvalidCookieMetadata
4210 );
4211 }
4212
4213 #[test]
4214 fn cookie_secret_is_exact_bounded_redacted_and_expiry_aware() {
4215 let application = Url::parse("https://example.test/app/").unwrap();
4216 let parsed = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4217 for invalid in [
4218 b"broad-session\tVALUE\n".as_slice(),
4219 b"broad-session\tVALUE\nnarrow-session\tVALUE\nextra\tVALUE\n".as_slice(),
4220 b"broad-session\tVALUE\nbroad-session\tOTHER\nnarrow-session\tVALUE\n".as_slice(),
4221 b"broad-session\tBAD VALUE\nnarrow-session\tVALUE\n".as_slice(),
4222 b"broad-session\tBAD;VALUE\nnarrow-session\tVALUE\n".as_slice(),
4223 b"\n".as_slice(),
4224 ] {
4225 assert_eq!(
4226 SuppliedSessionCookies::parse_tsv(&parsed, invalid.to_vec(), 2_000_000_000)
4227 .unwrap_err(),
4228 SuppliedSessionCookieError::MalformedSecret
4229 );
4230 }
4231 assert_eq!(
4232 SuppliedSessionCookies::parse_tsv(
4233 &parsed,
4234 vec![b'a'; HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES + 1],
4235 2_000_000_000,
4236 )
4237 .unwrap_err(),
4238 SuppliedSessionCookieError::SecretTooLarge
4239 );
4240
4241 let expired = std::str::from_utf8(cookie_policy()).unwrap().replace(
4242 "expires_unix_seconds = 4102444800",
4243 "expires_unix_seconds = 1",
4244 );
4245 let expired = SuppliedSessionPolicy::parse_toml(&application, expired.as_bytes()).unwrap();
4246 let values = b"broad-session\tBROAD\nnarrow-session\tNARROW\n".to_vec();
4247 assert_eq!(
4248 SuppliedSessionCookies::parse_tsv(&expired, values, 2_000_000_000).unwrap_err(),
4249 SuppliedSessionCookieError::IncompleteInitialCoverage
4250 );
4251
4252 let authorization_policy =
4253 SuppliedSessionPolicy::parse_toml(&application, policy()).unwrap();
4254 assert_eq!(
4255 SuppliedSessionCookies::parse_tsv(
4256 &authorization_policy,
4257 b"broad-session\tSECRET\n".to_vec(),
4258 2_000_000_000,
4259 )
4260 .unwrap_err(),
4261 SuppliedSessionCookieError::PolicyMismatch
4262 );
4263 }
4264
4265 #[test]
4266 fn cookie_header_limit_and_mid_run_expiry_fail_closed() {
4267 let application = Url::parse("https://example.test/app/").unwrap();
4268 let parsed = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4269 let secret = format!(
4270 "broad-session\t{}\nnarrow-session\t{}\n",
4271 "a".repeat(MAX_COOKIE_VALUE_BYTES),
4272 "b".repeat(MAX_COOKIE_VALUE_BYTES)
4273 );
4274 let cookies =
4275 SuppliedSessionCookies::parse_tsv(&parsed, secret.into_bytes(), 2_000_000_000).unwrap();
4276 let target = Url::parse("https://example.test/app/private-one").unwrap();
4277 assert_eq!(
4278 cookies
4279 .sensitive_header_for_target(&parsed, &target, 2_000_000_000)
4280 .unwrap_err(),
4281 SuppliedSessionCookieError::HeaderTooLarge
4282 );
4283
4284 let one_cookie = std::str::from_utf8(cookie_policy()).unwrap();
4285 let one_cookie = one_cookie
4286 .split("\n[[cookies]]\nid = \"narrow-session\"")
4287 .next()
4288 .unwrap();
4289 let one_cookie = format!("{one_cookie}\nexpires_unix_seconds = 2000000001\n");
4290 let one_cookie =
4291 SuppliedSessionPolicy::parse_toml(&application, one_cookie.as_bytes()).unwrap();
4292 let cookies = SuppliedSessionCookies::parse_tsv(
4293 &one_cookie,
4294 b"broad-session\tSECRET\n".to_vec(),
4295 2_000_000_000,
4296 )
4297 .unwrap();
4298 let health = SuppliedSessionRequestDescriptor::health(&one_cookie);
4299 assert_eq!(
4300 cookies
4301 .sensitive_header_for_target(&one_cookie, health.target(), 2_000_000_001)
4302 .unwrap_err(),
4303 SuppliedSessionCookieError::NoApplicableCookie
4304 );
4305
4306 let partially_expiring = std::str::from_utf8(cookie_policy()).unwrap().replace(
4307 "expires_unix_seconds = 4102444800",
4308 "expires_unix_seconds = 2000000001",
4309 );
4310 let partially_expiring =
4311 SuppliedSessionPolicy::parse_toml(&application, partially_expiring.as_bytes()).unwrap();
4312 let cookies = SuppliedSessionCookies::parse_tsv(
4313 &partially_expiring,
4314 b"broad-session\tBROAD\nnarrow-session\tNARROW\n".to_vec(),
4315 2_000_000_000,
4316 )
4317 .unwrap();
4318 let target = Url::parse("https://example.test/app/private-one").unwrap();
4319 assert_eq!(
4320 cookies
4321 .sensitive_header_for_target(&partially_expiring, &target, 2_000_000_001)
4322 .unwrap_err(),
4323 SuppliedSessionCookieError::NoApplicableCookie
4324 );
4325 }
4326
4327 #[test]
4328 fn typed_credentials_are_redacted_and_mechanism_specific() {
4329 let authorization = SuppliedSessionCredential::Authorization(
4330 SuppliedSessionAuthorization::new("Bearer SECRET-CANARY").unwrap(),
4331 );
4332 assert_eq!(
4333 authorization.mechanism(),
4334 SuppliedSessionCredentialMechanism::AuthorizationHeader
4335 );
4336 assert!(authorization.authorization().is_some());
4337 assert!(authorization.cookies().is_none());
4338 assert!(!format!("{authorization:?}").contains("SECRET-CANARY"));
4339
4340 let application = Url::parse("https://example.test/app/").unwrap();
4341 let policy = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4342 let cookies = SuppliedSessionCookies::parse_tsv(
4343 &policy,
4344 b"broad-session\tBROAD\nnarrow-session\tNARROW\n".to_vec(),
4345 2_000_000_000,
4346 )
4347 .unwrap();
4348 let cookies = SuppliedSessionCredential::SuppliedCookies(cookies);
4349 assert_eq!(
4350 cookies.mechanism(),
4351 SuppliedSessionCredentialMechanism::CookieJar
4352 );
4353 assert!(cookies.authorization().is_none());
4354 assert!(cookies.cookies().is_some());
4355 assert!(!format!("{cookies:?}").contains("BROAD"));
4356 }
4357
4358 #[test]
4359 fn request_descriptor_binds_its_closed_purpose() {
4360 let application = Url::parse("https://example.test/app/").unwrap();
4361 let parsed = SuppliedSessionPolicy::parse_toml(&application, policy()).unwrap();
4362 let mut health = SuppliedSessionRequestDescriptor::health(&parsed);
4363 assert!(health.validate());
4364 assert!(health.validate_against(&parsed));
4365 health.purpose = SuppliedSessionRequestPurpose::Resource;
4366 assert!(!health.validate());
4367 assert!(!health.validate_against(&parsed));
4368
4369 let (target, reference) = parsed.execution_resources().next().unwrap();
4370 let mut resource = SuppliedSessionRequestDescriptor::resource(&parsed, target, reference)
4371 .expect("literal resource must be admitted");
4372 assert!(resource.validate());
4373 assert!(resource.validate_against(&parsed));
4374 resource.purpose = SuppliedSessionRequestPurpose::Health;
4375 assert!(!resource.validate());
4376
4377 let mut mechanism = SuppliedSessionRequestDescriptor::health(&parsed);
4378 assert_eq!(
4379 mechanism.credential_mechanism(),
4380 SuppliedSessionCredentialMechanism::AuthorizationHeader
4381 );
4382 mechanism.credential_mechanism = SuppliedSessionCredentialMechanism::CookieJar;
4383 assert!(!mechanism.validate());
4384
4385 let changed_policy_source = std::str::from_utf8(policy())
4386 .unwrap()
4387 .replace("max_wall_time_ms = 10000", "max_wall_time_ms = 9999");
4388 let changed_policy =
4389 SuppliedSessionPolicy::parse_toml(&application, changed_policy_source.as_bytes())
4390 .unwrap();
4391 let descriptor = SuppliedSessionRequestDescriptor::health(&parsed);
4392 assert!(descriptor.validate());
4393 assert!(!descriptor.validate_against(&changed_policy));
4394
4395 let mut wrong_reference = SuppliedSessionRequestDescriptor::health(&parsed);
4396 wrong_reference.policy_reference = changed_policy.policy_reference().to_owned();
4397 assert!(!wrong_reference.validate());
4398 assert!(!wrong_reference.validate_against(&parsed));
4399 }
4400}