1use std::time::Duration;
5
6use serde::Serialize;
7use termivar_core::{
8 ConfidenceScore, EntityId, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
9 KnowledgePredicate,
10};
11
12use super::{
13 oauth_code::CommittedOAuthCodeReceipt, DispatchBudget, RuntimeExecution,
14 SharedWebRuntimeAuthority, SuppliedSessionAuditOutcome, SuppliedSessionBodyState,
15 SuppliedSessionCredential, SuppliedSessionHealthCheckpointPhase, SuppliedSessionHealthOutcome,
16 SuppliedSessionPolicy, SuppliedSessionPolicyVersion, SuppliedSessionPredicateOutcome,
17 SuppliedSessionResourceOutcome, WebAssessmentSuppliedSessionCheckpointAudit,
18 WebAssessmentSuppliedSessionResourceAudit, RESOURCE_COMMIT_EVIDENCE_NAMESPACE,
19};
20use crate::{
21 http_evidence::{OAuthCodeRequestBroker, OAuthCodeRequestError, OAuthRefreshRequestDescriptor},
22 knowledge::KnowledgeAuthority,
23 supplied_session_review::oauth_refresh::{
24 parse_renewable_token_response, OAuthRefreshFormBody, OAuthRefreshToken,
25 },
26 DecisionExecutionLimits, KnowledgeBase,
27};
28
29const REFRESH_PREDICATE: &str = "oauth-refresh-credential-committed";
30const REFRESH_DOMAIN: &[u8] = b"security.supplied-session-oauth-refresh-evidence.v1\0";
31const REFRESH_PREFIX: &str = "supplied-session-oauth-refresh-evidence-sha256";
32
33#[cfg(test)]
34#[path = "oauth_refresh_tests.rs"]
35mod tests;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "snake_case")]
39#[non_exhaustive]
40pub enum SuppliedSessionOAuthRefreshOutcome {
41 NotReached,
42 Cancelled,
43 RuntimeLimit,
44 ContextUnavailable,
45 TransportFailed,
46 Throttled,
47 ResponseRejected,
48 CommitFailed,
49 CredentialCommitted,
50}
51
52impl SuppliedSessionOAuthRefreshOutcome {
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::NotReached => "not_reached",
56 Self::Cancelled => "cancelled",
57 Self::RuntimeLimit => "runtime_limit",
58 Self::ContextUnavailable => "context_unavailable",
59 Self::TransportFailed => "transport_failed",
60 Self::Throttled => "throttled",
61 Self::ResponseRejected => "response_rejected",
62 Self::CommitFailed => "commit_failed",
63 Self::CredentialCommitted => "credential_committed",
64 }
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70pub struct SuppliedSessionOAuthRefreshAudit {
71 profile: &'static str,
72 idp_reference: String,
73 refresh_policy_reference: String,
74 after_resource_count: u8,
75 max_attempts: u8,
76 token_interpretation: &'static str,
77 rotation_assurance: &'static str,
78 server_replay_detection_established: bool,
79 initial_epoch: u8,
80 final_epoch: u8,
81 request_attempt_count: u8,
82 request_admitted_count: u8,
83 response_completed_count: u8,
84 credential_commit_count: u8,
85 status: Option<u16>,
86 body_state: SuppliedSessionBodyState,
87 response_bytes: u64,
88 configured_token_response_byte_limit: u64,
89 token_response_validated: bool,
90 response_scope_matched: Option<bool>,
91 declared_token_lifetime_seconds: Option<u64>,
92 replacement_refresh_token_received: Option<bool>,
93 replacement_value_differs: Option<bool>,
94 prior_credential_evidence_reference: Option<String>,
95 boundary_resource_evidence_reference: Option<String>,
96 boundary_health_evidence_reference: Option<String>,
97 evidence_reference: Option<String>,
98 health: Option<Box<WebAssessmentSuppliedSessionCheckpointAudit>>,
99 outcome: SuppliedSessionOAuthRefreshOutcome,
100}
101
102impl SuppliedSessionOAuthRefreshAudit {
103 pub(super) fn selected(policy: &SuppliedSessionPolicy) -> Option<Self> {
104 if policy.version() != SuppliedSessionPolicyVersion::V6 {
105 return None;
106 }
107 let code = policy.oauth_code_policy()?;
108 let refresh = policy.oauth_refresh_policy()?;
109 Some(Self {
110 profile: refresh.profile(),
111 idp_reference: code.binding_reference().to_owned(),
112 refresh_policy_reference: refresh.binding_reference().to_owned(),
113 after_resource_count: refresh.after_resource_count(),
114 max_attempts: refresh.max_attempts(),
115 token_interpretation: "opaque_bearer_not_oidc_identity",
116 rotation_assurance: "replacement_value_observed_not_server_revocation",
117 server_replay_detection_established: false,
118 initial_epoch: 1,
119 final_epoch: 1,
120 request_attempt_count: 0,
121 request_admitted_count: 0,
122 response_completed_count: 0,
123 credential_commit_count: 0,
124 status: None,
125 body_state: SuppliedSessionBodyState::Unavailable,
126 response_bytes: 0,
127 configured_token_response_byte_limit: u64::try_from(code.max_token_response_bytes())
128 .ok()?,
129 token_response_validated: false,
130 response_scope_matched: None,
131 declared_token_lifetime_seconds: None,
132 replacement_refresh_token_received: None,
133 replacement_value_differs: None,
134 prior_credential_evidence_reference: None,
135 boundary_resource_evidence_reference: None,
136 boundary_health_evidence_reference: None,
137 evidence_reference: None,
138 health: None,
139 outcome: SuppliedSessionOAuthRefreshOutcome::NotReached,
140 })
141 }
142
143 pub const fn outcome(&self) -> SuppliedSessionOAuthRefreshOutcome {
144 self.outcome
145 }
146 pub fn refresh_policy_reference(&self) -> &str {
147 &self.refresh_policy_reference
148 }
149 pub const fn initial_epoch(&self) -> u8 {
150 self.initial_epoch
151 }
152 pub const fn final_epoch(&self) -> u8 {
153 self.final_epoch
154 }
155 pub const fn request_attempt_count(&self) -> u8 {
156 self.request_attempt_count
157 }
158 pub const fn response_completed_count(&self) -> u8 {
159 self.response_completed_count
160 }
161 pub const fn replacement_refresh_token_received(&self) -> Option<bool> {
162 self.replacement_refresh_token_received
163 }
164 pub const fn replacement_value_differs(&self) -> Option<bool> {
165 self.replacement_value_differs
166 }
167 pub const fn request_admitted_count(&self) -> u8 {
168 self.request_admitted_count
169 }
170 pub const fn credential_commit_count(&self) -> u8 {
171 self.credential_commit_count
172 }
173 pub const fn response_bytes(&self) -> u64 {
174 self.response_bytes
175 }
176 pub fn health(&self) -> Option<&WebAssessmentSuppliedSessionCheckpointAudit> {
177 self.health.as_deref()
178 }
179 pub(super) fn set_health(&mut self, health: WebAssessmentSuppliedSessionCheckpointAudit) {
180 self.health = Some(Box::new(health));
181 }
182 pub(super) fn context_unavailable(&mut self) {
183 self.outcome = SuppliedSessionOAuthRefreshOutcome::ContextUnavailable;
184 }
185
186 pub(crate) fn is_consistent_for(
189 &self,
190 audit: &super::WebAssessmentSuppliedSessionAudit,
191 ) -> bool {
192 let Some(code) = audit.oauth_code() else {
193 return false;
194 };
195 if !self.is_consistent()
196 || audit.schema() != super::SUPPLIED_SESSION_OAUTH_REFRESH_AUDIT_SCHEMA
197 || audit.selected_resource_count() != 2
198 || audit.resources().len() != 2
199 || audit.checkpoints().len() > 3
200 || self.idp_reference != code.idp_reference()
201 || self.configured_token_response_byte_limit
202 != code.configured_token_response_byte_limit()
203 || audit.refresh_performed() != (self.credential_commit_count == 1)
204 || audit.checkpoints().iter().enumerate().any(|(index, row)| {
205 row.epoch()
206 != if self.final_epoch == 2 && index == 2 {
207 2
208 } else {
209 1
210 }
211 })
212 || audit.resources().iter().enumerate().any(|(index, row)| {
213 row.epoch()
214 != if self.final_epoch == 2 && index == 1 {
215 2
216 } else {
217 1
218 }
219 })
220 {
221 return false;
222 }
223 if code.credential_commit_count() == 0 {
224 return self.outcome == SuppliedSessionOAuthRefreshOutcome::NotReached;
225 }
226 let first = &audit.resources()[0];
227 let second = &audit.resources()[1];
228 if self.outcome != SuppliedSessionOAuthRefreshOutcome::NotReached
229 && (first.outcome() != SuppliedSessionResourceOutcome::Committed
230 || audit
231 .checkpoints()
232 .get(1)
233 .is_none_or(|health| health.outcome() != SuppliedSessionHealthOutcome::Healthy))
234 {
235 return false;
236 }
237 if self.request_attempt_count == 1
238 && (self.prior_credential_evidence_reference.as_deref() != code.evidence_reference()
239 || self.boundary_resource_evidence_reference.as_deref()
240 != first.evidence_reference()
241 || self.boundary_health_evidence_reference.as_deref()
242 != audit
243 .checkpoints()
244 .get(1)
245 .and_then(|row| row.evidence_reference()))
246 {
247 return false;
248 }
249 if self.credential_commit_count == 0
250 && (second.outcome() != SuppliedSessionResourceOutcome::NotDispatched
251 || audit.checkpoints().len() > 2)
252 {
253 return false;
254 }
255 let renewed_health = self.health().map(|health| health.outcome());
256 if second.outcome() != SuppliedSessionResourceOutcome::NotDispatched
257 && renewed_health != Some(SuppliedSessionHealthOutcome::Healthy)
258 {
259 return false;
260 }
261 match self.outcome {
262 SuppliedSessionOAuthRefreshOutcome::NotReached => !matches!(
263 audit.outcome(),
264 SuppliedSessionAuditOutcome::Complete
265 | SuppliedSessionAuditOutcome::OAuthRefreshUnavailable
266 ),
267 SuppliedSessionOAuthRefreshOutcome::Cancelled => {
268 audit.outcome() == SuppliedSessionAuditOutcome::Cancelled
269 },
270 SuppliedSessionOAuthRefreshOutcome::RuntimeLimit => {
271 audit.outcome() == SuppliedSessionAuditOutcome::RuntimeLimit
272 },
273 SuppliedSessionOAuthRefreshOutcome::CredentialCommitted => {
274 if renewed_health == Some(SuppliedSessionHealthOutcome::Unhealthy) {
275 audit.outcome() == SuppliedSessionAuditOutcome::SessionLost
276 } else if renewed_health != Some(SuppliedSessionHealthOutcome::Healthy) {
277 matches!(
278 audit.outcome(),
279 SuppliedSessionAuditOutcome::RuntimeLimit
280 | SuppliedSessionAuditOutcome::Cancelled
281 | SuppliedSessionAuditOutcome::ResourceUnavailable
282 )
283 } else {
284 !matches!(
285 audit.outcome(),
286 SuppliedSessionAuditOutcome::OAuthRefreshUnavailable
287 | SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable
288 )
289 }
290 },
291 _ => audit.outcome() == SuppliedSessionAuditOutcome::OAuthRefreshUnavailable,
292 }
293 }
294
295 pub(crate) fn is_consistent(&self) -> bool {
296 let reference = |value: &str, prefix: &str| {
297 value.strip_prefix(prefix).is_some_and(|tail| {
298 tail.len() == 64
299 && tail
300 .bytes()
301 .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
302 })
303 };
304 let complete = self.body_state == SuppliedSessionBodyState::Complete;
305 let response_present = self.body_state != SuppliedSessionBodyState::Unavailable;
306 let parsed = self.response_scope_matched == Some(true)
307 && self
308 .declared_token_lifetime_seconds
309 .is_some_and(|n| (1..=3600).contains(&n))
310 && self.replacement_refresh_token_received == Some(true)
311 && self.replacement_value_differs == Some(true);
312 let bound = self
313 .prior_credential_evidence_reference
314 .as_deref()
315 .is_some_and(|r| reference(r, "supplied-session-oauth-code-evidence-sha256:"))
316 && self
317 .boundary_resource_evidence_reference
318 .as_deref()
319 .is_some_and(|r| reference(r, "supplied-session-resource-evidence-sha256:"))
320 && self
321 .boundary_health_evidence_reference
322 .as_deref()
323 .is_some_and(|r| reference(r, "supplied-session-checkpoint-evidence-sha256:"));
324 if self.profile != "scheduled-public-bearer-refresh/v1"
325 || !reference(&self.idp_reference, "oauth-code-policy-sha256:")
326 || !reference(
327 &self.refresh_policy_reference,
328 "oauth-refresh-policy-sha256:",
329 )
330 || self.after_resource_count != 1
331 || self.max_attempts != 1
332 || self.token_interpretation != "opaque_bearer_not_oidc_identity"
333 || self.rotation_assurance != "replacement_value_observed_not_server_revocation"
334 || self.server_replay_detection_established
335 || self.initial_epoch != 1
336 || self.final_epoch != 1 + self.credential_commit_count
337 || self.request_attempt_count > 1
338 || self.request_admitted_count > self.request_attempt_count
339 || self.response_completed_count > self.request_admitted_count
340 || self.response_completed_count != u8::from(complete)
341 || self.credential_commit_count > u8::from(self.token_response_validated)
342 || self.status.is_some() != response_present
343 || self
344 .status
345 .is_some_and(|status| !(100..=599).contains(&status))
346 || (response_present || self.response_bytes > 0) && self.request_admitted_count != 1
347 || !(1..=32768).contains(&self.configured_token_response_byte_limit)
348 || self.token_response_validated != parsed
349 || (!parsed
350 && (self.response_scope_matched.is_some()
351 || self.declared_token_lifetime_seconds.is_some()
352 || self.replacement_refresh_token_received.is_some()
353 || self.replacement_value_differs.is_some()))
354 || (parsed
355 && (!complete
356 || self.status != Some(200)
357 || self.response_bytes == 0
358 || self.response_bytes > self.configured_token_response_byte_limit))
359 || self.request_attempt_count == 1 && !bound
360 || self.credential_commit_count != u8::from(self.evidence_reference.is_some())
361 || self
362 .evidence_reference
363 .as_deref()
364 .is_some_and(|r| !reference(r, "supplied-session-oauth-refresh-evidence-sha256:"))
365 {
366 return false;
367 }
368 if let Some(health) = self.health() {
369 if self.credential_commit_count != 1
370 || health.epoch() != 2
371 || health.phase() != SuppliedSessionHealthCheckpointPhase::Renewal
372 || health.sequence() != 2
373 || health.after_subject_count() != 1
374 {
375 return false;
376 }
377 }
378 match self.outcome {
379 SuppliedSessionOAuthRefreshOutcome::NotReached => {
380 self.request_attempt_count == 0
381 && self.response_bytes == 0
382 && self.status.is_none()
383 && self.prior_credential_evidence_reference.is_none()
384 && self.boundary_resource_evidence_reference.is_none()
385 && self.boundary_health_evidence_reference.is_none()
386 && self.health.is_none()
387 },
388 SuppliedSessionOAuthRefreshOutcome::CredentialCommitted => {
389 self.credential_commit_count == 1
390 },
391 _ => self.credential_commit_count == 0 && self.health.is_none(),
392 }
393 }
394}
395
396struct BoundaryEvidence {
398 reference: String,
399 expected: Evidence,
400}
401
402fn credential_binding(
403 policy: &SuppliedSessionPolicy,
404 code_reference: &str,
405 resource_reference: &str,
406 health_reference: &str,
407 response_bytes: u64,
408 lifetime: u64,
409) -> Option<String> {
410 let idp = policy.oauth_code_policy()?;
411 let refresh = policy.oauth_refresh_policy()?;
412 Some(format!(
413 "policy={};application={};idp={};refresh_policy={};purpose=oauth_refresh;prior_credential={code_reference};boundary_resource={resource_reference};boundary_health={health_reference};initial_epoch=1;final_epoch=2;status=200;body_state=complete;response_bytes={response_bytes};scope=exact_supported_echo;declared_lifetime={lifetime};replacement_value_differs=true;revocation=not_established;outcome=credential_committed",
414 policy.policy_reference(), policy.application_reference(), idp.binding_reference(), refresh.binding_reference()
415 ))
416}
417
418impl BoundaryEvidence {
419 #[allow(clippy::too_many_arguments)]
420 fn read(
421 policy: &SuppliedSessionPolicy,
422 knowledge: &KnowledgeBase,
423 reference: &str,
424 domain: &[u8],
425 prefix: &str,
426 predicate: &str,
427 method: &str,
428 correlation: &str,
429 binding: String,
430 ) -> Option<Self> {
431 use sha2::{Digest, Sha256};
432 let mut digest = Sha256::new();
433 digest.update(domain);
434 digest.update(binding.as_bytes());
435 let expected_reference = format!("{prefix}:{:x}", digest.finalize());
436 if reference != expected_reference {
437 return None;
438 }
439 let id = EvidenceId::parse(reference).ok()?;
440 let observed_at = knowledge.inspect_evidence(&id, Evidence::observed_at_ms)?;
444 let expected = Evidence::with_id_at(
445 id.clone(),
446 EntityId::new(policy.application_reference().to_owned()).ok()?,
447 EvidenceKind::Authentication,
448 KnowledgePredicate::new(RESOURCE_COMMIT_EVIDENCE_NAMESPACE, predicate).ok()?,
449 EvidenceValue::Text(binding),
450 EvidenceSource::new(super::RESOURCE_COMMIT_EVIDENCE_COMPONENT, method)
451 .and_then(|source| source.with_correlation_id(correlation.to_owned()))
452 .ok()?,
453 ConfidenceScore::MAX,
454 observed_at,
455 );
456 (knowledge.inspect_evidence(&id, |stored| stored == &expected) == Some(true)).then_some(
457 Self {
458 reference: reference.to_owned(),
459 expected,
460 },
461 )
462 }
463 fn remains_committed(&self, knowledge: &KnowledgeBase) -> bool {
464 EvidenceId::parse(&self.reference)
465 .ok()
466 .and_then(|id| knowledge.inspect_evidence(&id, |stored| stored == &self.expected))
467 == Some(true)
468 }
469}
470
471pub(crate) struct OAuthRefreshDispatchPermit<'a> {
474 policy: &'a SuppliedSessionPolicy,
475 knowledge: &'a KnowledgeBase,
476 code: &'a CommittedOAuthCodeReceipt,
477 token: &'a OAuthRefreshToken,
478 resource: BoundaryEvidence,
479 health: BoundaryEvidence,
480}
481
482impl<'a> OAuthRefreshDispatchPermit<'a> {
483 fn from_boundary(
484 context: &OAuthRefreshContext<'a>,
485 token: &'a OAuthRefreshToken,
486 ) -> Option<Self> {
487 let policy = context.policy;
488 if policy.version() != SuppliedSessionPolicyVersion::V6
489 || context.budget.dispatched_requests != 4
490 || !token.matches_policy(policy)
491 || !context
492 .code
493 .is_valid_for(policy, context.authority.knowledge())
494 || context.resource.sequence() != 0
495 || context.resource.epoch() != 1
496 || context.resource.status() != Some(200)
497 || context.resource.outcome() != SuppliedSessionResourceOutcome::Committed
498 || context.health.sequence() != 1
499 || context.health.epoch() != 1
500 || context.health.phase() != SuppliedSessionHealthCheckpointPhase::SubjectBoundary
501 || context.health.after_subject_count() != 1
502 || context.health.status() != Some(200)
503 || context.health.outcome() != SuppliedSessionHealthOutcome::Healthy
504 || context.health.body_state() != SuppliedSessionBodyState::Complete
505 || context.health.predicate() != SuppliedSessionPredicateOutcome::Matched
506 {
507 return None;
508 }
509 let (_, expected_resource) = policy.execution_resources().next()?;
510 if context.resource.resource_reference() != expected_resource {
511 return None;
512 }
513 let health_reference = context.health.evidence_reference()?;
514 let health_binding = format!(
515 "policy={};application={};health_field={};purpose=health;sequence=1;phase=subject_boundary;after_subject_count=1;target=exact;status=200;body_state=complete;predicate=matched;outcome=healthy;response_bytes={};epoch=1",
516 policy.policy_reference(),policy.application_reference(),policy.health_field_reference(),
517 context.health.response_bytes());
518 let health = BoundaryEvidence::read(
519 policy,
520 context.authority.knowledge(),
521 health_reference,
522 super::HEALTH_COMMIT_EVIDENCE_DOMAIN,
523 super::HEALTH_COMMIT_EVIDENCE_PREFIX,
524 super::HEALTH_COMMIT_EVIDENCE_PREDICATE,
525 "supplied-session-health/v6",
526 policy.health_field_reference(),
527 health_binding,
528 )?;
529 let resource_binding = format!(
530 "policy={};application={};resource={};purpose=resource;status=200;response_bytes={};qualifying_checkpoint={};outcome=committed;epoch=1",
531 policy.policy_reference(),policy.application_reference(),expected_resource,
532 context.resource.response_bytes(),health_reference);
533 let resource = BoundaryEvidence::read(
534 policy,
535 context.authority.knowledge(),
536 context.resource.evidence_reference()?,
537 super::RESOURCE_COMMIT_EVIDENCE_DOMAIN,
538 super::RESOURCE_COMMIT_EVIDENCE_PREFIX,
539 super::RESOURCE_COMMIT_EVIDENCE_PREDICATE,
540 "supplied-session-resource/v6",
541 expected_resource,
542 resource_binding,
543 )?;
544 Some(Self {
545 policy,
546 knowledge: context.authority.knowledge(),
547 code: context.code,
548 token,
549 resource,
550 health,
551 })
552 }
553
554 pub(crate) fn is_valid_for(
555 &self,
556 policy: &SuppliedSessionPolicy,
557 mint: &crate::web_runtime::OAuthRefreshMintToken,
558 ) -> bool {
559 mint.is_valid_for(policy, self.knowledge)
560 && policy.policy_reference() == self.policy.policy_reference()
561 && self.code.is_valid_for(policy, self.knowledge)
562 && self.token.matches_policy(policy)
563 && self.resource.remains_committed(self.knowledge)
564 && self.health.remains_committed(self.knowledge)
565 }
566
567 pub(crate) fn compose_form_body(
568 &self,
569 policy: &SuppliedSessionPolicy,
570 ) -> Result<OAuthRefreshFormBody, crate::supplied_session_review::OAuthCodeInputError> {
571 self.token.compose_form_body(policy)
572 }
573}
574
575pub(super) struct CommittedOAuthRefreshReceipt {
576 knowledge_authority: KnowledgeAuthority,
577 policy_reference: String,
578 application_reference: String,
579 idp_reference: String,
580 refresh_reference: String,
581 principal_alias: String,
582 prior_credential_reference: String,
583 resource: BoundaryEvidence,
584 health: BoundaryEvidence,
585 evidence_reference: String,
586 expected_evidence: Evidence,
587 response_bytes: u64,
588 declared_lifetime_seconds: u64,
589}
590
591impl CommittedOAuthRefreshReceipt {
592 pub(super) fn is_valid_for(
593 &self,
594 policy: &SuppliedSessionPolicy,
595 code: &CommittedOAuthCodeReceipt,
596 knowledge: &KnowledgeBase,
597 ) -> bool {
598 let Some(binding) = credential_binding(
599 policy,
600 &self.prior_credential_reference,
601 &self.resource.reference,
602 &self.health.reference,
603 self.response_bytes,
604 self.declared_lifetime_seconds,
605 ) else {
606 return false;
607 };
608 self.knowledge_authority.is_same_as(knowledge.authority())
609 && policy.version() == SuppliedSessionPolicyVersion::V6
610 && policy.policy_reference() == self.policy_reference
611 && policy.application_reference() == self.application_reference
612 && policy.principal_alias() == self.principal_alias
613 && policy
614 .oauth_code_policy()
615 .is_some_and(|idp| idp.binding_reference() == self.idp_reference)
616 && policy
617 .oauth_refresh_policy()
618 .is_some_and(|refresh| refresh.binding_reference() == self.refresh_reference)
619 && code.is_valid_for(policy, knowledge)
620 && code.evidence_reference() == self.prior_credential_reference
621 && self.resource.remains_committed(knowledge)
622 && self.health.remains_committed(knowledge)
623 && (1..=32768).contains(&self.response_bytes)
624 && (1..=3600).contains(&self.declared_lifetime_seconds)
625 && BoundaryEvidence::read(
626 policy,
627 knowledge,
628 &self.evidence_reference,
629 REFRESH_DOMAIN,
630 REFRESH_PREFIX,
631 REFRESH_PREDICATE,
632 "descriptor-bound-scheduled-oauth-refresh",
633 &self.refresh_reference,
634 binding,
635 )
636 .is_some_and(|expected| expected.expected == self.expected_evidence)
637 }
638}
639
640pub(super) struct AcquiredOAuthRefreshCredential {
641 pub(super) credential: SuppliedSessionCredential,
642 pub(super) receipt: CommittedOAuthRefreshReceipt,
643 pub(super) declared_lifetime: Duration,
644}
645
646pub(super) struct OAuthRefreshContext<'a> {
647 pub(super) authority: &'a SharedWebRuntimeAuthority,
648 pub(super) requests: &'a OAuthCodeRequestBroker,
649 pub(super) descriptor: &'a OAuthRefreshRequestDescriptor,
650 pub(super) policy: &'a SuppliedSessionPolicy,
651 pub(super) code: &'a CommittedOAuthCodeReceipt,
652 pub(super) resource: &'a WebAssessmentSuppliedSessionResourceAudit,
653 pub(super) health: &'a WebAssessmentSuppliedSessionCheckpointAudit,
654 pub(super) deadline: Option<tokio::time::Instant>,
655 pub(super) budget: DispatchBudget,
656}
657
658pub(super) async fn execute_refresh<'a>(
659 context: OAuthRefreshContext<'a>,
660 token: &'a OAuthRefreshToken,
661) -> (
662 SuppliedSessionOAuthRefreshAudit,
663 Result<AcquiredOAuthRefreshCredential, SuppliedSessionAuditOutcome>,
664) {
665 let mut audit = SuppliedSessionOAuthRefreshAudit::selected(context.policy)
666 .expect("only explicit V6 reaches refresh");
667 if context.authority.cancellation().is_cancelled() {
668 audit.outcome = SuppliedSessionOAuthRefreshOutcome::Cancelled;
669 return (audit, Err(SuppliedSessionAuditOutcome::Cancelled));
670 }
671 if context
672 .deadline
673 .is_some_and(|end| tokio::time::Instant::now() >= end)
674 || context.budget.dispatched_requests >= context.policy.max_session_requests()
675 || context.budget.accounted_response_bytes >= context.policy.max_total_response_bytes()
676 {
677 audit.outcome = SuppliedSessionOAuthRefreshOutcome::RuntimeLimit;
678 return (audit, Err(SuppliedSessionAuditOutcome::RuntimeLimit));
679 }
680 let Some(permit) = OAuthRefreshDispatchPermit::from_boundary(&context, token) else {
681 audit.outcome = SuppliedSessionOAuthRefreshOutcome::ContextUnavailable;
682 return (
683 audit,
684 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
685 );
686 };
687 audit.prior_credential_evidence_reference = Some(context.code.evidence_reference().to_owned());
688 audit.boundary_resource_evidence_reference = Some(permit.resource.reference.clone());
689 audit.boundary_health_evidence_reference = Some(permit.health.reference.clone());
690 let remaining = context
691 .policy
692 .max_total_response_bytes()
693 .saturating_sub(context.budget.accounted_response_bytes);
694 let body_limit = remaining.min(audit.configured_token_response_byte_limit);
695 let before = context.authority.request_accounting().snapshot();
696 audit.request_attempt_count = 1;
697 let execution = super::boxed_dispatch_execution(|| {
698 context.requests.collect_refresh(
699 context.descriptor,
700 context.policy,
701 &permit,
702 DecisionExecutionLimits::new().with_max_response_body_bytes(body_limit),
703 )
704 });
705 let result = super::super::await_execution(
706 context.authority.cancellation(),
707 context.deadline,
708 execution,
709 )
710 .await;
711 let after = context.authority.request_accounting().snapshot();
712 let admitted = after
713 .total_requests()
714 .saturating_sub(before.total_requests());
715 audit.request_admitted_count = u8::try_from(admitted).unwrap_or(u8::MAX);
716 audit.response_bytes = after
717 .response_bytes()
718 .saturating_sub(before.response_bytes());
719 if admitted > 1 {
720 audit.outcome = SuppliedSessionOAuthRefreshOutcome::CommitFailed;
721 return (
722 audit,
723 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
724 );
725 }
726 let response = match result {
727 RuntimeExecution::Completed(Ok(response)) => response,
728 RuntimeExecution::Cancelled => {
729 audit.outcome = SuppliedSessionOAuthRefreshOutcome::Cancelled;
730 return (audit, Err(SuppliedSessionAuditOutcome::Cancelled));
731 },
732 RuntimeExecution::WallTimeExceeded
733 | RuntimeExecution::Completed(Err(OAuthCodeRequestError::RuntimeLimit)) => {
734 audit.outcome = SuppliedSessionOAuthRefreshOutcome::RuntimeLimit;
735 return (audit, Err(SuppliedSessionAuditOutcome::RuntimeLimit));
736 },
737 RuntimeExecution::Completed(Err(OAuthCodeRequestError::Http)) => {
738 audit.outcome = SuppliedSessionOAuthRefreshOutcome::TransportFailed;
739 return (
740 audit,
741 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
742 );
743 },
744 RuntimeExecution::Completed(Err(_)) => {
745 audit.outcome = SuppliedSessionOAuthRefreshOutcome::ContextUnavailable;
746 return (
747 audit,
748 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
749 );
750 },
751 };
752 audit.status = Some(response.status);
753 audit.body_state = if response.complete {
754 SuppliedSessionBodyState::Complete
755 } else {
756 SuppliedSessionBodyState::Incomplete
757 };
758 audit.response_completed_count = u8::from(response.complete);
759 if audit.response_bytes > body_limit {
760 audit.outcome = SuppliedSessionOAuthRefreshOutcome::RuntimeLimit;
761 return (audit, Err(SuppliedSessionAuditOutcome::RuntimeLimit));
762 }
763 if response.status == 429 {
764 audit.outcome = SuppliedSessionOAuthRefreshOutcome::Throttled;
765 return (
766 audit,
767 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
768 );
769 }
770 if admitted != 1
771 || response.status != 200
772 || !response.complete
773 || !response.representation_supported
774 {
775 audit.outcome = SuppliedSessionOAuthRefreshOutcome::ResponseRejected;
776 return (
777 audit,
778 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
779 );
780 }
781 let parsed = match parse_renewable_token_response(context.policy, response.body, Some(token)) {
782 Ok(parsed) => parsed,
783 Err(_) => {
784 audit.outcome = SuppliedSessionOAuthRefreshOutcome::ResponseRejected;
785 return (
786 audit,
787 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
788 );
789 },
790 };
791 let lifetime = parsed.expires_in_seconds();
792 let differs = parsed.replacement_value_differs();
793 let (authorization, replacement_refresh) = match parsed.into_parts() {
794 Ok(parts) => parts,
795 Err(_) => {
796 audit.outcome = SuppliedSessionOAuthRefreshOutcome::ResponseRejected;
797 return (
798 audit,
799 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
800 );
801 },
802 };
803 drop(replacement_refresh);
805 audit.token_response_validated = true;
806 audit.response_scope_matched = Some(true);
807 audit.declared_token_lifetime_seconds = Some(lifetime);
808 audit.replacement_refresh_token_received = Some(true);
809 audit.replacement_value_differs = differs;
810 let idp = context
811 .policy
812 .oauth_code_policy()
813 .expect("validated IdP binding");
814 let refresh = context
815 .policy
816 .oauth_refresh_policy()
817 .expect("validated refresh binding");
818 let binding = credential_binding(
819 context.policy,
820 context.code.evidence_reference(),
821 &permit.resource.reference,
822 &permit.health.reference,
823 audit.response_bytes,
824 lifetime,
825 )
826 .expect("validated refresh binding");
827 let receipt = super::commit_value_safe_evidence(
828 context.policy,
829 refresh.binding_reference(),
830 REFRESH_DOMAIN,
831 REFRESH_PREFIX,
832 REFRESH_PREDICATE,
833 "descriptor-bound-scheduled-oauth-refresh",
834 binding,
835 context.authority.knowledge(),
836 )
837 .and_then(|reference| {
838 let id = EvidenceId::parse(&reference).ok()?;
839 let expected_evidence = context
840 .authority
841 .knowledge()
842 .inspect_evidence(&id, Clone::clone)?;
843 let receipt = CommittedOAuthRefreshReceipt {
844 knowledge_authority: context.authority.knowledge().authority().clone(),
845 policy_reference: context.policy.policy_reference().to_owned(),
846 application_reference: context.policy.application_reference().to_owned(),
847 idp_reference: idp.binding_reference().to_owned(),
848 refresh_reference: refresh.binding_reference().to_owned(),
849 principal_alias: context.policy.principal_alias().to_owned(),
850 prior_credential_reference: context.code.evidence_reference().to_owned(),
851 resource: permit.resource,
852 health: permit.health,
853 evidence_reference: reference,
854 expected_evidence,
855 response_bytes: audit.response_bytes,
856 declared_lifetime_seconds: lifetime,
857 };
858 receipt
859 .is_valid_for(context.policy, context.code, context.authority.knowledge())
860 .then_some(receipt)
861 });
862 let Some(receipt) = receipt else {
863 audit.outcome = SuppliedSessionOAuthRefreshOutcome::CommitFailed;
864 return (
865 audit,
866 Err(SuppliedSessionAuditOutcome::OAuthRefreshUnavailable),
867 );
868 };
869 audit.evidence_reference = Some(receipt.evidence_reference.clone());
870 audit.credential_commit_count = 1;
871 audit.final_epoch = 2;
872 audit.outcome = SuppliedSessionOAuthRefreshOutcome::CredentialCommitted;
873 (
874 audit,
875 Ok(AcquiredOAuthRefreshCredential {
876 credential: SuppliedSessionCredential::Authorization(authorization),
877 receipt,
878 declared_lifetime: Duration::from_secs(lifetime),
879 }),
880 )
881}