1mod oauth_authorization;
4mod oauth_code;
5pub(crate) use oauth_authorization::OAuthAuthorizationDispatchPermit;
6pub use oauth_authorization::{
7 SuppliedSessionOAuthAuthorizationAudit, SuppliedSessionOAuthAuthorizationOutcome,
8};
9mod oauth_refresh;
10use oauth_code::{CommittedOAuthCodeReceipt, OAuthCodeAcquisitionContext};
11pub use oauth_code::{
12 SuppliedSessionOAuthCodeAudit, SuppliedSessionOAuthCodeOutcome,
13 SuppliedSessionOAuthInitiatedTokenAudit,
14};
15pub(crate) use oauth_refresh::OAuthRefreshDispatchPermit;
16use oauth_refresh::{CommittedOAuthRefreshReceipt, OAuthRefreshContext};
17pub use oauth_refresh::{SuppliedSessionOAuthRefreshAudit, SuppliedSessionOAuthRefreshOutcome};
18
19pub const SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA: &str = "security.supplied-session-audit/v6";
21pub const SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA: &str =
23 "security.supplied-session-audit/v7";
24
25use std::time::Duration;
26
27use serde::{
28 de::{DeserializeSeed, MapAccess, SeqAccess, Visitor},
29 Deserializer, Serialize,
30};
31use sha2::{Digest, Sha256};
32use termivar_core::{
33 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
34 KnowledgePredicate,
35};
36
37#[cfg(feature = "websocket-review")]
38use super::websocket_runtime::{
39 execute_supplied_session_websocket_review, websocket_review_not_run,
40 WebAssessmentWebSocketReviewAudit, WebSocketSuppliedSessionContextAudit,
41 WebSocketSuppliedSessionHealthAudit, WebSocketSuppliedSessionQualification,
42};
43use super::{authority::SharedWebRuntimeAuthority, RuntimeExecution};
44#[cfg(feature = "websocket-review")]
45use crate::supplied_session_review::SuppliedSessionAuthorization;
46use crate::{
47 http_evidence::{
48 extract_supplied_session_login_csrf, CollectedHttpResponse, HttpRequestBroker,
49 OAuthCodeRequestBroker, OAuthCodeRequestDescriptor, OAuthRefreshRequestDescriptor,
50 SuppliedSessionCookieUpdateClassification, SuppliedSessionCookieUpdateObservation,
51 SuppliedSessionLoginCsrfToken, SuppliedSessionLoginFormError, SuppliedSessionRequestError,
52 },
53 supplied_session_review::{
54 SuppliedSessionCookieSummary, SuppliedSessionCookieUpdatePolicy, SuppliedSessionCredential,
55 SuppliedSessionCredentialAcquisition, SuppliedSessionCredentialMechanism,
56 SuppliedSessionFormBody, SuppliedSessionPolicy, SuppliedSessionPolicyVersion,
57 SuppliedSessionRequestDescriptor, SuppliedSessionRequestPurpose,
58 SuppliedSessionRuntimeInput, MAX_SUPPLIED_SESSION_CHECKPOINTS,
59 MAX_SUPPLIED_SESSION_RESOURCES,
60 },
61 DecisionActionOrigin, DecisionExecutionLimits, DecisionExecutionStage, KnowledgeBase,
62};
63#[cfg(feature = "websocket-review")]
64use crate::{web_runtime::WebSocketReviewFailureKind, websocket_review::WebSocketReviewPolicy};
65
66pub(super) trait SuppliedSessionCommittedResourceObserver: Send + Sync {
70 fn begin(
71 &self,
72 policy: &SuppliedSessionPolicy,
73 resources: &[(SuppliedSessionRequestDescriptor, String)],
74 ) -> Result<(), ()>;
75
76 fn stage_response(
77 &self,
78 sequence: u8,
79 policy: &SuppliedSessionPolicy,
80 descriptor: &SuppliedSessionRequestDescriptor,
81 response: &CollectedHttpResponse,
82 ) -> Result<(), ()>;
83
84 fn commit_response(
85 &self,
86 sequence: u8,
87 policy: &SuppliedSessionPolicy,
88 descriptor: &SuppliedSessionRequestDescriptor,
89 receipt: &CommittedSuppliedSessionResourceReceipt,
90 knowledge: &KnowledgeBase,
91 ) -> Result<(), ()>;
92
93 fn discard_response(&self, sequence: u8);
94
95 fn fail(&self);
96}
97
98pub(super) struct CommittedSuppliedSessionResourceReceipt {
101 #[cfg(feature = "wordpress-review")]
102 sequence: u8,
103 #[cfg(feature = "wordpress-review")]
104 resource_reference: String,
105 #[cfg(feature = "wordpress-review")]
106 policy_reference: String,
107 #[cfg(feature = "wordpress-review")]
108 application_reference: String,
109 #[cfg(feature = "wordpress-review")]
110 principal_reference: String,
111 #[cfg(feature = "wordpress-review")]
112 credential_mechanism: SuppliedSessionCredentialMechanism,
113 #[cfg(feature = "wordpress-review")]
114 epoch: u8,
115 evidence_reference: String,
116 #[cfg(feature = "wordpress-review")]
117 expected_evidence: Evidence,
118}
119
120#[cfg(feature = "wordpress-review")]
121impl CommittedSuppliedSessionResourceReceipt {
122 pub(super) const fn sequence(&self) -> u8 {
123 self.sequence
124 }
125
126 pub(super) fn resource_reference(&self) -> &str {
127 &self.resource_reference
128 }
129
130 pub(super) fn evidence_reference(&self) -> &str {
131 &self.evidence_reference
132 }
133
134 pub(super) fn is_valid_for(
135 &self,
136 sequence: u8,
137 policy: &SuppliedSessionPolicy,
138 descriptor: &SuppliedSessionRequestDescriptor,
139 knowledge: &KnowledgeBase,
140 ) -> bool {
141 EvidenceId::parse(self.evidence_reference.clone())
142 .ok()
143 .and_then(|id| {
144 knowledge.inspect_evidence(&id, |stored| stored == &self.expected_evidence)
145 })
146 == Some(true)
147 && self.sequence == sequence
148 && self.resource_reference == descriptor.target_reference()
149 && self.policy_reference == policy.policy_reference()
150 && self.application_reference == policy.application_reference()
151 && self.principal_reference == policy.principal_reference()
152 && self.credential_mechanism == policy.credential_mechanism()
153 && self.epoch == 1
154 && descriptor.validate()
155 && descriptor.purpose() == SuppliedSessionRequestPurpose::Resource
156 && self.expected_evidence.kind() == &EvidenceKind::Authentication
157 && self.expected_evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
158 && self.expected_evidence.predicate().name() == RESOURCE_COMMIT_EVIDENCE_PREDICATE
159 }
160}
161
162#[cfg(feature = "websocket-review")]
166struct CommittedSuppliedSessionHealthReceipt {
167 sequence: u8,
168 phase: SuppliedSessionHealthCheckpointPhase,
169 after_subject_count: u8,
170 policy_reference: String,
171 application_reference: String,
172 principal_reference: String,
173 credential_mechanism: SuppliedSessionCredentialMechanism,
174 epoch: u8,
175 evidence_reference: String,
176 expected_evidence: Evidence,
177}
178
179#[cfg(feature = "websocket-review")]
180impl CommittedSuppliedSessionHealthReceipt {
181 fn from_checkpoint(
182 policy: &SuppliedSessionPolicy,
183 checkpoint: &WebAssessmentSuppliedSessionCheckpointAudit,
184 expected_phase: SuppliedSessionHealthCheckpointPhase,
185 expected_after_subject_count: u8,
186 knowledge: &KnowledgeBase,
187 ) -> Option<Self> {
188 if checkpoint.phase() != expected_phase
189 || checkpoint.after_subject_count() != expected_after_subject_count
190 || checkpoint.outcome() != SuppliedSessionHealthOutcome::Healthy
191 || checkpoint.body_state() != SuppliedSessionBodyState::Complete
192 || checkpoint.predicate() != SuppliedSessionPredicateOutcome::Matched
193 {
194 return None;
195 }
196 let evidence_reference = checkpoint.evidence_reference()?.to_owned();
197 let evidence_id = EvidenceId::parse(evidence_reference.clone()).ok()?;
198 let expected_evidence = knowledge.inspect_evidence(&evidence_id, Clone::clone)?;
199 Some(Self {
200 sequence: checkpoint.sequence(),
201 phase: checkpoint.phase(),
202 after_subject_count: checkpoint.after_subject_count(),
203 policy_reference: policy.policy_reference().to_owned(),
204 application_reference: policy.application_reference().to_owned(),
205 principal_reference: policy.principal_reference().to_owned(),
206 credential_mechanism: policy.credential_mechanism(),
207 epoch: 1,
208 evidence_reference,
209 expected_evidence,
210 })
211 }
212
213 fn is_valid_for(
214 &self,
215 policy: &SuppliedSessionPolicy,
216 expected_phase: SuppliedSessionHealthCheckpointPhase,
217 expected_after_subject_count: u8,
218 knowledge: &KnowledgeBase,
219 ) -> bool {
220 EvidenceId::parse(self.evidence_reference.clone())
221 .ok()
222 .and_then(|id| {
223 knowledge.inspect_evidence(&id, |stored| stored == &self.expected_evidence)
224 })
225 == Some(true)
226 && self.sequence
227 == match expected_phase {
228 SuppliedSessionHealthCheckpointPhase::Startup => 0,
229 SuppliedSessionHealthCheckpointPhase::Terminal => 1,
230 SuppliedSessionHealthCheckpointPhase::SubjectBoundary
231 | SuppliedSessionHealthCheckpointPhase::Renewal => return false,
232 }
233 && self.phase == expected_phase
234 && self.after_subject_count == expected_after_subject_count
235 && self.policy_reference == policy.policy_reference()
236 && self.application_reference == policy.application_reference()
237 && self.principal_reference == policy.principal_reference()
238 && self.credential_mechanism == SuppliedSessionCredentialMechanism::AuthorizationHeader
239 && policy.version() == SuppliedSessionPolicyVersion::V1
240 && policy.credential_mechanism()
241 == SuppliedSessionCredentialMechanism::AuthorizationHeader
242 && policy.resource_count() == 1
243 && self.epoch == 1
244 && self.expected_evidence.kind() == &EvidenceKind::Authentication
245 && self.expected_evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
246 && self.expected_evidence.predicate().name() == HEALTH_COMMIT_EVIDENCE_PREDICATE
247 }
248}
249
250#[cfg(feature = "websocket-review")]
253pub(super) struct SuppliedSessionWebSocketPermit<'a> {
254 policy: &'a SuppliedSessionPolicy,
255 authorization: &'a SuppliedSessionAuthorization,
256 websocket_policy_reference: &'a str,
257 websocket_endpoint_reference: &'a str,
258 session_deadline: Option<tokio::time::Instant>,
259 startup: CommittedSuppliedSessionHealthReceipt,
260}
261
262#[cfg(feature = "websocket-review")]
263impl<'a> SuppliedSessionWebSocketPermit<'a> {
264 fn new(
265 policy: &'a SuppliedSessionPolicy,
266 authorization: &'a SuppliedSessionAuthorization,
267 websocket: &'a WebSocketReviewPolicy,
268 session_deadline: Option<tokio::time::Instant>,
269 startup: CommittedSuppliedSessionHealthReceipt,
270 ) -> Self {
271 Self {
272 policy,
273 authorization,
274 websocket_policy_reference: websocket.policy_reference(),
275 websocket_endpoint_reference: websocket.endpoint_reference(),
276 session_deadline,
277 startup,
278 }
279 }
280
281 pub(super) fn is_valid_for(
282 &self,
283 selected_application: &url::Url,
284 websocket: &WebSocketReviewPolicy,
285 knowledge: &KnowledgeBase,
286 parent_deadline: Option<tokio::time::Instant>,
287 ) -> bool {
288 self.policy.application_url() == selected_application
289 && self.policy.version() == SuppliedSessionPolicyVersion::V1
290 && self.policy.credential_mechanism()
291 == SuppliedSessionCredentialMechanism::AuthorizationHeader
292 && self.policy.resource_count() == 1
293 && self.websocket_policy_reference == websocket.policy_reference()
294 && self.websocket_endpoint_reference == websocket.endpoint_reference()
295 && self.session_deadline.is_some()
296 && parent_deadline.is_none_or(|parent| {
297 self.session_deadline
298 .is_some_and(|session| session <= parent)
299 })
300 && self.startup.is_valid_for(
301 self.policy,
302 SuppliedSessionHealthCheckpointPhase::Startup,
303 0,
304 knowledge,
305 )
306 }
307
308 pub(super) const fn authorization(&self) -> &SuppliedSessionAuthorization {
309 self.authorization
310 }
311
312 pub(super) const fn session_deadline(&self) -> Option<tokio::time::Instant> {
313 self.session_deadline
314 }
315}
316
317pub const SUPPLIED_SESSION_AUDIT_SCHEMA: &str = "security.supplied-session-audit/v1";
319pub const SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA: &str = "security.supplied-session-audit/v2";
321pub const SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA: &str = "security.supplied-session-audit/v3";
323pub const SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA: &str = "security.supplied-session-audit/v4";
325pub const SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA: &str = "security.supplied-session-audit/v5";
327pub const SUPPLIED_SESSION_CAPABILITY_ID: &str = "session.supplied-context-assessment@1";
329
330const HEALTH_JSON_MAX_DEPTH: usize = 16;
331const HEALTH_JSON_MAX_NODES: usize = 512;
332const HEALTH_JSON_MAX_KEY_BYTES: usize = 256;
333const RESOURCE_COMMIT_EVIDENCE_DOMAIN: &[u8] = b"security.supplied-session-resource-evidence.v1\0";
334const HEALTH_COMMIT_EVIDENCE_DOMAIN: &[u8] = b"security.supplied-session-health-evidence.v1\0";
335const RESOURCE_COMMIT_EVIDENCE_PREFIX: &str = "supplied-session-resource-evidence-sha256";
336const HEALTH_COMMIT_EVIDENCE_PREFIX: &str = "supplied-session-checkpoint-evidence-sha256";
337const RESOURCE_COMMIT_EVIDENCE_NAMESPACE: &str = "session.supplied-context-assessment";
338const RESOURCE_COMMIT_EVIDENCE_PREDICATE: &str = "protected-resource-response-committed";
339const RESOURCE_HEALTH_UNQUALIFIED_EVIDENCE_PREDICATE: &str =
340 "protected-resource-response-health-unqualified";
341const HEALTH_COMMIT_EVIDENCE_PREDICATE: &str = "health-checkpoint-observation-committed";
342const RESOURCE_COMMIT_EVIDENCE_COMPONENT: &str = "supplied-session.runtime";
343const RESOURCE_COMMIT_EVIDENCE_METHOD_V1: &str = "descriptor-bound-complete-authorization-get";
344const HEALTH_COMMIT_EVIDENCE_METHOD_V1: &str = "descriptor-bound-health-authorization-get";
345const RESOURCE_COMMIT_EVIDENCE_METHOD_V2: &str = "descriptor-bound-complete-supplied-cookie-get";
346const HEALTH_COMMIT_EVIDENCE_METHOD_V2: &str = "descriptor-bound-health-supplied-cookie-get";
347const RESOURCE_COMMIT_EVIDENCE_METHOD_V3: &str = "descriptor-bound-complete-form-login-cookie-get";
348const HEALTH_COMMIT_EVIDENCE_METHOD_V3: &str = "descriptor-bound-health-form-login-cookie-get";
349const SUPPLIED_SESSION_NON_BROWSER_COOKIE_SEMANTICS: &str =
350 "attributes_preserved_not_browser_csrf_emulation";
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
354#[serde(rename_all = "snake_case")]
355#[non_exhaustive]
356pub enum SuppliedSessionPrincipalAssurance {
357 OperatorDeclared,
359}
360
361#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
363#[serde(rename_all = "snake_case")]
364#[non_exhaustive]
365pub enum SuppliedSessionHealthOracleKind {
366 JsonBooleanTrue,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
372pub struct SuppliedSessionHealthOracleAudit {
373 kind: SuppliedSessionHealthOracleKind,
374 field_reference: String,
375}
376
377impl SuppliedSessionHealthOracleAudit {
378 pub const fn kind(&self) -> SuppliedSessionHealthOracleKind {
379 self.kind
380 }
381
382 pub fn field_reference(&self) -> &str {
383 &self.field_reference
384 }
385}
386
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
389#[serde(rename_all = "snake_case")]
390#[non_exhaustive]
391pub enum SuppliedSessionAuditOutcome {
392 Complete,
393 StartupUnhealthy,
394 SessionLost,
395 ResourceUnavailable,
396 RuntimeLimit,
397 Cancelled,
398 CredentialUpdateRequired,
400 CredentialUpdateUnusable,
402 LoginFormUnavailable,
404 LoginSubmitUnavailable,
406 LoginCookieUnavailable,
408 RenewalUnavailable,
410 #[serde(rename = "oauth_code_exchange_unavailable")]
412 OAuthCodeExchangeUnavailable,
413 OAuthRefreshUnavailable,
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
419#[serde(rename_all = "snake_case")]
420#[non_exhaustive]
421pub enum SuppliedSessionLoginFormOutcome {
422 Matched,
423 IneligibleResponse,
424 Missing,
425 Ambiguous,
426 Invalid,
427 TransportFailed,
428 RuntimeLimit,
429 Cancelled,
430 NotEvaluated,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
435#[serde(rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum SuppliedSessionLoginSubmitOutcome {
438 CookieAcquired,
439 CookieUnavailable,
440 HttpError,
441 RedirectRefused,
442 Incomplete,
443 TransportFailed,
444 RuntimeLimit,
445 Cancelled,
446 NotDispatched,
447}
448
449#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
452pub struct SuppliedSessionLoginAudit {
453 login_reference: String,
454 max_attempts: u8,
455 attempt_count: u8,
456 page_dispatched: bool,
457 page_status: Option<u16>,
458 page_body_state: SuppliedSessionBodyState,
459 form_outcome: SuppliedSessionLoginFormOutcome,
460 #[serde(skip_serializing_if = "Option::is_none")]
461 form_error_code: Option<&'static str>,
462 submit_dispatched: bool,
463 submit_status: Option<u16>,
464 submit_body_state: SuppliedSessionBodyState,
465 submit_outcome: SuppliedSessionLoginSubmitOutcome,
466 acquired_cookie_count: u8,
467 response_bytes: u64,
468 initial_epoch: u8,
469 final_epoch: u8,
470 pre_session_cookie_applied: bool,
471}
472
473impl SuppliedSessionLoginAudit {
474 pub fn login_reference(&self) -> &str {
475 &self.login_reference
476 }
477 pub const fn max_attempts(&self) -> u8 {
478 self.max_attempts
479 }
480 pub const fn attempt_count(&self) -> u8 {
481 self.attempt_count
482 }
483 pub const fn page_dispatched(&self) -> bool {
484 self.page_dispatched
485 }
486 pub const fn page_status(&self) -> Option<u16> {
487 self.page_status
488 }
489 pub const fn page_body_state(&self) -> SuppliedSessionBodyState {
490 self.page_body_state
491 }
492 pub const fn form_outcome(&self) -> SuppliedSessionLoginFormOutcome {
493 self.form_outcome
494 }
495 pub const fn form_error_code(&self) -> Option<&'static str> {
496 self.form_error_code
497 }
498 pub const fn submit_dispatched(&self) -> bool {
499 self.submit_dispatched
500 }
501 pub const fn submit_status(&self) -> Option<u16> {
502 self.submit_status
503 }
504 pub const fn submit_body_state(&self) -> SuppliedSessionBodyState {
505 self.submit_body_state
506 }
507 pub const fn submit_outcome(&self) -> SuppliedSessionLoginSubmitOutcome {
508 self.submit_outcome
509 }
510 pub const fn acquired_cookie_count(&self) -> u8 {
511 self.acquired_cookie_count
512 }
513 pub const fn response_bytes(&self) -> u64 {
514 self.response_bytes
515 }
516 pub const fn initial_epoch(&self) -> u8 {
517 self.initial_epoch
518 }
519 pub const fn final_epoch(&self) -> u8 {
520 self.final_epoch
521 }
522 pub const fn pre_session_cookie_applied(&self) -> bool {
523 self.pre_session_cookie_applied
524 }
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
529#[serde(rename_all = "snake_case")]
530#[non_exhaustive]
531pub enum SuppliedSessionCoverage {
532 Complete,
533 Partial,
534 None,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
539#[serde(rename_all = "snake_case")]
540#[non_exhaustive]
541pub enum SuppliedSessionHealthCheckpointPhase {
542 Startup,
543 SubjectBoundary,
544 Terminal,
545 Renewal,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
551#[serde(rename_all = "snake_case")]
552#[non_exhaustive]
553pub enum SuppliedSessionHealthOutcome {
554 Healthy,
555 Unhealthy,
556 Indeterminate,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
561#[serde(rename_all = "snake_case")]
562#[non_exhaustive]
563pub enum SuppliedSessionBodyState {
564 Complete,
565 Incomplete,
566 Unavailable,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
571#[serde(rename_all = "snake_case")]
572#[non_exhaustive]
573pub enum SuppliedSessionPredicateOutcome {
574 Matched,
575 NotMatched,
576 NotEvaluated,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
581#[serde(rename_all = "snake_case")]
582#[non_exhaustive]
583pub enum SuppliedSessionResourceOutcome {
584 Committed,
585 HealthUnqualified,
588 HttpError,
589 RedirectRefused,
590 Incomplete,
591 TransportFailed,
592 NotDispatched,
593}
594
595#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
597pub struct WebAssessmentSuppliedSessionCheckpointAudit {
598 sequence: u8,
599 evidence_reference: Option<String>,
600 phase: SuppliedSessionHealthCheckpointPhase,
601 after_subject_count: u8,
602 outcome: SuppliedSessionHealthOutcome,
603 status: Option<u16>,
604 body_state: SuppliedSessionBodyState,
605 predicate: SuppliedSessionPredicateOutcome,
606 response_bytes: u64,
607 #[serde(skip_serializing_if = "Option::is_none")]
608 epoch: Option<u8>,
609 #[serde(skip)]
610 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
611}
612
613impl WebAssessmentSuppliedSessionCheckpointAudit {
614 pub fn epoch(&self) -> u8 {
615 self.epoch.unwrap_or(1)
616 }
617 pub const fn sequence(&self) -> u8 {
618 self.sequence
619 }
620 pub fn evidence_reference(&self) -> Option<&str> {
621 self.evidence_reference.as_deref()
622 }
623 pub const fn phase(&self) -> SuppliedSessionHealthCheckpointPhase {
624 self.phase
625 }
626 pub const fn after_subject_count(&self) -> u8 {
627 self.after_subject_count
628 }
629 pub const fn outcome(&self) -> SuppliedSessionHealthOutcome {
630 self.outcome
631 }
632 pub const fn status(&self) -> Option<u16> {
633 self.status
634 }
635 pub const fn body_state(&self) -> SuppliedSessionBodyState {
636 self.body_state
637 }
638 pub const fn predicate(&self) -> SuppliedSessionPredicateOutcome {
639 self.predicate
640 }
641 pub const fn response_bytes(&self) -> u64 {
642 self.response_bytes
643 }
644}
645
646#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
648pub struct WebAssessmentSuppliedSessionResourceAudit {
649 sequence: u8,
650 resource_reference: String,
651 evidence_reference: Option<String>,
652 outcome: SuppliedSessionResourceOutcome,
653 status: Option<u16>,
654 response_bytes: u64,
655 epoch: u8,
656 #[serde(skip)]
657 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
658}
659
660impl WebAssessmentSuppliedSessionResourceAudit {
661 pub const fn sequence(&self) -> u8 {
662 self.sequence
663 }
664 pub fn resource_reference(&self) -> &str {
665 &self.resource_reference
666 }
667 pub fn evidence_reference(&self) -> Option<&str> {
668 self.evidence_reference.as_deref()
669 }
670 pub const fn outcome(&self) -> SuppliedSessionResourceOutcome {
671 self.outcome
672 }
673 pub const fn status(&self) -> Option<u16> {
674 self.status
675 }
676 pub const fn response_bytes(&self) -> u64 {
677 self.response_bytes
678 }
679 pub const fn epoch(&self) -> u8 {
680 self.epoch
681 }
682}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685enum SuppliedSessionResponseCookieUpdateKind {
686 None,
687 UnselectedOnly,
688 Selected,
689 Unusable,
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
693struct SuppliedSessionResponseCookieUpdate {
694 kind: SuppliedSessionResponseCookieUpdateKind,
695 selected_count: u8,
696 unselected_count: u8,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
701pub struct SuppliedSessionCookiePolicyAudit {
702 declared_count: u8,
703 host_only_count: u8,
704 domain_count: u8,
705 secure_count: u8,
706 http_only_count: u8,
707 session_count: u8,
708 persistent_count: u8,
709 same_site_missing_count: u8,
710 same_site_strict_count: u8,
711 same_site_lax_count: u8,
712 same_site_none_count: u8,
713 update_policy: SuppliedSessionCookieUpdatePolicy,
714}
715
716impl SuppliedSessionCookiePolicyAudit {
717 pub const fn declared_count(&self) -> u8 {
718 self.declared_count
719 }
720 pub const fn host_only_count(&self) -> u8 {
721 self.host_only_count
722 }
723 pub const fn domain_count(&self) -> u8 {
724 self.domain_count
725 }
726 pub const fn secure_count(&self) -> u8 {
727 self.secure_count
728 }
729 pub const fn http_only_count(&self) -> u8 {
730 self.http_only_count
731 }
732 pub const fn session_count(&self) -> u8 {
733 self.session_count
734 }
735 pub const fn persistent_count(&self) -> u8 {
736 self.persistent_count
737 }
738 pub const fn same_site_missing_count(&self) -> u8 {
739 self.same_site_missing_count
740 }
741 pub const fn same_site_strict_count(&self) -> u8 {
742 self.same_site_strict_count
743 }
744 pub const fn same_site_lax_count(&self) -> u8 {
745 self.same_site_lax_count
746 }
747 pub const fn same_site_none_count(&self) -> u8 {
748 self.same_site_none_count
749 }
750 pub const fn update_policy(&self) -> SuppliedSessionCookieUpdatePolicy {
751 self.update_policy
752 }
753 pub const fn browser_semantics(&self) -> &'static str {
754 SUPPLIED_SESSION_NON_BROWSER_COOKIE_SEMANTICS
755 }
756}
757
758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
760pub struct SuppliedSessionCookieLifecycleAudit {
761 initial_epoch: u8,
762 final_epoch: u8,
763 selected_update_response_count: u8,
764 unselected_update_response_count: u8,
765 update_classification_failure_count: u8,
766 updates_applied: u8,
767}
768
769impl SuppliedSessionCookieLifecycleAudit {
770 pub const fn initial_epoch(&self) -> u8 {
771 self.initial_epoch
772 }
773 pub const fn final_epoch(&self) -> u8 {
774 self.final_epoch
775 }
776 pub const fn selected_update_response_count(&self) -> u8 {
777 self.selected_update_response_count
778 }
779 pub const fn unselected_update_response_count(&self) -> u8 {
780 self.unselected_update_response_count
781 }
782 pub const fn update_classification_failure_count(&self) -> u8 {
783 self.update_classification_failure_count
784 }
785 pub const fn updates_applied(&self) -> u8 {
786 self.updates_applied
787 }
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
792pub struct WebAssessmentSuppliedSessionAudit {
793 schema: &'static str,
794 capability_id: &'static str,
795 policy_reference: String,
796 application_reference: String,
797 principal_reference: String,
798 principal_alias: String,
799 principal_assurance: SuppliedSessionPrincipalAssurance,
800 credential_mechanism: SuppliedSessionCredentialMechanism,
801 #[serde(skip_serializing_if = "legacy_credential_acquisition")]
802 credential_acquisition: SuppliedSessionCredentialAcquisition,
803 health_oracle: SuppliedSessionHealthOracleAudit,
804 outcome: SuppliedSessionAuditOutcome,
805 coverage: SuppliedSessionCoverage,
806 checkpoints: Vec<WebAssessmentSuppliedSessionCheckpointAudit>,
807 resources: Vec<WebAssessmentSuppliedSessionResourceAudit>,
808 selected_resource_count: u8,
809 dispatched_resource_count: u8,
810 committed_resource_count: u8,
811 dispatched_request_count: u8,
812 response_bytes: u64,
813 response_byte_limit: u64,
814 response_byte_limit_exceeded: bool,
815 refresh_performed: bool,
816 anonymous_fallback_performed: bool,
817 continuous_authentication_established: bool,
818 exploit_execution_performed: bool,
819 impact_validation_performed: bool,
820 #[serde(skip_serializing_if = "Option::is_none")]
821 cookie_policy: Option<SuppliedSessionCookiePolicyAudit>,
822 #[serde(skip_serializing_if = "Option::is_none")]
823 cookie_lifecycle: Option<SuppliedSessionCookieLifecycleAudit>,
824 #[serde(skip_serializing_if = "Option::is_none")]
825 login: Option<SuppliedSessionLoginAudit>,
826 #[serde(skip_serializing_if = "Option::is_none")]
827 renewal: Option<SuppliedSessionRenewalAudit>,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 oauth_code: Option<Box<SuppliedSessionOAuthCodeAudit>>,
831 #[serde(skip_serializing_if = "Option::is_none")]
832 oauth_refresh: Option<Box<SuppliedSessionOAuthRefreshAudit>>,
833 #[serde(skip_serializing_if = "Option::is_none")]
834 oauth_authorization: Option<Box<SuppliedSessionOAuthAuthorizationAudit>>,
835 #[serde(skip_serializing_if = "Option::is_none")]
836 oauth_token_exchange: Option<Box<SuppliedSessionOAuthInitiatedTokenAudit>>,
837}
838
839#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
841pub struct SuppliedSessionRenewalAudit {
842 renewal_reference: String,
843 after_resource_count: u8,
844 attempt_count: u8,
845 status: Option<u16>,
846 body_state: SuppliedSessionBodyState,
847 outcome: SuppliedSessionLoginSubmitOutcome,
848 response_bytes: u64,
849 applied_cookie_count: u8,
850 initial_epoch: u8,
851 final_epoch: u8,
852 #[serde(skip_serializing_if = "Option::is_none")]
853 health: Option<WebAssessmentSuppliedSessionCheckpointAudit>,
854 #[serde(skip)]
855 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
856}
857
858impl SuppliedSessionRenewalAudit {
859 pub fn renewal_reference(&self) -> &str {
860 &self.renewal_reference
861 }
862 pub const fn after_resource_count(&self) -> u8 {
863 self.after_resource_count
864 }
865 pub const fn attempt_count(&self) -> u8 {
866 self.attempt_count
867 }
868 pub const fn status(&self) -> Option<u16> {
869 self.status
870 }
871 pub const fn body_state(&self) -> SuppliedSessionBodyState {
872 self.body_state
873 }
874 pub const fn outcome(&self) -> SuppliedSessionLoginSubmitOutcome {
875 self.outcome
876 }
877 pub const fn response_bytes(&self) -> u64 {
878 self.response_bytes
879 }
880 pub const fn applied_cookie_count(&self) -> u8 {
881 self.applied_cookie_count
882 }
883 pub const fn initial_epoch(&self) -> u8 {
884 self.initial_epoch
885 }
886 pub const fn final_epoch(&self) -> u8 {
887 self.final_epoch
888 }
889 pub fn health(&self) -> Option<&WebAssessmentSuppliedSessionCheckpointAudit> {
890 self.health.as_ref()
891 }
892}
893
894const fn legacy_credential_acquisition(value: &SuppliedSessionCredentialAcquisition) -> bool {
895 matches!(
896 value,
897 SuppliedSessionCredentialAcquisition::SuppliedAuthorization
898 | SuppliedSessionCredentialAcquisition::SuppliedCookieJar
899 )
900}
901
902impl WebAssessmentSuppliedSessionAudit {
903 pub fn oauth_authorization(&self) -> Option<&SuppliedSessionOAuthAuthorizationAudit> {
904 self.oauth_authorization.as_deref()
905 }
906 pub fn oauth_token_exchange(&self) -> Option<&SuppliedSessionOAuthInitiatedTokenAudit> {
907 self.oauth_token_exchange.as_deref()
908 }
909 pub fn oauth_refresh(&self) -> Option<&SuppliedSessionOAuthRefreshAudit> {
910 self.oauth_refresh.as_deref()
911 }
912 pub fn oauth_code(&self) -> Option<&SuppliedSessionOAuthCodeAudit> {
913 self.oauth_code.as_deref()
914 }
915 pub fn renewal(&self) -> Option<&SuppliedSessionRenewalAudit> {
916 self.renewal.as_ref()
917 }
918 pub const fn schema(&self) -> &'static str {
919 self.schema
920 }
921 pub const fn capability_id(&self) -> &'static str {
922 self.capability_id
923 }
924 pub fn policy_reference(&self) -> &str {
925 &self.policy_reference
926 }
927 pub fn application_reference(&self) -> &str {
928 &self.application_reference
929 }
930 pub fn principal_reference(&self) -> &str {
931 &self.principal_reference
932 }
933 pub fn principal_alias(&self) -> &str {
935 &self.principal_alias
936 }
937 pub const fn principal_assurance(&self) -> SuppliedSessionPrincipalAssurance {
938 self.principal_assurance
939 }
940 pub const fn credential_mechanism(&self) -> SuppliedSessionCredentialMechanism {
941 self.credential_mechanism
942 }
943 pub const fn credential_acquisition(&self) -> SuppliedSessionCredentialAcquisition {
944 self.credential_acquisition
945 }
946 pub const fn health_oracle(&self) -> &SuppliedSessionHealthOracleAudit {
947 &self.health_oracle
948 }
949 pub const fn outcome(&self) -> SuppliedSessionAuditOutcome {
950 self.outcome
951 }
952 pub const fn coverage(&self) -> SuppliedSessionCoverage {
953 self.coverage
954 }
955 pub fn checkpoints(&self) -> &[WebAssessmentSuppliedSessionCheckpointAudit] {
956 &self.checkpoints
957 }
958 pub fn resources(&self) -> &[WebAssessmentSuppliedSessionResourceAudit] {
959 &self.resources
960 }
961 pub const fn selected_resource_count(&self) -> u8 {
962 self.selected_resource_count
963 }
964 pub const fn dispatched_resource_count(&self) -> u8 {
965 self.dispatched_resource_count
966 }
967 pub const fn committed_resource_count(&self) -> u8 {
968 self.committed_resource_count
969 }
970 pub const fn dispatched_request_count(&self) -> u8 {
971 self.dispatched_request_count
972 }
973 pub const fn response_bytes(&self) -> u64 {
974 self.response_bytes
975 }
976 pub const fn response_byte_limit(&self) -> u64 {
979 self.response_byte_limit
980 }
981 pub const fn response_byte_limit_exceeded(&self) -> bool {
982 self.response_byte_limit_exceeded
983 }
984 pub const fn refresh_performed(&self) -> bool {
985 self.refresh_performed
986 }
987 pub const fn anonymous_fallback_performed(&self) -> bool {
988 self.anonymous_fallback_performed
989 }
990 pub const fn continuous_authentication_established(&self) -> bool {
991 self.continuous_authentication_established
992 }
993 pub const fn exploit_execution_performed(&self) -> bool {
994 self.exploit_execution_performed
995 }
996 pub const fn impact_validation_performed(&self) -> bool {
997 self.impact_validation_performed
998 }
999 pub const fn cookie_policy(&self) -> Option<&SuppliedSessionCookiePolicyAudit> {
1000 self.cookie_policy.as_ref()
1001 }
1002 pub const fn cookie_lifecycle(&self) -> Option<&SuppliedSessionCookieLifecycleAudit> {
1003 self.cookie_lifecycle.as_ref()
1004 }
1005 pub const fn login(&self) -> Option<&SuppliedSessionLoginAudit> {
1006 self.login.as_ref()
1007 }
1008}
1009
1010pub(super) struct SuppliedSessionRuntimeConfig {
1011 policy: SuppliedSessionPolicy,
1012 input: SuppliedSessionRuntimeInput,
1013 requests: HttpRequestBroker,
1014 login_page: Option<SuppliedSessionRequestDescriptor>,
1015 login_submit: Option<SuppliedSessionRequestDescriptor>,
1016 oauth_code_requests: Option<Box<OAuthCodeRequestBroker>>,
1019 oauth_code_descriptor: Option<Box<OAuthCodeRequestDescriptor>>,
1020 oauth_refresh_descriptor: Option<Box<OAuthRefreshRequestDescriptor>>,
1021 health: SuppliedSessionRequestDescriptor,
1022 resources: Vec<(SuppliedSessionRequestDescriptor, String)>,
1023}
1024
1025impl SuppliedSessionRuntimeConfig {
1026 pub(super) fn new<C>(
1027 policy: SuppliedSessionPolicy,
1028 credential: C,
1029 authority: &SharedWebRuntimeAuthority,
1030 ) -> Result<Self, ()>
1031 where
1032 C: Into<SuppliedSessionRuntimeInput>,
1033 {
1034 let input = credential.into();
1035 if !input.matches_policy(&policy) {
1036 return Err(());
1037 }
1038 let requests = authority
1039 .requests()
1040 .isolated_supplied_session()
1041 .map_err(|_| ())?;
1042 let health = SuppliedSessionRequestDescriptor::health(&policy);
1043 let (login_page, login_submit) = if policy.version() == SuppliedSessionPolicyVersion::V3 {
1044 (
1045 Some(SuppliedSessionRequestDescriptor::login_page(&policy).ok_or(())?),
1046 Some(SuppliedSessionRequestDescriptor::login_submit(&policy).ok_or(())?),
1047 )
1048 } else {
1049 (None, None)
1050 };
1051 let resources = policy
1052 .execution_resources()
1053 .map(|(target, reference)| {
1054 SuppliedSessionRequestDescriptor::resource(&policy, target, reference)
1055 .map(|descriptor| (descriptor, reference.to_owned()))
1056 })
1057 .collect::<Option<Vec<_>>>()
1058 .ok_or(())?;
1059 let (oauth_code_requests, oauth_code_descriptor) = if matches!(
1060 policy.version(),
1061 SuppliedSessionPolicyVersion::V5
1062 | SuppliedSessionPolicyVersion::V6
1063 | SuppliedSessionPolicyVersion::V7
1064 ) {
1065 (
1066 Some(Box::new(authority.mint_oauth_code_broker(&policy)?)),
1067 Some(Box::new(
1068 OAuthCodeRequestDescriptor::from_policy(&policy).ok_or(())?,
1069 )),
1070 )
1071 } else {
1072 (None, None)
1073 };
1074 let oauth_refresh_descriptor = if policy.version() == SuppliedSessionPolicyVersion::V6 {
1075 Some(Box::new(
1076 OAuthRefreshRequestDescriptor::from_policy(&policy).ok_or(())?,
1077 ))
1078 } else {
1079 None
1080 };
1081 Ok(Self {
1082 policy,
1083 input,
1084 requests,
1085 login_page,
1086 login_submit,
1087 oauth_code_requests,
1088 oauth_code_descriptor,
1089 oauth_refresh_descriptor,
1090 health,
1091 resources,
1092 })
1093 }
1094
1095 pub(super) async fn execute(
1096 self,
1097 authority: &SharedWebRuntimeAuthority,
1098 resource_observer: Option<&dyn SuppliedSessionCommittedResourceObserver>,
1099 ) -> WebAssessmentSuppliedSessionAudit {
1100 let Self {
1101 policy,
1102 input,
1103 requests,
1104 login_page,
1105 login_submit,
1106 oauth_code_requests,
1107 oauth_code_descriptor,
1108 oauth_refresh_descriptor,
1109 health,
1110 resources,
1111 } = self;
1112 let selected_resource_count = u8::try_from(resources.len()).unwrap_or(u8::MAX);
1113 let timing = authority.start();
1114 let local_deadline = tokio::time::Instant::now()
1115 .checked_add(Duration::from_millis(policy.max_wall_time_ms()));
1116 let mut session_deadline = earliest_deadline(timing.deadline(), local_deadline);
1117 let mut deadline = session_deadline;
1118 let mut state = ExecutionState::new(&policy, selected_resource_count);
1119 if resource_observer.is_some_and(|observer| observer.begin(&policy, &resources).is_err()) {
1120 resource_observer.expect("observer remains present").fail();
1121 }
1122
1123 let mut oauth_receipt = None;
1124 let mut oauth_refresh_token = None;
1125 let mut oauth_refresh_receipt = None;
1126 let mut credential = match input {
1127 SuppliedSessionRuntimeInput::Ready(credential) => credential,
1128 SuppliedSessionRuntimeInput::OAuthAuthorization(output) => {
1129 let mint = match authority.mint_oauth_authorization_callback(&policy) {
1130 Ok(mint) => mint,
1131 Err(()) => {
1132 state.outcome = SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable;
1133 state.add_not_dispatched(&resources);
1134 return state.finish(policy);
1135 },
1136 };
1137 let (authorization_audit, permit) = boxed_dispatch_execution(|| {
1138 oauth_authorization::receive(authority, mint, &policy, *output)
1139 })
1140 .await;
1141 let authorization_outcome = authorization_audit.outcome();
1142 state.oauth_authorization = Some(Box::new(authorization_audit));
1143 let permit = match permit {
1144 Ok(permit) => permit,
1145 Err(()) => {
1146 state.outcome = match authorization_outcome {
1147 SuppliedSessionOAuthAuthorizationOutcome::Cancelled => {
1148 SuppliedSessionAuditOutcome::Cancelled
1149 },
1150 SuppliedSessionOAuthAuthorizationOutcome::RuntimeLimit => {
1151 SuppliedSessionAuditOutcome::RuntimeLimit
1152 },
1153 _ => SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable,
1154 };
1155 state.add_not_dispatched(&resources);
1156 return state.finish(policy);
1157 },
1158 };
1159 session_deadline = earliest_deadline(timing.deadline(), Some(permit.deadline()));
1162 deadline = session_deadline;
1163 let (Some(idp_requests), Some(descriptor)) =
1164 (oauth_code_requests.as_ref(), oauth_code_descriptor.as_ref())
1165 else {
1166 state.outcome = SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable;
1167 state.add_not_dispatched(&resources);
1168 return state.finish(policy);
1169 };
1170 let (audit, acquired) = boxed_dispatch_execution(|| {
1171 oauth_code::acquire_authorized(
1172 OAuthCodeAcquisitionContext {
1173 authority,
1174 requests: idp_requests,
1175 descriptor,
1176 policy: &policy,
1177 deadline,
1178 budget: state.dispatch_budget(),
1179 },
1180 permit,
1181 )
1182 })
1183 .await;
1184 state.response_bytes = state.response_bytes.saturating_add(audit.response_bytes());
1185 state.oauth_code = Some(Box::new(audit));
1186 match acquired {
1187 Ok(acquired) => {
1188 deadline = earliest_deadline(
1189 session_deadline,
1190 tokio::time::Instant::now().checked_add(acquired.declared_lifetime),
1191 );
1192 oauth_receipt = Some(Box::new(acquired.receipt));
1193 acquired.credential
1194 },
1195 Err(outcome) => {
1196 state.outcome = outcome;
1197 state.add_not_dispatched(&resources);
1198 return state.finish(policy);
1199 },
1200 }
1201 },
1202 SuppliedSessionRuntimeInput::FormLogin(form_credential) => {
1203 let (Some(login_page), Some(login_submit)) =
1204 (login_page.as_ref(), login_submit.as_ref())
1205 else {
1206 state.outcome = SuppliedSessionAuditOutcome::LoginFormUnavailable;
1207 state.add_not_dispatched(&resources);
1208 return state.finish(policy);
1209 };
1210 state.begin_login(&policy, login_page);
1211 let acquisition_context = LoginDispatchContext {
1212 authority,
1213 requests: &requests,
1214 policy: &policy,
1215 deadline,
1216 };
1217 let login_page_result =
1218 dispatch_login_page(&acquisition_context, login_page, state.dispatch_budget())
1219 .await;
1220 let csrf = match state.record_login_page(login_page_result, &policy) {
1221 Ok(csrf) => csrf,
1222 Err(outcome) => {
1223 state.outcome = outcome;
1224 state.add_not_dispatched(&resources);
1225 return state.finish(policy);
1226 },
1227 };
1228 let body = match form_credential.compose_form_body(&policy, csrf.as_str()) {
1229 Ok(body) => body,
1230 Err(_) => {
1231 state.mark_login_form_body_invalid();
1232 state.outcome = SuppliedSessionAuditOutcome::LoginFormUnavailable;
1233 state.add_not_dispatched(&resources);
1234 return state.finish(policy);
1235 },
1236 };
1237 let login_submit_result = dispatch_login_submit(
1238 &acquisition_context,
1239 login_submit,
1240 &body,
1241 state.dispatch_budget(),
1242 )
1243 .await;
1244 match state.record_login_submit(login_submit_result, &policy) {
1245 Ok(credential) => credential,
1246 Err(outcome) => {
1247 state.outcome = outcome;
1248 state.add_not_dispatched(&resources);
1249 return state.finish(policy);
1250 },
1251 }
1252 },
1253 SuppliedSessionRuntimeInput::OAuthCode(grant) => {
1254 let (Some(idp_requests), Some(descriptor)) =
1255 (oauth_code_requests.as_ref(), oauth_code_descriptor.as_ref())
1256 else {
1257 state.outcome = SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable;
1258 state.add_not_dispatched(&resources);
1259 return state.finish(policy);
1260 };
1261 let (audit, acquired) = boxed_dispatch_execution(|| {
1264 oauth_code::acquire(
1265 OAuthCodeAcquisitionContext {
1266 authority,
1267 requests: idp_requests,
1268 descriptor,
1269 policy: &policy,
1270 deadline,
1271 budget: state.dispatch_budget(),
1272 },
1273 &grant,
1274 )
1275 })
1276 .await;
1277 state.response_bytes = state.response_bytes.saturating_add(audit.response_bytes());
1278 state.oauth_code = Some(Box::new(audit));
1279 match acquired {
1280 Ok(acquired) => {
1281 deadline = earliest_deadline(
1284 session_deadline,
1285 tokio::time::Instant::now().checked_add(acquired.declared_lifetime),
1286 );
1287 oauth_receipt = Some(Box::new(acquired.receipt));
1288 oauth_refresh_token = acquired.refresh_token.map(Box::new);
1289 acquired.credential
1290 },
1291 Err(outcome) => {
1292 state.outcome = outcome;
1293 state.add_not_dispatched(&resources);
1294 return state.finish(policy);
1295 },
1296 }
1297 },
1298 };
1299 let dispatch_context = DispatchContext {
1300 authority,
1301 requests: &requests,
1302 credential: &credential,
1303 credential_acquisition: oauth_receipt.as_deref(),
1304 credential_renewal: oauth_refresh_receipt.as_deref(),
1305 credential_epoch: state.epoch,
1306 policy: &policy,
1307 deadline,
1308 };
1309 let resource_commit_context = ResourceCommitContext {
1310 policy: &policy,
1311 knowledge: authority.knowledge(),
1312 observer: resource_observer,
1313 };
1314
1315 let startup = dispatch(
1316 &dispatch_context,
1317 &health,
1318 state.dispatch_budget(),
1319 DecisionActionOrigin::Bootstrap,
1320 SuppliedSessionRequestPurpose::Health,
1321 )
1322 .await;
1323 match state.record_health(
1324 startup,
1325 &health,
1326 SuppliedSessionHealthCheckpointPhase::Startup,
1327 0,
1328 &policy,
1329 authority.knowledge(),
1330 ) {
1331 Continue::Yes => state.mark_login_authenticated(),
1332 Continue::No(outcome) => {
1333 state.outcome = outcome;
1334 state.add_not_dispatched(&resources);
1335 return state.finish(policy);
1336 },
1337 }
1338
1339 for (index, (descriptor, reference)) in resources.iter().enumerate() {
1340 let dispatch_context = DispatchContext {
1341 authority,
1342 requests: &requests,
1343 credential: &credential,
1344 credential_acquisition: oauth_receipt.as_deref(),
1345 credential_renewal: oauth_refresh_receipt.as_deref(),
1346 credential_epoch: state.epoch,
1347 policy: &policy,
1348 deadline,
1349 };
1350 let sequence = u8::try_from(index).unwrap_or(u8::MAX);
1351 let resource = dispatch(
1352 &dispatch_context,
1353 descriptor,
1354 state.dispatch_budget(),
1355 DecisionActionOrigin::Planned,
1356 SuppliedSessionRequestPurpose::Resource,
1357 )
1358 .await;
1359 let staged = match state.record_resource(
1360 sequence,
1361 descriptor,
1362 reference,
1363 resource,
1364 resource_commit_context,
1365 ) {
1366 ResourceContinuation::AwaitingHealth(staged) => staged,
1367 ResourceContinuation::No(outcome) => {
1368 state.outcome = outcome;
1369 state.add_not_dispatched(&resources[index + 1..]);
1370 return state.finish(policy);
1371 },
1372 };
1373
1374 let phase = if index + 1 == resources.len() {
1375 SuppliedSessionHealthCheckpointPhase::Terminal
1376 } else {
1377 SuppliedSessionHealthCheckpointPhase::SubjectBoundary
1378 };
1379 let health_result = dispatch(
1380 &dispatch_context,
1381 &health,
1382 state.dispatch_budget_with_pending_resource(staged),
1383 DecisionActionOrigin::Planned,
1384 SuppliedSessionRequestPurpose::Health,
1385 )
1386 .await;
1387 let after_subject_count = u8::try_from(index + 1).unwrap_or(u8::MAX);
1388 match state.record_health(
1389 health_result,
1390 &health,
1391 phase,
1392 after_subject_count,
1393 &policy,
1394 authority.knowledge(),
1395 ) {
1396 Continue::Yes => {
1397 if let Continue::No(outcome) = state.record_health_qualified_resource(
1398 sequence,
1399 descriptor,
1400 reference,
1401 staged,
1402 resource_commit_context,
1403 ) {
1404 state.outcome = outcome;
1405 state.add_not_dispatched(&resources[index + 1..]);
1406 return state.finish(policy);
1407 }
1408 },
1409 Continue::No(outcome) => {
1410 state.record_health_unqualified_resource(
1411 sequence,
1412 descriptor,
1413 reference,
1414 staged,
1415 &policy,
1416 authority.knowledge(),
1417 );
1418 if let Some(observer) = resource_observer {
1419 observer.discard_response(sequence);
1420 }
1421 state.outcome = outcome;
1422 state.add_not_dispatched(&resources[index + 1..]);
1423 return state.finish(policy);
1424 },
1425 }
1426 if policy.renewal_after_resource_count() == Some(after_subject_count) {
1427 let renewal = SuppliedSessionRequestDescriptor::renewal(&policy)
1428 .expect("V4 policy owns the validated renewal descriptor");
1429 let result = dispatch(
1430 &dispatch_context,
1431 &renewal,
1432 state.dispatch_budget(),
1433 DecisionActionOrigin::Planned,
1434 SuppliedSessionRequestPurpose::Renewal,
1435 )
1436 .await;
1437 let replacement = match state.record_renewal(result, &renewal, &policy) {
1438 Ok(replacement) => replacement,
1439 Err(outcome) => {
1440 state.outcome = outcome;
1441 state.add_not_dispatched(&resources[index + 1..]);
1442 return state.finish(policy);
1443 },
1444 };
1445 credential = replacement;
1446 let dispatch_context = DispatchContext {
1447 authority,
1448 requests: &requests,
1449 credential: &credential,
1450 credential_acquisition: oauth_receipt.as_deref(),
1451 credential_renewal: oauth_refresh_receipt.as_deref(),
1452 credential_epoch: state.epoch,
1453 policy: &policy,
1454 deadline,
1455 };
1456 let result = dispatch(
1457 &dispatch_context,
1458 &health,
1459 state.dispatch_budget(),
1460 DecisionActionOrigin::Planned,
1461 SuppliedSessionRequestPurpose::Health,
1462 )
1463 .await;
1464 let previous_checkpoint_count = state.checkpoints.len();
1465 let continued = state.record_health(
1466 result,
1467 &health,
1468 SuppliedSessionHealthCheckpointPhase::Renewal,
1469 after_subject_count,
1470 &policy,
1471 authority.knowledge(),
1472 );
1473 let renewal_health = (state.checkpoints.len() > previous_checkpoint_count)
1474 .then(|| state.checkpoints.pop())
1475 .flatten();
1476 state
1477 .renewal
1478 .as_mut()
1479 .expect("renewal is initialized")
1480 .health = renewal_health;
1481 if let Continue::No(outcome) = continued {
1482 state.outcome = outcome;
1483 state.add_not_dispatched(&resources[index + 1..]);
1484 return state.finish(policy);
1485 }
1486 }
1487 if policy
1488 .oauth_refresh_policy()
1489 .is_some_and(|refresh| refresh.after_resource_count() == after_subject_count)
1490 {
1491 let (
1492 Some(idp_requests),
1493 Some(refresh_descriptor),
1494 Some(code),
1495 Some(old_refresh),
1496 Some(resource),
1497 Some(boundary_health),
1498 ) = (
1499 oauth_code_requests.as_deref(),
1500 oauth_refresh_descriptor.as_deref(),
1501 oauth_receipt.as_deref(),
1502 oauth_refresh_token.take(),
1503 state.resources.last(),
1504 state.checkpoints.last(),
1505 )
1506 else {
1507 if let Some(audit) = state.oauth_refresh.as_mut() {
1508 audit.context_unavailable();
1509 }
1510 state.outcome = SuppliedSessionAuditOutcome::OAuthRefreshUnavailable;
1511 state.add_not_dispatched(&resources[index + 1..]);
1512 return state.finish(policy);
1513 };
1514 let refresh_context = OAuthRefreshContext {
1515 authority,
1516 requests: idp_requests,
1517 descriptor: refresh_descriptor,
1518 policy: &policy,
1519 code,
1520 resource,
1521 health: boundary_health,
1522 deadline: session_deadline,
1525 budget: state.dispatch_budget(),
1526 };
1527 let (audit, acquired) = boxed_dispatch_execution(|| {
1528 oauth_refresh::execute_refresh(refresh_context, &old_refresh)
1529 })
1530 .await;
1531 drop(old_refresh);
1534 state.response_bytes = state.response_bytes.saturating_add(audit.response_bytes());
1535 state.oauth_refresh = Some(Box::new(audit));
1536 match acquired {
1537 Ok(acquired) => {
1538 credential = acquired.credential;
1539 oauth_refresh_receipt = Some(Box::new(acquired.receipt));
1540 state.epoch = 2;
1541 deadline = earliest_deadline(
1542 session_deadline,
1543 tokio::time::Instant::now().checked_add(acquired.declared_lifetime),
1544 );
1545 },
1546 Err(outcome) => {
1547 state.outcome = outcome;
1548 state.add_not_dispatched(&resources[index + 1..]);
1549 return state.finish(policy);
1550 },
1551 }
1552 let renewed_context = DispatchContext {
1553 authority,
1554 requests: &requests,
1555 credential: &credential,
1556 credential_acquisition: oauth_receipt.as_deref(),
1557 credential_renewal: oauth_refresh_receipt.as_deref(),
1558 credential_epoch: state.epoch,
1559 policy: &policy,
1560 deadline,
1561 };
1562 let result = dispatch(
1563 &renewed_context,
1564 &health,
1565 state.dispatch_budget(),
1566 DecisionActionOrigin::Planned,
1567 SuppliedSessionRequestPurpose::Health,
1568 )
1569 .await;
1570 let previous_count = state.checkpoints.len();
1571 let continued = state.record_health(
1572 result,
1573 &health,
1574 SuppliedSessionHealthCheckpointPhase::Renewal,
1575 after_subject_count,
1576 &policy,
1577 authority.knowledge(),
1578 );
1579 if state.checkpoints.len() > previous_count {
1580 if let Some(health) = state.checkpoints.pop() {
1581 state
1582 .oauth_refresh
1583 .as_mut()
1584 .expect("selected refresh audit")
1585 .set_health(health);
1586 }
1587 }
1588 if let Continue::No(outcome) = continued {
1589 state.outcome = outcome;
1590 state.add_not_dispatched(&resources[index + 1..]);
1591 return state.finish(policy);
1592 }
1593 }
1594 }
1595 state.outcome = SuppliedSessionAuditOutcome::Complete;
1596 state.finish(policy)
1597 }
1598
1599 #[cfg(feature = "websocket-review")]
1605 pub(super) async fn execute_with_websocket(
1606 self,
1607 authority: &SharedWebRuntimeAuthority,
1608 websocket: &WebSocketReviewPolicy,
1609 ) -> (
1610 WebAssessmentSuppliedSessionAudit,
1611 WebAssessmentWebSocketReviewAudit,
1612 ) {
1613 let Self {
1614 policy,
1615 input,
1616 requests,
1617 login_page,
1618 login_submit,
1619 oauth_code_requests: _,
1620 oauth_code_descriptor: _,
1621 oauth_refresh_descriptor: _,
1622 health,
1623 resources,
1624 } = self;
1625 let selected_resource_count = u8::try_from(resources.len()).unwrap_or(u8::MAX);
1626 let timing = authority.start();
1627 let local_deadline = tokio::time::Instant::now()
1628 .checked_add(Duration::from_millis(policy.max_wall_time_ms()));
1629 let deadline = earliest_deadline(timing.deadline(), local_deadline);
1630 let mut state = ExecutionState::new(&policy, selected_resource_count);
1631
1632 if policy.version() != SuppliedSessionPolicyVersion::V1
1633 || policy.credential_mechanism()
1634 != SuppliedSessionCredentialMechanism::AuthorizationHeader
1635 || policy.resource_count() != 1
1636 || login_page.is_some()
1637 || login_submit.is_some()
1638 || resources.len() != 1
1639 {
1640 state.outcome = SuppliedSessionAuditOutcome::ResourceUnavailable;
1641 state.add_not_dispatched(&resources);
1642 let websocket_audit = supplied_session_websocket_not_run(
1643 websocket,
1644 &policy,
1645 &state,
1646 WebSocketReviewFailureKind::ParentAuthorityUnavailable,
1647 );
1648 return (state.finish(policy), websocket_audit);
1649 }
1650 let credential = match input {
1651 SuppliedSessionRuntimeInput::Ready(SuppliedSessionCredential::Authorization(value)) => {
1652 SuppliedSessionCredential::Authorization(value)
1653 },
1654 SuppliedSessionRuntimeInput::Ready(SuppliedSessionCredential::SuppliedCookies(_))
1655 | SuppliedSessionRuntimeInput::FormLogin(_)
1656 | SuppliedSessionRuntimeInput::OAuthCode(_)
1657 | SuppliedSessionRuntimeInput::OAuthAuthorization(_) => {
1658 state.outcome = SuppliedSessionAuditOutcome::ResourceUnavailable;
1659 state.add_not_dispatched(&resources);
1660 let websocket_audit = supplied_session_websocket_not_run(
1661 websocket,
1662 &policy,
1663 &state,
1664 WebSocketReviewFailureKind::ParentAuthorityUnavailable,
1665 );
1666 return (state.finish(policy), websocket_audit);
1667 },
1668 };
1669 let dispatch_context = DispatchContext {
1670 authority,
1671 requests: &requests,
1672 credential: &credential,
1673 credential_acquisition: None,
1674 credential_renewal: None,
1675 credential_epoch: 1,
1676 policy: &policy,
1677 deadline,
1678 };
1679 let resource_commit_context = ResourceCommitContext {
1680 policy: &policy,
1681 knowledge: authority.knowledge(),
1682 observer: None,
1683 };
1684
1685 let startup = dispatch(
1686 &dispatch_context,
1687 &health,
1688 state.dispatch_budget(),
1689 DecisionActionOrigin::Bootstrap,
1690 SuppliedSessionRequestPurpose::Health,
1691 )
1692 .await;
1693 match state.record_health(
1694 startup,
1695 &health,
1696 SuppliedSessionHealthCheckpointPhase::Startup,
1697 0,
1698 &policy,
1699 authority.knowledge(),
1700 ) {
1701 Continue::Yes => state.mark_login_authenticated(),
1702 Continue::No(outcome) => {
1703 state.outcome = outcome;
1704 state.add_not_dispatched(&resources);
1705 let websocket_audit = supplied_session_websocket_not_run(
1706 websocket,
1707 &policy,
1708 &state,
1709 WebSocketReviewFailureKind::ParentAuthorityUnavailable,
1710 );
1711 return (state.finish(policy), websocket_audit);
1712 },
1713 }
1714 let Some(startup_receipt) = state.checkpoints.last().and_then(|checkpoint| {
1715 CommittedSuppliedSessionHealthReceipt::from_checkpoint(
1716 &policy,
1717 checkpoint,
1718 SuppliedSessionHealthCheckpointPhase::Startup,
1719 0,
1720 authority.knowledge(),
1721 )
1722 }) else {
1723 state.outcome = SuppliedSessionAuditOutcome::ResourceUnavailable;
1724 state.add_not_dispatched(&resources);
1725 let websocket_audit = supplied_session_websocket_not_run(
1726 websocket,
1727 &policy,
1728 &state,
1729 WebSocketReviewFailureKind::ParentAuthorityUnavailable,
1730 );
1731 return (state.finish(policy), websocket_audit);
1732 };
1733
1734 let (descriptor, reference) = &resources[0];
1735 let resource = dispatch(
1736 &dispatch_context,
1737 descriptor,
1738 state.dispatch_budget(),
1739 DecisionActionOrigin::Planned,
1740 SuppliedSessionRequestPurpose::Resource,
1741 )
1742 .await;
1743 let staged = match state.record_resource(
1744 0,
1745 descriptor,
1746 reference,
1747 resource,
1748 resource_commit_context,
1749 ) {
1750 ResourceContinuation::AwaitingHealth(staged) => staged,
1751 ResourceContinuation::No(outcome) => {
1752 state.outcome = outcome;
1753 let websocket_audit = supplied_session_websocket_not_run(
1754 websocket,
1755 &policy,
1756 &state,
1757 WebSocketReviewFailureKind::ParentAuthorityUnavailable,
1758 );
1759 return (state.finish(policy), websocket_audit);
1760 },
1761 };
1762
1763 let authorization = credential
1764 .authorization()
1765 .expect("combined V1 credential was matched above");
1766 let permit = SuppliedSessionWebSocketPermit::new(
1767 &policy,
1768 authorization,
1769 websocket,
1770 deadline,
1771 startup_receipt,
1772 );
1773 let mut websocket_audit =
1774 execute_supplied_session_websocket_review(authority, websocket, &permit).await;
1775
1776 let terminal = dispatch(
1777 &dispatch_context,
1778 &health,
1779 state.dispatch_budget_with_pending_resource(staged),
1780 DecisionActionOrigin::Planned,
1781 SuppliedSessionRequestPurpose::Health,
1782 )
1783 .await;
1784 let health_qualification = match state.record_health(
1785 terminal,
1786 &health,
1787 SuppliedSessionHealthCheckpointPhase::Terminal,
1788 1,
1789 &policy,
1790 authority.knowledge(),
1791 ) {
1792 Continue::Yes => {
1793 let terminal_receipt = state.checkpoints.last().and_then(|checkpoint| {
1794 CommittedSuppliedSessionHealthReceipt::from_checkpoint(
1795 &policy,
1796 checkpoint,
1797 SuppliedSessionHealthCheckpointPhase::Terminal,
1798 1,
1799 authority.knowledge(),
1800 )
1801 });
1802 if terminal_receipt.is_none() {
1803 state.record_health_unqualified_resource(
1804 0,
1805 descriptor,
1806 reference,
1807 staged,
1808 &policy,
1809 authority.knowledge(),
1810 );
1811 state.outcome = SuppliedSessionAuditOutcome::ResourceUnavailable;
1812 WebSocketSuppliedSessionQualification::ContextUnqualified
1813 } else if let Continue::No(outcome) = state.record_health_qualified_resource(
1814 0,
1815 descriptor,
1816 reference,
1817 staged,
1818 resource_commit_context,
1819 ) {
1820 state.outcome = outcome;
1821 WebSocketSuppliedSessionQualification::ContextQualified
1822 } else {
1823 state.outcome = SuppliedSessionAuditOutcome::Complete;
1824 WebSocketSuppliedSessionQualification::ContextQualified
1825 }
1826 },
1827 Continue::No(outcome) => {
1828 state.record_health_unqualified_resource(
1829 0,
1830 descriptor,
1831 reference,
1832 staged,
1833 &policy,
1834 authority.knowledge(),
1835 );
1836 state.outcome = outcome;
1837 WebSocketSuppliedSessionQualification::ContextUnqualified
1838 },
1839 };
1840 let qualification = if websocket_audit.request_admitted_count() == 0 {
1841 WebSocketSuppliedSessionQualification::NotEstablished
1842 } else {
1843 health_qualification
1844 };
1845 websocket_audit.bind_supplied_session_context(supplied_session_websocket_context(
1846 &policy,
1847 &state,
1848 qualification,
1849 ));
1850 debug_assert!(websocket_audit.is_consistent());
1851 (state.finish(policy), websocket_audit)
1852 }
1853}
1854
1855#[cfg(feature = "websocket-review")]
1856fn supplied_session_websocket_not_run(
1857 websocket: &WebSocketReviewPolicy,
1858 policy: &SuppliedSessionPolicy,
1859 state: &ExecutionState,
1860 failure: WebSocketReviewFailureKind,
1861) -> WebAssessmentWebSocketReviewAudit {
1862 let mut audit = websocket_review_not_run(websocket, failure);
1863 audit.bind_supplied_session_context(supplied_session_websocket_context(
1864 policy,
1865 state,
1866 WebSocketSuppliedSessionQualification::NotEstablished,
1867 ));
1868 debug_assert!(audit.is_consistent());
1869 audit
1870}
1871
1872#[cfg(feature = "websocket-review")]
1873fn supplied_session_websocket_context(
1874 policy: &SuppliedSessionPolicy,
1875 state: &ExecutionState,
1876 qualification: WebSocketSuppliedSessionQualification,
1877) -> WebSocketSuppliedSessionContextAudit {
1878 let health = |phase| {
1879 state
1880 .checkpoints
1881 .iter()
1882 .find(|checkpoint| checkpoint.phase() == phase)
1883 .map(|checkpoint| {
1884 WebSocketSuppliedSessionHealthAudit::new(
1885 health_outcome_token(checkpoint.outcome()),
1886 checkpoint.evidence_reference(),
1887 )
1888 })
1889 };
1890 WebSocketSuppliedSessionContextAudit::new(
1891 policy.policy_reference(),
1892 policy.application_reference(),
1893 policy.principal_reference(),
1894 policy.principal_alias(),
1895 health(SuppliedSessionHealthCheckpointPhase::Startup),
1896 health(SuppliedSessionHealthCheckpointPhase::Terminal),
1897 qualification,
1898 )
1899}
1900
1901struct ExecutionState {
1902 checkpoints: Vec<WebAssessmentSuppliedSessionCheckpointAudit>,
1903 resources: Vec<WebAssessmentSuppliedSessionResourceAudit>,
1904 login: Option<SuppliedSessionLoginAudit>,
1905 outcome: SuppliedSessionAuditOutcome,
1906 selected_resource_count: u8,
1907 response_bytes: u64,
1908 epoch: u8,
1909 renewal: Option<SuppliedSessionRenewalAudit>,
1910 oauth_code: Option<Box<SuppliedSessionOAuthCodeAudit>>,
1911 oauth_refresh: Option<Box<SuppliedSessionOAuthRefreshAudit>>,
1912 oauth_authorization: Option<Box<SuppliedSessionOAuthAuthorizationAudit>>,
1913}
1914
1915#[derive(Clone, Copy)]
1916struct ResourceCommitContext<'a> {
1917 policy: &'a SuppliedSessionPolicy,
1918 knowledge: &'a KnowledgeBase,
1919 observer: Option<&'a dyn SuppliedSessionCommittedResourceObserver>,
1920}
1921
1922impl ExecutionState {
1923 fn new(policy: &SuppliedSessionPolicy, selected_resource_count: u8) -> Self {
1924 debug_assert!(policy.resource_count() <= MAX_SUPPLIED_SESSION_RESOURCES);
1925 Self {
1926 checkpoints: Vec::with_capacity(MAX_SUPPLIED_SESSION_CHECKPOINTS),
1927 resources: Vec::with_capacity(MAX_SUPPLIED_SESSION_RESOURCES),
1928 login: None,
1929 outcome: SuppliedSessionAuditOutcome::RuntimeLimit,
1930 selected_resource_count,
1931 response_bytes: 0,
1932 epoch: 1,
1933 oauth_code: SuppliedSessionOAuthCodeAudit::selected(policy).map(Box::new),
1934 oauth_refresh: SuppliedSessionOAuthRefreshAudit::selected(policy).map(Box::new),
1935 oauth_authorization: SuppliedSessionOAuthAuthorizationAudit::selected(policy)
1936 .map(Box::new),
1937 renewal: policy
1938 .renewal_after_resource_count()
1939 .map(|after_resource_count| SuppliedSessionRenewalAudit {
1940 renewal_reference: SuppliedSessionRequestDescriptor::renewal(policy)
1941 .expect("validated V4 descriptor")
1942 .target_reference()
1943 .to_owned(),
1944 after_resource_count,
1945 attempt_count: 0,
1946 status: None,
1947 body_state: SuppliedSessionBodyState::Unavailable,
1948 outcome: SuppliedSessionLoginSubmitOutcome::NotDispatched,
1949 response_bytes: 0,
1950 applied_cookie_count: 0,
1951 initial_epoch: 1,
1952 final_epoch: 1,
1953 health: None,
1954 cookie_update: None,
1955 }),
1956 }
1957 }
1958
1959 fn begin_login(
1960 &mut self,
1961 policy: &SuppliedSessionPolicy,
1962 descriptor: &SuppliedSessionRequestDescriptor,
1963 ) {
1964 debug_assert_eq!(policy.version(), SuppliedSessionPolicyVersion::V3);
1965 debug_assert_eq!(
1966 descriptor.purpose(),
1967 SuppliedSessionRequestPurpose::LoginPage
1968 );
1969 self.login = Some(SuppliedSessionLoginAudit {
1970 login_reference: descriptor.target_reference().to_owned(),
1971 max_attempts: policy.max_login_attempts().unwrap_or(0),
1972 attempt_count: 0,
1973 page_dispatched: false,
1974 page_status: None,
1975 page_body_state: SuppliedSessionBodyState::Unavailable,
1976 form_outcome: SuppliedSessionLoginFormOutcome::NotEvaluated,
1977 form_error_code: None,
1978 submit_dispatched: false,
1979 submit_status: None,
1980 submit_body_state: SuppliedSessionBodyState::Unavailable,
1981 submit_outcome: SuppliedSessionLoginSubmitOutcome::NotDispatched,
1982 acquired_cookie_count: 0,
1983 response_bytes: 0,
1984 initial_epoch: 0,
1985 final_epoch: 0,
1986 pre_session_cookie_applied: false,
1987 });
1988 }
1989
1990 fn record_login_page(
1991 &mut self,
1992 dispatch: DispatchResult,
1993 policy: &SuppliedSessionPolicy,
1994 ) -> Result<SuppliedSessionLoginCsrfToken, SuppliedSessionAuditOutcome> {
1995 let login = self
1996 .login
1997 .as_mut()
1998 .expect("V3 login state is initialized before page dispatch");
1999 match dispatch {
2000 DispatchResult::Response {
2001 response,
2002 bytes,
2003 exact_target,
2004 cookie_update: _,
2005 } => {
2006 login.page_dispatched = true;
2007 login.page_status = Some(response.status());
2008 login.page_body_state = if response.body_complete() {
2009 SuppliedSessionBodyState::Complete
2010 } else {
2011 SuppliedSessionBodyState::Incomplete
2012 };
2013 login.response_bytes = login.response_bytes.saturating_add(bytes);
2014 self.response_bytes = self.response_bytes.saturating_add(bytes);
2015 if !exact_target || response.supplied_session_has_set_cookie_fields() {
2016 login.form_outcome = SuppliedSessionLoginFormOutcome::IneligibleResponse;
2017 login.form_error_code = Some(if exact_target {
2018 "pre_session_cookie_unsupported"
2019 } else {
2020 "inexact_final_target"
2021 });
2022 return Err(SuppliedSessionAuditOutcome::LoginFormUnavailable);
2023 }
2024 match extract_supplied_session_login_csrf(&response, policy) {
2025 Ok(token) => {
2026 login.form_outcome = SuppliedSessionLoginFormOutcome::Matched;
2027 Ok(token)
2028 },
2029 Err(error) => {
2030 login.form_outcome = login_form_outcome(error);
2031 login.form_error_code = Some(error.as_str());
2032 Err(SuppliedSessionAuditOutcome::LoginFormUnavailable)
2033 },
2034 }
2035 },
2036 DispatchResult::Transport { bytes, dispatched } => {
2037 login.page_dispatched = dispatched;
2038 login.form_outcome = SuppliedSessionLoginFormOutcome::TransportFailed;
2039 login.form_error_code = Some("transport_failed");
2040 login.response_bytes = login.response_bytes.saturating_add(bytes);
2041 self.response_bytes = self.response_bytes.saturating_add(bytes);
2042 Err(SuppliedSessionAuditOutcome::LoginFormUnavailable)
2043 },
2044 DispatchResult::RuntimeLimit { bytes, dispatched } => {
2045 login.page_dispatched = dispatched;
2046 login.form_outcome = SuppliedSessionLoginFormOutcome::RuntimeLimit;
2047 login.form_error_code = Some("runtime_limit");
2048 login.response_bytes = login.response_bytes.saturating_add(bytes);
2049 self.response_bytes = self.response_bytes.saturating_add(bytes);
2050 Err(SuppliedSessionAuditOutcome::RuntimeLimit)
2051 },
2052 DispatchResult::Cancelled { bytes, dispatched } => {
2053 login.page_dispatched = dispatched;
2054 login.form_outcome = SuppliedSessionLoginFormOutcome::Cancelled;
2055 login.form_error_code = Some("cancelled");
2056 login.response_bytes = login.response_bytes.saturating_add(bytes);
2057 self.response_bytes = self.response_bytes.saturating_add(bytes);
2058 Err(SuppliedSessionAuditOutcome::Cancelled)
2059 },
2060 DispatchResult::InvalidDescriptor => {
2061 login.form_outcome = SuppliedSessionLoginFormOutcome::Invalid;
2062 login.form_error_code = Some("invalid_descriptor");
2063 Err(SuppliedSessionAuditOutcome::LoginFormUnavailable)
2064 },
2065 }
2066 }
2067
2068 fn mark_login_form_body_invalid(&mut self) {
2069 if let Some(login) = self.login.as_mut() {
2070 login.form_outcome = SuppliedSessionLoginFormOutcome::Invalid;
2071 login.form_error_code = Some("invalid_form_body");
2072 }
2073 }
2074
2075 fn record_login_submit(
2076 &mut self,
2077 dispatch: DispatchResult,
2078 policy: &SuppliedSessionPolicy,
2079 ) -> Result<SuppliedSessionCredential, SuppliedSessionAuditOutcome> {
2080 let login = self
2081 .login
2082 .as_mut()
2083 .expect("V3 login state is initialized before submit dispatch");
2084 match dispatch {
2085 DispatchResult::Response {
2086 response,
2087 bytes,
2088 exact_target,
2089 cookie_update: _,
2090 } => {
2091 login.submit_dispatched = true;
2092 login.attempt_count = 1;
2093 login.submit_status = Some(response.status());
2094 login.submit_body_state = if response.body_complete() {
2095 SuppliedSessionBodyState::Complete
2096 } else {
2097 SuppliedSessionBodyState::Incomplete
2098 };
2099 login.response_bytes = login.response_bytes.saturating_add(bytes);
2100 self.response_bytes = self.response_bytes.saturating_add(bytes);
2101 if !exact_target || (300..400).contains(&response.status()) {
2102 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::RedirectRefused;
2103 return Err(SuppliedSessionAuditOutcome::LoginSubmitUnavailable);
2104 }
2105 if !response.body_complete() {
2106 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::Incomplete;
2107 return Err(SuppliedSessionAuditOutcome::LoginSubmitUnavailable);
2108 }
2109 if response.status() != 200 {
2110 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::HttpError;
2111 return Err(SuppliedSessionAuditOutcome::LoginSubmitUnavailable);
2112 }
2113 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::CookieUnavailable;
2114 let now = unix_time_seconds()
2115 .ok_or(SuppliedSessionAuditOutcome::LoginCookieUnavailable)?;
2116 let cookies = response
2117 .supplied_session_form_login_cookies(policy, now)
2118 .map_err(|_| SuppliedSessionAuditOutcome::LoginCookieUnavailable)?;
2119 login.acquired_cookie_count =
2120 u8::try_from(cookies.cookie_count()).unwrap_or(u8::MAX);
2121 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::CookieAcquired;
2122 Ok(SuppliedSessionCredential::SuppliedCookies(cookies))
2123 },
2124 DispatchResult::Transport { bytes, dispatched } => {
2125 login.submit_dispatched = dispatched;
2126 login.attempt_count = u8::from(dispatched);
2127 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::TransportFailed;
2128 login.response_bytes = login.response_bytes.saturating_add(bytes);
2129 self.response_bytes = self.response_bytes.saturating_add(bytes);
2130 Err(SuppliedSessionAuditOutcome::LoginSubmitUnavailable)
2131 },
2132 DispatchResult::RuntimeLimit { bytes, dispatched } => {
2133 login.submit_dispatched = dispatched;
2134 login.attempt_count = u8::from(dispatched);
2135 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::RuntimeLimit;
2136 login.response_bytes = login.response_bytes.saturating_add(bytes);
2137 self.response_bytes = self.response_bytes.saturating_add(bytes);
2138 Err(SuppliedSessionAuditOutcome::RuntimeLimit)
2139 },
2140 DispatchResult::Cancelled { bytes, dispatched } => {
2141 login.submit_dispatched = dispatched;
2142 login.attempt_count = u8::from(dispatched);
2143 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::Cancelled;
2144 login.response_bytes = login.response_bytes.saturating_add(bytes);
2145 self.response_bytes = self.response_bytes.saturating_add(bytes);
2146 Err(SuppliedSessionAuditOutcome::Cancelled)
2147 },
2148 DispatchResult::InvalidDescriptor => {
2149 login.submit_outcome = SuppliedSessionLoginSubmitOutcome::NotDispatched;
2150 Err(SuppliedSessionAuditOutcome::LoginSubmitUnavailable)
2151 },
2152 }
2153 }
2154
2155 fn mark_login_authenticated(&mut self) {
2156 if let Some(login) = self.login.as_mut() {
2157 login.final_epoch = 1;
2158 }
2159 }
2160
2161 fn record_renewal(
2162 &mut self,
2163 dispatch: DispatchResult,
2164 descriptor: &SuppliedSessionRequestDescriptor,
2165 policy: &SuppliedSessionPolicy,
2166 ) -> Result<SuppliedSessionCredential, SuppliedSessionAuditOutcome> {
2167 let renewal = self
2168 .renewal
2169 .as_mut()
2170 .ok_or(SuppliedSessionAuditOutcome::RenewalUnavailable)?;
2171 if renewal.attempt_count != 0 || !descriptor.validate_against(policy) {
2172 return Err(SuppliedSessionAuditOutcome::RenewalUnavailable);
2173 }
2174 let (bytes, dispatched, outcome) = match dispatch {
2175 DispatchResult::Response {
2176 response,
2177 bytes,
2178 exact_target,
2179 cookie_update,
2180 } => {
2181 renewal.attempt_count = 1;
2182 renewal.response_bytes = bytes;
2183 renewal.status = Some(response.status());
2184 renewal.body_state = if response.body_complete() {
2185 SuppliedSessionBodyState::Complete
2186 } else {
2187 SuppliedSessionBodyState::Incomplete
2188 };
2189 renewal.cookie_update = cookie_update;
2190 renewal.outcome = if (300..400).contains(&response.status()) {
2191 SuppliedSessionLoginSubmitOutcome::RedirectRefused
2192 } else if !response.body_complete() {
2193 SuppliedSessionLoginSubmitOutcome::Incomplete
2194 } else if response.status() != 200 {
2195 SuppliedSessionLoginSubmitOutcome::HttpError
2196 } else {
2197 SuppliedSessionLoginSubmitOutcome::CookieUnavailable
2198 };
2199 self.response_bytes = self.response_bytes.saturating_add(bytes);
2200 if !exact_target
2201 || !cookie_update.is_some_and(|update| {
2202 update.kind == SuppliedSessionResponseCookieUpdateKind::Selected
2203 })
2204 {
2205 return Err(SuppliedSessionAuditOutcome::RenewalUnavailable);
2206 }
2207 let now =
2208 unix_time_seconds().ok_or(SuppliedSessionAuditOutcome::RenewalUnavailable)?;
2209 let cookies = response
2210 .supplied_session_renewal_cookies(policy, now)
2211 .map_err(|_| SuppliedSessionAuditOutcome::RenewalUnavailable)?;
2212 renewal.applied_cookie_count =
2213 u8::try_from(cookies.cookie_count()).unwrap_or(u8::MAX);
2214 renewal.outcome = SuppliedSessionLoginSubmitOutcome::CookieAcquired;
2215 renewal.final_epoch = 2;
2216 self.epoch = 2;
2217 return Ok(SuppliedSessionCredential::SuppliedCookies(cookies));
2218 },
2219 DispatchResult::Transport { bytes, dispatched } => (
2220 bytes,
2221 dispatched,
2222 SuppliedSessionAuditOutcome::RenewalUnavailable,
2223 ),
2224 DispatchResult::RuntimeLimit { bytes, dispatched } => {
2225 (bytes, dispatched, SuppliedSessionAuditOutcome::RuntimeLimit)
2226 },
2227 DispatchResult::Cancelled { bytes, dispatched } => {
2228 (bytes, dispatched, SuppliedSessionAuditOutcome::Cancelled)
2229 },
2230 DispatchResult::InvalidDescriptor => {
2231 (0, false, SuppliedSessionAuditOutcome::RenewalUnavailable)
2232 },
2233 };
2234 renewal.attempt_count = u8::from(dispatched);
2235 renewal.outcome = match outcome {
2236 SuppliedSessionAuditOutcome::RuntimeLimit => {
2237 SuppliedSessionLoginSubmitOutcome::RuntimeLimit
2238 },
2239 SuppliedSessionAuditOutcome::Cancelled => SuppliedSessionLoginSubmitOutcome::Cancelled,
2240 _ if dispatched => SuppliedSessionLoginSubmitOutcome::TransportFailed,
2241 _ => SuppliedSessionLoginSubmitOutcome::NotDispatched,
2242 };
2243 renewal.response_bytes = bytes;
2244 self.response_bytes = self.response_bytes.saturating_add(bytes);
2245 Err(outcome)
2246 }
2247
2248 fn dispatched_request_count(&self) -> u8 {
2249 let dispatched_resources = self
2250 .resources
2251 .iter()
2252 .filter(|resource| resource.outcome != SuppliedSessionResourceOutcome::NotDispatched)
2253 .count();
2254 let login_requests = self.login.as_ref().map_or(0, |login| {
2255 u8::from(login.page_dispatched).saturating_add(u8::from(login.submit_dispatched))
2256 });
2257 u8::try_from(self.checkpoints.len())
2258 .unwrap_or(u8::MAX)
2259 .saturating_add(u8::try_from(dispatched_resources).unwrap_or(u8::MAX))
2260 .saturating_add(login_requests)
2261 .saturating_add(
2262 self.oauth_code
2263 .as_ref()
2264 .map_or(0, |audit| audit.request_admitted_count()),
2265 )
2266 .saturating_add(self.renewal.as_ref().map_or(0, |renewal| {
2267 renewal
2268 .attempt_count
2269 .saturating_add(u8::from(renewal.health.is_some()))
2270 }))
2271 .saturating_add(self.oauth_refresh.as_ref().map_or(0, |refresh| {
2272 refresh
2273 .request_admitted_count()
2274 .saturating_add(u8::from(refresh.health().is_some()))
2275 }))
2276 }
2277
2278 fn dispatch_budget(&self) -> DispatchBudget {
2279 DispatchBudget {
2280 accounted_response_bytes: self.response_bytes,
2281 dispatched_requests: self.dispatched_request_count(),
2282 }
2283 }
2284
2285 fn dispatch_budget_with_pending_resource(
2286 &self,
2287 staged: StagedSuppliedSessionResource,
2288 ) -> DispatchBudget {
2289 DispatchBudget {
2290 accounted_response_bytes: self.response_bytes.saturating_add(staged.response_bytes),
2291 dispatched_requests: self.dispatched_request_count().saturating_add(1),
2292 }
2293 }
2294
2295 fn record_health(
2296 &mut self,
2297 dispatch: DispatchResult,
2298 descriptor: &SuppliedSessionRequestDescriptor,
2299 phase: SuppliedSessionHealthCheckpointPhase,
2300 after_subject_count: u8,
2301 policy: &SuppliedSessionPolicy,
2302 knowledge: &KnowledgeBase,
2303 ) -> Continue {
2304 let is_startup = phase == SuppliedSessionHealthCheckpointPhase::Startup;
2305 let sequence = u8::try_from(self.checkpoints.len()).unwrap_or(u8::MAX);
2306 let (record, stop) = match dispatch {
2307 DispatchResult::Response {
2308 response,
2309 bytes,
2310 exact_target,
2311 cookie_update,
2312 } => {
2313 let mut record = classify_health(
2314 *response,
2315 bytes,
2316 exact_target,
2317 cookie_update,
2318 HealthCheckpointPosition {
2319 sequence,
2320 phase,
2321 after_subject_count,
2322 },
2323 policy,
2324 );
2325 record.epoch = matches!(
2326 policy.version(),
2327 SuppliedSessionPolicyVersion::V4 | SuppliedSessionPolicyVersion::V6
2328 )
2329 .then_some(self.epoch);
2330 bind_health_evidence(
2331 &mut record,
2332 policy,
2333 descriptor,
2334 Some(exact_target),
2335 knowledge,
2336 );
2337 let stop = cookie_update_stop_outcome(record.cookie_update)
2338 .or_else(|| health_stop_outcome(record.outcome, is_startup));
2339 (Some(record), stop)
2340 },
2341 DispatchResult::Transport { bytes, dispatched } => {
2342 let record = dispatched.then(|| {
2343 committed_health_unavailable(
2344 policy,
2345 descriptor,
2346 sequence,
2347 phase,
2348 after_subject_count,
2349 bytes,
2350 knowledge,
2351 )
2352 });
2353 (
2354 record,
2355 Some(SuppliedSessionAuditOutcome::ResourceUnavailable),
2356 )
2357 },
2358 DispatchResult::RuntimeLimit { bytes, dispatched } => {
2359 let record = dispatched.then(|| {
2360 committed_health_unavailable(
2361 policy,
2362 descriptor,
2363 sequence,
2364 phase,
2365 after_subject_count,
2366 bytes,
2367 knowledge,
2368 )
2369 });
2370 (record, Some(SuppliedSessionAuditOutcome::RuntimeLimit))
2371 },
2372 DispatchResult::Cancelled { bytes, dispatched } => {
2373 let record = dispatched.then(|| {
2374 committed_health_unavailable(
2375 policy,
2376 descriptor,
2377 sequence,
2378 phase,
2379 after_subject_count,
2380 bytes,
2381 knowledge,
2382 )
2383 });
2384 (record, Some(SuppliedSessionAuditOutcome::Cancelled))
2385 },
2386 DispatchResult::InvalidDescriptor => {
2387 (None, Some(SuppliedSessionAuditOutcome::ResourceUnavailable))
2388 },
2389 };
2390 if let Some(mut record) = record {
2391 if matches!(
2392 policy.version(),
2393 SuppliedSessionPolicyVersion::V4 | SuppliedSessionPolicyVersion::V6
2394 ) && record.epoch != Some(self.epoch)
2395 {
2396 record.epoch = Some(self.epoch);
2397 bind_health_evidence(&mut record, policy, descriptor, None, knowledge);
2398 }
2399 self.response_bytes = self.response_bytes.saturating_add(record.response_bytes);
2400 self.checkpoints.push(record);
2401 }
2402 stop.map_or(Continue::Yes, Continue::No)
2403 }
2404
2405 fn record_resource(
2406 &mut self,
2407 sequence: u8,
2408 descriptor: &SuppliedSessionRequestDescriptor,
2409 reference: &str,
2410 dispatch: DispatchResult,
2411 context: ResourceCommitContext<'_>,
2412 ) -> ResourceContinuation {
2413 let (mut record, stop) = match dispatch {
2414 DispatchResult::Response {
2415 response,
2416 bytes,
2417 exact_target,
2418 cookie_update,
2419 } => {
2420 let mut staged =
2421 stage_resource_response(&response, bytes, exact_target, cookie_update);
2422 staged.epoch = self.epoch;
2423 if let Some(outcome) = cookie_update_stop_outcome(staged.cookie_update) {
2424 let record = commit_health_unqualified_resource(
2425 sequence,
2426 descriptor,
2427 reference,
2428 context.policy,
2429 context.knowledge,
2430 staged,
2431 None,
2432 );
2433 self.response_bytes = self.response_bytes.saturating_add(record.response_bytes);
2434 self.resources.push(record);
2435 return ResourceContinuation::No(outcome);
2436 }
2437 if staged_resource_can_await_health(descriptor, reference, staged) {
2438 if let Some(observer) = context.observer {
2439 if observer
2440 .stage_response(sequence, context.policy, descriptor, &response)
2441 .is_err()
2442 {
2443 observer.fail();
2444 }
2445 }
2446 return ResourceContinuation::AwaitingHealth(staged);
2447 }
2448 let record = commit_staged_resource(
2449 sequence,
2450 descriptor,
2451 reference,
2452 context.policy,
2453 context.knowledge,
2454 staged,
2455 None,
2456 );
2457 let outcome = record.outcome;
2458 let stop = (outcome != SuppliedSessionResourceOutcome::Committed)
2459 .then_some(SuppliedSessionAuditOutcome::ResourceUnavailable);
2460 (record, stop)
2461 },
2462 DispatchResult::Transport { bytes, dispatched } => {
2463 if !dispatched {
2464 self.resources
2465 .push(resource_not_dispatched(sequence, reference));
2466 return ResourceContinuation::No(
2467 SuppliedSessionAuditOutcome::ResourceUnavailable,
2468 );
2469 }
2470 (
2471 resource_transport_failure(sequence, reference, bytes),
2472 Some(SuppliedSessionAuditOutcome::ResourceUnavailable),
2473 )
2474 },
2475 DispatchResult::RuntimeLimit { bytes, dispatched } => {
2476 if !dispatched {
2477 self.resources
2478 .push(resource_not_dispatched(sequence, reference));
2479 return ResourceContinuation::No(SuppliedSessionAuditOutcome::RuntimeLimit);
2480 }
2481 (
2482 resource_transport_failure(sequence, reference, bytes),
2483 Some(SuppliedSessionAuditOutcome::RuntimeLimit),
2484 )
2485 },
2486 DispatchResult::Cancelled { bytes, dispatched } => {
2487 if !dispatched {
2488 self.resources
2489 .push(resource_not_dispatched(sequence, reference));
2490 return ResourceContinuation::No(SuppliedSessionAuditOutcome::Cancelled);
2491 }
2492 (
2493 resource_transport_failure(sequence, reference, bytes),
2494 Some(SuppliedSessionAuditOutcome::Cancelled),
2495 )
2496 },
2497 DispatchResult::InvalidDescriptor => {
2498 self.resources
2499 .push(resource_not_dispatched(sequence, reference));
2500 return ResourceContinuation::No(SuppliedSessionAuditOutcome::ResourceUnavailable);
2501 },
2502 };
2503 if context.policy.version() == SuppliedSessionPolicyVersion::V6 {
2504 record.epoch = self.epoch;
2505 }
2506 self.response_bytes = self.response_bytes.saturating_add(record.response_bytes);
2507 self.resources.push(record);
2508 ResourceContinuation::No(stop.unwrap_or(SuppliedSessionAuditOutcome::ResourceUnavailable))
2509 }
2510
2511 fn record_health_qualified_resource(
2512 &mut self,
2513 sequence: u8,
2514 descriptor: &SuppliedSessionRequestDescriptor,
2515 reference: &str,
2516 staged: StagedSuppliedSessionResource,
2517 context: ResourceCommitContext<'_>,
2518 ) -> Continue {
2519 let qualifying_checkpoint = self.checkpoints.last().and_then(|checkpoint| {
2520 (checkpoint.outcome == SuppliedSessionHealthOutcome::Healthy
2521 && checkpoint.after_subject_count == sequence.saturating_add(1))
2522 .then_some(checkpoint.evidence_reference.as_deref())
2523 .flatten()
2524 });
2525 let (record, receipt) = commit_staged_resource_with_receipt(
2526 sequence,
2527 descriptor,
2528 reference,
2529 context.policy,
2530 context.knowledge,
2531 staged,
2532 qualifying_checkpoint,
2533 );
2534 let committed = record.outcome == SuppliedSessionResourceOutcome::Committed;
2535 if let (Some(observer), Some(receipt)) = (context.observer, receipt.as_ref()) {
2536 if observer
2537 .commit_response(
2538 sequence,
2539 context.policy,
2540 descriptor,
2541 receipt,
2542 context.knowledge,
2543 )
2544 .is_err()
2545 {
2546 observer.fail();
2547 }
2548 } else if let Some(observer) = context.observer {
2549 observer.discard_response(sequence);
2550 }
2551 self.response_bytes = self.response_bytes.saturating_add(record.response_bytes);
2552 self.resources.push(record);
2553 if committed {
2554 Continue::Yes
2555 } else {
2556 Continue::No(SuppliedSessionAuditOutcome::ResourceUnavailable)
2557 }
2558 }
2559
2560 fn record_health_unqualified_resource(
2561 &mut self,
2562 sequence: u8,
2563 descriptor: &SuppliedSessionRequestDescriptor,
2564 reference: &str,
2565 staged: StagedSuppliedSessionResource,
2566 policy: &SuppliedSessionPolicy,
2567 knowledge: &KnowledgeBase,
2568 ) {
2569 let checkpoint_reference = self.checkpoints.last().and_then(|checkpoint| {
2570 (checkpoint.after_subject_count == sequence.saturating_add(1))
2571 .then_some(checkpoint.evidence_reference.as_deref())
2572 .flatten()
2573 });
2574 let record = commit_health_unqualified_resource(
2575 sequence,
2576 descriptor,
2577 reference,
2578 policy,
2579 knowledge,
2580 staged,
2581 checkpoint_reference,
2582 );
2583 self.response_bytes = self.response_bytes.saturating_add(record.response_bytes);
2584 self.resources.push(record);
2585 }
2586
2587 fn add_not_dispatched(&mut self, resources: &[(SuppliedSessionRequestDescriptor, String)]) {
2588 for (_, reference) in resources {
2589 let sequence = u8::try_from(self.resources.len()).unwrap_or(u8::MAX);
2590 self.resources
2591 .push(resource_not_dispatched(sequence, reference));
2592 }
2593 }
2594
2595 fn finish(mut self, policy: SuppliedSessionPolicy) -> WebAssessmentSuppliedSessionAudit {
2596 if let Some(boundary) = policy.renewal_after_resource_count() {
2597 for resource in &mut self.resources {
2598 resource.epoch = if self.epoch == 2 && resource.sequence >= boundary {
2599 2
2600 } else {
2601 1
2602 };
2603 }
2604 }
2605 if policy.version() == SuppliedSessionPolicyVersion::V6 {
2606 for resource in &mut self.resources {
2610 if resource.outcome == SuppliedSessionResourceOutcome::NotDispatched {
2611 resource.epoch = if self.epoch == 2 && resource.sequence >= 1 {
2612 2
2613 } else {
2614 1
2615 };
2616 }
2617 }
2618 }
2619 let response_byte_limit = policy.max_total_response_bytes();
2620 let cookie_policy = policy
2621 .cookie_summary()
2622 .map(|summary| cookie_policy_audit(summary, &policy));
2623 let cookie_lifecycle = cookie_policy.is_some().then(|| {
2624 let mut lifecycle = cookie_lifecycle_audit(
2625 &self.checkpoints,
2626 &self.resources,
2627 self.login.as_ref(),
2628 self.renewal.as_ref(),
2629 );
2630 if let Some(renewal) = self.renewal.as_ref() {
2631 lifecycle.final_epoch = renewal.final_epoch;
2632 lifecycle.updates_applied = u8::from(renewal.applied_cookie_count > 0);
2633 }
2634 lifecycle
2635 });
2636 let dispatched_resource_count = u8::try_from(
2637 self.resources
2638 .iter()
2639 .filter(|resource| {
2640 resource.outcome != SuppliedSessionResourceOutcome::NotDispatched
2641 })
2642 .count(),
2643 )
2644 .unwrap_or(u8::MAX);
2645 let committed_resource_count = u8::try_from(
2646 self.resources
2647 .iter()
2648 .filter(|resource| resource.outcome == SuppliedSessionResourceOutcome::Committed)
2649 .count(),
2650 )
2651 .unwrap_or(u8::MAX);
2652 let coverage = if self.outcome == SuppliedSessionAuditOutcome::Complete
2653 && committed_resource_count == self.selected_resource_count
2654 {
2655 SuppliedSessionCoverage::Complete
2656 } else if committed_resource_count == 0 {
2657 SuppliedSessionCoverage::None
2658 } else {
2659 SuppliedSessionCoverage::Partial
2660 };
2661 let dispatched_request_count = self.dispatched_request_count();
2662 debug_assert_eq!(
2663 self.resources.len(),
2664 usize::from(self.selected_resource_count)
2665 );
2666 debug_assert!(self
2667 .checkpoints
2668 .iter()
2669 .enumerate()
2670 .all(
2671 |(index, checkpoint)| checkpoint.sequence == u8::try_from(index).unwrap_or(u8::MAX)
2672 ));
2673 debug_assert!(self.checkpoints.iter().all(|checkpoint| {
2674 checkpoint.outcome == SuppliedSessionHealthOutcome::Indeterminate
2675 || checkpoint.evidence_reference.is_some()
2676 }));
2677 debug_assert!(self
2678 .resources
2679 .iter()
2680 .enumerate()
2681 .all(|(index, resource)| resource.sequence == u8::try_from(index).unwrap_or(u8::MAX)));
2682 debug_assert!(self.resources.iter().all(|resource| {
2683 matches!(
2684 resource.outcome,
2685 SuppliedSessionResourceOutcome::Committed
2686 | SuppliedSessionResourceOutcome::HealthUnqualified
2687 ) == resource.evidence_reference.is_some()
2688 }));
2689 debug_assert_eq!(
2690 self.response_bytes,
2691 self.login
2692 .as_ref()
2693 .map_or(0, SuppliedSessionLoginAudit::response_bytes)
2694 .saturating_add(
2695 self.oauth_code
2696 .as_ref()
2697 .map_or(0, |audit| audit.response_bytes())
2698 )
2699 .saturating_add(
2700 self.checkpoints
2701 .iter()
2702 .map(WebAssessmentSuppliedSessionCheckpointAudit::response_bytes)
2703 .sum::<u64>()
2704 )
2705 .saturating_add(
2706 self.resources
2707 .iter()
2708 .map(WebAssessmentSuppliedSessionResourceAudit::response_bytes)
2709 .sum::<u64>()
2710 )
2711 .saturating_add(self.renewal.as_ref().map_or(0, |renewal| {
2712 renewal.response_bytes.saturating_add(
2713 renewal
2714 .health
2715 .as_ref()
2716 .map_or(0, |health| health.response_bytes),
2717 )
2718 }))
2719 .saturating_add(self.oauth_refresh.as_ref().map_or(0, |refresh| {
2720 refresh.response_bytes().saturating_add(
2721 refresh.health().map_or(0, |health| health.response_bytes()),
2722 )
2723 }))
2724 );
2725 let oauth_token_exchange = if policy.version() == SuppliedSessionPolicyVersion::V7 {
2726 self.oauth_code.take().map(|audit| {
2727 Box::new(SuppliedSessionOAuthInitiatedTokenAudit::from_exchange(
2728 *audit,
2729 ))
2730 })
2731 } else {
2732 None
2733 };
2734 WebAssessmentSuppliedSessionAudit {
2735 schema: match policy.version() {
2736 SuppliedSessionPolicyVersion::V1 => SUPPLIED_SESSION_AUDIT_SCHEMA,
2737 SuppliedSessionPolicyVersion::V2 => SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA,
2738 SuppliedSessionPolicyVersion::V3 => SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA,
2739 SuppliedSessionPolicyVersion::V4 => SUPPLIED_SESSION_RENEWAL_AUDIT_SCHEMA,
2740 SuppliedSessionPolicyVersion::V5 => SUPPLIED_SESSION_OAUTH_CODE_AUDIT_SCHEMA,
2741 SuppliedSessionPolicyVersion::V6 => SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA,
2742 SuppliedSessionPolicyVersion::V7 => {
2743 SUPPLIED_SESSION_OAUTH_AUTHORIZATION_AUDIT_SCHEMA
2744 },
2745 },
2746 capability_id: SUPPLIED_SESSION_CAPABILITY_ID,
2747 policy_reference: policy.policy_reference().to_owned(),
2748 application_reference: policy.application_reference().to_owned(),
2749 principal_reference: policy.principal_reference().to_owned(),
2750 principal_alias: policy.principal_alias().to_owned(),
2751 principal_assurance: SuppliedSessionPrincipalAssurance::OperatorDeclared,
2752 credential_mechanism: policy.credential_mechanism(),
2753 credential_acquisition: policy.credential_acquisition(),
2754 health_oracle: SuppliedSessionHealthOracleAudit {
2755 kind: SuppliedSessionHealthOracleKind::JsonBooleanTrue,
2756 field_reference: policy.health_field_reference().to_owned(),
2757 },
2758 outcome: self.outcome,
2759 coverage,
2760 checkpoints: self.checkpoints,
2761 resources: self.resources,
2762 selected_resource_count: self.selected_resource_count,
2763 dispatched_resource_count,
2764 committed_resource_count,
2765 dispatched_request_count,
2766 response_bytes: self.response_bytes,
2767 response_byte_limit,
2768 response_byte_limit_exceeded: self.response_bytes > response_byte_limit,
2769 refresh_performed: self
2770 .renewal
2771 .as_ref()
2772 .is_some_and(|renewal| renewal.applied_cookie_count > 0)
2773 || self
2774 .oauth_refresh
2775 .as_ref()
2776 .is_some_and(|refresh| refresh.credential_commit_count() == 1),
2777 anonymous_fallback_performed: false,
2778 continuous_authentication_established: false,
2779 exploit_execution_performed: false,
2780 impact_validation_performed: false,
2781 cookie_policy,
2782 cookie_lifecycle,
2783 login: self.login,
2784 renewal: self.renewal,
2785 oauth_code: self.oauth_code,
2786 oauth_refresh: self.oauth_refresh,
2787 oauth_authorization: self.oauth_authorization,
2788 oauth_token_exchange,
2789 }
2790 }
2791}
2792
2793fn cookie_policy_audit(
2794 summary: SuppliedSessionCookieSummary,
2795 policy: &SuppliedSessionPolicy,
2796) -> SuppliedSessionCookiePolicyAudit {
2797 SuppliedSessionCookiePolicyAudit {
2798 declared_count: u8::try_from(summary.cookie_count()).unwrap_or(u8::MAX),
2799 host_only_count: u8::try_from(summary.host_only_count()).unwrap_or(u8::MAX),
2800 domain_count: u8::try_from(summary.domain_count()).unwrap_or(u8::MAX),
2801 secure_count: u8::try_from(summary.secure_count()).unwrap_or(u8::MAX),
2802 http_only_count: u8::try_from(summary.http_only_count()).unwrap_or(u8::MAX),
2803 session_count: u8::try_from(summary.session_count()).unwrap_or(u8::MAX),
2804 persistent_count: u8::try_from(summary.persistent_count()).unwrap_or(u8::MAX),
2805 same_site_missing_count: u8::try_from(summary.same_site_missing_count()).unwrap_or(u8::MAX),
2806 same_site_strict_count: u8::try_from(summary.same_site_strict_count()).unwrap_or(u8::MAX),
2807 same_site_lax_count: u8::try_from(summary.same_site_lax_count()).unwrap_or(u8::MAX),
2808 same_site_none_count: u8::try_from(summary.same_site_none_count()).unwrap_or(u8::MAX),
2809 update_policy: policy
2810 .cookie_update_policy()
2811 .expect("cookie policy summary requires an update policy"),
2812 }
2813}
2814
2815fn cookie_lifecycle_audit(
2816 checkpoints: &[WebAssessmentSuppliedSessionCheckpointAudit],
2817 resources: &[WebAssessmentSuppliedSessionResourceAudit],
2818 login: Option<&SuppliedSessionLoginAudit>,
2819 renewal: Option<&SuppliedSessionRenewalAudit>,
2820) -> SuppliedSessionCookieLifecycleAudit {
2821 let updates = checkpoints
2822 .iter()
2823 .filter_map(|record| record.cookie_update)
2824 .chain(resources.iter().filter_map(|record| record.cookie_update))
2825 .chain(renewal.and_then(|renewal| renewal.cookie_update))
2826 .chain(
2827 renewal
2828 .and_then(|renewal| renewal.health.as_ref())
2829 .and_then(|health| health.cookie_update),
2830 );
2831 let mut selected_update_response_count = 0_u8;
2832 let mut unselected_update_response_count = 0_u8;
2833 let mut update_classification_failure_count = 0_u8;
2834 for update in updates {
2835 if update.selected_count > 0 {
2836 selected_update_response_count = selected_update_response_count.saturating_add(1);
2837 }
2838 if update.unselected_count > 0 {
2839 unselected_update_response_count = unselected_update_response_count.saturating_add(1);
2840 }
2841 if update.kind == SuppliedSessionResponseCookieUpdateKind::Unusable {
2842 update_classification_failure_count =
2843 update_classification_failure_count.saturating_add(1);
2844 }
2845 }
2846 SuppliedSessionCookieLifecycleAudit {
2847 initial_epoch: login.map_or(1, SuppliedSessionLoginAudit::initial_epoch),
2848 final_epoch: login.map_or(1, SuppliedSessionLoginAudit::final_epoch),
2849 selected_update_response_count,
2850 unselected_update_response_count,
2851 update_classification_failure_count,
2852 updates_applied: 0,
2853 }
2854}
2855
2856fn health_stop_outcome(
2857 outcome: SuppliedSessionHealthOutcome,
2858 is_startup: bool,
2859) -> Option<SuppliedSessionAuditOutcome> {
2860 match outcome {
2861 SuppliedSessionHealthOutcome::Healthy => None,
2862 SuppliedSessionHealthOutcome::Unhealthy if is_startup => {
2863 Some(SuppliedSessionAuditOutcome::StartupUnhealthy)
2864 },
2865 SuppliedSessionHealthOutcome::Unhealthy => Some(SuppliedSessionAuditOutcome::SessionLost),
2866 SuppliedSessionHealthOutcome::Indeterminate => {
2867 Some(SuppliedSessionAuditOutcome::ResourceUnavailable)
2868 },
2869 }
2870}
2871
2872enum Continue {
2873 Yes,
2874 No(SuppliedSessionAuditOutcome),
2875}
2876
2877enum ResourceContinuation {
2878 AwaitingHealth(StagedSuppliedSessionResource),
2879 No(SuppliedSessionAuditOutcome),
2880}
2881
2882enum DispatchResult {
2883 Response {
2884 response: Box<CollectedHttpResponse>,
2885 bytes: u64,
2886 exact_target: bool,
2887 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
2888 },
2889 Transport {
2890 bytes: u64,
2891 dispatched: bool,
2892 },
2893 RuntimeLimit {
2894 bytes: u64,
2895 dispatched: bool,
2896 },
2897 Cancelled {
2898 bytes: u64,
2899 dispatched: bool,
2900 },
2901 InvalidDescriptor,
2902}
2903
2904struct DispatchContext<'a> {
2905 authority: &'a SharedWebRuntimeAuthority,
2906 requests: &'a HttpRequestBroker,
2907 credential: &'a SuppliedSessionCredential,
2908 credential_acquisition: Option<&'a CommittedOAuthCodeReceipt>,
2909 credential_renewal: Option<&'a CommittedOAuthRefreshReceipt>,
2910 credential_epoch: u8,
2911 policy: &'a SuppliedSessionPolicy,
2912 deadline: Option<tokio::time::Instant>,
2913}
2914
2915struct LoginDispatchContext<'a> {
2916 authority: &'a SharedWebRuntimeAuthority,
2917 requests: &'a HttpRequestBroker,
2918 policy: &'a SuppliedSessionPolicy,
2919 deadline: Option<tokio::time::Instant>,
2920}
2921
2922#[derive(Clone, Copy)]
2923struct DispatchBudget {
2924 accounted_response_bytes: u64,
2925 dispatched_requests: u8,
2926}
2927
2928#[derive(Clone, Copy)]
2929struct HealthCheckpointPosition {
2930 sequence: u8,
2931 phase: SuppliedSessionHealthCheckpointPhase,
2932 after_subject_count: u8,
2933}
2934
2935async fn dispatch_login_page(
2936 context: &LoginDispatchContext<'_>,
2937 descriptor: &SuppliedSessionRequestDescriptor,
2938 budget: DispatchBudget,
2939) -> DispatchResult {
2940 if descriptor.purpose() != SuppliedSessionRequestPurpose::LoginPage {
2941 return DispatchResult::InvalidDescriptor;
2942 }
2943 let Some(limits) = login_dispatch_limits(context, budget) else {
2944 return if context.authority.cancellation().is_cancelled() {
2945 DispatchResult::Cancelled {
2946 bytes: 0,
2947 dispatched: false,
2948 }
2949 } else {
2950 DispatchResult::RuntimeLimit {
2951 bytes: 0,
2952 dispatched: false,
2953 }
2954 };
2955 };
2956 let before = context.authority.request_accounting().snapshot();
2957 let execution = context
2958 .requests
2959 .collect_supplied_session_login_page_for_runtime(
2960 DecisionExecutionStage::Passive,
2961 Some(DecisionActionOrigin::Bootstrap),
2962 limits,
2963 descriptor,
2964 context.policy,
2965 );
2966 let result = super::await_execution(
2967 context.authority.cancellation(),
2968 context.deadline,
2969 execution,
2970 )
2971 .await;
2972 login_dispatch_result(context, descriptor, before, result)
2973}
2974
2975async fn dispatch_login_submit(
2976 context: &LoginDispatchContext<'_>,
2977 descriptor: &SuppliedSessionRequestDescriptor,
2978 body: &SuppliedSessionFormBody,
2979 budget: DispatchBudget,
2980) -> DispatchResult {
2981 if descriptor.purpose() != SuppliedSessionRequestPurpose::LoginSubmit {
2982 return DispatchResult::InvalidDescriptor;
2983 }
2984 let Some(limits) = login_dispatch_limits(context, budget) else {
2985 return if context.authority.cancellation().is_cancelled() {
2986 DispatchResult::Cancelled {
2987 bytes: 0,
2988 dispatched: false,
2989 }
2990 } else {
2991 DispatchResult::RuntimeLimit {
2992 bytes: 0,
2993 dispatched: false,
2994 }
2995 };
2996 };
2997 let before = context.authority.request_accounting().snapshot();
2998 let execution = context
2999 .requests
3000 .collect_supplied_session_login_submit_for_runtime(
3001 DecisionExecutionStage::Active,
3002 None,
3003 limits,
3004 descriptor,
3005 context.policy,
3006 body,
3007 );
3008 let result = super::await_execution(
3009 context.authority.cancellation(),
3010 context.deadline,
3011 execution,
3012 )
3013 .await;
3014 login_dispatch_result(context, descriptor, before, result)
3015}
3016
3017fn login_dispatch_limits(
3018 context: &LoginDispatchContext<'_>,
3019 budget: DispatchBudget,
3020) -> Option<DecisionExecutionLimits> {
3021 if context.authority.cancellation().is_cancelled()
3022 || context
3023 .deadline
3024 .is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
3025 || budget.accounted_response_bytes >= context.policy.max_total_response_bytes()
3026 || budget.dispatched_requests >= context.policy.max_session_requests()
3027 {
3028 return None;
3029 }
3030 let remaining = context
3031 .policy
3032 .max_total_response_bytes()
3033 .saturating_sub(budget.accounted_response_bytes);
3034 let response_limit =
3035 remaining.min(u64::try_from(context.policy.max_response_body_bytes()).unwrap_or(u64::MAX));
3036 (response_limit > 0)
3037 .then(|| DecisionExecutionLimits::new().with_max_response_body_bytes(response_limit))
3038}
3039
3040fn login_dispatch_result(
3041 context: &LoginDispatchContext<'_>,
3042 descriptor: &SuppliedSessionRequestDescriptor,
3043 before: crate::runtime_budget::RequestAccountingSnapshot,
3044 result: RuntimeExecution<Result<CollectedHttpResponse, SuppliedSessionRequestError>>,
3045) -> DispatchResult {
3046 let after = context.authority.request_accounting().snapshot();
3047 let dispatched = after.total_requests() > before.total_requests();
3048 let bytes = after
3049 .response_bytes()
3050 .saturating_sub(before.response_bytes());
3051 match result {
3052 RuntimeExecution::Completed(Ok(response)) => DispatchResult::Response {
3053 exact_target: response.final_url() == descriptor.target(),
3054 response: Box::new(response),
3055 bytes,
3056 cookie_update: None,
3057 },
3058 RuntimeExecution::Completed(Err(SuppliedSessionRequestError::RuntimeLimit)) => {
3059 DispatchResult::RuntimeLimit { bytes, dispatched }
3060 },
3061 RuntimeExecution::Completed(Err(SuppliedSessionRequestError::Http)) => {
3062 DispatchResult::Transport { bytes, dispatched }
3063 },
3064 RuntimeExecution::Completed(Err(SuppliedSessionRequestError::InvalidDescriptor)) => {
3065 DispatchResult::InvalidDescriptor
3066 },
3067 RuntimeExecution::Cancelled => DispatchResult::Cancelled { bytes, dispatched },
3068 RuntimeExecution::WallTimeExceeded => DispatchResult::RuntimeLimit { bytes, dispatched },
3069 }
3070}
3071
3072#[inline(never)]
3075fn boxed_dispatch_execution<F: std::future::Future>(
3076 make: impl FnOnce() -> F,
3077) -> std::pin::Pin<Box<F>> {
3078 Box::pin(make())
3079}
3080
3081async fn dispatch(
3082 context: &DispatchContext<'_>,
3083 descriptor: &SuppliedSessionRequestDescriptor,
3084 budget: DispatchBudget,
3085 origin: DecisionActionOrigin,
3086 expected_purpose: SuppliedSessionRequestPurpose,
3087) -> DispatchResult {
3088 if descriptor.purpose() != expected_purpose {
3089 return DispatchResult::InvalidDescriptor;
3090 }
3091 let acquisition_is_valid = if context.policy.version() == SuppliedSessionPolicyVersion::V6 {
3092 context.credential_acquisition.is_some_and(|code| {
3093 code.is_valid_for(context.policy, context.authority.knowledge())
3094 && match context.credential_epoch {
3095 1 => context.credential_renewal.is_none(),
3096 2 => context.credential_renewal.is_some_and(|renewed| {
3097 renewed.is_valid_for(context.policy, code, context.authority.knowledge())
3098 }),
3099 _ => false,
3100 }
3101 })
3102 } else if matches!(
3103 context.policy.version(),
3104 SuppliedSessionPolicyVersion::V5 | SuppliedSessionPolicyVersion::V7
3105 ) {
3106 context.credential_acquisition.is_some_and(|receipt| {
3107 context.credential_epoch == 1
3108 && context.credential_renewal.is_none()
3109 && receipt.is_valid_for(context.policy, context.authority.knowledge())
3110 })
3111 } else {
3112 context.credential_acquisition.is_none() && context.credential_renewal.is_none()
3113 };
3114 if !acquisition_is_valid {
3115 return DispatchResult::InvalidDescriptor;
3116 }
3117 if context.authority.cancellation().is_cancelled() {
3118 return DispatchResult::Cancelled {
3119 bytes: 0,
3120 dispatched: false,
3121 };
3122 }
3123 if context
3124 .deadline
3125 .is_some_and(|deadline| tokio::time::Instant::now() >= deadline)
3126 || budget.accounted_response_bytes >= context.policy.max_total_response_bytes()
3127 || budget.dispatched_requests >= context.policy.max_session_requests()
3128 {
3129 return DispatchResult::RuntimeLimit {
3130 bytes: 0,
3131 dispatched: false,
3132 };
3133 }
3134 let remaining = context
3135 .policy
3136 .max_total_response_bytes()
3137 .saturating_sub(budget.accounted_response_bytes);
3138 let response_limit =
3139 remaining.min(u64::try_from(context.policy.max_response_body_bytes()).unwrap_or(u64::MAX));
3140 if response_limit == 0 {
3141 return DispatchResult::RuntimeLimit {
3142 bytes: 0,
3143 dispatched: false,
3144 };
3145 }
3146 let limits = DecisionExecutionLimits::new().with_max_response_body_bytes(response_limit);
3147 let before = context.authority.request_accounting().snapshot();
3148 let execution = boxed_dispatch_execution(|| async {
3149 if expected_purpose == SuppliedSessionRequestPurpose::Renewal {
3150 context
3151 .requests
3152 .collect_supplied_session_renewal_for_runtime(
3153 limits,
3154 descriptor,
3155 context.policy,
3156 context.credential,
3157 )
3158 .await
3159 } else {
3160 context
3161 .requests
3162 .collect_supplied_session_get_for_runtime(
3163 DecisionExecutionStage::Passive,
3164 Some(origin),
3165 limits,
3166 descriptor,
3167 context.policy,
3168 context.credential,
3169 )
3170 .await
3171 }
3172 });
3173 let result = super::await_execution(
3174 context.authority.cancellation(),
3175 context.deadline,
3176 execution,
3177 )
3178 .await;
3179 let after = context.authority.request_accounting().snapshot();
3180 let dispatched = after.total_requests() > before.total_requests();
3181 let bytes = after
3182 .response_bytes()
3183 .saturating_sub(before.response_bytes());
3184 match result {
3185 RuntimeExecution::Completed(Ok(response)) => {
3186 let exact_target = response.final_url() == descriptor.target();
3187 let cookie_update = response_cookie_update(context.policy, &response, exact_target);
3188 DispatchResult::Response {
3189 response: Box::new(response),
3190 bytes,
3191 exact_target,
3192 cookie_update,
3193 }
3194 },
3195 RuntimeExecution::Completed(Err(SuppliedSessionRequestError::RuntimeLimit)) => {
3196 DispatchResult::RuntimeLimit { bytes, dispatched }
3197 },
3198 RuntimeExecution::Completed(Err(SuppliedSessionRequestError::Http)) => {
3199 DispatchResult::Transport { bytes, dispatched }
3200 },
3201 RuntimeExecution::Completed(Err(SuppliedSessionRequestError::InvalidDescriptor)) => {
3202 DispatchResult::InvalidDescriptor
3203 },
3204 RuntimeExecution::Cancelled => DispatchResult::Cancelled { bytes, dispatched },
3205 RuntimeExecution::WallTimeExceeded => DispatchResult::RuntimeLimit { bytes, dispatched },
3206 }
3207}
3208
3209fn cookie_update_stop_outcome(
3210 update: Option<SuppliedSessionResponseCookieUpdate>,
3211) -> Option<SuppliedSessionAuditOutcome> {
3212 match update.map(|value| value.kind) {
3213 Some(SuppliedSessionResponseCookieUpdateKind::Selected) => {
3214 Some(SuppliedSessionAuditOutcome::CredentialUpdateRequired)
3215 },
3216 Some(SuppliedSessionResponseCookieUpdateKind::Unusable) => {
3217 Some(SuppliedSessionAuditOutcome::CredentialUpdateUnusable)
3218 },
3219 None
3220 | Some(SuppliedSessionResponseCookieUpdateKind::None)
3221 | Some(SuppliedSessionResponseCookieUpdateKind::UnselectedOnly) => None,
3222 }
3223}
3224
3225fn response_cookie_update(
3226 policy: &SuppliedSessionPolicy,
3227 response: &CollectedHttpResponse,
3228 exact_target: bool,
3229) -> Option<SuppliedSessionResponseCookieUpdate> {
3230 if !matches!(
3231 policy.version(),
3232 SuppliedSessionPolicyVersion::V2
3233 | SuppliedSessionPolicyVersion::V3
3234 | SuppliedSessionPolicyVersion::V4
3235 ) {
3236 return None;
3237 }
3238 if !exact_target {
3239 return Some(SuppliedSessionResponseCookieUpdate {
3240 kind: SuppliedSessionResponseCookieUpdateKind::Unusable,
3241 selected_count: 0,
3242 unselected_count: 0,
3243 });
3244 }
3245 Some(response.supplied_session_cookie_updates(policy).into())
3246}
3247
3248fn login_form_outcome(error: SuppliedSessionLoginFormError) -> SuppliedSessionLoginFormOutcome {
3249 match error {
3250 SuppliedSessionLoginFormError::IneligibleResponse => {
3251 SuppliedSessionLoginFormOutcome::IneligibleResponse
3252 },
3253 SuppliedSessionLoginFormError::MissingForm | SuppliedSessionLoginFormError::MissingCsrf => {
3254 SuppliedSessionLoginFormOutcome::Missing
3255 },
3256 SuppliedSessionLoginFormError::AmbiguousForm
3257 | SuppliedSessionLoginFormError::AmbiguousCsrf => {
3258 SuppliedSessionLoginFormOutcome::Ambiguous
3259 },
3260 SuppliedSessionLoginFormError::InvalidFormContract
3261 | SuppliedSessionLoginFormError::InvalidCsrf => SuppliedSessionLoginFormOutcome::Invalid,
3262 }
3263}
3264
3265fn unix_time_seconds() -> Option<i64> {
3266 std::time::SystemTime::now()
3267 .duration_since(std::time::UNIX_EPOCH)
3268 .ok()
3269 .and_then(|duration| i64::try_from(duration.as_secs()).ok())
3270}
3271
3272impl From<SuppliedSessionCookieUpdateObservation> for SuppliedSessionResponseCookieUpdate {
3273 fn from(observation: SuppliedSessionCookieUpdateObservation) -> Self {
3274 let kind = match observation.classification() {
3275 SuppliedSessionCookieUpdateClassification::None => {
3276 SuppliedSessionResponseCookieUpdateKind::None
3277 },
3278 SuppliedSessionCookieUpdateClassification::UnselectedOnly => {
3279 SuppliedSessionResponseCookieUpdateKind::UnselectedOnly
3280 },
3281 SuppliedSessionCookieUpdateClassification::Selected => {
3282 SuppliedSessionResponseCookieUpdateKind::Selected
3283 },
3284 SuppliedSessionCookieUpdateClassification::Unusable => {
3285 SuppliedSessionResponseCookieUpdateKind::Unusable
3286 },
3287 };
3288 Self {
3289 kind,
3290 selected_count: observation.selected_count(),
3291 unselected_count: observation.unselected_count(),
3292 }
3293 }
3294}
3295
3296fn earliest_deadline(
3297 parent: Option<tokio::time::Instant>,
3298 local: Option<tokio::time::Instant>,
3299) -> Option<tokio::time::Instant> {
3300 match (parent, local) {
3301 (Some(parent), Some(local)) => Some(parent.min(local)),
3302 (Some(parent), None) => Some(parent),
3303 (None, Some(local)) => Some(local),
3304 (None, None) => None,
3305 }
3306}
3307
3308fn classify_health(
3309 response: CollectedHttpResponse,
3310 bytes: u64,
3311 exact_target: bool,
3312 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
3313 position: HealthCheckpointPosition,
3314 policy: &SuppliedSessionPolicy,
3315) -> WebAssessmentSuppliedSessionCheckpointAudit {
3316 let status = response.status();
3317 let complete = response.body_complete();
3318 let body_state = if complete {
3319 SuppliedSessionBodyState::Complete
3320 } else {
3321 SuppliedSessionBodyState::Incomplete
3322 };
3323 let predicate =
3324 if exact_target && complete && status == 200 && response.has_json_compatible_media_type() {
3325 if strict_top_level_boolean(response.body(), policy.health_json_field()) == Some(true) {
3326 SuppliedSessionPredicateOutcome::Matched
3327 } else {
3328 SuppliedSessionPredicateOutcome::NotMatched
3329 }
3330 } else {
3331 SuppliedSessionPredicateOutcome::NotEvaluated
3332 };
3333 let outcome = if predicate == SuppliedSessionPredicateOutcome::Matched {
3334 SuppliedSessionHealthOutcome::Healthy
3335 } else if complete {
3336 SuppliedSessionHealthOutcome::Unhealthy
3337 } else {
3338 SuppliedSessionHealthOutcome::Indeterminate
3339 };
3340 WebAssessmentSuppliedSessionCheckpointAudit {
3341 sequence: position.sequence,
3342 evidence_reference: None,
3343 phase: position.phase,
3344 after_subject_count: position.after_subject_count,
3345 outcome,
3346 status: Some(status),
3347 body_state,
3348 predicate,
3349 response_bytes: bytes,
3350 epoch: None,
3351 cookie_update,
3352 }
3353}
3354
3355fn health_unavailable(
3356 sequence: u8,
3357 phase: SuppliedSessionHealthCheckpointPhase,
3358 after_subject_count: u8,
3359 bytes: u64,
3360) -> WebAssessmentSuppliedSessionCheckpointAudit {
3361 WebAssessmentSuppliedSessionCheckpointAudit {
3362 sequence,
3363 evidence_reference: None,
3364 phase,
3365 after_subject_count,
3366 outcome: SuppliedSessionHealthOutcome::Indeterminate,
3367 status: None,
3368 body_state: SuppliedSessionBodyState::Unavailable,
3369 predicate: SuppliedSessionPredicateOutcome::NotEvaluated,
3370 response_bytes: bytes,
3371 epoch: None,
3372 cookie_update: None,
3373 }
3374}
3375
3376fn committed_health_unavailable(
3377 policy: &SuppliedSessionPolicy,
3378 descriptor: &SuppliedSessionRequestDescriptor,
3379 sequence: u8,
3380 phase: SuppliedSessionHealthCheckpointPhase,
3381 after_subject_count: u8,
3382 response_bytes: u64,
3383 knowledge: &KnowledgeBase,
3384) -> WebAssessmentSuppliedSessionCheckpointAudit {
3385 let mut record = health_unavailable(sequence, phase, after_subject_count, response_bytes);
3386 bind_health_evidence(&mut record, policy, descriptor, None, knowledge);
3387 record
3388}
3389
3390fn bind_health_evidence(
3391 checkpoint: &mut WebAssessmentSuppliedSessionCheckpointAudit,
3392 policy: &SuppliedSessionPolicy,
3393 descriptor: &SuppliedSessionRequestDescriptor,
3394 exact_response_target: Option<bool>,
3395 knowledge: &KnowledgeBase,
3396) {
3397 checkpoint.evidence_reference = commit_health_evidence(
3398 policy,
3399 descriptor,
3400 checkpoint,
3401 exact_response_target,
3402 knowledge,
3403 );
3404 if checkpoint.evidence_reference.is_none() {
3405 checkpoint.outcome = SuppliedSessionHealthOutcome::Indeterminate;
3406 checkpoint.predicate = SuppliedSessionPredicateOutcome::NotEvaluated;
3407 }
3408}
3409
3410fn commit_health_evidence(
3414 policy: &SuppliedSessionPolicy,
3415 descriptor: &SuppliedSessionRequestDescriptor,
3416 checkpoint: &WebAssessmentSuppliedSessionCheckpointAudit,
3417 exact_response_target: Option<bool>,
3418 knowledge: &KnowledgeBase,
3419) -> Option<String> {
3420 if !descriptor.validate()
3421 || descriptor.purpose() != SuppliedSessionRequestPurpose::Health
3422 || exact_response_target == Some(false)
3423 {
3424 return None;
3425 }
3426 let status = checkpoint
3427 .status
3428 .map_or_else(|| "unavailable".to_owned(), |status| status.to_string());
3429 let target_state = if exact_response_target == Some(true) {
3430 "exact"
3431 } else {
3432 "unavailable"
3433 };
3434 let mut binding = format!(
3435 "policy={};application={};health_field={};purpose=health;sequence={};phase={};after_subject_count={};target={target_state};status={status};body_state={};predicate={};outcome={};response_bytes={}",
3436 policy.policy_reference(),
3437 policy.application_reference(),
3438 policy.health_field_reference(),
3439 checkpoint.sequence,
3440 checkpoint_phase_token(checkpoint.phase),
3441 checkpoint.after_subject_count,
3442 body_state_token(checkpoint.body_state),
3443 predicate_token(checkpoint.predicate),
3444 health_outcome_token(checkpoint.outcome),
3445 checkpoint.response_bytes,
3446 );
3447 if matches!(
3448 policy.version(),
3449 SuppliedSessionPolicyVersion::V4 | SuppliedSessionPolicyVersion::V6
3450 ) {
3451 use std::fmt::Write as _;
3452 let _ = write!(binding, ";epoch={}", checkpoint.epoch());
3453 }
3454 append_cookie_update_binding(policy, &mut binding, checkpoint.cookie_update);
3455 commit_value_safe_evidence(
3456 policy,
3457 policy.health_field_reference(),
3458 HEALTH_COMMIT_EVIDENCE_DOMAIN,
3459 HEALTH_COMMIT_EVIDENCE_PREFIX,
3460 HEALTH_COMMIT_EVIDENCE_PREDICATE,
3461 health_commit_evidence_method(policy),
3462 binding,
3463 knowledge,
3464 )
3465}
3466
3467fn checkpoint_phase_token(phase: SuppliedSessionHealthCheckpointPhase) -> &'static str {
3468 match phase {
3469 SuppliedSessionHealthCheckpointPhase::Startup => "startup",
3470 SuppliedSessionHealthCheckpointPhase::SubjectBoundary => "subject_boundary",
3471 SuppliedSessionHealthCheckpointPhase::Terminal => "terminal",
3472 SuppliedSessionHealthCheckpointPhase::Renewal => "renewal",
3473 }
3474}
3475
3476fn health_outcome_token(outcome: SuppliedSessionHealthOutcome) -> &'static str {
3477 match outcome {
3478 SuppliedSessionHealthOutcome::Healthy => "healthy",
3479 SuppliedSessionHealthOutcome::Unhealthy => "unhealthy",
3480 SuppliedSessionHealthOutcome::Indeterminate => "indeterminate",
3481 }
3482}
3483
3484fn body_state_token(state: SuppliedSessionBodyState) -> &'static str {
3485 match state {
3486 SuppliedSessionBodyState::Complete => "complete",
3487 SuppliedSessionBodyState::Incomplete => "incomplete",
3488 SuppliedSessionBodyState::Unavailable => "unavailable",
3489 }
3490}
3491
3492fn predicate_token(predicate: SuppliedSessionPredicateOutcome) -> &'static str {
3493 match predicate {
3494 SuppliedSessionPredicateOutcome::Matched => "matched",
3495 SuppliedSessionPredicateOutcome::NotMatched => "not_matched",
3496 SuppliedSessionPredicateOutcome::NotEvaluated => "not_evaluated",
3497 }
3498}
3499
3500#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3507struct StagedSuppliedSessionResource {
3508 status: u16,
3509 response_bytes: u64,
3510 exact_target: bool,
3511 body_complete: bool,
3512 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
3513 epoch: u8,
3514}
3515
3516fn stage_resource_response(
3517 response: &CollectedHttpResponse,
3518 response_bytes: u64,
3519 exact_target: bool,
3520 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
3521) -> StagedSuppliedSessionResource {
3522 StagedSuppliedSessionResource {
3523 status: response.status(),
3524 response_bytes,
3525 exact_target,
3526 body_complete: response.body_complete(),
3527 cookie_update,
3528 epoch: 1,
3529 }
3530}
3531
3532fn staged_resource_can_await_health(
3533 descriptor: &SuppliedSessionRequestDescriptor,
3534 expected_reference: &str,
3535 staged: StagedSuppliedSessionResource,
3536) -> bool {
3537 descriptor.validate()
3538 && descriptor.purpose() == SuppliedSessionRequestPurpose::Resource
3539 && descriptor.target_reference() == expected_reference
3540 && staged.exact_target
3541 && staged.body_complete
3542 && staged.status == 200
3543}
3544
3545fn commit_staged_resource(
3546 sequence: u8,
3547 descriptor: &SuppliedSessionRequestDescriptor,
3548 expected_reference: &str,
3549 policy: &SuppliedSessionPolicy,
3550 knowledge: &KnowledgeBase,
3551 staged: StagedSuppliedSessionResource,
3552 qualifying_checkpoint_reference: Option<&str>,
3553) -> WebAssessmentSuppliedSessionResourceAudit {
3554 commit_staged_resource_with_receipt(
3555 sequence,
3556 descriptor,
3557 expected_reference,
3558 policy,
3559 knowledge,
3560 staged,
3561 qualifying_checkpoint_reference,
3562 )
3563 .0
3564}
3565
3566fn commit_staged_resource_with_receipt(
3567 sequence: u8,
3568 descriptor: &SuppliedSessionRequestDescriptor,
3569 expected_reference: &str,
3570 policy: &SuppliedSessionPolicy,
3571 knowledge: &KnowledgeBase,
3572 staged: StagedSuppliedSessionResource,
3573 qualifying_checkpoint_reference: Option<&str>,
3574) -> (
3575 WebAssessmentSuppliedSessionResourceAudit,
3576 Option<CommittedSuppliedSessionResourceReceipt>,
3577) {
3578 let descriptor_is_exact = descriptor.validate()
3579 && descriptor.purpose() == SuppliedSessionRequestPurpose::Resource
3580 && descriptor.target_reference() == expected_reference;
3581 let (outcome, receipt) = if (300..400).contains(&staged.status) {
3582 (SuppliedSessionResourceOutcome::RedirectRefused, None)
3583 } else if !descriptor_is_exact || !staged.exact_target || !staged.body_complete {
3584 (SuppliedSessionResourceOutcome::Incomplete, None)
3585 } else if staged.status == 200 {
3586 match qualifying_checkpoint_reference.and_then(|checkpoint_reference| {
3587 commit_resource_evidence(
3588 ResourceEvidenceCommitInput {
3589 #[cfg(feature = "wordpress-review")]
3590 sequence,
3591 descriptor,
3592 resource_reference: expected_reference,
3593 response_bytes: staged.response_bytes,
3594 cookie_update: staged.cookie_update,
3595 epoch: staged.epoch,
3596 qualifying_checkpoint_reference: checkpoint_reference,
3597 },
3598 policy,
3599 knowledge,
3600 )
3601 }) {
3602 Some(receipt) => (SuppliedSessionResourceOutcome::Committed, Some(receipt)),
3603 None => (SuppliedSessionResourceOutcome::Incomplete, None),
3604 }
3605 } else {
3606 (SuppliedSessionResourceOutcome::HttpError, None)
3607 };
3608 let evidence_reference = receipt
3609 .as_ref()
3610 .map(|receipt| receipt.evidence_reference.clone());
3611 (
3612 WebAssessmentSuppliedSessionResourceAudit {
3613 sequence,
3614 resource_reference: expected_reference.to_owned(),
3615 evidence_reference,
3616 outcome,
3617 status: Some(staged.status),
3618 response_bytes: staged.response_bytes,
3619 epoch: staged.epoch,
3620 cookie_update: staged.cookie_update,
3621 },
3622 receipt,
3623 )
3624}
3625
3626fn commit_health_unqualified_resource(
3627 sequence: u8,
3628 descriptor: &SuppliedSessionRequestDescriptor,
3629 expected_reference: &str,
3630 policy: &SuppliedSessionPolicy,
3631 knowledge: &KnowledgeBase,
3632 staged: StagedSuppliedSessionResource,
3633 checkpoint_reference: Option<&str>,
3634) -> WebAssessmentSuppliedSessionResourceAudit {
3635 let evidence_reference =
3636 staged_resource_can_await_health(descriptor, expected_reference, staged)
3637 .then(|| {
3638 commit_health_unqualified_resource_evidence(
3639 policy,
3640 descriptor,
3641 expected_reference,
3642 staged.response_bytes,
3643 staged.cookie_update,
3644 checkpoint_reference,
3645 knowledge,
3646 )
3647 })
3648 .flatten();
3649 WebAssessmentSuppliedSessionResourceAudit {
3650 sequence,
3651 resource_reference: expected_reference.to_owned(),
3652 outcome: if evidence_reference.is_some() {
3653 SuppliedSessionResourceOutcome::HealthUnqualified
3654 } else {
3655 SuppliedSessionResourceOutcome::Incomplete
3656 },
3657 evidence_reference,
3658 status: Some(staged.status),
3659 response_bytes: staged.response_bytes,
3660 epoch: staged.epoch,
3661 cookie_update: staged.cookie_update,
3662 }
3663}
3664
3665struct ResourceEvidenceCommitInput<'a> {
3670 #[cfg(feature = "wordpress-review")]
3671 sequence: u8,
3672 descriptor: &'a SuppliedSessionRequestDescriptor,
3673 resource_reference: &'a str,
3674 response_bytes: u64,
3675 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
3676 qualifying_checkpoint_reference: &'a str,
3677 epoch: u8,
3678}
3679
3680fn commit_resource_evidence(
3681 input: ResourceEvidenceCommitInput<'_>,
3682 policy: &SuppliedSessionPolicy,
3683 knowledge: &KnowledgeBase,
3684) -> Option<CommittedSuppliedSessionResourceReceipt> {
3685 if !input.descriptor.validate()
3686 || input.descriptor.purpose() != SuppliedSessionRequestPurpose::Resource
3687 || input.descriptor.target_reference() != input.resource_reference
3688 {
3689 return None;
3690 }
3691 require_committed_health_evidence(input.qualifying_checkpoint_reference, knowledge)?;
3692 if matches!(
3693 policy.version(),
3694 SuppliedSessionPolicyVersion::V4 | SuppliedSessionPolicyVersion::V6
3695 ) {
3696 let id = EvidenceId::parse(input.qualifying_checkpoint_reference).ok()?;
3697 let expected_binding = format!(
3698 "policy={};application={};",
3699 policy.policy_reference(),
3700 policy.application_reference()
3701 );
3702 let epoch_binding = format!(";epoch={};", input.epoch);
3703 let v6_epoch_field = format!("epoch={}", input.epoch);
3704 if !knowledge.inspect_evidence(&id, |evidence| {
3705 matches!(evidence.value(), EvidenceValue::Text(binding)
3706 if binding.starts_with(&expected_binding)
3707 && if policy.version() == SuppliedSessionPolicyVersion::V6 {
3708 binding.split(';').any(|field| field == v6_epoch_field)
3709 } else { binding.contains(&epoch_binding) }
3710 && binding.contains(";outcome=healthy;"))
3711 })? {
3712 return None;
3713 }
3714 }
3715 let mut binding = format!(
3716 "policy={};application={};resource={};purpose=resource;status=200;response_bytes={};qualifying_checkpoint={};outcome=committed",
3717 policy.policy_reference(),
3718 policy.application_reference(),
3719 input.resource_reference,
3720 input.response_bytes,
3721 input.qualifying_checkpoint_reference,
3722 );
3723 if matches!(
3724 policy.version(),
3725 SuppliedSessionPolicyVersion::V4 | SuppliedSessionPolicyVersion::V6
3726 ) {
3727 use std::fmt::Write as _;
3728 let _ = write!(binding, ";epoch={}", input.epoch);
3729 }
3730 append_cookie_update_binding(policy, &mut binding, input.cookie_update);
3731 let evidence_reference = commit_value_safe_evidence(
3732 policy,
3733 input.resource_reference,
3734 RESOURCE_COMMIT_EVIDENCE_DOMAIN,
3735 RESOURCE_COMMIT_EVIDENCE_PREFIX,
3736 RESOURCE_COMMIT_EVIDENCE_PREDICATE,
3737 resource_commit_evidence_method(policy),
3738 binding,
3739 knowledge,
3740 )?;
3741 #[cfg(feature = "wordpress-review")]
3742 let expected_evidence = {
3743 let evidence_id = EvidenceId::parse(evidence_reference.clone()).ok()?;
3744 knowledge.inspect_evidence(&evidence_id, Clone::clone)?
3745 };
3746 Some(CommittedSuppliedSessionResourceReceipt {
3747 #[cfg(feature = "wordpress-review")]
3748 sequence: input.sequence,
3749 #[cfg(feature = "wordpress-review")]
3750 resource_reference: input.resource_reference.to_owned(),
3751 #[cfg(feature = "wordpress-review")]
3752 policy_reference: policy.policy_reference().to_owned(),
3753 #[cfg(feature = "wordpress-review")]
3754 application_reference: policy.application_reference().to_owned(),
3755 #[cfg(feature = "wordpress-review")]
3756 principal_reference: policy.principal_reference().to_owned(),
3757 #[cfg(feature = "wordpress-review")]
3758 credential_mechanism: policy.credential_mechanism(),
3759 #[cfg(feature = "wordpress-review")]
3760 epoch: input.epoch,
3761 evidence_reference,
3762 #[cfg(feature = "wordpress-review")]
3763 expected_evidence,
3764 })
3765}
3766
3767fn commit_health_unqualified_resource_evidence(
3768 policy: &SuppliedSessionPolicy,
3769 descriptor: &SuppliedSessionRequestDescriptor,
3770 resource_reference: &str,
3771 response_bytes: u64,
3772 cookie_update: Option<SuppliedSessionResponseCookieUpdate>,
3773 checkpoint_reference: Option<&str>,
3774 knowledge: &KnowledgeBase,
3775) -> Option<String> {
3776 if !descriptor.validate()
3777 || descriptor.purpose() != SuppliedSessionRequestPurpose::Resource
3778 || descriptor.target_reference() != resource_reference
3779 {
3780 return None;
3781 }
3782 if let Some(reference) = checkpoint_reference {
3783 require_committed_health_evidence(reference, knowledge)?;
3784 }
3785 let checkpoint = checkpoint_reference.unwrap_or("unavailable");
3786 let mut binding = format!(
3787 "policy={};application={};resource={resource_reference};purpose=resource;status=200;response_bytes={response_bytes};qualifying_checkpoint={checkpoint};outcome=health_unqualified",
3788 policy.policy_reference(),
3789 policy.application_reference(),
3790 );
3791 append_cookie_update_binding(policy, &mut binding, cookie_update);
3792 commit_value_safe_evidence(
3793 policy,
3794 resource_reference,
3795 RESOURCE_COMMIT_EVIDENCE_DOMAIN,
3796 RESOURCE_COMMIT_EVIDENCE_PREFIX,
3797 RESOURCE_HEALTH_UNQUALIFIED_EVIDENCE_PREDICATE,
3798 resource_commit_evidence_method(policy),
3799 binding,
3800 knowledge,
3801 )
3802}
3803
3804fn health_commit_evidence_method(policy: &SuppliedSessionPolicy) -> &'static str {
3805 match policy.version() {
3806 SuppliedSessionPolicyVersion::V1 => HEALTH_COMMIT_EVIDENCE_METHOD_V1,
3807 SuppliedSessionPolicyVersion::V2 => HEALTH_COMMIT_EVIDENCE_METHOD_V2,
3808 SuppliedSessionPolicyVersion::V3 => HEALTH_COMMIT_EVIDENCE_METHOD_V3,
3809 SuppliedSessionPolicyVersion::V4 => "supplied-session-health/v4",
3810 SuppliedSessionPolicyVersion::V5 => "supplied-session-health/v5",
3811 SuppliedSessionPolicyVersion::V6 => "supplied-session-health/v6",
3812 SuppliedSessionPolicyVersion::V7 => "supplied-session-health/v7",
3813 }
3814}
3815
3816fn resource_commit_evidence_method(policy: &SuppliedSessionPolicy) -> &'static str {
3817 match policy.version() {
3818 SuppliedSessionPolicyVersion::V1 => RESOURCE_COMMIT_EVIDENCE_METHOD_V1,
3819 SuppliedSessionPolicyVersion::V2 => RESOURCE_COMMIT_EVIDENCE_METHOD_V2,
3820 SuppliedSessionPolicyVersion::V3 => RESOURCE_COMMIT_EVIDENCE_METHOD_V3,
3821 SuppliedSessionPolicyVersion::V4 => "supplied-session-resource/v4",
3822 SuppliedSessionPolicyVersion::V5 => "supplied-session-resource/v5",
3823 SuppliedSessionPolicyVersion::V6 => "supplied-session-resource/v6",
3824 SuppliedSessionPolicyVersion::V7 => "supplied-session-resource/v7",
3825 }
3826}
3827
3828fn append_cookie_update_binding(
3829 policy: &SuppliedSessionPolicy,
3830 binding: &mut String,
3831 update: Option<SuppliedSessionResponseCookieUpdate>,
3832) {
3833 if !matches!(
3834 policy.version(),
3835 SuppliedSessionPolicyVersion::V2
3836 | SuppliedSessionPolicyVersion::V3
3837 | SuppliedSessionPolicyVersion::V4
3838 ) {
3839 return;
3840 }
3841 let update = update.unwrap_or(SuppliedSessionResponseCookieUpdate {
3842 kind: SuppliedSessionResponseCookieUpdateKind::None,
3843 selected_count: 0,
3844 unselected_count: 0,
3845 });
3846 use std::fmt::Write as _;
3847 let _ = write!(
3848 binding,
3849 ";cookie_update={};selected_update_count={};unselected_update_count={}",
3850 cookie_update_token(update.kind),
3851 update.selected_count,
3852 update.unselected_count,
3853 );
3854}
3855
3856fn cookie_update_token(kind: SuppliedSessionResponseCookieUpdateKind) -> &'static str {
3857 match kind {
3858 SuppliedSessionResponseCookieUpdateKind::None => "none",
3859 SuppliedSessionResponseCookieUpdateKind::UnselectedOnly => "unselected_only",
3860 SuppliedSessionResponseCookieUpdateKind::Selected => "selected",
3861 SuppliedSessionResponseCookieUpdateKind::Unusable => "unusable",
3862 }
3863}
3864
3865fn require_committed_health_evidence(reference: &str, knowledge: &KnowledgeBase) -> Option<()> {
3866 let evidence_id = EvidenceId::parse(reference).ok()?;
3867 knowledge
3868 .inspect_evidence(&evidence_id, |evidence| {
3869 evidence.kind() == &EvidenceKind::Authentication
3870 && evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
3871 && evidence.predicate().name() == HEALTH_COMMIT_EVIDENCE_PREDICATE
3872 })?
3873 .then_some(())
3874}
3875
3876#[allow(clippy::too_many_arguments)]
3877fn commit_value_safe_evidence(
3878 policy: &SuppliedSessionPolicy,
3879 correlation_reference: &str,
3880 digest_domain: &[u8],
3881 evidence_prefix: &str,
3882 predicate_name: &str,
3883 method: &str,
3884 binding: String,
3885 knowledge: &KnowledgeBase,
3886) -> Option<String> {
3887 let mut digest = Sha256::new();
3888 digest.update(digest_domain);
3889 digest.update(binding.as_bytes());
3890 let evidence_reference = format!("{evidence_prefix}:{:x}", digest.finalize());
3891 let evidence_id = EvidenceId::parse(evidence_reference.clone()).ok()?;
3892 let subject = EntityId::new(policy.application_reference().to_owned()).ok()?;
3893 let predicate =
3894 KnowledgePredicate::new(RESOURCE_COMMIT_EVIDENCE_NAMESPACE, predicate_name).ok()?;
3895 let source = EvidenceSource::new(RESOURCE_COMMIT_EVIDENCE_COMPONENT, method)
3896 .and_then(|source| source.with_correlation_id(correlation_reference.to_owned()))
3897 .ok()?;
3898 let evidence = Evidence::with_id(
3899 evidence_id.clone(),
3900 subject,
3901 EvidenceKind::Authentication,
3902 predicate,
3903 EvidenceValue::Text(binding),
3904 source,
3905 ConfidenceScore::MAX,
3906 );
3907 knowledge.insert_evidence(evidence.clone()).ok()?;
3908 (knowledge.inspect_evidence(&evidence_id, |stored| stored == &evidence) == Some(true))
3909 .then_some(evidence_reference)
3910}
3911
3912fn resource_transport_failure(
3913 sequence: u8,
3914 reference: &str,
3915 bytes: u64,
3916) -> WebAssessmentSuppliedSessionResourceAudit {
3917 WebAssessmentSuppliedSessionResourceAudit {
3918 sequence,
3919 resource_reference: reference.to_owned(),
3920 evidence_reference: None,
3921 outcome: SuppliedSessionResourceOutcome::TransportFailed,
3922 status: None,
3923 response_bytes: bytes,
3924 epoch: 1,
3925 cookie_update: None,
3926 }
3927}
3928
3929fn resource_not_dispatched(
3930 sequence: u8,
3931 reference: &str,
3932) -> WebAssessmentSuppliedSessionResourceAudit {
3933 WebAssessmentSuppliedSessionResourceAudit {
3934 sequence,
3935 resource_reference: reference.to_owned(),
3936 evidence_reference: None,
3937 outcome: SuppliedSessionResourceOutcome::NotDispatched,
3938 status: None,
3939 response_bytes: 0,
3940 epoch: 1,
3941 cookie_update: None,
3942 }
3943}
3944
3945#[derive(Clone, Copy)]
3946enum StrictHealthValue {
3947 Bool(bool),
3948 Other,
3949}
3950
3951struct StrictHealthSeed<'a> {
3952 depth: usize,
3953 nodes: &'a mut usize,
3954}
3955
3956impl<'de> DeserializeSeed<'de> for StrictHealthSeed<'_> {
3957 type Value = StrictHealthValue;
3958 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
3959 where
3960 D: Deserializer<'de>,
3961 {
3962 if self.depth > HEALTH_JSON_MAX_DEPTH || *self.nodes >= HEALTH_JSON_MAX_NODES {
3963 return Err(serde::de::Error::custom("bounded health JSON limit"));
3964 }
3965 *self.nodes += 1;
3966 deserializer.deserialize_any(StrictHealthVisitor {
3967 depth: self.depth,
3968 nodes: self.nodes,
3969 })
3970 }
3971}
3972
3973struct StrictHealthVisitor<'a> {
3974 depth: usize,
3975 nodes: &'a mut usize,
3976}
3977
3978impl<'de> Visitor<'de> for StrictHealthVisitor<'_> {
3979 type Value = StrictHealthValue;
3980 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3981 formatter.write_str("bounded JSON")
3982 }
3983 fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
3984 Ok(StrictHealthValue::Bool(value))
3985 }
3986 fn visit_i64<E>(self, _: i64) -> Result<Self::Value, E> {
3987 Ok(StrictHealthValue::Other)
3988 }
3989 fn visit_u64<E>(self, _: u64) -> Result<Self::Value, E> {
3990 Ok(StrictHealthValue::Other)
3991 }
3992 fn visit_f64<E>(self, _: f64) -> Result<Self::Value, E> {
3993 Ok(StrictHealthValue::Other)
3994 }
3995 fn visit_none<E>(self) -> Result<Self::Value, E> {
3996 Ok(StrictHealthValue::Other)
3997 }
3998 fn visit_unit<E>(self) -> Result<Self::Value, E> {
3999 Ok(StrictHealthValue::Other)
4000 }
4001 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
4002 where
4003 E: serde::de::Error,
4004 {
4005 if value.len() > HEALTH_JSON_MAX_KEY_BYTES {
4006 return Err(E::custom("bounded health JSON string"));
4007 }
4008 Ok(StrictHealthValue::Other)
4009 }
4010 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
4011 where
4012 A: SeqAccess<'de>,
4013 {
4014 while sequence
4015 .next_element_seed(StrictHealthSeed {
4016 depth: self.depth + 1,
4017 nodes: self.nodes,
4018 })?
4019 .is_some()
4020 {}
4021 Ok(StrictHealthValue::Other)
4022 }
4023 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
4024 where
4025 A: MapAccess<'de>,
4026 {
4027 let mut seen = std::collections::BTreeSet::new();
4028 while let Some(key) = map.next_key::<String>()? {
4029 if key.len() > HEALTH_JSON_MAX_KEY_BYTES || !seen.insert(key) {
4030 return Err(serde::de::Error::custom(
4031 "duplicate or oversized health JSON key",
4032 ));
4033 }
4034 let _ = map.next_value_seed(StrictHealthSeed {
4035 depth: self.depth + 1,
4036 nodes: self.nodes,
4037 })?;
4038 }
4039 Ok(StrictHealthValue::Other)
4040 }
4041}
4042
4043fn strict_top_level_boolean(bytes: &[u8], field: &str) -> Option<bool> {
4044 struct RootSeed<'a> {
4045 field: &'a str,
4046 nodes: &'a mut usize,
4047 }
4048 impl<'de> DeserializeSeed<'de> for RootSeed<'_> {
4049 type Value = Option<bool>;
4050 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
4051 where
4052 D: Deserializer<'de>,
4053 {
4054 struct RootVisitor<'a> {
4055 field: &'a str,
4056 nodes: &'a mut usize,
4057 }
4058 impl<'de> Visitor<'de> for RootVisitor<'_> {
4059 type Value = Option<bool>;
4060 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4061 formatter.write_str("a bounded JSON object")
4062 }
4063 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
4064 where
4065 A: MapAccess<'de>,
4066 {
4067 let mut seen = std::collections::BTreeSet::new();
4068 let mut selected = None;
4069 while let Some(key) = map.next_key::<String>()? {
4070 if key.len() > HEALTH_JSON_MAX_KEY_BYTES || !seen.insert(key.clone()) {
4071 return Err(serde::de::Error::custom(
4072 "duplicate or oversized health JSON key",
4073 ));
4074 }
4075 let value = map.next_value_seed(StrictHealthSeed {
4076 depth: 1,
4077 nodes: self.nodes,
4078 })?;
4079 if key == self.field {
4080 selected = match value {
4081 StrictHealthValue::Bool(value) => Some(value),
4082 StrictHealthValue::Other => None,
4083 };
4084 }
4085 }
4086 Ok(selected)
4087 }
4088 }
4089 deserializer.deserialize_map(RootVisitor {
4090 field: self.field,
4091 nodes: self.nodes,
4092 })
4093 }
4094 }
4095 let mut nodes = 0;
4096 let mut decoder = serde_json::Deserializer::from_slice(bytes);
4097 let parsed = RootSeed {
4098 field,
4099 nodes: &mut nodes,
4100 }
4101 .deserialize(&mut decoder)
4102 .ok()?;
4103 decoder.end().ok()?;
4104 parsed
4105}
4106
4107#[cfg(test)]
4108mod tests {
4109 #[test]
4110 fn oauth_optional_runtime_storage_is_indirect_for_legacy_contexts() {
4111 let _: fn(&SuppliedSessionRuntimeConfig) -> &Option<Box<OAuthCodeRequestBroker>> =
4112 |config| &config.oauth_code_requests;
4113 let _: fn(&SuppliedSessionRuntimeConfig) -> &Option<Box<OAuthCodeRequestDescriptor>> =
4114 |config| &config.oauth_code_descriptor;
4115 let _: fn(
4116 &WebAssessmentSuppliedSessionAudit,
4117 ) -> &Option<Box<SuppliedSessionOAuthCodeAudit>> = |audit| &audit.oauth_code;
4118 let legacy = policy(&url::Url::parse("http://127.0.0.1:1/app/").unwrap());
4119 let state = ExecutionState::new(&legacy, 1);
4120 assert!(state.oauth_code.is_none());
4121 assert_eq!(
4122 std::mem::size_of_val(&state.oauth_code),
4123 std::mem::size_of::<usize>()
4124 );
4125 }
4126 use super::*;
4127 use crate::supplied_session_review::{
4128 SuppliedSessionAuthorization, SuppliedSessionCookies, SuppliedSessionFormCredential,
4129 };
4130 use crate::web_runtime::{
4131 WebAssessmentLimits, WebAssessmentRuntimeBuilder, WebAssessmentRuntimeError,
4132 };
4133 #[cfg(feature = "wordpress-review")]
4134 use crate::wordpress_review::WordPressReviewInputs;
4135 #[cfg(feature = "websocket-review")]
4136 use futures::{SinkExt, StreamExt};
4137 #[cfg(feature = "websocket-review")]
4138 use serde_json::json;
4139 #[cfg(feature = "websocket-review")]
4140 use std::sync::atomic::{AtomicBool, Ordering};
4141 use std::sync::Arc;
4142 use tokio::{
4143 io::{AsyncReadExt, AsyncWriteExt},
4144 net::TcpListener,
4145 sync::Mutex,
4146 };
4147
4148 fn policy(application: &url::Url) -> SuppliedSessionPolicy {
4149 policy_with_wall_time(application, 5_000)
4150 }
4151
4152 fn policy_with_wall_time(
4153 application: &url::Url,
4154 max_wall_time_ms: u64,
4155 ) -> SuppliedSessionPolicy {
4156 let source = format!(
4157 r#"schema = "security.supplied-session-policy/v1"
4158principal_alias = "fixture-principal"
4159credential_mechanism = "authorization_header"
4160health_path = "/app/session-health"
4161health_json_field = "authenticated"
4162resources = ["/app/private"]
4163max_session_requests = 3
4164max_total_response_bytes = 65536
4165max_response_body_bytes = 16384
4166max_wall_time_ms = {max_wall_time_ms}
4167"#
4168 );
4169 SuppliedSessionPolicy::parse_toml(application, source.as_bytes()).unwrap()
4170 }
4171
4172 #[cfg(feature = "websocket-review")]
4173 fn websocket_policy(
4174 application: &url::Url,
4175 endpoint: &url::Url,
4176 max_wall_time_ms: u64,
4177 ) -> WebSocketReviewPolicy {
4178 let expected = b"expected";
4179 let source = serde_json::to_vec(&json!({
4180 "schema": "security.websocket-review-policy/v1",
4181 "target_authorized": true,
4182 "messages_read_only_acknowledged": true,
4183 "message_content_is_non_secret": true,
4184 "endpoint": endpoint.as_str(),
4185 "origin_mode": "application_origin",
4186 "compression": false,
4187 "reconnect": false,
4188 "messages": [{
4189 "id": "combined-session-message",
4190 "text": "request",
4191 "expected_response": {
4192 "sha256": format!("{:x}", Sha256::digest(expected)),
4193 "length": expected.len()
4194 }
4195 }],
4196 "limits": {
4197 "max_inbound_message_bytes": 4096,
4198 "max_outbound_message_bytes": 4096,
4199 "max_messages": 1,
4200 "max_control_frames": 4,
4201 "max_wall_time_ms": max_wall_time_ms
4202 }
4203 }))
4204 .unwrap();
4205 WebSocketReviewPolicy::parse_json(application, &source).unwrap()
4206 }
4207
4208 #[cfg(feature = "websocket-review")]
4209 struct ExactAuthorizationCallback(&'static str);
4210
4211 #[cfg(feature = "websocket-review")]
4212 impl tokio_tungstenite::tungstenite::handshake::server::Callback for ExactAuthorizationCallback {
4213 fn on_request(
4214 self,
4215 request: &tokio_tungstenite::tungstenite::handshake::server::Request,
4216 response: tokio_tungstenite::tungstenite::handshake::server::Response,
4217 ) -> Result<
4218 tokio_tungstenite::tungstenite::handshake::server::Response,
4219 tokio_tungstenite::tungstenite::handshake::server::ErrorResponse,
4220 > {
4221 assert!(
4222 request
4223 .headers()
4224 .get("authorization")
4225 .and_then(|value| value.to_str().ok())
4226 == Some(self.0),
4227 "fixture WebSocket authorization mismatch"
4228 );
4229 Ok(response)
4230 }
4231 }
4232
4233 #[cfg(feature = "websocket-review")]
4234 struct ObserveAuthorizationCallback {
4235 expected: &'static [u8],
4236 observed: Arc<AtomicBool>,
4237 }
4238
4239 #[cfg(feature = "websocket-review")]
4240 impl tokio_tungstenite::tungstenite::handshake::server::Callback for ObserveAuthorizationCallback {
4241 fn on_request(
4242 self,
4243 request: &tokio_tungstenite::tungstenite::handshake::server::Request,
4244 response: tokio_tungstenite::tungstenite::handshake::server::Response,
4245 ) -> Result<
4246 tokio_tungstenite::tungstenite::handshake::server::Response,
4247 tokio_tungstenite::tungstenite::handshake::server::ErrorResponse,
4248 > {
4249 self.observed.store(
4250 request
4251 .headers()
4252 .get("authorization")
4253 .is_some_and(|value| value.as_bytes() == self.expected),
4254 Ordering::SeqCst,
4255 );
4256 Ok(response)
4257 }
4258 }
4259
4260 fn cookie_policy(application: &url::Url) -> SuppliedSessionPolicy {
4261 let bytes = format!(
4262 r#"schema = "security.supplied-session-policy/v2"
4263principal_alias = "cookie-fixture-principal"
4264credential_mechanism = "cookie_jar"
4265cookie_update_policy = "stop_on_selected_cookie"
4266health_path = "/app/session-health"
4267health_json_field = "authenticated"
4268resources = ["/app/private"]
4269max_session_requests = 3
4270max_total_response_bytes = 65536
4271max_response_body_bytes = 16384
4272max_wall_time_ms = 5000
4273
4274[[cookies]]
4275id = "session-secret"
4276name = "session"
4277domain = "{}"
4278host_only = true
4279path = "/app/"
4280secure = false
4281http_only = true
4282same_site = "lax"
4283"#,
4284 application.host_str().unwrap()
4285 );
4286 SuppliedSessionPolicy::parse_toml(application, bytes.as_bytes()).unwrap()
4287 }
4288
4289 fn renewal_policy(application: &url::Url) -> SuppliedSessionPolicy {
4290 let source = format!(
4291 r#"schema = "security.supplied-session-policy/v4"
4292principal_alias = "renewal-fixture"
4293credential_mechanism = "cookie_jar"
4294cookie_update_policy = "stop_on_selected_cookie"
4295renewal_path = "/app/renew"
4296renewal_method = "post"
4297renewal_after_resource_count = 1
4298max_renewal_attempts = 1
4299health_path = "/app/health"
4300health_json_field = "authenticated"
4301resources = ["/app/before", "/app/after"]
4302max_session_requests = 7
4303max_total_response_bytes = 65536
4304max_response_body_bytes = 16384
4305max_wall_time_ms = 5000
4306[[cookies]]
4307id = "session"
4308name = "session"
4309domain = "{}"
4310host_only = true
4311path = "/app/"
4312secure = {}
4313http_only = true
4314same_site = "lax"
4315"#,
4316 application.host_str().unwrap(),
4317 application.scheme() == "https"
4318 );
4319 SuppliedSessionPolicy::parse_toml(application, source.as_bytes()).unwrap()
4320 }
4321
4322 #[test]
4323 fn renewal_resource_commit_requires_the_committed_matching_epoch_not_a_reported_number() {
4324 let policy = renewal_policy(&url::Url::parse("https://example.test/app/").unwrap());
4325 let health = SuppliedSessionRequestDescriptor::health(&policy);
4326 let (target, reference) = policy.execution_resources().nth(1).unwrap();
4327 let descriptor =
4328 SuppliedSessionRequestDescriptor::resource(&policy, target, reference).unwrap();
4329 let knowledge = KnowledgeBase::new();
4330 let mut checkpoint = WebAssessmentSuppliedSessionCheckpointAudit {
4331 sequence: 2,
4332 evidence_reference: None,
4333 phase: SuppliedSessionHealthCheckpointPhase::Terminal,
4334 after_subject_count: 2,
4335 outcome: SuppliedSessionHealthOutcome::Healthy,
4336 status: Some(200),
4337 body_state: SuppliedSessionBodyState::Complete,
4338 predicate: SuppliedSessionPredicateOutcome::Matched,
4339 response_bytes: 22,
4340 epoch: Some(1),
4341 cookie_update: None,
4342 };
4343 bind_health_evidence(&mut checkpoint, &policy, &health, Some(true), &knowledge);
4344 let staged = StagedSuppliedSessionResource {
4345 status: 200,
4346 response_bytes: 17,
4347 exact_target: true,
4348 body_complete: true,
4349 cookie_update: None,
4350 epoch: 2,
4351 };
4352 let (rejected, receipt) = commit_staged_resource_with_receipt(
4353 1,
4354 &descriptor,
4355 reference,
4356 &policy,
4357 &knowledge,
4358 staged,
4359 checkpoint.evidence_reference(),
4360 );
4361 assert_eq!(
4362 rejected.outcome(),
4363 SuppliedSessionResourceOutcome::Incomplete
4364 );
4365 assert!(
4366 receipt.is_none(),
4367 "old-epoch committed health authorized renewed resource"
4368 );
4369 checkpoint.epoch = Some(2);
4370 bind_health_evidence(&mut checkpoint, &policy, &health, Some(true), &knowledge);
4371 let (accepted, receipt) = commit_staged_resource_with_receipt(
4372 1,
4373 &descriptor,
4374 reference,
4375 &policy,
4376 &knowledge,
4377 staged,
4378 checkpoint.evidence_reference(),
4379 );
4380 assert_eq!(
4381 accepted.outcome(),
4382 SuppliedSessionResourceOutcome::Committed
4383 );
4384 assert_eq!(accepted.epoch(), 2);
4385 assert!(receipt.is_some());
4386 }
4387
4388 #[tokio::test]
4389 async fn normal_runtime_renewal_preserves_epochs_classifier_parent_budget_and_cancellation() {
4390 for case in 0_u8..4 {
4391 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4392 let address = listener.local_addr().unwrap();
4393 let trace = Arc::new(Mutex::new(Vec::new()));
4394 let server_trace = Arc::clone(&trace);
4395 let replacement = if case == 1 {
4396 format!(
4397 "Set-Cookie: session={}; Path=/app/; HttpOnly; SameSite=Lax\r\n",
4398 "COOKIE-OVERFLOW-CANARY".repeat(185)
4399 )
4400 } else {
4401 "Set-Cookie: session=NEW-RENEWAL-CANARY; Path=/app/; HttpOnly; SameSite=Lax\r\n"
4402 .to_owned()
4403 };
4404 let server = tokio::spawn(async move {
4405 let mut renewed = false;
4406 loop {
4407 let Ok((mut stream, _)) = listener.accept().await else {
4408 break;
4409 };
4410 let mut request = Vec::new();
4411 while request.len() <= 16 * 1024 {
4412 let mut chunk = [0_u8; 1024];
4413 let read = stream.read(&mut chunk).await.unwrap_or(0);
4414 if read == 0 {
4415 break;
4416 }
4417 request.extend_from_slice(&chunk[..read]);
4418 if request.windows(4).any(|window| window == b"\r\n\r\n") {
4419 break;
4420 }
4421 }
4422 let text = String::from_utf8_lossy(&request);
4423 let mut line = text.lines().next().unwrap_or("").split_whitespace();
4424 let method = line.next().unwrap_or("");
4425 let path = line.next().unwrap_or("");
4426 let old = text.lines().any(|line| {
4427 line.eq_ignore_ascii_case("cookie: session=OLD-RENEWAL-CANARY")
4428 });
4429 let new = text.lines().any(|line| {
4430 line.eq_ignore_ascii_case("cookie: session=NEW-RENEWAL-CANARY")
4431 });
4432 server_trace
4433 .lock()
4434 .await
4435 .push((method.to_owned(), path.to_owned(), old, new));
4436 let active = if renewed { new } else { old };
4437 let (status, media, body, headers) = match (method, path) {
4438 ("GET", "/app/health") => (
4439 200,
4440 "application/json",
4441 if active {
4442 r#"{"authenticated":true}"#
4443 } else {
4444 r#"{"authenticated":false}"#
4445 },
4446 "",
4447 ),
4448 ("GET", "/app/before") if old && !renewed => {
4449 (200, "text/html", "PRIVATE-RENEWAL-BODY", "")
4450 },
4451 ("POST", "/app/renew") if old && !renewed => {
4452 renewed = true;
4453 (200, "application/json", "{}", replacement.as_str())
4454 },
4455 ("GET", "/app/after") if new && renewed => {
4456 (200, "text/html", "PRIVATE-RENEWAL-BODY", "")
4457 },
4458 (_, "/app/before" | "/app/after" | "/app/renew") => {
4459 (403, "text/plain", "denied", "")
4460 },
4461 _ => (200, "text/html", "<title>public</title>", ""),
4462 };
4463 let response = format!("HTTP/1.1 {status} OK\r\nContent-Type: {media}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
4464 let _ = stream.write_all(response.as_bytes()).await;
4465 let _ = stream.shutdown().await;
4466 }
4467 });
4468 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
4469 let policy = renewal_policy(&application);
4470 let credential = SuppliedSessionCookies::parse_tsv(
4471 &policy,
4472 b"session\tOLD-RENEWAL-CANARY".to_vec(),
4473 0,
4474 )
4475 .unwrap();
4476 let limits = if case == 2 {
4477 WebAssessmentLimits::default()
4478 .with_max_total_requests(4)
4479 .unwrap()
4480 } else {
4481 WebAssessmentLimits::default()
4482 };
4483 let cancellation = tokio_util::sync::CancellationToken::new();
4484 if case == 3 {
4485 cancellation.cancel();
4486 }
4487 let mut runtime = WebAssessmentRuntimeBuilder::new(application)
4488 .limits(limits)
4489 .cancellation_token(cancellation)
4490 .with_supplied_session_review(policy, credential)
4491 .build()
4492 .unwrap();
4493 let report = runtime.analyze().await.unwrap();
4494 let audit = report.supplied_session_audit().unwrap();
4495 let (expected_outcome, expected_requests, expected_committed, expected_epoch) =
4496 match case {
4497 0 => (SuppliedSessionAuditOutcome::Complete, 7, 2, 2),
4498 1 => (SuppliedSessionAuditOutcome::RenewalUnavailable, 4, 1, 1),
4499 2 => (SuppliedSessionAuditOutcome::RuntimeLimit, 4, 1, 2),
4500 _ => (SuppliedSessionAuditOutcome::Cancelled, 0, 0, 1),
4501 };
4502 assert_eq!(audit.outcome(), expected_outcome, "case {case}");
4503 assert_eq!(audit.dispatched_request_count(), expected_requests);
4504 assert_eq!(audit.committed_resource_count(), expected_committed);
4505 assert_eq!(audit.resources()[0].epoch(), 1);
4506 assert_eq!(audit.resources()[1].epoch(), expected_epoch);
4507 assert_eq!(
4508 audit.renewal().unwrap().attempt_count(),
4509 u8::from(case != 3)
4510 );
4511 if case == 0 {
4512 assert_eq!(audit.renewal().unwrap().health().unwrap().epoch(), 2);
4513 } else {
4514 assert!(audit.renewal().unwrap().health().is_none());
4515 }
4516 if case == 2 {
4517 assert_eq!(report.usage().total_requests(), 4);
4518 }
4519 let saved = serde_json::to_string(audit).unwrap();
4520 for private in [
4521 "OLD-RENEWAL-CANARY",
4522 "NEW-RENEWAL-CANARY",
4523 "PRIVATE-RENEWAL-BODY",
4524 "COOKIE-OVERFLOW-CANARY",
4525 "/app/renew",
4526 ] {
4527 assert!(!saved.contains(private));
4528 }
4529 let trace = trace.lock().await;
4530 let actual: Vec<_> = trace
4531 .iter()
4532 .filter(|(_, _, old, new)| *old || *new)
4533 .cloned()
4534 .collect();
4535 let expected = [
4536 ("GET".into(), "/app/health".into(), true, false),
4537 ("GET".into(), "/app/before".into(), true, false),
4538 ("GET".into(), "/app/health".into(), true, false),
4539 ("POST".into(), "/app/renew".into(), true, false),
4540 ("GET".into(), "/app/health".into(), false, true),
4541 ("GET".into(), "/app/after".into(), false, true),
4542 ("GET".into(), "/app/health".into(), false, true),
4543 ];
4544 assert_eq!(
4545 actual,
4546 expected[..expected_requests as usize],
4547 "case {case}"
4548 );
4549 server.abort();
4550 }
4551 }
4552
4553 fn cookie_credential(policy: &SuppliedSessionPolicy) -> SuppliedSessionCookies {
4554 SuppliedSessionCookies::parse_tsv(
4555 policy,
4556 b"session-secret\tCOOKIE-RUNTIME-CANARY\n".to_vec(),
4557 1,
4558 )
4559 .unwrap()
4560 }
4561
4562 fn form_login_policy(application: &url::Url) -> SuppliedSessionPolicy {
4563 let bytes = format!(
4564 r#"schema = "security.supplied-session-policy/v3"
4565principal_alias = "login-fixture-principal"
4566credential_mechanism = "cookie_jar"
4567credential_acquisition = "bounded_form_login"
4568cookie_update_policy = "stop_on_selected_cookie"
4569login_path = "/app/login"
4570login_method = "post"
4571login_encoding = "application/x-www-form-urlencoded"
4572username_field = "user"
4573password_field = "pass"
4574csrf_field = "csrf"
4575max_login_attempts = 1
4576health_path = "/app/session-health"
4577health_json_field = "authenticated"
4578resources = ["/app/private"]
4579max_session_requests = 5
4580max_total_response_bytes = 65536
4581max_response_body_bytes = 16384
4582max_wall_time_ms = 5000
4583
4584[[cookies]]
4585id = "generated-session"
4586name = "session"
4587domain = "{}"
4588host_only = true
4589path = "/app/"
4590secure = false
4591http_only = true
4592same_site = "lax"
4593"#,
4594 application.host_str().unwrap()
4595 );
4596 SuppliedSessionPolicy::parse_toml(application, bytes.as_bytes()).unwrap()
4597 }
4598
4599 fn committed_health_qualification(
4600 policy: &SuppliedSessionPolicy,
4601 knowledge: &KnowledgeBase,
4602 ) -> String {
4603 let descriptor = SuppliedSessionRequestDescriptor::health(policy);
4604 let mut checkpoint = WebAssessmentSuppliedSessionCheckpointAudit {
4605 sequence: 1,
4606 evidence_reference: None,
4607 phase: SuppliedSessionHealthCheckpointPhase::Terminal,
4608 after_subject_count: 1,
4609 outcome: SuppliedSessionHealthOutcome::Healthy,
4610 status: Some(200),
4611 body_state: SuppliedSessionBodyState::Complete,
4612 predicate: SuppliedSessionPredicateOutcome::Matched,
4613 response_bytes: 22,
4614 epoch: None,
4615 cookie_update: None,
4616 };
4617 bind_health_evidence(&mut checkpoint, policy, &descriptor, Some(true), knowledge);
4618 checkpoint
4619 .evidence_reference
4620 .expect("fixture health receipt must commit")
4621 }
4622
4623 #[test]
4624 fn strict_health_json_rejects_duplicates_and_non_boolean_values() {
4625 assert_eq!(
4626 strict_top_level_boolean(br#"{"authenticated":true}"#, "authenticated"),
4627 Some(true)
4628 );
4629 assert_eq!(
4630 strict_top_level_boolean(br#"{"authenticated":false}"#, "authenticated"),
4631 Some(false)
4632 );
4633 assert_eq!(
4634 strict_top_level_boolean(br#"{"authenticated":"true"}"#, "authenticated"),
4635 None
4636 );
4637 assert_eq!(
4638 strict_top_level_boolean(
4639 br#"{"authenticated":true,"authenticated":false}"#,
4640 "authenticated"
4641 ),
4642 None
4643 );
4644 assert_eq!(
4645 strict_top_level_boolean(br#"[true]"#, "authenticated"),
4646 None
4647 );
4648 }
4649
4650 #[test]
4651 fn only_a_complete_negative_post_startup_checkpoint_claims_session_loss() {
4652 assert_eq!(
4653 health_stop_outcome(SuppliedSessionHealthOutcome::Healthy, true),
4654 None
4655 );
4656 assert_eq!(
4657 health_stop_outcome(SuppliedSessionHealthOutcome::Unhealthy, true),
4658 Some(SuppliedSessionAuditOutcome::StartupUnhealthy)
4659 );
4660 assert_eq!(
4661 health_stop_outcome(SuppliedSessionHealthOutcome::Indeterminate, true),
4662 Some(SuppliedSessionAuditOutcome::ResourceUnavailable)
4663 );
4664 assert_eq!(
4665 health_stop_outcome(SuppliedSessionHealthOutcome::Unhealthy, false),
4666 Some(SuppliedSessionAuditOutcome::SessionLost)
4667 );
4668 assert_eq!(
4669 health_stop_outcome(SuppliedSessionHealthOutcome::Indeterminate, false),
4670 Some(SuppliedSessionAuditOutcome::ResourceUnavailable)
4671 );
4672 }
4673
4674 #[test]
4675 fn health_truth_requires_a_parent_knowledge_commit() {
4676 let application = url::Url::parse("https://example.test/app/").unwrap();
4677 let policy = policy(&application);
4678 let descriptor = SuppliedSessionRequestDescriptor::health(&policy);
4679 let healthy = || WebAssessmentSuppliedSessionCheckpointAudit {
4680 sequence: 0,
4681 evidence_reference: None,
4682 phase: SuppliedSessionHealthCheckpointPhase::Startup,
4683 after_subject_count: 0,
4684 outcome: SuppliedSessionHealthOutcome::Healthy,
4685 status: Some(200),
4686 body_state: SuppliedSessionBodyState::Complete,
4687 predicate: SuppliedSessionPredicateOutcome::Matched,
4688 response_bytes: 22,
4689 epoch: None,
4690 cookie_update: None,
4691 };
4692
4693 let committed_knowledge = KnowledgeBase::new();
4694 let mut committed = healthy();
4695 bind_health_evidence(
4696 &mut committed,
4697 &policy,
4698 &descriptor,
4699 Some(true),
4700 &committed_knowledge,
4701 );
4702 assert_eq!(committed.outcome(), SuppliedSessionHealthOutcome::Healthy);
4703 let evidence_reference = committed
4704 .evidence_reference()
4705 .expect("healthy checkpoint must have a committed receipt")
4706 .to_owned();
4707
4708 let conflicting = Evidence::with_id(
4709 EvidenceId::parse(evidence_reference).unwrap(),
4710 EntityId::new("supplied-session-conflict-fixture").unwrap(),
4711 EvidenceKind::Authentication,
4712 KnowledgePredicate::new("fixture", "conflict").unwrap(),
4713 EvidenceValue::Boolean(false),
4714 EvidenceSource::new("fixture", "conflict").unwrap(),
4715 ConfidenceScore::MAX,
4716 );
4717 let conflicting_knowledge = KnowledgeBase::new();
4718 conflicting_knowledge.insert_evidence(conflicting).unwrap();
4719 let mut rejected = healthy();
4720 bind_health_evidence(
4721 &mut rejected,
4722 &policy,
4723 &descriptor,
4724 Some(true),
4725 &conflicting_knowledge,
4726 );
4727 assert_eq!(rejected.evidence_reference(), None);
4728 assert_eq!(
4729 rejected.outcome(),
4730 SuppliedSessionHealthOutcome::Indeterminate
4731 );
4732 assert_eq!(
4733 rejected.predicate(),
4734 SuppliedSessionPredicateOutcome::NotEvaluated
4735 );
4736
4737 let mut wrong_target = healthy();
4738 bind_health_evidence(
4739 &mut wrong_target,
4740 &policy,
4741 &descriptor,
4742 Some(false),
4743 &KnowledgeBase::new(),
4744 );
4745 assert_eq!(wrong_target.evidence_reference(), None);
4746 assert_eq!(
4747 wrong_target.outcome(),
4748 SuppliedSessionHealthOutcome::Indeterminate
4749 );
4750 }
4751
4752 #[cfg(feature = "websocket-review")]
4753 #[test]
4754 fn startup_health_permit_rejects_mutated_binding_and_widened_deadline() {
4755 let application = url::Url::parse("http://127.0.0.1:8123/app/").unwrap();
4756 let endpoint = url::Url::parse("ws://127.0.0.1:8123/app/socket").unwrap();
4757 let policy = policy(&application);
4758 let websocket = websocket_policy(&application, &endpoint, 1_000);
4759 let descriptor = SuppliedSessionRequestDescriptor::health(&policy);
4760 let knowledge = KnowledgeBase::new();
4761 let mut checkpoint = WebAssessmentSuppliedSessionCheckpointAudit {
4762 sequence: 0,
4763 evidence_reference: None,
4764 phase: SuppliedSessionHealthCheckpointPhase::Startup,
4765 after_subject_count: 0,
4766 outcome: SuppliedSessionHealthOutcome::Healthy,
4767 status: Some(200),
4768 body_state: SuppliedSessionBodyState::Complete,
4769 predicate: SuppliedSessionPredicateOutcome::Matched,
4770 response_bytes: 22,
4771 epoch: None,
4772 cookie_update: None,
4773 };
4774 bind_health_evidence(
4775 &mut checkpoint,
4776 &policy,
4777 &descriptor,
4778 Some(true),
4779 &knowledge,
4780 );
4781 let startup = CommittedSuppliedSessionHealthReceipt::from_checkpoint(
4782 &policy,
4783 &checkpoint,
4784 SuppliedSessionHealthCheckpointPhase::Startup,
4785 0,
4786 &knowledge,
4787 )
4788 .expect("fixture must commit one exact startup receipt");
4789 let authorization =
4790 SuppliedSessionAuthorization::new("Bearer STARTUP-PERMIT-CANARY").unwrap();
4791 let parent_deadline = tokio::time::Instant::now()
4792 .checked_add(Duration::from_secs(10))
4793 .unwrap();
4794 let session_deadline = tokio::time::Instant::now()
4795 .checked_add(Duration::from_secs(5))
4796 .unwrap();
4797 let mut permit = SuppliedSessionWebSocketPermit::new(
4798 &policy,
4799 &authorization,
4800 &websocket,
4801 Some(session_deadline),
4802 startup,
4803 );
4804 assert!(permit.is_valid_for(&application, &websocket, &knowledge, Some(parent_deadline)));
4805
4806 permit.startup.epoch = 2;
4807 assert!(!permit.is_valid_for(&application, &websocket, &knowledge, Some(parent_deadline)));
4808 permit.startup.epoch = 1;
4809 permit.startup.after_subject_count = 1;
4810 assert!(!permit.is_valid_for(&application, &websocket, &knowledge, Some(parent_deadline)));
4811 permit.startup.after_subject_count = 0;
4812 permit.websocket_endpoint_reference = "websocket-endpoint-sha256:wrong";
4813 assert!(!permit.is_valid_for(&application, &websocket, &knowledge, Some(parent_deadline)));
4814 permit.websocket_endpoint_reference = websocket.endpoint_reference();
4815 permit.session_deadline = None;
4816 assert!(!permit.is_valid_for(&application, &websocket, &knowledge, Some(parent_deadline)));
4817 permit.session_deadline = parent_deadline.checked_add(Duration::from_millis(1));
4818 assert!(!permit.is_valid_for(&application, &websocket, &knowledge, Some(parent_deadline)));
4819 }
4820
4821 #[test]
4822 fn resource_commit_revalidates_descriptor_and_complete_response_receipt() {
4823 let application = url::Url::parse("https://example.test/app/").unwrap();
4824 let policy = policy(&application);
4825 let (target, reference) = policy.execution_resources().next().unwrap();
4826 let descriptor = SuppliedSessionRequestDescriptor::resource(&policy, target, reference)
4827 .expect("fixture resource descriptor");
4828 let complete = StagedSuppliedSessionResource {
4829 status: 200,
4830 response_bytes: 17,
4831 exact_target: true,
4832 body_complete: true,
4833 cookie_update: None,
4834 epoch: 1,
4835 };
4836 let knowledge = KnowledgeBase::new();
4837 assert_eq!(
4838 commit_staged_resource(
4839 0,
4840 &descriptor,
4841 reference,
4842 &policy,
4843 &knowledge,
4844 complete,
4845 None,
4846 )
4847 .outcome(),
4848 SuppliedSessionResourceOutcome::Incomplete,
4849 "a complete 200 is not authenticated coverage without its following health receipt"
4850 );
4851 let qualifier = committed_health_qualification(&policy, &knowledge);
4852 let committed = commit_staged_resource(
4853 0,
4854 &descriptor,
4855 reference,
4856 &policy,
4857 &knowledge,
4858 complete,
4859 Some(&qualifier),
4860 );
4861 assert_eq!(
4862 committed.outcome(),
4863 SuppliedSessionResourceOutcome::Committed
4864 );
4865 let evidence_reference = committed
4866 .evidence_reference()
4867 .expect("knowledge commit must leave an opaque receipt")
4868 .to_owned();
4869 assert_eq!(knowledge.stats().evidence, 2);
4870
4871 let conflicting = Evidence::with_id(
4872 EvidenceId::parse(evidence_reference).unwrap(),
4873 EntityId::new("supplied-session-conflict-fixture").unwrap(),
4874 EvidenceKind::Authentication,
4875 KnowledgePredicate::new("fixture", "conflict").unwrap(),
4876 EvidenceValue::Boolean(false),
4877 EvidenceSource::new("fixture", "conflict").unwrap(),
4878 ConfidenceScore::MAX,
4879 );
4880 let conflicting_knowledge = KnowledgeBase::new();
4881 let conflicting_qualifier = committed_health_qualification(&policy, &conflicting_knowledge);
4882 conflicting_knowledge.insert_evidence(conflicting).unwrap();
4883 let rejected = commit_staged_resource(
4884 0,
4885 &descriptor,
4886 reference,
4887 &policy,
4888 &conflicting_knowledge,
4889 complete,
4890 Some(&conflicting_qualifier),
4891 );
4892 assert_eq!(
4893 rejected.outcome(),
4894 SuppliedSessionResourceOutcome::Incomplete
4895 );
4896 assert_eq!(rejected.evidence_reference(), None);
4897
4898 for mutation in [
4899 StagedSuppliedSessionResource {
4900 exact_target: false,
4901 ..complete
4902 },
4903 StagedSuppliedSessionResource {
4904 body_complete: false,
4905 ..complete
4906 },
4907 ] {
4908 assert_eq!(
4909 commit_staged_resource(
4910 0,
4911 &descriptor,
4912 reference,
4913 &policy,
4914 &KnowledgeBase::new(),
4915 mutation,
4916 None,
4917 )
4918 .outcome(),
4919 SuppliedSessionResourceOutcome::Incomplete
4920 );
4921 }
4922 assert_eq!(
4923 commit_staged_resource(
4924 0,
4925 &descriptor,
4926 "supplied-session-resource-sha256:wrong",
4927 &policy,
4928 &KnowledgeBase::new(),
4929 complete,
4930 None,
4931 )
4932 .outcome(),
4933 SuppliedSessionResourceOutcome::Incomplete
4934 );
4935 assert_eq!(
4936 commit_staged_resource(
4937 0,
4938 &descriptor,
4939 reference,
4940 &policy,
4941 &KnowledgeBase::new(),
4942 StagedSuppliedSessionResource {
4943 status: 302,
4944 ..complete
4945 },
4946 None,
4947 )
4948 .outcome(),
4949 SuppliedSessionResourceOutcome::RedirectRefused
4950 );
4951 assert_eq!(
4952 commit_staged_resource(
4953 0,
4954 &descriptor,
4955 reference,
4956 &policy,
4957 &KnowledgeBase::new(),
4958 StagedSuppliedSessionResource {
4959 status: 403,
4960 ..complete
4961 },
4962 None,
4963 )
4964 .outcome(),
4965 SuppliedSessionResourceOutcome::HttpError
4966 );
4967 }
4968
4969 #[cfg(feature = "wordpress-review")]
4970 #[test]
4971 fn committed_resource_receipt_revalidates_the_full_session_binding() {
4972 let application = url::Url::parse("https://example.test/app/").unwrap();
4973 let policy = policy(&application);
4974 let (target, reference) = policy.execution_resources().next().unwrap();
4975 let descriptor = SuppliedSessionRequestDescriptor::resource(&policy, target, reference)
4976 .expect("fixture resource descriptor");
4977 let staged = StagedSuppliedSessionResource {
4978 status: 200,
4979 response_bytes: 17,
4980 exact_target: true,
4981 body_complete: true,
4982 cookie_update: None,
4983 epoch: 1,
4984 };
4985 let knowledge = KnowledgeBase::new();
4986 let qualifier = committed_health_qualification(&policy, &knowledge);
4987 let (audit, receipt) = commit_staged_resource_with_receipt(
4988 0,
4989 &descriptor,
4990 reference,
4991 &policy,
4992 &knowledge,
4993 staged,
4994 Some(&qualifier),
4995 );
4996 assert_eq!(audit.outcome(), SuppliedSessionResourceOutcome::Committed);
4997 let mut receipt = receipt.expect("complete qualified response must mint a sealed receipt");
4998 assert!(receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
4999
5000 receipt.epoch = 2;
5001 assert!(!receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5002 receipt.epoch = 1;
5003 receipt.principal_reference = "supplied-session-principal-9999".to_owned();
5004 assert!(!receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5005 receipt.principal_reference = policy.principal_reference().to_owned();
5006 receipt.credential_mechanism = SuppliedSessionCredentialMechanism::CookieJar;
5007 assert!(!receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5008 receipt.credential_mechanism = SuppliedSessionCredentialMechanism::AuthorizationHeader;
5009 receipt.application_reference =
5010 "supplied-session-application-sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
5011 .to_owned();
5012 assert!(!receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5013 receipt.application_reference = policy.application_reference().to_owned();
5014 receipt.policy_reference =
5015 "supplied-session-policy-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
5016 .to_owned();
5017 assert!(!receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5018 receipt.policy_reference = policy.policy_reference().to_owned();
5019 receipt.resource_reference =
5020 "supplied-session-resource-sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
5021 .to_owned();
5022 assert!(!receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5023 receipt.resource_reference = descriptor.target_reference().to_owned();
5024 assert!(!receipt.is_valid_for(1, &policy, &descriptor, &knowledge));
5025 assert!(receipt.is_valid_for(0, &policy, &descriptor, &knowledge));
5026 }
5027
5028 #[test]
5029 fn builder_rejects_cleartext_non_loopback_and_application_mismatch() {
5030 let insecure = url::Url::parse("http://example.test/app/").unwrap();
5031 let error = WebAssessmentRuntimeBuilder::new(insecure.clone())
5032 .with_supplied_session_review(
5033 policy(&insecure),
5034 SuppliedSessionAuthorization::new("Bearer REDACTED-CANARY").unwrap(),
5035 )
5036 .build()
5037 .err()
5038 .expect("cleartext non-loopback session must be rejected");
5039 assert!(matches!(
5040 error,
5041 WebAssessmentRuntimeError::InsecureSuppliedSessionTransport
5042 ));
5043
5044 let selected = url::Url::parse("https://example.test/app/").unwrap();
5045 let other = url::Url::parse("https://example.test/other/").unwrap();
5046 let other_policy = SuppliedSessionPolicy::parse_toml(
5047 &other,
5048 br#"schema = "security.supplied-session-policy/v1"
5049principal_alias = "fixture-principal"
5050credential_mechanism = "authorization_header"
5051health_path = "/other/session-health"
5052health_json_field = "authenticated"
5053resources = ["/other/private"]
5054max_session_requests = 3
5055max_total_response_bytes = 65536
5056max_response_body_bytes = 16384
5057max_wall_time_ms = 5000
5058"#,
5059 )
5060 .unwrap();
5061 let error = WebAssessmentRuntimeBuilder::new(selected)
5062 .with_supplied_session_review(
5063 other_policy,
5064 SuppliedSessionAuthorization::new("Bearer REDACTED-CANARY").unwrap(),
5065 )
5066 .build()
5067 .err()
5068 .expect("policy bound to another application must be rejected");
5069 assert!(matches!(
5070 error,
5071 WebAssessmentRuntimeError::SuppliedSessionApplicationMismatch
5072 ));
5073 }
5074
5075 #[cfg(feature = "wordpress-review")]
5076 #[test]
5077 fn wordpress_supplied_session_selection_requires_every_runtime_input() {
5078 let application = url::Url::parse("https://example.test/app/").unwrap();
5079 let authorization =
5080 || SuppliedSessionAuthorization::new("Bearer REDACTED-WORDPRESS-CANARY").unwrap();
5081
5082 let missing_all = WebAssessmentRuntimeBuilder::new(application.clone())
5083 .with_wordpress_supplied_session()
5084 .build()
5085 .err()
5086 .expect("integration without either child must fail preflight");
5087 assert!(matches!(
5088 missing_all,
5089 WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs
5090 ));
5091
5092 let missing_session = WebAssessmentRuntimeBuilder::new(application.clone())
5093 .with_wordpress_review(WordPressReviewInputs::new(None, None))
5094 .with_wordpress_discovery()
5095 .with_wordpress_supplied_session()
5096 .build()
5097 .err()
5098 .expect("WordPress discovery without a supplied session must fail preflight");
5099 assert!(matches!(
5100 missing_session,
5101 WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs
5102 ));
5103
5104 let missing_discovery = WebAssessmentRuntimeBuilder::new(application.clone())
5105 .with_wordpress_review(WordPressReviewInputs::new(None, None))
5106 .with_supplied_session_review(policy(&application), authorization())
5107 .with_wordpress_supplied_session()
5108 .build()
5109 .err()
5110 .expect("integration without metadata discovery must fail preflight");
5111 assert!(matches!(
5112 missing_discovery,
5113 WebAssessmentRuntimeError::WordPressSuppliedSessionRequiresInputs
5114 ));
5115
5116 let form_application = url::Url::parse("http://127.0.0.1:8123/app/").unwrap();
5117 let form_policy = form_login_policy(&form_application);
5118 let form_credential = SuppliedSessionFormCredential::parse_tsv(
5119 &form_policy,
5120 b"username\tUSER-RUNTIME-CANARY\npassword\tPASSWORD-RUNTIME-CANARY\n".to_vec(),
5121 )
5122 .unwrap();
5123 WebAssessmentRuntimeBuilder::new(form_application)
5124 .with_wordpress_review(WordPressReviewInputs::new(None, None))
5125 .with_wordpress_discovery()
5126 .with_supplied_session_review(form_policy, form_credential)
5127 .with_wordpress_supplied_session()
5128 .build()
5129 .expect("V3 is a bounded fixed-epoch WordPress session consumer");
5130
5131 let renewal = renewal_policy(&application);
5132 let credential =
5133 SuppliedSessionCookies::parse_tsv(&renewal, b"session\tRENEWAL-WP-CANARY".to_vec(), 0)
5134 .unwrap();
5135 let unsupported_renewal = WebAssessmentRuntimeBuilder::new(application.clone())
5136 .with_wordpress_review(WordPressReviewInputs::new(None, None))
5137 .with_wordpress_discovery()
5138 .with_supplied_session_review(renewal, credential)
5139 .with_wordpress_supplied_session()
5140 .build()
5141 .err()
5142 .expect("V4 renewal must not reach the fixed-epoch WordPress consumer");
5143 assert!(matches!(
5144 unsupported_renewal,
5145 WebAssessmentRuntimeError::WordPressSuppliedSessionUnsupportedPolicy
5146 ));
5147
5148 WebAssessmentRuntimeBuilder::new(application.clone())
5149 .with_wordpress_review(WordPressReviewInputs::new(None, None))
5150 .with_wordpress_discovery()
5151 .with_supplied_session_review(policy(&application), authorization())
5152 .with_wordpress_supplied_session()
5153 .build()
5154 .expect("complete integration selection must build");
5155 }
5156
5157 #[tokio::test]
5158 async fn normal_runtime_runs_session_before_anonymous_bfs_and_keeps_bodies_private() {
5159 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5160 let address = listener.local_addr().unwrap();
5161 let observed = Arc::new(Mutex::new(Vec::<(String, bool, bool)>::new()));
5162 let server_observed = observed.clone();
5163 let server = tokio::spawn(async move {
5164 loop {
5165 let Ok((mut stream, _)) = listener.accept().await else {
5166 break;
5167 };
5168 let mut request = Vec::new();
5169 loop {
5170 let mut chunk = [0_u8; 1024];
5171 let read = stream.read(&mut chunk).await.unwrap_or(0);
5172 if read == 0 {
5173 break;
5174 }
5175 request.extend_from_slice(&chunk[..read]);
5176 if request.windows(4).any(|window| window == b"\r\n\r\n")
5177 || request.len() > 16 * 1024
5178 {
5179 break;
5180 }
5181 }
5182 let text = String::from_utf8_lossy(&request);
5183 let path = text
5184 .lines()
5185 .next()
5186 .and_then(|line| line.split_ascii_whitespace().nth(1))
5187 .unwrap_or("")
5188 .to_owned();
5189 let has_authorization = text.lines().any(|line| {
5190 line.eq_ignore_ascii_case("authorization: Bearer SECRET-RUNTIME-CANARY")
5191 });
5192 let has_cookie = text
5193 .lines()
5194 .any(|line| line.to_ascii_lowercase().starts_with("cookie:"));
5195 server_observed
5196 .lock()
5197 .await
5198 .push((path.clone(), has_authorization, has_cookie));
5199 let (media_type, body) = match path.as_str() {
5200 "/app/session-health" => ("application/json", r#"{"authenticated":true}"#),
5201 "/app/private" => ("text/html", "PRIVATE-BODY-MUST-NOT-BE-RETAINED"),
5202 _ => ("text/html", "<!doctype html><title>public root</title>"),
5203 };
5204 let response = format!(
5205 "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
5206 body.len()
5207 );
5208 let _ = stream.write_all(response.as_bytes()).await;
5209 let _ = stream.shutdown().await;
5210 }
5211 });
5212
5213 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
5214 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
5215 .with_supplied_session_review(
5216 policy(&application),
5217 SuppliedSessionAuthorization::new("Bearer SECRET-RUNTIME-CANARY").unwrap(),
5218 )
5219 .build()
5220 .unwrap();
5221 let report = runtime.analyze().await.unwrap();
5222 let audit = report
5223 .supplied_session_audit()
5224 .expect("selected child must retain an audit");
5225 assert_eq!(audit.capability_id(), SUPPLIED_SESSION_CAPABILITY_ID);
5226 assert_eq!(audit.outcome(), SuppliedSessionAuditOutcome::Complete);
5227 assert_eq!(audit.coverage(), SuppliedSessionCoverage::Complete);
5228 assert_eq!(audit.selected_resource_count(), 1);
5229 assert_eq!(audit.dispatched_resource_count(), 1);
5230 assert_eq!(audit.committed_resource_count(), 1);
5231 assert_eq!(audit.dispatched_request_count(), 3);
5232 assert_eq!(audit.checkpoints().len(), 2);
5233 assert_eq!(audit.checkpoints()[0].sequence(), 0);
5234 assert!(audit.checkpoints()[0]
5235 .evidence_reference()
5236 .is_some_and(|reference| reference.starts_with(HEALTH_COMMIT_EVIDENCE_PREFIX)));
5237 assert_eq!(audit.checkpoints()[0].after_subject_count(), 0);
5238 assert_eq!(
5239 audit.checkpoints()[0].phase(),
5240 SuppliedSessionHealthCheckpointPhase::Startup
5241 );
5242 assert_eq!(audit.checkpoints()[1].sequence(), 1);
5243 assert!(audit.checkpoints()[1]
5244 .evidence_reference()
5245 .is_some_and(|reference| reference.starts_with(HEALTH_COMMIT_EVIDENCE_PREFIX)));
5246 assert_eq!(audit.checkpoints()[1].after_subject_count(), 1);
5247 assert_eq!(
5248 audit.checkpoints()[1].phase(),
5249 SuppliedSessionHealthCheckpointPhase::Terminal
5250 );
5251 assert_eq!(audit.resources()[0].sequence(), 0);
5252 assert_eq!(
5253 audit.resources()[0].outcome(),
5254 SuppliedSessionResourceOutcome::Committed
5255 );
5256 assert_eq!(audit.resources()[0].epoch(), 1);
5257 let evidence_reference = audit.resources()[0]
5258 .evidence_reference()
5259 .expect("committed resource must retain its knowledge receipt");
5260 assert!(evidence_reference.starts_with(RESOURCE_COMMIT_EVIDENCE_PREFIX));
5261 let evidence_id = EvidenceId::parse(evidence_reference).unwrap();
5262 assert_eq!(
5263 runtime
5264 .knowledge()
5265 .inspect_evidence(&evidence_id, |evidence| {
5266 evidence.kind() == &EvidenceKind::Authentication
5267 && evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
5268 && evidence.predicate().name() == RESOURCE_COMMIT_EVIDENCE_PREDICATE
5269 }),
5270 Some(true)
5271 );
5272 for checkpoint in audit.checkpoints() {
5273 let evidence_id = EvidenceId::parse(checkpoint.evidence_reference().unwrap()).unwrap();
5274 assert_eq!(
5275 runtime
5276 .knowledge()
5277 .inspect_evidence(&evidence_id, |evidence| {
5278 evidence.kind() == &EvidenceKind::Authentication
5279 && evidence.predicate().namespace()
5280 == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
5281 && evidence.predicate().name() == HEALTH_COMMIT_EVIDENCE_PREDICATE
5282 }),
5283 Some(true)
5284 );
5285 }
5286 assert_eq!(
5287 audit.response_bytes(),
5288 audit
5289 .checkpoints()
5290 .iter()
5291 .map(WebAssessmentSuppliedSessionCheckpointAudit::response_bytes)
5292 .chain(
5293 audit
5294 .resources()
5295 .iter()
5296 .map(WebAssessmentSuppliedSessionResourceAudit::response_bytes),
5297 )
5298 .sum::<u64>()
5299 );
5300 let saved = serde_json::to_string(audit).unwrap();
5301 let direct = serde_json::to_value(audit).unwrap();
5302 assert_eq!(
5303 direct.get("schema").and_then(serde_json::Value::as_str),
5304 Some(SUPPLIED_SESSION_AUDIT_SCHEMA)
5305 );
5306 assert!(!direct
5307 .as_object()
5308 .unwrap()
5309 .contains_key("credential_acquisition"));
5310 assert_eq!(audit.principal_alias(), "fixture-principal");
5311 assert!(saved.contains(r#""principal_alias":"fixture-principal""#));
5312 for forbidden in [
5313 "SECRET-RUNTIME-CANARY",
5314 "PRIVATE-BODY-MUST-NOT-BE-RETAINED",
5315 "/app/private",
5316 ] {
5317 assert!(!saved.contains(forbidden));
5318 }
5319
5320 let observed = observed.lock().await.clone();
5321 assert!(
5322 observed.len() >= 4,
5323 "session plus ordinary root must execute"
5324 );
5325 assert_eq!(observed[0], ("/app/session-health".to_owned(), true, false));
5326 assert_eq!(observed[1], ("/app/private".to_owned(), true, false));
5327 assert_eq!(observed[2], ("/app/session-health".to_owned(), true, false));
5328 assert_eq!(observed[3], ("/app/".to_owned(), false, false));
5329 server.abort();
5330 }
5331
5332 #[cfg(feature = "websocket-review")]
5333 #[tokio::test]
5334 async fn shared_request_refusal_retains_health_projection_without_claiming_ws_context() {
5335 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5336 let address = listener.local_addr().unwrap();
5337 let observed = Arc::new(Mutex::new(Vec::<String>::new()));
5338 let server_observed = Arc::clone(&observed);
5339 let server = tokio::spawn(async move {
5340 loop {
5341 let Ok((mut stream, _)) = listener.accept().await else {
5342 break;
5343 };
5344 let mut request = Vec::new();
5345 loop {
5346 let mut chunk = [0_u8; 1024];
5347 let read = stream.read(&mut chunk).await.unwrap_or(0);
5348 if read == 0 {
5349 break;
5350 }
5351 request.extend_from_slice(&chunk[..read]);
5352 if request.windows(4).any(|window| window == b"\r\n\r\n")
5353 || request.len() > 16 * 1024
5354 {
5355 break;
5356 }
5357 }
5358 let text = String::from_utf8_lossy(&request);
5359 let path = text
5360 .lines()
5361 .next()
5362 .and_then(|line| line.split_ascii_whitespace().nth(1))
5363 .unwrap_or("")
5364 .to_owned();
5365 server_observed.lock().await.push(path.clone());
5366 let (media_type, body) = match path.as_str() {
5367 "/app/session-health" => ("application/json", r#"{"authenticated":true}"#),
5368 "/app/private" => ("text/html", "PRIVATE-ZERO-ADMISSION-CANARY"),
5369 _ => ("text/html", "unexpected"),
5370 };
5371 let response = format!(
5372 "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
5373 body.len()
5374 );
5375 let _ = stream.write_all(response.as_bytes()).await;
5376 let _ = stream.shutdown().await;
5377 }
5378 });
5379
5380 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
5381 let endpoint = url::Url::parse(&format!("ws://{address}/app/socket")).unwrap();
5382 let limits = WebAssessmentLimits::default()
5383 .with_max_total_requests(2)
5384 .unwrap();
5385 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
5386 .limits(limits)
5387 .with_supplied_session_review(
5388 policy(&application),
5389 SuppliedSessionAuthorization::new("Bearer ZERO-ADMISSION-SECRET-CANARY").unwrap(),
5390 )
5391 .with_websocket_review(websocket_policy(&application, &endpoint, 2_000))
5392 .with_websocket_supplied_session()
5393 .build()
5394 .unwrap();
5395 let report = runtime.analyze().await.unwrap();
5396 assert!(report
5397 .completion()
5398 .reasons()
5399 .contains(&crate::web_runtime::WebAssessmentIncompleteReason::TotalRequestLimit));
5400
5401 let session = report.supplied_session_audit().unwrap();
5402 assert_eq!(session.checkpoints().len(), 1);
5403 assert_eq!(
5404 session.checkpoints()[0].outcome(),
5405 SuppliedSessionHealthOutcome::Healthy
5406 );
5407 let websocket = report.websocket_review_audit().unwrap();
5408 assert_eq!(websocket.request_attempt_count(), 1);
5409 assert_eq!(websocket.request_admitted_count(), 0);
5410 assert_eq!(websocket.connection_attempt_count(), 0);
5411 assert_eq!(websocket.handshake_completed_count(), 0);
5412 let context = websocket.supplied_session_context().unwrap();
5413 assert_eq!(
5414 context.qualification(),
5415 WebSocketSuppliedSessionQualification::NotEstablished
5416 );
5417 assert_eq!(
5418 context
5419 .startup_health()
5420 .map(WebSocketSuppliedSessionHealthAudit::outcome),
5421 Some("healthy")
5422 );
5423 assert!(context.terminal_health().is_none());
5424 assert!(websocket.is_consistent());
5425
5426 let diagnostic = format!("{websocket:?}");
5427 for forbidden in [
5428 "ZERO-ADMISSION-SECRET-CANARY",
5429 "PRIVATE-ZERO-ADMISSION-CANARY",
5430 "/app/private",
5431 "/app/socket",
5432 ] {
5433 assert!(!diagnostic.contains(forbidden));
5434 }
5435 assert_eq!(
5436 observed.lock().await.clone(),
5437 vec!["/app/session-health".to_owned(), "/app/private".to_owned()]
5438 );
5439 server.abort();
5440 }
5441
5442 #[cfg(feature = "websocket-review")]
5443 #[tokio::test]
5444 async fn completed_combined_ws_prefix_survives_later_ordinary_runtime_failure() {
5445 const SECRET: &str = "Bearer FAILURE-RECEIPT-SECRET-CANARY";
5446 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5447 let address = listener.local_addr().unwrap();
5448 let server = tokio::spawn(async move {
5449 loop {
5450 let Ok((mut stream, _)) = listener.accept().await else {
5451 break;
5452 };
5453 tokio::spawn(async move {
5454 let mut preview = [0_u8; 4096];
5455 let Ok(Ok(preview_len)) =
5456 tokio::time::timeout(Duration::from_secs(1), stream.peek(&mut preview))
5457 .await
5458 else {
5459 return;
5460 };
5461 let preview = String::from_utf8_lossy(&preview[..preview_len]);
5462 if preview.starts_with("GET /app/socket HTTP/1.1\r\n") {
5463 let accepted = tokio_tungstenite::accept_hdr_async(
5464 stream,
5465 ExactAuthorizationCallback(SECRET),
5466 )
5467 .await;
5468 if let Ok(mut socket) = accepted {
5469 if socket.next().await.is_some() {
5470 let _ = socket
5471 .send(tokio_tungstenite::tungstenite::Message::Text(
5472 "expected".into(),
5473 ))
5474 .await;
5475 }
5476 let _ = socket.close(None).await;
5477 }
5478 return;
5479 }
5480
5481 let mut request = Vec::new();
5482 loop {
5483 let mut chunk = [0_u8; 1024];
5484 let read = stream.read(&mut chunk).await.unwrap_or(0);
5485 if read == 0 {
5486 break;
5487 }
5488 request.extend_from_slice(&chunk[..read]);
5489 if request.windows(4).any(|window| window == b"\r\n\r\n")
5490 || request.len() > 16 * 1024
5491 {
5492 break;
5493 }
5494 }
5495 let text = String::from_utf8_lossy(&request);
5496 let path = text
5497 .lines()
5498 .next()
5499 .and_then(|line| line.split_ascii_whitespace().nth(1))
5500 .unwrap_or("");
5501 if path == "/app/" {
5502 let _ = stream.shutdown().await;
5503 return;
5504 }
5505 let (media_type, body) = match path {
5506 "/app/session-health" => ("application/json", r#"{"authenticated":true}"#),
5507 "/app/private" => ("text/html", "PRIVATE-FAILURE-BODY-CANARY"),
5508 _ => ("text/html", "unexpected"),
5509 };
5510 let response = format!(
5511 "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
5512 body.len()
5513 );
5514 let _ = stream.write_all(response.as_bytes()).await;
5515 let _ = stream.shutdown().await;
5516 });
5517 }
5518 });
5519
5520 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
5521 let endpoint = url::Url::parse(&format!("ws://{address}/app/socket")).unwrap();
5522 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
5523 .with_supplied_session_review(
5524 policy(&application),
5525 SuppliedSessionAuthorization::new(SECRET).unwrap(),
5526 )
5527 .with_websocket_review(websocket_policy(&application, &endpoint, 2_000))
5528 .with_websocket_supplied_session()
5529 .build()
5530 .unwrap();
5531 let error = runtime
5532 .analyze()
5533 .await
5534 .expect_err("ordinary root failure must preserve the completed combined prefix");
5535 let receipt = error.failure_receipt().expect("started failure receipt");
5536
5537 let session = receipt.supplied_session_audit().unwrap();
5538 assert_eq!(session.outcome(), SuppliedSessionAuditOutcome::Complete);
5539 assert_eq!(session.checkpoints().len(), 2);
5540 let websocket = receipt.websocket_review_audit().unwrap();
5541 assert_eq!(
5542 websocket.schema(),
5543 crate::web_runtime::WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA
5544 );
5545 assert_eq!(websocket.request_attempt_count(), 1);
5546 assert_eq!(websocket.request_admitted_count(), 1);
5547 assert_eq!(websocket.handshake_completed_count(), 1);
5548 assert_eq!(websocket.outbound_message_count(), 1);
5549 assert_eq!(websocket.inbound_message_count(), 1);
5550 assert_eq!(websocket.matched_response_count(), 1);
5551 assert_eq!(
5552 websocket
5553 .supplied_session_context()
5554 .unwrap()
5555 .qualification(),
5556 WebSocketSuppliedSessionQualification::ContextQualified
5557 );
5558 assert!(websocket.is_consistent());
5559
5560 let diagnostic = format!("{error:?}{websocket:?}");
5561 for forbidden in [
5562 "FAILURE-RECEIPT-SECRET-CANARY",
5563 "PRIVATE-FAILURE-BODY-CANARY",
5564 "/app/private",
5565 "/app/socket",
5566 ] {
5567 assert!(!diagnostic.contains(forbidden));
5568 }
5569 server.abort();
5570 }
5571
5572 #[cfg(feature = "websocket-review")]
5573 #[tokio::test]
5574 async fn supplied_session_deadline_bounds_websocket_and_leaves_context_unqualified() {
5575 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5576 let address = listener.local_addr().unwrap();
5577 let http_observed = Arc::new(Mutex::new(Vec::<(String, bool)>::new()));
5578 let websocket_authorized = Arc::new(AtomicBool::new(false));
5579 let server_http_observed = http_observed.clone();
5580 let server_websocket_authorized = websocket_authorized.clone();
5581 let server = tokio::spawn(async move {
5582 loop {
5583 let Ok((mut stream, _)) = listener.accept().await else {
5584 break;
5585 };
5586 let observed = server_http_observed.clone();
5587 let websocket_authorized = server_websocket_authorized.clone();
5588 tokio::spawn(async move {
5589 let mut preview = [0_u8; 4096];
5590 let Ok(Ok(preview_len)) =
5591 tokio::time::timeout(Duration::from_secs(1), stream.peek(&mut preview))
5592 .await
5593 else {
5594 return;
5595 };
5596 let preview = String::from_utf8_lossy(&preview[..preview_len]);
5597 if preview.starts_with("GET /app/socket HTTP/1.1\r\n") {
5598 let accepted = tokio_tungstenite::accept_hdr_async(
5599 stream,
5600 ObserveAuthorizationCallback {
5601 expected: b"Bearer SESSION-DEADLINE-CANARY",
5602 observed: websocket_authorized,
5603 },
5604 )
5605 .await;
5606 if let Ok(mut socket) = accepted {
5607 let _ = socket.next().await;
5608 tokio::time::sleep(Duration::from_secs(2)).await;
5609 }
5610 return;
5611 }
5612
5613 let mut request = Vec::new();
5614 loop {
5615 let mut chunk = [0_u8; 1024];
5616 let read = stream.read(&mut chunk).await.unwrap_or(0);
5617 if read == 0 {
5618 break;
5619 }
5620 request.extend_from_slice(&chunk[..read]);
5621 if request.windows(4).any(|window| window == b"\r\n\r\n")
5622 || request.len() > 16 * 1024
5623 {
5624 break;
5625 }
5626 }
5627 let text = String::from_utf8_lossy(&request);
5628 let path = text
5629 .lines()
5630 .next()
5631 .and_then(|line| line.split_ascii_whitespace().nth(1))
5632 .unwrap_or("")
5633 .to_owned();
5634 let authorized = text.lines().any(|line| {
5635 line.eq_ignore_ascii_case("authorization: Bearer SESSION-DEADLINE-CANARY")
5636 });
5637 observed.lock().await.push((path.clone(), authorized));
5638 let (media_type, body) = match path.as_str() {
5639 "/app/session-health" => ("application/json", r#"{"authenticated":true}"#),
5640 "/app/private" => ("text/html", "PRIVATE-DEADLINE-BODY"),
5641 _ => ("text/html", "<!doctype html><title>public root</title>"),
5642 };
5643 let response = format!(
5644 "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
5645 body.len()
5646 );
5647 let _ = stream.write_all(response.as_bytes()).await;
5648 let _ = stream.shutdown().await;
5649 });
5650 }
5651 });
5652
5653 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
5654 let endpoint = url::Url::parse(&format!("ws://{address}/app/socket")).unwrap();
5655 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
5656 .with_supplied_session_review(
5657 policy_with_wall_time(&application, 250),
5658 SuppliedSessionAuthorization::new("Bearer SESSION-DEADLINE-CANARY").unwrap(),
5659 )
5660 .with_websocket_review(websocket_policy(&application, &endpoint, 2_000))
5661 .with_websocket_supplied_session()
5662 .build()
5663 .unwrap();
5664 let report = runtime.analyze().await.unwrap();
5665 let session = report.supplied_session_audit().unwrap();
5666 assert_eq!(session.outcome(), SuppliedSessionAuditOutcome::RuntimeLimit);
5667 assert_eq!(session.checkpoints().len(), 1);
5668 assert_eq!(session.committed_resource_count(), 0);
5669 assert_eq!(
5670 session.resources()[0].outcome(),
5671 SuppliedSessionResourceOutcome::HealthUnqualified
5672 );
5673
5674 let websocket = report.websocket_review_audit().unwrap();
5675 assert_eq!(
5676 websocket.schema(),
5677 crate::web_runtime::WEBSOCKET_SUPPLIED_SESSION_AUDIT_SCHEMA
5678 );
5679 assert_eq!(
5680 websocket.terminal(),
5681 crate::web_runtime::WebSocketReviewTerminal::DeadlineReached
5682 );
5683 assert_eq!(websocket.request_attempt_count(), 1);
5684 assert_eq!(websocket.request_admitted_count(), 1);
5685 assert_eq!(websocket.handshake_completed_count(), 1);
5686 let context = websocket.supplied_session_context().unwrap();
5687 assert_eq!(
5688 context.qualification(),
5689 WebSocketSuppliedSessionQualification::ContextUnqualified
5690 );
5691 assert!(context.startup_health().is_some_and(|health| {
5692 health.outcome() == "healthy" && health.evidence_reference().is_some()
5693 }));
5694 assert_eq!(context.terminal_health(), None);
5695 assert!(websocket.is_consistent());
5696 assert!(websocket_authorized.load(Ordering::SeqCst));
5697
5698 let observed = http_observed.lock().await.clone();
5699 assert_eq!(
5700 observed
5701 .iter()
5702 .filter(|(path, authorized)| { path == "/app/session-health" && *authorized })
5703 .count(),
5704 1,
5705 "the elapsed child deadline must prevent a terminal-health dispatch"
5706 );
5707 assert!(observed
5708 .iter()
5709 .any(|(path, authorized)| path == "/app/private" && *authorized));
5710 assert!(observed
5711 .iter()
5712 .any(|(path, authorized)| path == "/app/" && !authorized));
5713 server.abort();
5714 }
5715
5716 #[cfg(feature = "wordpress-review")]
5717 #[tokio::test]
5718 async fn wordpress_integration_commits_health_qualified_html_before_anonymous_metadata() {
5719 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5720 let address = listener.local_addr().unwrap();
5721 let observed = Arc::new(Mutex::new(Vec::<(String, bool)>::new()));
5722 let server_observed = observed.clone();
5723 let server = tokio::spawn(async move {
5724 loop {
5725 let Ok((mut stream, _)) = listener.accept().await else {
5726 break;
5727 };
5728 let mut request = Vec::new();
5729 loop {
5730 let mut chunk = [0_u8; 1024];
5731 let read = stream.read(&mut chunk).await.unwrap_or(0);
5732 if read == 0 {
5733 break;
5734 }
5735 request.extend_from_slice(&chunk[..read]);
5736 if request.windows(4).any(|window| window == b"\r\n\r\n")
5737 || request.len() > 16 * 1024
5738 {
5739 break;
5740 }
5741 }
5742 let request = String::from_utf8_lossy(&request);
5743 let request_line = request.lines().next().unwrap_or_default();
5744 let method = request_line
5745 .split_ascii_whitespace()
5746 .next()
5747 .unwrap_or_default();
5748 let path = request_line
5749 .split_ascii_whitespace()
5750 .nth(1)
5751 .unwrap_or_default()
5752 .to_owned();
5753 let authorized = request.lines().any(|line| {
5754 line.eq_ignore_ascii_case("authorization: Bearer WORDPRESS-SESSION-CANARY")
5755 });
5756 server_observed
5757 .lock()
5758 .await
5759 .push((path.clone(), authorized));
5760 let (media_type, body) = match path.as_str() {
5761 "/app/" => (
5762 "text/html",
5763 r#"<meta name="generator" content="WordPress 6.9.4">
5764 <link rel="https://api.w.org/" href="/app/wp-json/">
5765 <link rel="stylesheet" href="/app/wp-content/themes/session-theme/style.css">"#,
5766 ),
5767 "/app/session-health" => ("application/json", r#"{"authenticated":true}"#),
5768 "/app/private" => (
5769 "text/html",
5770 r#"<script src="/app/wp-content/plugins/private-component/assets/site.js"></script>
5771 <link rel="stylesheet" href="/app/wp-content/plugins/private-component/assets/site.css">"#,
5772 ),
5773 "/app/wp-json/" => ("application/json", r#"{"namespaces":["wp/v2"]}"#),
5774 "/app/wp-content/themes/session-theme/style.css" => (
5775 "text/css",
5776 "/*\nTheme Name: Session Theme\nVersion: 1.5\n*/\n",
5777 ),
5778 "/app/wp-content/plugins/private-component/readme.txt" => (
5779 "text/plain",
5780 "=== Private Component ===\nStable tag: 9.9.9\n",
5781 ),
5782 _ => ("text/plain", "not found"),
5783 };
5784 let status = if body == "not found" {
5785 "404 Not Found"
5786 } else {
5787 "200 OK"
5788 };
5789 let response = format!(
5790 "HTTP/1.1 {status}\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
5791 body.len()
5792 );
5793 let _ = stream.write_all(response.as_bytes()).await;
5794 let _ = stream.shutdown().await;
5795 if method.is_empty() {
5796 break;
5797 }
5798 }
5799 });
5800
5801 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
5802 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
5803 .with_wordpress_review(WordPressReviewInputs::new(None, None))
5804 .with_wordpress_discovery()
5805 .with_supplied_session_review(
5806 policy(&application),
5807 SuppliedSessionAuthorization::new("Bearer WORDPRESS-SESSION-CANARY").unwrap(),
5808 )
5809 .with_wordpress_supplied_session()
5810 .build()
5811 .unwrap();
5812 let report = runtime.analyze().await.unwrap();
5813 let session = report.supplied_session_audit().unwrap();
5814 assert_eq!(session.outcome(), SuppliedSessionAuditOutcome::Complete);
5815 assert_eq!(session.committed_resource_count(), 1);
5816 let wordpress = report.wordpress_review_audit().unwrap();
5817 let discovery = wordpress.discovery().unwrap();
5818 let pages = discovery.supplied_session_pages().unwrap();
5819 assert_eq!(pages.selected_count(), 1);
5820 assert_eq!(pages.committed_count(), 1);
5821 assert_eq!(pages.accepted_count(), 1);
5822 assert_eq!(pages.rejected_count(), 0);
5823 assert_eq!(pages.not_established_count(), 0);
5824 assert_eq!(pages.not_evaluated_count(), 0);
5825 assert_eq!(pages.pages().len(), 1);
5826 let page = &pages.pages()[0];
5827 assert_eq!(page.association().as_str(), "accepted");
5828 assert_eq!(page.outcome().as_str(), "accepted");
5829 assert!(page.resource_evidence_reference().is_some());
5830 assert_eq!(page.evidence_ids().len(), 1);
5831
5832 let private_source = discovery
5833 .sources()
5834 .iter()
5835 .find(|source| source.component_slug() == Some("private-component"))
5836 .expect("health-qualified private page must nominate one plugin readme");
5837 assert!(private_source.source_page_references().is_empty());
5838 assert_eq!(
5839 private_source.source_supplied_session_page_references(),
5840 [page.context_page_reference()]
5841 );
5842 assert!(discovery
5843 .sources()
5844 .iter()
5845 .filter(|source| source.component_slug() != Some("private-component"))
5846 .all(|source| source.source_supplied_session_page_references().is_empty()));
5847
5848 let observed = observed.lock().await.clone();
5849 let root_index = observed
5850 .iter()
5851 .position(|(path, authorized)| path == "/app/" && !authorized)
5852 .unwrap();
5853 let startup_index = observed
5854 .iter()
5855 .position(|(path, authorized)| path == "/app/session-health" && *authorized)
5856 .unwrap();
5857 let metadata_index = observed
5858 .iter()
5859 .position(|(path, authorized)| {
5860 path == "/app/wp-content/plugins/private-component/readme.txt" && !authorized
5861 })
5862 .unwrap();
5863 assert!(root_index < startup_index && startup_index < metadata_index);
5864 assert_eq!(
5865 observed
5866 .iter()
5867 .filter(|(path, _)| path == "/app/private")
5868 .count(),
5869 1
5870 );
5871 assert!(observed
5872 .iter()
5873 .all(|(path, authorized)| { !path.starts_with("/app/wp-content/") || !authorized }));
5874 server.abort();
5875 }
5876
5877 #[tokio::test]
5878 async fn bounded_form_login_uses_one_stateful_attempt_then_existing_health_oracle() {
5879 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5880 let address = listener.local_addr().unwrap();
5881 let observed = Arc::new(Mutex::new(
5882 Vec::<(String, String, bool, bool, bool, String)>::new(),
5883 ));
5884 let server_observed = observed.clone();
5885 let server = tokio::spawn(async move {
5886 loop {
5887 let Ok((mut stream, _)) = listener.accept().await else {
5888 break;
5889 };
5890 let mut request = Vec::new();
5891 let header_end = loop {
5892 if let Some(offset) =
5893 request.windows(4).position(|window| window == b"\r\n\r\n")
5894 {
5895 break offset + 4;
5896 }
5897 let mut chunk = [0_u8; 1024];
5898 let read = stream.read(&mut chunk).await.unwrap_or(0);
5899 if read == 0 || request.len() > 16 * 1024 {
5900 break request.len();
5901 }
5902 request.extend_from_slice(&chunk[..read]);
5903 };
5904 let header_text = String::from_utf8_lossy(&request[..header_end]).into_owned();
5905 let content_length = header_text
5906 .lines()
5907 .find_map(|line| {
5908 line.strip_prefix("content-length: ")
5909 .or_else(|| line.strip_prefix("Content-Length: "))
5910 })
5911 .and_then(|value| value.trim().parse::<usize>().ok())
5912 .unwrap_or(0);
5913 while request.len() < header_end.saturating_add(content_length) {
5914 let mut chunk = [0_u8; 1024];
5915 let read = stream.read(&mut chunk).await.unwrap_or(0);
5916 if read == 0 || request.len() > 24 * 1024 {
5917 break;
5918 }
5919 request.extend_from_slice(&chunk[..read]);
5920 }
5921 let request_line = header_text.lines().next().unwrap_or("");
5922 let mut parts = request_line.split_ascii_whitespace();
5923 let method = parts.next().unwrap_or("").to_owned();
5924 let path = parts.next().unwrap_or("").to_owned();
5925 let has_cookie = header_text.lines().any(|line| {
5926 line.eq_ignore_ascii_case("cookie: session=SESSION-RUNTIME-CANARY")
5927 });
5928 let has_authorization = header_text
5929 .lines()
5930 .any(|line| line.to_ascii_lowercase().starts_with("authorization:"));
5931 let is_form_encoded = header_text.lines().any(|line| {
5932 line.eq_ignore_ascii_case("content-type: application/x-www-form-urlencoded")
5933 });
5934 let body = String::from_utf8_lossy(&request[header_end..]).into_owned();
5935 server_observed.lock().await.push((
5936 method.clone(),
5937 path.clone(),
5938 has_cookie,
5939 has_authorization,
5940 is_form_encoded,
5941 body.clone(),
5942 ));
5943
5944 let (status, media_type, extra_headers, response_body) =
5945 match (method.as_str(), path.as_str()) {
5946 ("GET", "/app/login") => (
5947 "200 OK",
5948 "text/html",
5949 "",
5950 concat!(
5951 "<form action=\"/app/login\" method=\"post\">",
5952 "<input name=\"user\">",
5953 "<input type=\"password\" name=\"pass\">",
5954 "<input type=\"hidden\" name=\"csrf\" value=\"CSRF-RUNTIME-CANARY\">",
5955 "</form>"
5956 ),
5957 ),
5958 ("POST", "/app/login") => (
5959 "200 OK",
5960 "application/json",
5961 concat!(
5962 "Set-Cookie: session=SESSION-RUNTIME-CANARY; ",
5963 "Path=/app/; HttpOnly; SameSite=Lax\r\n"
5964 ),
5965 r#"{"login":"received"}"#,
5966 ),
5967 ("GET", "/app/session-health") if has_cookie => (
5968 "200 OK",
5969 "application/json",
5970 "",
5971 r#"{"authenticated":true}"#,
5972 ),
5973 ("GET", "/app/session-health") => (
5974 "200 OK",
5975 "application/json",
5976 "",
5977 r#"{"authenticated":false}"#,
5978 ),
5979 ("GET", "/app/private") if has_cookie => (
5980 "200 OK",
5981 "text/html",
5982 "",
5983 "PRIVATE-FORM-LOGIN-BODY-MUST-NOT-BE-RETAINED",
5984 ),
5985 _ => ("200 OK", "text/html", "", "<title>public root</title>"),
5986 };
5987 let response = format!(
5988 "HTTP/1.1 {status}\r\nContent-Type: {media_type}\r\n{extra_headers}Content-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
5989 response_body.len()
5990 );
5991 let _ = stream.write_all(response.as_bytes()).await;
5992 let _ = stream.shutdown().await;
5993 }
5994 });
5995
5996 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
5997 let policy = form_login_policy(&application);
5998 let credential = SuppliedSessionFormCredential::parse_tsv(
5999 &policy,
6000 b"username\tUSER-RUNTIME-CANARY\npassword\tPASSWORD-RUNTIME-CANARY\n".to_vec(),
6001 )
6002 .unwrap();
6003 let mut runtime = WebAssessmentRuntimeBuilder::new(application)
6004 .with_supplied_session_review(policy, credential)
6005 .build()
6006 .unwrap();
6007 let report = runtime.analyze().await.unwrap();
6008 let audit = report.supplied_session_audit().unwrap();
6009 assert_eq!(audit.schema(), SUPPLIED_SESSION_FORM_LOGIN_AUDIT_SCHEMA);
6010 assert_eq!(
6011 audit.credential_acquisition(),
6012 SuppliedSessionCredentialAcquisition::BoundedFormLogin
6013 );
6014 assert_eq!(audit.outcome(), SuppliedSessionAuditOutcome::Complete);
6015 assert_eq!(audit.coverage(), SuppliedSessionCoverage::Complete);
6016 assert_eq!(audit.dispatched_request_count(), 5);
6017 assert_eq!(audit.committed_resource_count(), 1);
6018 let login = audit.login().unwrap();
6019 assert_eq!(login.max_attempts(), 1);
6020 assert_eq!(login.attempt_count(), 1);
6021 assert!(login.page_dispatched());
6022 assert_eq!(
6023 login.form_outcome(),
6024 SuppliedSessionLoginFormOutcome::Matched
6025 );
6026 assert_eq!(login.form_error_code(), None);
6027 assert!(login.submit_dispatched());
6028 assert_eq!(
6029 login.submit_outcome(),
6030 SuppliedSessionLoginSubmitOutcome::CookieAcquired
6031 );
6032 assert_eq!(login.acquired_cookie_count(), 1);
6033 assert_eq!(login.initial_epoch(), 0);
6034 assert_eq!(login.final_epoch(), 1);
6035 assert!(!login.pre_session_cookie_applied());
6036 let lifecycle = audit.cookie_lifecycle().unwrap();
6037 assert_eq!(lifecycle.initial_epoch(), 0);
6038 assert_eq!(lifecycle.final_epoch(), 1);
6039 assert_eq!(lifecycle.updates_applied(), 0);
6040 assert_eq!(report.usage().active_verifications(), 1);
6041 assert_eq!(
6042 audit.response_bytes(),
6043 login
6044 .response_bytes()
6045 .saturating_add(
6046 audit
6047 .checkpoints()
6048 .iter()
6049 .map(WebAssessmentSuppliedSessionCheckpointAudit::response_bytes)
6050 .sum::<u64>()
6051 )
6052 .saturating_add(
6053 audit
6054 .resources()
6055 .iter()
6056 .map(WebAssessmentSuppliedSessionResourceAudit::response_bytes)
6057 .sum::<u64>()
6058 )
6059 );
6060
6061 let observed = observed.lock().await.clone();
6062 assert!(
6063 observed.len() >= 6,
6064 "session child plus public root must run"
6065 );
6066 assert_eq!(observed[0].0, "GET");
6067 assert_eq!(observed[0].1, "/app/login");
6068 assert!(!observed[0].2 && !observed[0].3 && !observed[0].4);
6069 assert_eq!(observed[1].0, "POST");
6070 assert_eq!(observed[1].1, "/app/login");
6071 assert!(!observed[1].2 && !observed[1].3 && observed[1].4);
6072 assert_eq!(
6073 observed[1].5,
6074 "csrf=CSRF-RUNTIME-CANARY&user=USER-RUNTIME-CANARY&pass=PASSWORD-RUNTIME-CANARY"
6075 );
6076 assert_eq!(observed[2].1, "/app/session-health");
6077 assert!(observed[2].2 && !observed[2].3);
6078 assert_eq!(observed[3].1, "/app/private");
6079 assert!(observed[3].2 && !observed[3].3);
6080 assert_eq!(observed[4].1, "/app/session-health");
6081 assert!(observed[4].2 && !observed[4].3);
6082 assert_eq!(observed[5].1, "/app/");
6083 assert!(!observed[5].2 && !observed[5].3);
6084 assert_eq!(
6085 observed
6086 .iter()
6087 .filter(|request| request.0 == "POST" && request.1 == "/app/login")
6088 .count(),
6089 1
6090 );
6091
6092 let saved = serde_json::to_string(audit).unwrap();
6093 let direct = serde_json::to_value(audit).unwrap();
6094 assert_eq!(
6095 direct
6096 .get("credential_acquisition")
6097 .and_then(serde_json::Value::as_str),
6098 Some("bounded_form_login")
6099 );
6100 for forbidden in [
6101 "USER-RUNTIME-CANARY",
6102 "PASSWORD-RUNTIME-CANARY",
6103 "CSRF-RUNTIME-CANARY",
6104 "SESSION-RUNTIME-CANARY",
6105 "PRIVATE-FORM-LOGIN-BODY-MUST-NOT-BE-RETAINED",
6106 "/app/login",
6107 "/app/private",
6108 ] {
6109 assert!(!saved.contains(forbidden));
6110 }
6111 server.abort();
6112 }
6113
6114 async fn run_cookie_update_fixture(
6115 set_cookie: &'static str,
6116 update_path: &'static str,
6117 ) -> (WebAssessmentSuppliedSessionAudit, Vec<(String, bool, bool)>) {
6118 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6119 let address = listener.local_addr().unwrap();
6120 let observed = Arc::new(Mutex::new(Vec::<(String, bool, bool)>::new()));
6121 let server_observed = observed.clone();
6122 let server = tokio::spawn(async move {
6123 loop {
6124 let Ok((mut stream, _)) = listener.accept().await else {
6125 break;
6126 };
6127 let mut request = Vec::new();
6128 loop {
6129 let mut chunk = [0_u8; 1024];
6130 let read = stream.read(&mut chunk).await.unwrap_or(0);
6131 if read == 0 {
6132 break;
6133 }
6134 request.extend_from_slice(&chunk[..read]);
6135 if request.windows(4).any(|window| window == b"\r\n\r\n")
6136 || request.len() > 16 * 1024
6137 {
6138 break;
6139 }
6140 }
6141 let text = String::from_utf8_lossy(&request);
6142 let path = text
6143 .lines()
6144 .next()
6145 .and_then(|line| line.split_ascii_whitespace().nth(1))
6146 .unwrap_or("")
6147 .to_owned();
6148 let exact_cookie = text
6149 .lines()
6150 .any(|line| line.eq_ignore_ascii_case("cookie: session=COOKIE-RUNTIME-CANARY"));
6151 let has_authorization = text
6152 .lines()
6153 .any(|line| line.to_ascii_lowercase().starts_with("authorization:"));
6154 server_observed
6155 .lock()
6156 .await
6157 .push((path.clone(), exact_cookie, has_authorization));
6158 let (media_type, body) = match path.as_str() {
6159 "/app/session-health" => ("application/json", r#"{"authenticated":true}"#),
6160 "/app/private" => ("text/html", "COOKIE-PRIVATE-BODY-MUST-NOT-BE-RETAINED"),
6161 _ => ("text/html", "<title>public root</title>"),
6162 };
6163 let update = if path == update_path { set_cookie } else { "" };
6164 let response = format!(
6165 "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\n{update}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
6166 body.len()
6167 );
6168 let _ = stream.write_all(response.as_bytes()).await;
6169 let _ = stream.shutdown().await;
6170 }
6171 });
6172
6173 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
6174 let policy = cookie_policy(&application);
6175 let cookies = cookie_credential(&policy);
6176 let mut runtime = WebAssessmentRuntimeBuilder::new(application)
6177 .with_supplied_session_review(policy, cookies)
6178 .build()
6179 .unwrap();
6180 let report = runtime.analyze().await.unwrap();
6181 let audit = report.supplied_session_audit().unwrap().clone();
6182 let observed = observed.lock().await.clone();
6183 server.abort();
6184 (audit, observed)
6185 }
6186
6187 #[tokio::test]
6188 async fn selected_cookie_update_stops_before_following_health_and_is_value_free() {
6189 let (audit, observed) = run_cookie_update_fixture(
6190 "Set-Cookie: session=ROTATED-SECRET; Path=/app/\r\n",
6191 "/app/private",
6192 )
6193 .await;
6194 assert_eq!(audit.schema(), SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA);
6195 assert_eq!(
6196 audit.outcome(),
6197 SuppliedSessionAuditOutcome::CredentialUpdateRequired
6198 );
6199 assert_eq!(audit.coverage(), SuppliedSessionCoverage::None);
6200 assert_eq!(audit.dispatched_request_count(), 2);
6201 assert_eq!(audit.checkpoints().len(), 1);
6202 assert_eq!(audit.resources().len(), 1);
6203 assert_eq!(
6204 audit.resources()[0].outcome(),
6205 SuppliedSessionResourceOutcome::HealthUnqualified
6206 );
6207 let lifecycle = audit.cookie_lifecycle().unwrap();
6208 assert_eq!(lifecycle.selected_update_response_count(), 1);
6209 assert_eq!(lifecycle.unselected_update_response_count(), 0);
6210 assert_eq!(lifecycle.update_classification_failure_count(), 0);
6211 assert_eq!(lifecycle.initial_epoch(), 1);
6212 assert_eq!(lifecycle.final_epoch(), 1);
6213 assert_eq!(lifecycle.updates_applied(), 0);
6214 assert_eq!(observed[0], ("/app/session-health".to_owned(), true, false));
6215 assert_eq!(observed[1], ("/app/private".to_owned(), true, false));
6216 assert!(observed
6217 .iter()
6218 .skip(2)
6219 .all(|(_, has_cookie, _)| !has_cookie));
6220 let saved = serde_json::to_string(&audit).unwrap();
6221 let direct = serde_json::to_value(&audit).unwrap();
6222 assert_eq!(
6223 direct.get("schema").and_then(serde_json::Value::as_str),
6224 Some(SUPPLIED_SESSION_COOKIE_AUDIT_SCHEMA)
6225 );
6226 assert!(!direct
6227 .as_object()
6228 .unwrap()
6229 .contains_key("credential_acquisition"));
6230 for forbidden in [
6231 "COOKIE-RUNTIME-CANARY",
6232 "ROTATED-SECRET",
6233 "COOKIE-PRIVATE-BODY-MUST-NOT-BE-RETAINED",
6234 ] {
6235 assert!(!saved.contains(forbidden));
6236 }
6237 }
6238
6239 #[tokio::test]
6240 async fn unselected_cookie_update_does_not_mutate_or_stop_the_session() {
6241 let (audit, observed) =
6242 run_cookie_update_fixture("Set-Cookie: analytics=OPAQUE; Path=/\r\n", "/app/private")
6243 .await;
6244 assert_eq!(audit.outcome(), SuppliedSessionAuditOutcome::Complete);
6245 assert_eq!(audit.coverage(), SuppliedSessionCoverage::Complete);
6246 assert_eq!(audit.dispatched_request_count(), 3);
6247 assert_eq!(audit.checkpoints().len(), 2);
6248 assert_eq!(audit.committed_resource_count(), 1);
6249 let lifecycle = audit.cookie_lifecycle().unwrap();
6250 assert_eq!(lifecycle.selected_update_response_count(), 0);
6251 assert_eq!(lifecycle.unselected_update_response_count(), 1);
6252 assert_eq!(lifecycle.update_classification_failure_count(), 0);
6253 assert_eq!(lifecycle.updates_applied(), 0);
6254 assert_eq!(
6255 &observed[..3],
6256 &[
6257 ("/app/session-health".to_owned(), true, false),
6258 ("/app/private".to_owned(), true, false),
6259 ("/app/session-health".to_owned(), true, false),
6260 ]
6261 );
6262 }
6263
6264 #[tokio::test]
6265 async fn selected_startup_cookie_update_stops_before_any_resource() {
6266 let (audit, observed) = run_cookie_update_fixture(
6267 "Set-Cookie: session=ROTATED-AT-STARTUP; Path=/app/\r\n",
6268 "/app/session-health",
6269 )
6270 .await;
6271 assert_eq!(
6272 audit.outcome(),
6273 SuppliedSessionAuditOutcome::CredentialUpdateRequired
6274 );
6275 assert_eq!(audit.coverage(), SuppliedSessionCoverage::None);
6276 assert_eq!(audit.dispatched_request_count(), 1);
6277 assert_eq!(audit.dispatched_resource_count(), 0);
6278 assert_eq!(audit.committed_resource_count(), 0);
6279 assert_eq!(audit.checkpoints().len(), 1);
6280 assert!(audit
6281 .resources()
6282 .iter()
6283 .all(|resource| resource.outcome() == SuppliedSessionResourceOutcome::NotDispatched));
6284 let lifecycle = audit.cookie_lifecycle().unwrap();
6285 assert_eq!(lifecycle.selected_update_response_count(), 1);
6286 assert_eq!(lifecycle.update_classification_failure_count(), 0);
6287 assert_eq!(lifecycle.updates_applied(), 0);
6288 assert_eq!(observed[0], ("/app/session-health".to_owned(), true, false));
6289 assert!(observed
6290 .iter()
6291 .skip(1)
6292 .all(|(_, has_cookie, has_authorization)| !has_cookie && !has_authorization));
6293 assert!(observed.iter().any(|(path, _, _)| path == "/app/"));
6294 assert!(!serde_json::to_string(&audit)
6295 .unwrap()
6296 .contains("ROTATED-AT-STARTUP"));
6297 }
6298
6299 #[tokio::test]
6300 async fn unusable_cookie_update_stops_without_guessing_selected_names() {
6301 let (audit, observed) = run_cookie_update_fixture(
6302 "Set-Cookie: bad name=UNUSABLE-COOKIE-CANARY; Path=/app/\r\n",
6303 "/app/private",
6304 )
6305 .await;
6306 assert_eq!(
6307 audit.outcome(),
6308 SuppliedSessionAuditOutcome::CredentialUpdateUnusable
6309 );
6310 assert_eq!(audit.coverage(), SuppliedSessionCoverage::None);
6311 assert_eq!(audit.dispatched_request_count(), 2);
6312 assert_eq!(audit.dispatched_resource_count(), 1);
6313 assert_eq!(audit.committed_resource_count(), 0);
6314 assert_eq!(
6315 audit.resources()[0].outcome(),
6316 SuppliedSessionResourceOutcome::HealthUnqualified
6317 );
6318 let lifecycle = audit.cookie_lifecycle().unwrap();
6319 assert_eq!(lifecycle.selected_update_response_count(), 0);
6320 assert_eq!(lifecycle.unselected_update_response_count(), 0);
6321 assert_eq!(lifecycle.update_classification_failure_count(), 1);
6322 assert_eq!(lifecycle.updates_applied(), 0);
6323 assert_eq!(
6324 &observed[..2],
6325 &[
6326 ("/app/session-health".to_owned(), true, false),
6327 ("/app/private".to_owned(), true, false),
6328 ]
6329 );
6330 assert!(!serde_json::to_string(&audit)
6331 .unwrap()
6332 .contains("UNUSABLE-COOKIE-CANARY"));
6333 }
6334
6335 #[tokio::test]
6336 async fn unhealthy_post_resource_checkpoint_prevents_authenticated_coverage() {
6337 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6338 let address = listener.local_addr().unwrap();
6339 let observed = Arc::new(Mutex::new(Vec::<(String, bool)>::new()));
6340 let health_count = Arc::new(Mutex::new(0_u8));
6341 let server_observed = observed.clone();
6342 let server_health_count = health_count.clone();
6343 let server = tokio::spawn(async move {
6344 loop {
6345 let Ok((mut stream, _)) = listener.accept().await else {
6346 break;
6347 };
6348 let mut request = Vec::new();
6349 loop {
6350 let mut chunk = [0_u8; 1024];
6351 let read = stream.read(&mut chunk).await.unwrap_or(0);
6352 if read == 0 {
6353 break;
6354 }
6355 request.extend_from_slice(&chunk[..read]);
6356 if request.windows(4).any(|window| window == b"\r\n\r\n")
6357 || request.len() > 16 * 1024
6358 {
6359 break;
6360 }
6361 }
6362 let text = String::from_utf8_lossy(&request);
6363 let path = text
6364 .lines()
6365 .next()
6366 .and_then(|line| line.split_ascii_whitespace().nth(1))
6367 .unwrap_or("")
6368 .to_owned();
6369 let authorized = text.lines().any(|line| {
6370 line.eq_ignore_ascii_case("authorization: Bearer HEALTH-LOSS-CANARY")
6371 });
6372 server_observed
6373 .lock()
6374 .await
6375 .push((path.clone(), authorized));
6376 let (media_type, body) = match path.as_str() {
6377 "/app/session-health" => {
6378 let mut count = server_health_count.lock().await;
6379 *count = count.saturating_add(1);
6380 if *count == 1 {
6381 ("application/json", r#"{"authenticated":true}"#)
6382 } else {
6383 ("application/json", r#"{"authenticated":false}"#)
6384 }
6385 },
6386 "/app/private" => (
6387 "text/html",
6388 "<form>LOGIN-PAGE-BODY-MUST-NOT-BE-RETAINED</form>",
6389 ),
6390 _ => ("text/html", "<!doctype html><title>public root</title>"),
6391 };
6392 let response = format!(
6393 "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
6394 body.len()
6395 );
6396 let _ = stream.write_all(response.as_bytes()).await;
6397 let _ = stream.shutdown().await;
6398 }
6399 });
6400
6401 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
6402 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
6403 .with_supplied_session_review(
6404 policy(&application),
6405 SuppliedSessionAuthorization::new("Bearer HEALTH-LOSS-CANARY").unwrap(),
6406 )
6407 .build()
6408 .unwrap();
6409 let report = runtime.analyze().await.unwrap();
6410 let audit = report.supplied_session_audit().unwrap();
6411 assert_eq!(audit.outcome(), SuppliedSessionAuditOutcome::SessionLost);
6412 assert_eq!(audit.coverage(), SuppliedSessionCoverage::None);
6413 assert_eq!(audit.selected_resource_count(), 1);
6414 assert_eq!(audit.dispatched_resource_count(), 1);
6415 assert_eq!(audit.committed_resource_count(), 0);
6416 assert_eq!(audit.dispatched_request_count(), 3);
6417 assert_eq!(audit.checkpoints().len(), 2);
6418 assert_eq!(
6419 audit.checkpoints()[0].outcome(),
6420 SuppliedSessionHealthOutcome::Healthy
6421 );
6422 assert_eq!(
6423 audit.checkpoints()[1].outcome(),
6424 SuppliedSessionHealthOutcome::Unhealthy
6425 );
6426 assert_eq!(audit.resources().len(), 1);
6427 assert_eq!(
6428 audit.resources()[0].outcome(),
6429 SuppliedSessionResourceOutcome::HealthUnqualified
6430 );
6431 assert_eq!(audit.resources()[0].status(), Some(200));
6432 let evidence_reference = audit.resources()[0]
6433 .evidence_reference()
6434 .expect("observed but health-unqualified response keeps a value-safe receipt");
6435 let evidence_id = EvidenceId::parse(evidence_reference).unwrap();
6436 assert_eq!(
6437 runtime
6438 .knowledge()
6439 .inspect_evidence(&evidence_id, |evidence| {
6440 evidence.kind() == &EvidenceKind::Authentication
6441 && evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
6442 && evidence.predicate().name()
6443 == RESOURCE_HEALTH_UNQUALIFIED_EVIDENCE_PREDICATE
6444 }),
6445 Some(true)
6446 );
6447 let saved = serde_json::to_string(audit).unwrap();
6448 for forbidden in [
6449 "HEALTH-LOSS-CANARY",
6450 "LOGIN-PAGE-BODY-MUST-NOT-BE-RETAINED",
6451 "/app/private",
6452 ] {
6453 assert!(!saved.contains(forbidden));
6454 }
6455
6456 let observed = observed.lock().await.clone();
6457 assert_eq!(
6458 &observed[..3],
6459 &[
6460 ("/app/session-health".to_owned(), true),
6461 ("/app/private".to_owned(), true),
6462 ("/app/session-health".to_owned(), true),
6463 ]
6464 );
6465 server.abort();
6466 }
6467
6468 #[tokio::test]
6469 async fn hostile_resource_redirect_is_not_followed_or_committed() {
6470 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6471 let address = listener.local_addr().unwrap();
6472 let observed = Arc::new(Mutex::new(Vec::<String>::new()));
6473 let server_observed = observed.clone();
6474 let server = tokio::spawn(async move {
6475 loop {
6476 let Ok((mut stream, _)) = listener.accept().await else {
6477 break;
6478 };
6479 let mut request = Vec::new();
6480 loop {
6481 let mut chunk = [0_u8; 1024];
6482 let read = stream.read(&mut chunk).await.unwrap_or(0);
6483 if read == 0 {
6484 break;
6485 }
6486 request.extend_from_slice(&chunk[..read]);
6487 if request.windows(4).any(|window| window == b"\r\n\r\n")
6488 || request.len() > 16 * 1024
6489 {
6490 break;
6491 }
6492 }
6493 let text = String::from_utf8_lossy(&request);
6494 let path = text
6495 .lines()
6496 .next()
6497 .and_then(|line| line.split_ascii_whitespace().nth(1))
6498 .unwrap_or("")
6499 .to_owned();
6500 server_observed.lock().await.push(path.clone());
6501 let response = match path.as_str() {
6502 "/app/session-health" => concat!(
6503 "HTTP/1.1 200 OK\r\n",
6504 "Content-Type: application/json\r\n",
6505 "Content-Length: 22\r\n",
6506 "Connection: close\r\n\r\n",
6507 "{\"authenticated\":true}"
6508 )
6509 .to_owned(),
6510 "/app/private" => concat!(
6511 "HTTP/1.1 302 Found\r\n",
6512 "Location: /app/redirect-sink\r\n",
6513 "Content-Length: 0\r\n",
6514 "Connection: close\r\n\r\n"
6515 )
6516 .to_owned(),
6517 "/app/redirect-sink" => concat!(
6518 "HTTP/1.1 200 OK\r\n",
6519 "Content-Type: text/html\r\n",
6520 "Content-Length: 21\r\n",
6521 "Connection: close\r\n\r\n",
6522 "REDIRECT-WAS-FOLLOWED"
6523 )
6524 .to_owned(),
6525 _ => concat!(
6526 "HTTP/1.1 200 OK\r\n",
6527 "Content-Type: text/html\r\n",
6528 "Content-Length: 19\r\n",
6529 "Connection: close\r\n\r\n",
6530 "<title>root</title>"
6531 )
6532 .to_owned(),
6533 };
6534 let _ = stream.write_all(response.as_bytes()).await;
6535 let _ = stream.shutdown().await;
6536 }
6537 });
6538
6539 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
6540 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
6541 .with_supplied_session_review(
6542 policy(&application),
6543 SuppliedSessionAuthorization::new("Bearer REDIRECT-TEST-CANARY").unwrap(),
6544 )
6545 .build()
6546 .unwrap();
6547 let report = runtime.analyze().await.unwrap();
6548 let audit = report.supplied_session_audit().unwrap();
6549 assert_eq!(
6550 audit.outcome(),
6551 SuppliedSessionAuditOutcome::ResourceUnavailable
6552 );
6553 assert_eq!(audit.coverage(), SuppliedSessionCoverage::None);
6554 assert_eq!(audit.dispatched_request_count(), 2);
6555 assert_eq!(audit.checkpoints().len(), 1);
6556 assert_eq!(audit.resources().len(), 1);
6557 assert_eq!(
6558 audit.resources()[0].outcome(),
6559 SuppliedSessionResourceOutcome::RedirectRefused
6560 );
6561 assert_eq!(audit.resources()[0].status(), Some(302));
6562 assert_eq!(audit.resources()[0].evidence_reference(), None);
6563
6564 let observed = observed.lock().await.clone();
6565 assert!(observed.iter().any(|path| path == "/app/private"));
6566 assert!(
6567 observed.iter().all(|path| path != "/app/redirect-sink"),
6568 "the session client must never follow even a same-origin Location"
6569 );
6570 server.abort();
6571 }
6572
6573 #[tokio::test]
6574 async fn pre_cancelled_parent_dispatches_nothing_and_commits_no_session_evidence() {
6575 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
6576 let address = listener.local_addr().unwrap();
6577 let application = url::Url::parse(&format!("http://{address}/app/")).unwrap();
6578 let cancellation = tokio_util::sync::CancellationToken::new();
6579 cancellation.cancel();
6580 let mut runtime = WebAssessmentRuntimeBuilder::new(application.clone())
6581 .cancellation_token(cancellation)
6582 .with_supplied_session_review(
6583 policy(&application),
6584 SuppliedSessionAuthorization::new("Bearer CANCELLED-TEST-CANARY").unwrap(),
6585 )
6586 .build()
6587 .unwrap();
6588
6589 let report = runtime.analyze().await.unwrap();
6590 let audit = report.supplied_session_audit().unwrap();
6591 assert_eq!(audit.outcome(), SuppliedSessionAuditOutcome::Cancelled);
6592 assert_eq!(audit.coverage(), SuppliedSessionCoverage::None);
6593 assert_eq!(audit.dispatched_request_count(), 0);
6594 assert_eq!(audit.dispatched_resource_count(), 0);
6595 assert_eq!(audit.committed_resource_count(), 0);
6596 assert_eq!(audit.response_bytes(), 0);
6597 assert!(audit.checkpoints().is_empty());
6598 assert_eq!(audit.resources().len(), 1);
6599 assert_eq!(
6600 audit.resources()[0].outcome(),
6601 SuppliedSessionResourceOutcome::NotDispatched
6602 );
6603 assert_eq!(audit.resources()[0].evidence_reference(), None);
6604 assert_eq!(runtime.knowledge().stats().evidence, 0);
6605 assert_eq!(report.usage().total_requests(), 0);
6606 assert!(report.transport().is_empty());
6607
6608 let no_connection =
6609 tokio::time::timeout(Duration::from_millis(50), listener.accept()).await;
6610 assert!(
6611 no_connection.is_err(),
6612 "pre-cancelled authority must not open a socket"
6613 );
6614 }
6615}