1use std::time::Duration;
8
9use serde::Serialize;
10use termivar_core::{Evidence, EvidenceId, EvidenceKind};
11
12use super::{
13 commit_value_safe_evidence, DispatchBudget, RuntimeExecution, SharedWebRuntimeAuthority,
14 SuppliedSessionAuditOutcome, SuppliedSessionBodyState, SuppliedSessionCredential,
15 SuppliedSessionPolicy, SuppliedSessionPolicyVersion, RESOURCE_COMMIT_EVIDENCE_NAMESPACE,
16};
17use crate::{
18 http_evidence::{OAuthCodeRequestBroker, OAuthCodeRequestDescriptor, OAuthCodeRequestError},
19 supplied_session_review::{oauth_code::parse_token_response, SuppliedSessionOAuthGrant},
20 DecisionExecutionLimits, KnowledgeBase,
21};
22
23#[cfg(test)]
24#[path = "oauth_code_tests.rs"]
25pub(super) mod tests;
26
27const ACQUISITION_PREDICATE: &str = "oauth-code-credential-acquisition-committed";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum SuppliedSessionOAuthCodeOutcome {
34 NotDispatched,
35 Cancelled,
36 RuntimeLimit,
37 GrantUnavailable,
38 TransportFailed,
39 Throttled,
40 ResponseRejected,
41 CommitFailed,
42 CredentialCommitted,
43}
44
45impl SuppliedSessionOAuthCodeOutcome {
46 pub const fn as_str(self) -> &'static str {
47 match self {
48 Self::NotDispatched => "not_dispatched",
49 Self::Cancelled => "cancelled",
50 Self::RuntimeLimit => "runtime_limit",
51 Self::GrantUnavailable => "grant_unavailable",
52 Self::TransportFailed => "transport_failed",
53 Self::Throttled => "throttled",
54 Self::ResponseRejected => "response_rejected",
55 Self::CommitFailed => "commit_failed",
56 Self::CredentialCommitted => "credential_committed",
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct SuppliedSessionOAuthCodeAudit {
64 profile: &'static str,
65 transport_profile: String,
66 idp_reference: String,
67 transaction_context_assurance: &'static str,
68 pkce_assurance: &'static str,
69 token_interpretation: &'static str,
70 authorization_initiated: bool,
71 browser_callback_received: bool,
72 state_nonce_verified: bool,
73 oidc_identity_validation_performed: bool,
74 token_signature_validation_performed: bool,
75 request_attempt_count: u8,
76 request_admitted_count: u8,
77 response_completed_count: u8,
78 credential_commit_count: u8,
79 verifier_in_admitted_request: bool,
80 status: Option<u16>,
81 body_state: SuppliedSessionBodyState,
82 response_bytes: u64,
83 configured_token_response_byte_limit: u64,
84 token_response_validated: bool,
85 response_scope_matched: Option<bool>,
86 declared_token_lifetime_seconds: Option<u64>,
87 refresh_token_returned: Option<bool>,
88 evidence_reference: Option<String>,
89 outcome: SuppliedSessionOAuthCodeOutcome,
90}
91
92impl SuppliedSessionOAuthCodeAudit {
93 pub(super) fn selected(policy: &SuppliedSessionPolicy) -> Option<Self> {
94 let idp = policy.oauth_code_policy()?;
95 Some(Self {
96 profile: "external-public-code-s256-bearer/v1",
97 transport_profile: idp.transport_profile().to_owned(),
98 idp_reference: idp.binding_reference().to_owned(),
99 transaction_context_assurance: "operator_declared_external_transaction",
100 pkce_assurance: "imported_s256_context_not_remote_enforcement",
101 token_interpretation: "opaque_bearer_not_oidc_identity",
102 authorization_initiated: false,
103 browser_callback_received: false,
104 state_nonce_verified: false,
105 oidc_identity_validation_performed: false,
106 token_signature_validation_performed: false,
107 request_attempt_count: 0,
108 request_admitted_count: 0,
109 response_completed_count: 0,
110 credential_commit_count: 0,
111 verifier_in_admitted_request: false,
112 status: None,
113 body_state: SuppliedSessionBodyState::Unavailable,
114 response_bytes: 0,
115 configured_token_response_byte_limit: u64::try_from(idp.max_token_response_bytes())
116 .unwrap_or(u64::MAX),
117 token_response_validated: false,
118 response_scope_matched: None,
119 declared_token_lifetime_seconds: None,
120 refresh_token_returned: None,
121 evidence_reference: None,
122 outcome: SuppliedSessionOAuthCodeOutcome::NotDispatched,
123 })
124 }
125
126 pub fn idp_reference(&self) -> &str {
127 &self.idp_reference
128 }
129 pub const fn outcome(&self) -> SuppliedSessionOAuthCodeOutcome {
130 self.outcome
131 }
132 pub const fn request_attempt_count(&self) -> u8 {
133 self.request_attempt_count
134 }
135 pub const fn request_admitted_count(&self) -> u8 {
136 self.request_admitted_count
137 }
138 pub const fn response_completed_count(&self) -> u8 {
139 self.response_completed_count
140 }
141 pub const fn credential_commit_count(&self) -> u8 {
142 self.credential_commit_count
143 }
144 pub const fn response_bytes(&self) -> u64 {
145 self.response_bytes
146 }
147 pub const fn configured_token_response_byte_limit(&self) -> u64 {
148 self.configured_token_response_byte_limit
149 }
150 pub const fn declared_token_lifetime_seconds(&self) -> Option<u64> {
151 self.declared_token_lifetime_seconds
152 }
153 pub fn evidence_reference(&self) -> Option<&str> {
154 self.evidence_reference.as_deref()
155 }
156
157 pub(crate) fn is_consistent(&self) -> bool {
158 let digest = |value: &str, prefix: &str| {
159 value.strip_prefix(prefix).is_some_and(|rest| {
160 rest.len() == 64
161 && rest
162 .bytes()
163 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
164 })
165 };
166 let complete = self.body_state == SuppliedSessionBodyState::Complete;
167 let has_response = self.body_state != SuppliedSessionBodyState::Unavailable;
168 let valid_token_facts = self
169 .declared_token_lifetime_seconds
170 .is_some_and(|seconds| (1..=3_600).contains(&seconds))
171 && self.response_scope_matched == Some(true)
172 && self.refresh_token_returned.is_some();
173 if self.profile != "external-public-code-s256-bearer/v1"
174 || !matches!(
175 self.transport_profile.as_str(),
176 "verified-https/v1" | "owned-numeric-loopback-http/v1"
177 )
178 || !digest(&self.idp_reference, "oauth-code-policy-sha256:")
179 || self.transaction_context_assurance != "operator_declared_external_transaction"
180 || self.pkce_assurance != "imported_s256_context_not_remote_enforcement"
181 || self.token_interpretation != "opaque_bearer_not_oidc_identity"
182 || self.authorization_initiated
183 || self.browser_callback_received
184 || self.state_nonce_verified
185 || self.oidc_identity_validation_performed
186 || self.token_signature_validation_performed
187 || self.request_attempt_count > 1
188 || self.request_admitted_count > self.request_attempt_count
189 || self.response_completed_count > self.request_admitted_count
190 || self.credential_commit_count > u8::from(self.token_response_validated)
191 || self.verifier_in_admitted_request != (self.request_admitted_count == 1)
192 || self.response_completed_count != u8::from(complete)
193 || self.status.is_some() != has_response
194 || self
195 .status
196 .is_some_and(|status| !(100..=599).contains(&status))
197 || (has_response || self.response_bytes > 0) && self.request_admitted_count != 1
198 || !(1..=32_768).contains(&self.configured_token_response_byte_limit)
199 || self.token_response_validated != valid_token_facts
200 || (!self.token_response_validated
201 && (self.declared_token_lifetime_seconds.is_some()
202 || self.response_scope_matched.is_some()
203 || self.refresh_token_returned.is_some()))
204 || self.token_response_validated
205 && (!complete
206 || self.status != Some(200)
207 || self.response_bytes == 0
208 || self.response_bytes > self.configured_token_response_byte_limit)
209 || (self.credential_commit_count == 1) != self.evidence_reference.is_some()
210 || self.evidence_reference.as_deref().is_some_and(|reference| {
211 !digest(reference, "supplied-session-oauth-code-evidence-sha256:")
212 })
213 {
214 return false;
215 }
216 match self.outcome {
217 SuppliedSessionOAuthCodeOutcome::NotDispatched => {
218 self.request_attempt_count == 0
219 && !has_response
220 && self.response_bytes == 0
221 && !self.token_response_validated
222 },
223 SuppliedSessionOAuthCodeOutcome::Cancelled
224 | SuppliedSessionOAuthCodeOutcome::RuntimeLimit => {
225 !self.token_response_validated && self.credential_commit_count == 0
226 },
227 SuppliedSessionOAuthCodeOutcome::GrantUnavailable => {
228 self.request_attempt_count == 1
229 && self.request_admitted_count == 0
230 && !has_response
231 && self.response_bytes == 0
232 },
233 SuppliedSessionOAuthCodeOutcome::TransportFailed => {
234 self.request_attempt_count == 1 && !has_response && !self.token_response_validated
235 },
236 SuppliedSessionOAuthCodeOutcome::Throttled => {
237 self.request_admitted_count == 1
238 && self.status == Some(429)
239 && !self.token_response_validated
240 },
241 SuppliedSessionOAuthCodeOutcome::ResponseRejected => {
242 self.request_admitted_count == 1 && has_response && !self.token_response_validated
243 },
244 SuppliedSessionOAuthCodeOutcome::CommitFailed => {
245 self.request_admitted_count == 1
246 && self.token_response_validated
247 && self.credential_commit_count == 0
248 },
249 SuppliedSessionOAuthCodeOutcome::CredentialCommitted => {
250 self.request_admitted_count == 1
251 && self.token_response_validated
252 && self.credential_commit_count == 1
253 },
254 }
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct SuppliedSessionOAuthInitiatedTokenAudit {
262 inner: SuppliedSessionOAuthCodeAudit,
263}
264
265impl SuppliedSessionOAuthInitiatedTokenAudit {
266 pub(super) fn from_exchange(inner: SuppliedSessionOAuthCodeAudit) -> Self {
267 Self { inner }
268 }
269 pub fn idp_reference(&self) -> &str {
270 self.inner.idp_reference()
271 }
272 pub const fn request_attempt_count(&self) -> u8 {
273 self.inner.request_attempt_count()
274 }
275 pub const fn request_admitted_count(&self) -> u8 {
276 self.inner.request_admitted_count()
277 }
278 pub const fn credential_commit_count(&self) -> u8 {
279 self.inner.credential_commit_count()
280 }
281 pub const fn response_bytes(&self) -> u64 {
282 self.inner.response_bytes()
283 }
284 pub const fn outcome(&self) -> SuppliedSessionOAuthCodeOutcome {
285 self.inner.outcome()
286 }
287 pub(crate) fn is_consistent(&self) -> bool {
288 self.inner.is_consistent()
289 }
290
291 pub(crate) fn is_consistent_for(
292 &self,
293 audit: &super::WebAssessmentSuppliedSessionAudit,
294 authorization: &super::SuppliedSessionOAuthAuthorizationAudit,
295 ) -> bool {
296 use super::SuppliedSessionOAuthAuthorizationOutcome as A;
297 let a = &self.inner;
298 if !a.is_consistent()
299 || !authorization.is_consistent()
300 || a.configured_token_response_byte_limit() > audit.response_byte_limit()
301 || (a.request_attempt_count() > 0 && authorization.callback_commit_count() != 1)
302 {
303 return false;
304 }
305 if a.credential_commit_count() == 1 {
306 authorization.callback_commit_count() == 1
307 && matches!(
308 audit.outcome(),
309 SuppliedSessionAuditOutcome::Complete
310 | SuppliedSessionAuditOutcome::StartupUnhealthy
311 | SuppliedSessionAuditOutcome::SessionLost
312 | SuppliedSessionAuditOutcome::ResourceUnavailable
313 | SuppliedSessionAuditOutcome::RuntimeLimit
314 | SuppliedSessionAuditOutcome::Cancelled
315 )
316 } else {
317 audit.checkpoints().is_empty()
318 && audit
319 .resources()
320 .iter()
321 .all(|r| r.outcome() == super::SuppliedSessionResourceOutcome::NotDispatched)
322 && audit.coverage() == super::SuppliedSessionCoverage::None
323 && if authorization.callback_commit_count() == 0 {
324 a.request_attempt_count() == 0
325 && a.outcome() == SuppliedSessionOAuthCodeOutcome::NotDispatched
326 && match authorization.outcome() {
327 A::Cancelled => {
328 audit.outcome() == SuppliedSessionAuditOutcome::Cancelled
329 },
330 A::RuntimeLimit => {
331 audit.outcome() == SuppliedSessionAuditOutcome::RuntimeLimit
332 },
333 _ => {
334 audit.outcome()
335 == SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable
336 },
337 }
338 } else {
339 match a.outcome() {
340 SuppliedSessionOAuthCodeOutcome::Cancelled => {
341 audit.outcome() == SuppliedSessionAuditOutcome::Cancelled
342 },
343 SuppliedSessionOAuthCodeOutcome::RuntimeLimit => {
344 audit.outcome() == SuppliedSessionAuditOutcome::RuntimeLimit
345 },
346 _ => {
347 audit.outcome()
348 == SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable
349 },
350 }
351 }
352 }
353 }
354}
355
356impl Serialize for SuppliedSessionOAuthInitiatedTokenAudit {
357 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
358 use serde::ser::SerializeStruct;
359 let a = &self.inner;
360 let mut s = serializer.serialize_struct("SuppliedSessionOAuthInitiatedTokenAudit", 20)?;
361 s.serialize_field("profile", "client-initiated-public-code-s256-bearer/v1")?;
362 s.serialize_field(
363 "pkce_assurance",
364 "client_generated_s256_not_remote_enforcement",
365 )?;
366 s.serialize_field("transport_profile", &a.transport_profile)?;
367 s.serialize_field("idp_reference", &a.idp_reference)?;
368 s.serialize_field("token_interpretation", &a.token_interpretation)?;
369 s.serialize_field("request_attempt_count", &a.request_attempt_count)?;
370 s.serialize_field("request_admitted_count", &a.request_admitted_count)?;
371 s.serialize_field("response_completed_count", &a.response_completed_count)?;
372 s.serialize_field("credential_commit_count", &a.credential_commit_count)?;
373 s.serialize_field(
374 "verifier_in_admitted_request",
375 &a.verifier_in_admitted_request,
376 )?;
377 s.serialize_field("status", &a.status)?;
378 s.serialize_field("body_state", &a.body_state)?;
379 s.serialize_field("response_bytes", &a.response_bytes)?;
380 s.serialize_field(
381 "configured_token_response_byte_limit",
382 &a.configured_token_response_byte_limit,
383 )?;
384 s.serialize_field("token_response_validated", &a.token_response_validated)?;
385 s.serialize_field("response_scope_matched", &a.response_scope_matched)?;
386 s.serialize_field(
387 "declared_token_lifetime_seconds",
388 &a.declared_token_lifetime_seconds,
389 )?;
390 s.serialize_field("refresh_token_returned", &a.refresh_token_returned)?;
391 s.serialize_field("evidence_reference", &a.evidence_reference)?;
392 s.serialize_field("outcome", &a.outcome)?;
393 s.end()
394 }
395}
396
397pub(super) struct CommittedOAuthCodeReceipt {
399 knowledge_authority: crate::knowledge::KnowledgeAuthority,
400 policy_reference: String,
401 application_reference: String,
402 idp_reference: String,
403 evidence_reference: String,
404 principal_alias: String,
405 expected_evidence: Evidence,
406 authorization: Option<Box<super::oauth_authorization::CommittedOAuthAuthorizationReceipt>>,
407}
408
409impl CommittedOAuthCodeReceipt {
410 pub(super) fn evidence_reference(&self) -> &str {
411 &self.evidence_reference
412 }
413
414 pub(super) fn is_valid_for(
415 &self,
416 policy: &SuppliedSessionPolicy,
417 knowledge: &KnowledgeBase,
418 ) -> bool {
419 self.knowledge_authority.is_same_as(knowledge.authority())
420 && matches!(
421 policy.version(),
422 SuppliedSessionPolicyVersion::V5
423 | SuppliedSessionPolicyVersion::V6
424 | SuppliedSessionPolicyVersion::V7
425 )
426 && match policy.version() {
427 SuppliedSessionPolicyVersion::V7 => self
428 .authorization
429 .as_ref()
430 .is_some_and(|receipt| receipt.is_valid_for(policy, knowledge)),
431 _ => self.authorization.is_none(),
432 }
433 && policy.policy_reference() == self.policy_reference
434 && policy.application_reference() == self.application_reference
435 && policy.principal_alias() == self.principal_alias
436 && policy
437 .oauth_code_policy()
438 .is_some_and(|idp| idp.binding_reference() == self.idp_reference)
439 && self.expected_evidence.kind() == &EvidenceKind::Authentication
440 && self.expected_evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
441 && self.expected_evidence.predicate().name() == ACQUISITION_PREDICATE
442 && EvidenceId::parse(&self.evidence_reference)
443 .ok()
444 .and_then(|id| {
445 knowledge.inspect_evidence(&id, |stored| stored == &self.expected_evidence)
446 })
447 == Some(true)
448 }
449}
450
451pub(super) struct AcquiredOAuthCodeCredential {
452 pub(super) credential: SuppliedSessionCredential,
453 pub(super) receipt: CommittedOAuthCodeReceipt,
454 pub(super) declared_lifetime: Duration,
455 pub(super) refresh_token:
456 Option<crate::supplied_session_review::oauth_refresh::OAuthRefreshToken>,
457}
458
459pub(super) struct OAuthCodeAcquisitionContext<'a> {
460 pub(super) authority: &'a SharedWebRuntimeAuthority,
461 pub(super) requests: &'a OAuthCodeRequestBroker,
462 pub(super) descriptor: &'a OAuthCodeRequestDescriptor,
463 pub(super) policy: &'a SuppliedSessionPolicy,
464 pub(super) deadline: Option<tokio::time::Instant>,
465 pub(super) budget: DispatchBudget,
466}
467
468pub(super) async fn acquire(
469 context: OAuthCodeAcquisitionContext<'_>,
470 grant: &SuppliedSessionOAuthGrant,
471) -> (
472 SuppliedSessionOAuthCodeAudit,
473 Result<AcquiredOAuthCodeCredential, SuppliedSessionAuditOutcome>,
474) {
475 acquire_from(context, AcquisitionSource::Imported(grant)).await
476}
477
478pub(super) async fn acquire_authorized(
479 context: OAuthCodeAcquisitionContext<'_>,
480 permit: super::oauth_authorization::OAuthAuthorizationDispatchPermit,
481) -> (
482 SuppliedSessionOAuthCodeAudit,
483 Result<AcquiredOAuthCodeCredential, SuppliedSessionAuditOutcome>,
484) {
485 acquire_from(context, AcquisitionSource::Authorized(Box::new(permit))).await
486}
487
488enum AcquisitionSource<'a> {
489 Imported(&'a SuppliedSessionOAuthGrant),
490 Authorized(Box<super::oauth_authorization::OAuthAuthorizationDispatchPermit>),
491}
492
493async fn acquire_from(
494 context: OAuthCodeAcquisitionContext<'_>,
495 source: AcquisitionSource<'_>,
496) -> (
497 SuppliedSessionOAuthCodeAudit,
498 Result<AcquiredOAuthCodeCredential, SuppliedSessionAuditOutcome>,
499) {
500 let initiated = matches!(&source, AcquisitionSource::Authorized(_));
501 let mut audit = SuppliedSessionOAuthCodeAudit::selected(context.policy)
502 .expect("only a validated V5 policy reaches code acquisition");
503 if context.authority.cancellation().is_cancelled() {
504 audit.outcome = SuppliedSessionOAuthCodeOutcome::Cancelled;
505 return (audit, Err(SuppliedSessionAuditOutcome::Cancelled));
506 }
507 if context
508 .deadline
509 .is_some_and(|end| tokio::time::Instant::now() >= end)
510 || context.budget.dispatched_requests >= context.policy.max_session_requests()
511 || context.budget.accounted_response_bytes >= context.policy.max_total_response_bytes()
512 {
513 audit.outcome = SuppliedSessionOAuthCodeOutcome::RuntimeLimit;
514 return (audit, Err(SuppliedSessionAuditOutcome::RuntimeLimit));
515 }
516 let remaining = context
517 .policy
518 .max_total_response_bytes()
519 .saturating_sub(context.budget.accounted_response_bytes);
520 let body_limit = remaining.min(audit.configured_token_response_byte_limit);
521 let before = context.authority.request_accounting().snapshot();
522 audit.request_attempt_count = 1;
523 let execution = super::boxed_dispatch_execution(|| async {
524 match &source {
525 AcquisitionSource::Imported(grant) => {
526 context
527 .requests
528 .collect(
529 context.descriptor,
530 context.policy,
531 grant,
532 DecisionExecutionLimits::new().with_max_response_body_bytes(body_limit),
533 )
534 .await
535 },
536 AcquisitionSource::Authorized(permit) => {
537 context
538 .requests
539 .collect_authorized(
540 context.descriptor,
541 context.policy,
542 permit,
543 DecisionExecutionLimits::new().with_max_response_body_bytes(body_limit),
544 )
545 .await
546 },
547 }
548 });
549 let result = super::super::await_execution(
550 context.authority.cancellation(),
551 context.deadline,
552 execution,
553 )
554 .await;
555 let after = context.authority.request_accounting().snapshot();
556 let admitted = after
557 .total_requests()
558 .saturating_sub(before.total_requests());
559 audit.request_admitted_count = u8::try_from(admitted).unwrap_or(u8::MAX);
560 audit.verifier_in_admitted_request = admitted == 1;
561 audit.response_bytes = after
562 .response_bytes()
563 .saturating_sub(before.response_bytes());
564 if admitted > 1 {
566 audit.outcome = SuppliedSessionOAuthCodeOutcome::CommitFailed;
567 return (
568 audit,
569 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
570 );
571 }
572 let response = match result {
573 RuntimeExecution::Completed(Ok(response)) => response,
574 RuntimeExecution::Cancelled => {
575 audit.outcome = SuppliedSessionOAuthCodeOutcome::Cancelled;
576 return (audit, Err(SuppliedSessionAuditOutcome::Cancelled));
577 },
578 RuntimeExecution::WallTimeExceeded
579 | RuntimeExecution::Completed(Err(OAuthCodeRequestError::RuntimeLimit)) => {
580 audit.outcome = SuppliedSessionOAuthCodeOutcome::RuntimeLimit;
581 return (audit, Err(SuppliedSessionAuditOutcome::RuntimeLimit));
582 },
583 RuntimeExecution::Completed(Err(OAuthCodeRequestError::Http)) => {
584 audit.outcome = SuppliedSessionOAuthCodeOutcome::TransportFailed;
585 return (
586 audit,
587 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
588 );
589 },
590 RuntimeExecution::Completed(Err(
591 OAuthCodeRequestError::InvalidDescriptor
592 | OAuthCodeRequestError::GrantExpired
593 | OAuthCodeRequestError::AlreadyAttempted,
594 )) => {
595 audit.outcome = SuppliedSessionOAuthCodeOutcome::GrantUnavailable;
596 return (
597 audit,
598 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
599 );
600 },
601 };
602 audit.status = Some(response.status);
603 audit.body_state = if response.complete {
604 SuppliedSessionBodyState::Complete
605 } else {
606 SuppliedSessionBodyState::Incomplete
607 };
608 audit.response_completed_count = u8::from(response.complete);
609 if audit.response_bytes > body_limit {
610 audit.outcome = SuppliedSessionOAuthCodeOutcome::RuntimeLimit;
611 return (audit, Err(SuppliedSessionAuditOutcome::RuntimeLimit));
612 }
613 if response.status == 429 {
614 audit.outcome = SuppliedSessionOAuthCodeOutcome::Throttled;
615 return (
616 audit,
617 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
618 );
619 }
620 if admitted != 1
621 || response.status != 200
622 || !response.complete
623 || !response.representation_supported
624 {
625 audit.outcome = SuppliedSessionOAuthCodeOutcome::ResponseRejected;
626 return (
627 audit,
628 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
629 );
630 }
631 let idp = context
632 .policy
633 .oauth_code_policy()
634 .expect("validated V5 IdP binding");
635 let parsed = if context.policy.version() == SuppliedSessionPolicyVersion::V6 {
636 crate::supplied_session_review::oauth_refresh::parse_renewable_token_response(
637 context.policy,
638 response.body,
639 None,
640 )
641 .and_then(|parsed| {
642 let lifetime = parsed.expires_in_seconds();
643 let (authorization, refresh_token) = parsed.into_parts()?;
644 Ok((authorization, lifetime, true, Some(refresh_token)))
645 })
646 } else {
647 parse_token_response(idp, response.body).and_then(|parsed| {
648 let lifetime = parsed.expires_in_seconds();
649 let refresh_returned = parsed.refresh_token_returned();
650 let authorization = parsed.into_authorization()?;
651 Ok((authorization, lifetime, refresh_returned, None))
652 })
653 };
654 let (authorization, lifetime, refresh_returned, refresh_token) = match parsed {
655 Ok(value) => value,
656 Err(_) => {
657 audit.outcome = SuppliedSessionOAuthCodeOutcome::ResponseRejected;
658 return (
659 audit,
660 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
661 );
662 },
663 };
664 audit.token_response_validated = true;
665 audit.response_scope_matched = Some(true);
666 audit.declared_token_lifetime_seconds = Some(lifetime);
667 audit.refresh_token_returned = Some(refresh_returned);
668 let transaction_context = if initiated {
669 "client_initiated_committed_callback"
670 } else {
671 "operator_declared_external"
672 };
673 let binding = format!(
674 "policy={};application={};idp={};purpose=oauth_code_exchange;status=200;body_state=complete;response_bytes={};scope=exact_supported_echo;declared_lifetime={lifetime};refresh_returned={refresh_returned};context={transaction_context};token=opaque_bearer;outcome=credential_committed",
675 context.policy.policy_reference(), context.policy.application_reference(),
676 idp.binding_reference(), audit.response_bytes,
677 );
678 let authorization_receipt = match source {
679 AcquisitionSource::Imported(_) => None,
680 AcquisitionSource::Authorized(permit) => Some(Box::new((*permit).into_receipt())),
681 };
682 let receipt = commit_value_safe_evidence(
683 context.policy,
684 idp.binding_reference(),
685 b"security.supplied-session-oauth-code-evidence.v1\0",
686 "supplied-session-oauth-code-evidence-sha256",
687 ACQUISITION_PREDICATE,
688 if initiated {
689 "descriptor-bound-client-initiated-code-exchange"
690 } else {
691 "descriptor-bound-imported-code-exchange"
692 },
693 binding,
694 context.authority.knowledge(),
695 )
696 .and_then(|reference| {
697 let id = EvidenceId::parse(&reference).ok()?;
698 let expected_evidence = context
699 .authority
700 .knowledge()
701 .inspect_evidence(&id, Clone::clone)?;
702 let receipt = CommittedOAuthCodeReceipt {
703 knowledge_authority: context.authority.knowledge().authority().clone(),
704 policy_reference: context.policy.policy_reference().to_owned(),
705 application_reference: context.policy.application_reference().to_owned(),
706 idp_reference: idp.binding_reference().to_owned(),
707 principal_alias: context.policy.principal_alias().to_owned(),
708 evidence_reference: reference,
709 expected_evidence,
710 authorization: authorization_receipt,
711 };
712 receipt
713 .is_valid_for(context.policy, context.authority.knowledge())
714 .then_some(receipt)
715 });
716 let Some(receipt) = receipt else {
717 audit.outcome = SuppliedSessionOAuthCodeOutcome::CommitFailed;
718 return (
719 audit,
720 Err(SuppliedSessionAuditOutcome::OAuthCodeExchangeUnavailable),
721 );
722 };
723 audit.evidence_reference = Some(receipt.evidence_reference.clone());
724 audit.credential_commit_count = 1;
725 audit.outcome = SuppliedSessionOAuthCodeOutcome::CredentialCommitted;
726 (
727 audit,
728 Ok(AcquiredOAuthCodeCredential {
729 credential: SuppliedSessionCredential::Authorization(authorization),
730 receipt,
731 declared_lifetime: Duration::from_secs(lifetime),
732 refresh_token,
733 }),
734 )
735}