Skip to main content

termivar_scanner/web_runtime/supplied_session_runtime/
oauth_refresh.rs

1//! One explicitly selected refresh phase of the existing supplied-session run.
2//! Returned token inequality is not proof of provider replay detection.
3
4use 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/// Value-free renewal facts; neither saved state nor this audit mints a permit.
69#[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    /// Links the typed child to this completed runtime audit. This validation
187    /// neither creates a receipt nor imports saved data into runtime authority.
188    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
396/// Exact read-back evidence, not an audit row accepted by shape alone.
397struct 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        // Validate the existing observation, not a new observation stamped at
441        // validation time. Preserve its timestamp and direct-evidence origin
442        // while independently reconstructing every authority-bearing field.
443        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
471/// Sealed borrowed authority for the ONE refresh request. All source evidence
472/// is checked both here and again inside the request broker at dispatch.
473pub(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    // No second renewal is authorized. Do not retain its unused secret.
804    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}