1use std::time::Duration;
5
6use serde::Serialize;
7use termivar_core::{Evidence, EvidenceId, EvidenceKind};
8use tokio::{
9 io::{AsyncReadExt, AsyncWriteExt},
10 net::{TcpListener, TcpStream},
11};
12use zeroize::Zeroizing;
13
14use super::{
15 commit_value_safe_evidence, earliest_deadline, SharedWebRuntimeAuthority,
16 SuppliedSessionPolicy, SuppliedSessionPolicyVersion, RESOURCE_COMMIT_EVIDENCE_NAMESPACE,
17};
18use crate::{
19 runtime_budget::{RequestAccountingLease, TransportDispatchOutcome},
20 supplied_session_review::{
21 oauth_authorization::{
22 OAuthAuthorizationCodeParts, OAuthAuthorizationResponse, PendingOAuthAuthorization,
23 },
24 SuppliedSessionOAuthAuthorization,
25 },
26 web_runtime::authority::OAuthAuthorizationCallbackMint,
27 DecisionExecutionStage, KnowledgeBase,
28};
29
30const ACTION: &str = "web.review.supplied-session.oauth-authorization-callback";
31const PREDICATE: &str = "oauth-authorization-callback-committed";
32const MAX_HEADERS: usize = 4096;
33const MAX_FORM: usize = 8192;
34const MAX_HEADER_COUNT: usize = 32;
35const REPLY: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 44\r\nCache-Control: no-store\r\nPragma: no-cache\r\nReferrer-Policy: no-referrer\r\nConnection: close\r\n\r\nTermivar received this single-use response.\n";
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum SuppliedSessionOAuthAuthorizationOutcome {
41 NotStarted,
42 RuntimeLimit,
43 Cancelled,
44 ListenerUnavailable,
45 HandoffFailed,
46 ReceptionFailed,
47 ResponseRejected,
48 ProviderError,
49 CommitFailed,
50 CallbackCommitted,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56pub struct SuppliedSessionOAuthAuthorizationAudit {
57 profile: &'static str,
58 authorization_policy_reference: String,
59 configured_wait_ms: u64,
60 reservation_count: u8,
61 listener_bound: bool,
62 handoff_written: bool,
63 accepted_connection_count: u8,
64 completed_form_count: u8,
65 state_validated: bool,
66 issuer_validated: bool,
67 callback_commit_count: u8,
68 ingress_bytes: u64,
69 reply_bytes_written: u64,
70 elapsed_ms: u64,
71 listener_closed: bool,
72 unknown_parameter_count: u8,
73 provider_error_class: Option<&'static str>,
74 evidence_reference: Option<String>,
75 oidc_nonce_validation_performed: bool,
76 oidc_identity_validation_performed: bool,
77 token_signature_validation_performed: bool,
78 outcome: SuppliedSessionOAuthAuthorizationOutcome,
79}
80
81impl SuppliedSessionOAuthAuthorizationAudit {
82 pub(super) fn selected(policy: &SuppliedSessionPolicy) -> Option<Self> {
83 let config = policy.oauth_authorization_policy()?;
84 Some(Self {
85 profile: "loopback-form-post-state/v1",
86 authorization_policy_reference: config.binding_reference().to_owned(),
87 configured_wait_ms: config.max_wait_ms(),
88 reservation_count: 0,
89 listener_bound: false,
90 handoff_written: false,
91 accepted_connection_count: 0,
92 completed_form_count: 0,
93 state_validated: false,
94 issuer_validated: false,
95 callback_commit_count: 0,
96 ingress_bytes: 0,
97 reply_bytes_written: 0,
98 elapsed_ms: 0,
99 listener_closed: true,
100 unknown_parameter_count: 0,
101 provider_error_class: None,
102 evidence_reference: None,
103 oidc_nonce_validation_performed: false,
104 oidc_identity_validation_performed: false,
105 token_signature_validation_performed: false,
106 outcome: SuppliedSessionOAuthAuthorizationOutcome::NotStarted,
107 })
108 }
109
110 pub const fn reservation_count(&self) -> u8 {
111 self.reservation_count
112 }
113 pub const fn accepted_connection_count(&self) -> u8 {
114 self.accepted_connection_count
115 }
116 pub const fn callback_commit_count(&self) -> u8 {
117 self.callback_commit_count
118 }
119 pub const fn state_validated(&self) -> bool {
120 self.state_validated
121 }
122 pub const fn issuer_validated(&self) -> bool {
123 self.issuer_validated
124 }
125 pub const fn ingress_bytes(&self) -> u64 {
126 self.ingress_bytes
127 }
128 pub const fn outcome(&self) -> SuppliedSessionOAuthAuthorizationOutcome {
129 self.outcome
130 }
131 pub fn authorization_policy_reference(&self) -> &str {
132 &self.authorization_policy_reference
133 }
134
135 pub(crate) fn is_consistent(&self) -> bool {
136 let reference = |s: &str, prefix: &str| {
137 s.strip_prefix(prefix).is_some_and(|v| {
138 v.len() == 64
139 && v.bytes()
140 .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
141 })
142 };
143 let basic = self.profile == "loopback-form-post-state/v1"
144 && reference(
145 &self.authorization_policy_reference,
146 "oauth-authorization-policy-sha256:",
147 )
148 && (1..=30000).contains(&self.configured_wait_ms)
149 && self.reservation_count <= 1
150 && self.accepted_connection_count <= self.reservation_count
151 && self.completed_form_count <= self.accepted_connection_count
152 && (self.completed_form_count == 0 || self.ingress_bytes > 0)
153 && self.callback_commit_count
154 <= u8::from(self.state_validated && self.issuer_validated)
155 && self.state_validated == self.issuer_validated
156 && (!self.state_validated || self.completed_form_count == 1)
157 && (!self.handoff_written || self.listener_bound)
158 && (!self.listener_bound || self.reservation_count == 1)
159 && (self.accepted_connection_count == 0 || self.handoff_written)
160 && (self.ingress_bytes == 0 || self.accepted_connection_count == 1)
161 && self.ingress_bytes <= (MAX_HEADERS + MAX_FORM + 1) as u64
162 && self.reply_bytes_written <= REPLY.len() as u64
163 && (self.reply_bytes_written == 0 || self.completed_form_count == 1)
164 && self.listener_closed
165 && self.unknown_parameter_count <= 16
166 && (self.unknown_parameter_count == 0 || self.state_validated)
167 && !self.oidc_nonce_validation_performed
168 && !self.oidc_identity_validation_performed
169 && !self.token_signature_validation_performed
170 && (self.evidence_reference.is_some() == (self.callback_commit_count == 1))
171 && self.evidence_reference.as_deref().is_none_or(|s| {
172 reference(s, "supplied-session-oauth-authorization-evidence-sha256:")
173 })
174 && self.provider_error_class.is_none_or(|s| {
175 matches!(
176 s,
177 "invalid_request"
178 | "unauthorized_client"
179 | "access_denied"
180 | "unsupported_response_type"
181 | "invalid_scope"
182 | "server_error"
183 | "temporarily_unavailable"
184 | "other"
185 )
186 })
187 && (self.provider_error_class.is_some()
188 == (self.outcome == SuppliedSessionOAuthAuthorizationOutcome::ProviderError))
189 && (self.provider_error_class.is_none() || self.state_validated)
190 && (self.outcome != SuppliedSessionOAuthAuthorizationOutcome::CallbackCommitted
191 || self.callback_commit_count == 1)
192 && (self.callback_commit_count != 1
193 || self.outcome == SuppliedSessionOAuthAuthorizationOutcome::CallbackCommitted);
194 basic
195 && match self.outcome {
196 SuppliedSessionOAuthAuthorizationOutcome::NotStarted => {
197 self.reservation_count == 0
198 && !self.listener_bound
199 && !self.handoff_written
200 && self.accepted_connection_count == 0
201 && self.completed_form_count == 0
202 && !self.state_validated
203 && self.ingress_bytes == 0
204 && self.reply_bytes_written == 0
205 },
206 SuppliedSessionOAuthAuthorizationOutcome::ListenerUnavailable => {
207 self.reservation_count == 1
208 && !self.listener_bound
209 && !self.handoff_written
210 && self.accepted_connection_count == 0
211 },
212 SuppliedSessionOAuthAuthorizationOutcome::HandoffFailed => {
213 self.reservation_count == 1
214 && self.listener_bound
215 && !self.handoff_written
216 && self.accepted_connection_count == 0
217 },
218 SuppliedSessionOAuthAuthorizationOutcome::ReceptionFailed => {
219 self.reservation_count == 1 && self.handoff_written && !self.state_validated
220 },
221 SuppliedSessionOAuthAuthorizationOutcome::ResponseRejected => {
222 self.reservation_count == 1
223 && self.handoff_written
224 && self.accepted_connection_count == 1
225 && !self.state_validated
226 },
227 SuppliedSessionOAuthAuthorizationOutcome::ProviderError => {
228 self.completed_form_count == 1
229 && self.state_validated
230 && self.callback_commit_count == 0
231 },
232 SuppliedSessionOAuthAuthorizationOutcome::CallbackCommitted => {
233 self.completed_form_count == 1
234 && self.state_validated
235 && self.callback_commit_count == 1
236 },
237 SuppliedSessionOAuthAuthorizationOutcome::CommitFailed => {
238 self.callback_commit_count == 0
239 },
240 SuppliedSessionOAuthAuthorizationOutcome::RuntimeLimit
241 | SuppliedSessionOAuthAuthorizationOutcome::Cancelled => {
242 self.callback_commit_count == 0 && !self.state_validated
243 },
244 }
245 }
246}
247
248pub(super) struct CommittedOAuthAuthorizationReceipt {
250 knowledge_authority: crate::knowledge::KnowledgeAuthority,
251 policy_reference: String,
252 application_reference: String,
253 authorization_reference: String,
254 idp_reference: String,
255 principal_alias: String,
256 transaction_reference: String,
257 evidence_reference: String,
258 expected_evidence: Evidence,
259}
260
261impl CommittedOAuthAuthorizationReceipt {
262 pub(super) fn is_valid_for(
263 &self,
264 policy: &SuppliedSessionPolicy,
265 knowledge: &KnowledgeBase,
266 ) -> bool {
267 policy.version() == SuppliedSessionPolicyVersion::V7
268 && self.knowledge_authority.is_same_as(knowledge.authority())
269 && self.policy_reference == policy.policy_reference()
270 && self.application_reference == policy.application_reference()
271 && self.principal_alias == policy.principal_alias()
272 && policy
273 .oauth_authorization_policy()
274 .is_some_and(|p| p.binding_reference() == self.authorization_reference)
275 && policy
276 .oauth_code_policy()
277 .is_some_and(|p| p.binding_reference() == self.idp_reference)
278 && self.expected_evidence.kind() == &EvidenceKind::Authentication
279 && self.expected_evidence.predicate().namespace() == RESOURCE_COMMIT_EVIDENCE_NAMESPACE
280 && self.expected_evidence.predicate().name() == PREDICATE
281 && self.expected_evidence.source().correlation_id()
282 == Some(self.transaction_reference.as_str())
283 && EvidenceId::parse(&self.evidence_reference)
284 .ok()
285 .and_then(|id| {
286 knowledge.inspect_evidence(&id, |value| value == &self.expected_evidence)
287 })
288 == Some(true)
289 }
290}
291
292pub(crate) struct OAuthAuthorizationDispatchPermit {
295 parts: OAuthAuthorizationCodeParts,
296 receipt: CommittedOAuthAuthorizationReceipt,
297 knowledge: KnowledgeBase,
298 deadline: tokio::time::Instant,
299}
300
301impl OAuthAuthorizationDispatchPermit {
302 pub(super) const fn deadline(&self) -> tokio::time::Instant {
303 self.deadline
304 }
305 pub(crate) fn is_valid_for(
306 &self,
307 policy: &SuppliedSessionPolicy,
308 mint: &crate::web_runtime::OAuthCodeMintToken,
309 ) -> bool {
310 tokio::time::Instant::now() < self.deadline
311 && mint.is_valid_for_knowledge(policy, &self.knowledge)
312 && self.receipt.is_valid_for(policy, &self.knowledge)
313 && self.parts.policy_reference == self.receipt.policy_reference
314 && self.parts.idp_reference == self.receipt.idp_reference
315 && self.parts.authorization_reference == self.receipt.authorization_reference
316 && self.parts.principal_alias == self.receipt.principal_alias
317 && self.parts.transaction_reference == self.receipt.transaction_reference
318 && self.parts.unknown_parameter_count <= 16
319 }
320
321 pub(crate) fn compose_form_body(
322 &self,
323 policy: &SuppliedSessionPolicy,
324 ) -> Result<Zeroizing<String>, ()> {
325 let idp = policy.oauth_code_policy().ok_or(())?;
326 if !self.receipt.is_valid_for(policy, &self.knowledge)
327 || tokio::time::Instant::now() >= self.deadline
328 {
329 return Err(());
330 }
331 let mut body = Zeroizing::new(String::new());
332 {
333 let mut form = url::form_urlencoded::Serializer::new(&mut *body);
334 form.append_pair("grant_type", "authorization_code");
335 form.append_pair("code", &self.parts.code);
336 form.append_pair("client_id", idp.client_id());
337 form.append_pair("redirect_uri", idp.redirect_uri().as_str());
338 form.append_pair("code_verifier", &self.parts.verifier);
339 }
340 if body.len() > 16 * 1024 {
341 return Err(());
342 }
343 Ok(body)
344 }
345
346 pub(super) fn into_receipt(self) -> CommittedOAuthAuthorizationReceipt {
347 self.receipt
348 }
349}
350
351pub(super) async fn receive(
352 authority: &SharedWebRuntimeAuthority,
353 mint: OAuthAuthorizationCallbackMint,
354 policy: &SuppliedSessionPolicy,
355 mut output: crate::supplied_session_review::SuppliedSessionOAuthAuthorization,
356) -> (
357 SuppliedSessionOAuthAuthorizationAudit,
358 Result<OAuthAuthorizationDispatchPermit, ()>,
359) {
360 let mut audit =
361 SuppliedSessionOAuthAuthorizationAudit::selected(policy).expect("validated V7 policy");
362 let started = tokio::time::Instant::now();
363 let result = receive_inner(authority, mint, policy, &mut output, &mut audit).await;
364 audit.elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
365 audit.listener_closed = true;
367 (audit, result)
368}
369
370async fn receive_inner(
371 authority: &SharedWebRuntimeAuthority,
372 mint: OAuthAuthorizationCallbackMint,
373 policy: &SuppliedSessionPolicy,
374 output: &mut SuppliedSessionOAuthAuthorization,
375 audit: &mut SuppliedSessionOAuthAuthorizationAudit,
376) -> Result<OAuthAuthorizationDispatchPermit, ()> {
377 use SuppliedSessionOAuthAuthorizationOutcome as O;
378 if !mint.is_valid_for(policy, authority.knowledge()) {
379 audit.outcome = O::CommitFailed;
380 return Err(());
381 }
382 let config = policy.oauth_authorization_policy().ok_or(())?;
383 let idp = policy.oauth_code_policy().ok_or(())?;
384 let end = earliest_deadline(
385 authority.start().deadline(),
386 tokio::time::Instant::now().checked_add(Duration::from_millis(config.max_wait_ms())),
387 )
388 .ok_or(())?;
389 if authority.cancellation().is_cancelled() {
390 audit.outcome = O::Cancelled;
391 return Err(());
392 }
393 if tokio::time::Instant::now() >= end {
394 audit.outcome = O::RuntimeLimit;
395 return Err(());
396 }
397 let mut lease = authority
398 .request_accounting()
399 .try_begin_with_request_body_bytes(ACTION, DecisionExecutionStage::Passive, None, 0)
400 .map_err(|_| {
401 audit.outcome = O::RuntimeLimit;
402 })?;
403 audit.reservation_count = 1;
404 let result = async {
405 let transaction = PendingOAuthAuthorization::generate(idp, config, policy.policy_reference(), policy.principal_alias(), end.into_std())
406 .map_err(|_| { audit.outcome = O::CommitFailed; })?;
407 let transaction_reference = transaction.transaction_reference().to_owned();
408 let listener = TcpListener::bind(config.callback_socket()).await.map_err(|_| { audit.outcome = O::ListenerUnavailable; })?;
409 audit.listener_bound = true;
410 let url = transaction.authorization_url().map_err(|_| { audit.outcome = O::HandoffFailed; })?;
411 if authority.cancellation().is_cancelled() { audit.outcome = O::Cancelled; return Err(()); }
412 if tokio::time::Instant::now() >= end { audit.outcome = O::RuntimeLimit; return Err(()); }
413 output.write_authorization_url(&url).map_err(|_| { audit.outcome = O::HandoffFailed; })?;
414 drop(url);
415 audit.handoff_written = true;
416 if authority.cancellation().is_cancelled() { audit.outcome = O::Cancelled; return Err(()); }
417 if tokio::time::Instant::now() >= end { audit.outcome = O::RuntimeLimit; return Err(()); }
418 let accepted = tokio::select! {
419 biased;
420 _ = authority.cancellation().cancelled() => { audit.outcome = O::Cancelled; return Err(()); },
421 _ = tokio::time::sleep_until(end) => { audit.outcome = O::RuntimeLimit; return Err(()); },
422 accepted = listener.accept() => accepted.map_err(|_| { audit.outcome = O::ReceptionFailed; })?,
423 };
424 drop(listener);
426 let (mut stream, peer) = accepted;
427 audit.accepted_connection_count = 1;
428 if !peer.ip().is_loopback() { audit.outcome = O::ResponseRejected; return Err(()); }
429 let form = read_form(&mut stream, &mut lease, authority, end, config.callback_host(), idp.issuer().origin().ascii_serialization().as_str(), audit).await;
430 let form = match form { Ok(form) => form, Err(()) => return Err(()) };
431 audit.completed_form_count = 1;
432 let response = transaction.accept_form_post(form, std::time::Instant::now());
433 let result = match response {
434 Ok(OAuthAuthorizationResponse::Code(code)) => {
435 if code.policy_reference() != policy.policy_reference()
436 || code.idp_reference() != idp.binding_reference()
437 || code.authorization_reference() != config.binding_reference()
438 || code.principal_alias() != policy.principal_alias()
439 || code.transaction_reference() != transaction_reference
440 { audit.outcome = O::CommitFailed; return Err(()); }
441 audit.state_validated = true; audit.issuer_validated = true;
442 audit.unknown_parameter_count = u8::try_from(code.unknown_parameter_count()).unwrap_or(u8::MAX);
443 let parts = code.into_parts();
444 let binding = format!("policy={};application={};idp={};authorization={};principal={};transaction={};purpose=oauth_authorization_callback;state=matched;issuer=matched;form=complete;ingress_bytes={}",
445 policy.policy_reference(), policy.application_reference(), parts.idp_reference, parts.authorization_reference,
446 policy.principal_alias(), parts.transaction_reference, audit.ingress_bytes);
447 let receipt = commit_value_safe_evidence(policy, &parts.transaction_reference,
448 b"security.supplied-session-oauth-authorization-evidence.v1\0",
449 "supplied-session-oauth-authorization-evidence-sha256", PREDICATE,
450 "client-initiated-loopback-form-post", binding, authority.knowledge())
451 .and_then(|reference| {
452 let id = EvidenceId::parse(&reference).ok()?;
453 let expected_evidence = authority.knowledge().inspect_evidence(&id, Clone::clone)?;
454 let receipt = CommittedOAuthAuthorizationReceipt {
455 knowledge_authority: authority.knowledge().authority().clone(),
456 policy_reference: policy.policy_reference().to_owned(), application_reference: policy.application_reference().to_owned(),
457 authorization_reference: parts.authorization_reference.clone(), idp_reference: parts.idp_reference.clone(),
458 principal_alias: policy.principal_alias().to_owned(), transaction_reference: parts.transaction_reference.clone(),
459 evidence_reference: reference, expected_evidence,
460 };
461 receipt.is_valid_for(policy, authority.knowledge()).then_some(receipt)
462 });
463 if let Some(receipt) = receipt {
464 audit.evidence_reference = Some(receipt.evidence_reference.clone());
465 audit.callback_commit_count = 1; audit.outcome = O::CallbackCommitted;
466 let deadline = earliest_deadline(authority.start().deadline(), tokio::time::Instant::now()
467 .checked_add(Duration::from_millis(policy.max_wall_time_ms()))).ok_or(())?;
468 Ok(OAuthAuthorizationDispatchPermit { parts, receipt, knowledge: authority.knowledge().clone(), deadline })
469 } else { audit.outcome = O::CommitFailed; Err(()) }
470 },
471 Ok(OAuthAuthorizationResponse::Error(error)) => {
472 if error.policy_reference() != policy.policy_reference()
473 || error.idp_reference() != idp.binding_reference()
474 || error.authorization_reference() != config.binding_reference()
475 || error.principal_alias() != policy.principal_alias()
476 || error.transaction_reference() != transaction_reference
477 { audit.outcome = O::CommitFailed; return Err(()); }
478 audit.state_validated = true; audit.issuer_validated = true;
479 audit.unknown_parameter_count = u8::try_from(error.unknown_parameter_count()).unwrap_or(u8::MAX);
480 audit.provider_error_class = Some(error.class().as_str());
481 audit.outcome = O::ProviderError; Err(())
482 },
483 Err(_) => { audit.outcome = O::ResponseRejected; Err(()) },
484 };
485 for chunk in REPLY.chunks(512) {
488 let written = tokio::select! {
489 biased;
490 _ = authority.cancellation().cancelled() => break,
491 _ = tokio::time::sleep_until(end) => break,
492 written = stream.write(chunk) => written,
493 };
494 match written { Ok(n) => { audit.reply_bytes_written += u64::try_from(n).unwrap_or(u64::MAX); if n != chunk.len() { break; } }, Err(_) => break }
495 }
496 result
497 }.await;
498 let transport_outcome = if audit.completed_form_count == 1 {
499 TransportDispatchOutcome::Completed
500 } else if audit.outcome == O::Cancelled {
501 TransportDispatchOutcome::Cancelled
502 } else if audit.outcome == O::RuntimeLimit {
503 if lease.response_budget_reached() || lease.remaining_response_bytes() == 0 {
504 TransportDispatchOutcome::ResponseBudgetReached
505 } else {
506 TransportDispatchOutcome::RequestTimeout
507 }
508 } else {
509 TransportDispatchOutcome::TransportFailure
510 };
511 lease.finish(transport_outcome);
512 result
513}
514
515async fn read_form(
516 stream: &mut TcpStream,
517 lease: &mut RequestAccountingLease,
518 authority: &SharedWebRuntimeAuthority,
519 end: tokio::time::Instant,
520 host: &str,
521 origin: &str,
522 audit: &mut SuppliedSessionOAuthAuthorizationAudit,
523) -> Result<Vec<u8>, ()> {
524 use SuppliedSessionOAuthAuthorizationOutcome as O;
525 let mut bytes = Zeroizing::new(Vec::with_capacity(MAX_HEADERS + MAX_FORM));
526 let mut expected = None;
527 loop {
528 if authority.cancellation().is_cancelled() {
529 audit.outcome = O::Cancelled;
530 return Err(());
531 }
532 if tokio::time::Instant::now() >= end || lease.remaining_response_bytes() == 0 {
533 audit.outcome = O::RuntimeLimit;
534 return Err(());
535 }
536 let remaining = (MAX_HEADERS + MAX_FORM + 1)
538 .saturating_sub(bytes.len())
539 .min(1024);
540 if remaining == 0 {
541 audit.outcome = O::ResponseRejected;
542 return Err(());
543 }
544 let mut chunk = Zeroizing::new([0_u8; 1024]);
545 let read = {
546 let gate = tokio::select! {
547 biased;
548 _ = authority.cancellation().cancelled() => { audit.outcome = O::Cancelled; return Err(()); },
549 _ = tokio::time::sleep_until(end) => { audit.outcome = O::RuntimeLimit; return Err(()); },
550 gate = lease.acquire_response_read() => gate,
551 };
552 if lease.remaining_response_bytes() == 0 {
553 audit.outcome = O::RuntimeLimit;
554 return Err(());
555 }
556 let read = tokio::select! {
557 biased;
558 _ = authority.cancellation().cancelled() => { audit.outcome = O::Cancelled; return Err(()); },
559 _ = tokio::time::sleep_until(end) => { audit.outcome = O::RuntimeLimit; return Err(()); },
560 read = stream.read(&mut chunk[..remaining]) => read,
561 };
562 let read = read.map_err(|_| {
563 audit.outcome = O::ReceptionFailed;
564 })?;
565 let count = u64::try_from(read).unwrap_or(u64::MAX);
566 audit.ingress_bytes = audit.ingress_bytes.saturating_add(count);
567 if lease.observe_response_bytes(count) < count {
568 audit.outcome = O::RuntimeLimit;
569 return Err(());
570 }
571 drop(gate);
572 read
573 };
574 if read == 0 {
575 audit.outcome = O::ReceptionFailed;
576 return Err(());
577 }
578 bytes.extend_from_slice(&chunk[..read]);
579 if expected.is_none() {
580 if let Some(position) = bytes.windows(4).position(|w| w == b"\r\n\r\n") {
581 let header_end = position + 4;
582 if header_end > MAX_HEADERS {
583 audit.outcome = O::ResponseRejected;
584 return Err(());
585 }
586 let length = parse_headers(&bytes[..position], host, origin).ok_or_else(|| {
587 audit.outcome = O::ResponseRejected;
588 })?;
589 expected = Some((header_end, length));
590 } else if bytes.len() >= MAX_HEADERS {
591 audit.outcome = O::ResponseRejected;
592 return Err(());
593 }
594 }
595 if let Some((header_end, length)) = expected {
596 if bytes.len() > header_end + length {
597 audit.outcome = O::ResponseRejected;
598 return Err(());
599 }
600 if bytes.len() == header_end + length {
601 let mut body = Zeroizing::new(Vec::new());
602 body.extend_from_slice(&bytes[header_end..]);
603 return Ok(std::mem::take(&mut *body));
604 }
605 }
606 }
607}
608
609fn parse_headers(bytes: &[u8], expected_host: &str, expected_origin: &str) -> Option<usize> {
610 let text = std::str::from_utf8(bytes).ok()?;
611 if bytes
612 .iter()
613 .any(|b| !matches!(b, b'\r' | b'\n' | 0x20..=0x7e))
614 {
615 return None;
616 }
617 let mut lines = text.split("\r\n");
618 if lines.next()? != "POST /termivar/oauth/callback HTTP/1.1" {
619 return None;
620 }
621 let mut host = None;
622 let mut content_type = None;
623 let mut length = None;
624 let mut origin = None;
625 for (index, line) in lines.enumerate() {
626 if index >= MAX_HEADER_COUNT || line.starts_with(' ') || line.contains(['\r', '\n']) {
627 return None;
628 }
629 let (key, value) = line.split_once(':')?;
630 if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
631 return None;
632 }
633 let value = value.trim_matches(' ');
634 match key.to_ascii_lowercase().as_str() {
635 "host" if host.is_none() => host = Some(value),
636 "content-type" if content_type.is_none() => content_type = Some(value),
637 "content-length" if length.is_none() => {
638 if value.is_empty()
639 || value.len() > 4
640 || !value.bytes().all(|b| b.is_ascii_digit())
641 || value.starts_with('0')
642 {
643 return None;
644 }
645 length = Some(value.parse::<usize>().ok()?);
646 },
647 "origin" if origin.is_none() => origin = Some(value),
648 "host"
649 | "content-type"
650 | "content-length"
651 | "origin"
652 | "transfer-encoding"
653 | "expect"
654 | "upgrade"
655 | "content-encoding"
656 | "authorization"
657 | "proxy-authorization"
658 | "cookie" => return None,
659 _ => {},
660 }
661 }
662 if host? != expected_host || origin.is_some_and(|v| v != expected_origin) {
663 return None;
664 }
665 let mut media = content_type?.split(';');
666 if !media
667 .next()?
668 .trim()
669 .eq_ignore_ascii_case("application/x-www-form-urlencoded")
670 {
671 return None;
672 }
673 match (media.next(), media.next()) {
674 (None, None) => {},
675 (Some(p), None) if p.trim().eq_ignore_ascii_case("charset=utf-8") => {},
676 _ => return None,
677 }
678 length.filter(|n| (1..=MAX_FORM).contains(n))
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[test]
686 fn callback_receipt_requires_exact_committed_authority_and_context() {
687 let application = url::Url::parse("https://application.test/app/").unwrap();
688 let source = r#"schema="security.supplied-session-policy/v7"
689principal_alias="original-proof-principal"
690credential_mechanism="authorization_header"
691credential_acquisition="client_initiated_oauth_pkce"
692health_path="/app/health"
693health_json_field="authenticated"
694resources=["/app/read-only"]
695max_session_requests=4
696max_total_response_bytes=65536
697max_response_body_bytes=8192
698max_wall_time_ms=1000
699[oauth_code]
700profile="client-initiated-public-code-s256-bearer/v1"
701transport_profile="verified-https/v1"
702issuer="https://issuer.test/realm"
703token_endpoint="https://issuer.test/realm/token"
704client_id="original-proof-public-client"
705redirect_uri="http://127.0.0.1:43123/termivar/oauth/callback"
706scopes=["canary-read"]
707max_token_response_bytes=8192
708[oauth_authorization]
709profile="loopback-form-post-state/v1"
710authorization_endpoint="https://issuer.test/realm/authorize"
711max_wait_ms=1000
712"#;
713 let policy = SuppliedSessionPolicy::parse_toml(&application, source.as_bytes()).unwrap();
714 let mut unstarted = SuppliedSessionOAuthAuthorizationAudit::selected(&policy).unwrap();
715 assert!(unstarted.is_consistent());
716 unstarted.unknown_parameter_count = 1;
717 assert!(
718 !unstarted.is_consistent(),
719 "extension facts cannot precede state and issuer qualification"
720 );
721 let knowledge = KnowledgeBase::default();
722 let transaction = format!("oauth-authorization-transaction:{}", "A".repeat(43));
723 let reference = commit_value_safe_evidence(
726 &policy,
727 &transaction,
728 b"original.synthetic.callback-proof.v1\0",
729 "supplied-session-oauth-authorization-evidence-sha256",
730 PREDICATE,
731 "original-synthetic-proof",
732 "original value-safe test proof".to_owned(),
733 &knowledge,
734 )
735 .unwrap();
736 let id = EvidenceId::parse(&reference).unwrap();
737 let expected = knowledge.inspect_evidence(&id, Clone::clone).unwrap();
738 let mut receipt = CommittedOAuthAuthorizationReceipt {
739 knowledge_authority: knowledge.authority().clone(),
740 policy_reference: policy.policy_reference().to_owned(),
741 application_reference: policy.application_reference().to_owned(),
742 authorization_reference: policy
743 .oauth_authorization_policy()
744 .unwrap()
745 .binding_reference()
746 .to_owned(),
747 idp_reference: policy
748 .oauth_code_policy()
749 .unwrap()
750 .binding_reference()
751 .to_owned(),
752 principal_alias: policy.principal_alias().to_owned(),
753 transaction_reference: transaction.clone(),
754 evidence_reference: reference.clone(),
755 expected_evidence: expected.clone(),
756 };
757 assert!(receipt.is_valid_for(&policy, &knowledge));
758 assert!(receipt.is_valid_for(&policy, &knowledge.clone()));
759 let foreign = KnowledgeBase::default();
760 foreign.insert_evidence(expected).unwrap();
761 assert!(
762 !receipt.is_valid_for(&policy, &foreign),
763 "copied saved shape cannot replace knowledge authority"
764 );
765 receipt.transaction_reference.push('B');
766 assert!(
767 !receipt.is_valid_for(&policy, &knowledge),
768 "transaction correlation must be exact"
769 );
770 receipt.transaction_reference = transaction;
771 receipt.evidence_reference = format!(
772 "supplied-session-oauth-authorization-evidence-sha256:{}",
773 "0".repeat(64)
774 );
775 assert!(
776 !receipt.is_valid_for(&policy, &knowledge),
777 "missing committed evidence cannot qualify"
778 );
779 receipt.evidence_reference = reference;
780 let changed = source.replace("original-proof-principal", "another-proof-principal");
781 assert_ne!(changed, source);
782 let changed = SuppliedSessionPolicy::parse_toml(&application, changed.as_bytes()).unwrap();
783 assert!(!receipt.is_valid_for(&changed, &knowledge));
784 assert!(receipt.is_valid_for(&policy, &knowledge));
785 }
786
787 #[test]
788 fn callback_framing_is_exact_bounded_and_never_accepts_credential_headers() {
789 let valid = b"POST /termivar/oauth/callback HTTP/1.1\r\nHost: 127.0.0.1:43123\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 17";
790 assert_eq!(
791 parse_headers(valid, "127.0.0.1:43123", "https://issuer.example"),
792 Some(17)
793 );
794 let valid = std::str::from_utf8(valid).unwrap();
795 for replacement in [
796 "GET /termivar/oauth/callback HTTP/1.1",
797 "POST /termivar/oauth/callback?code=secret HTTP/1.1",
798 "POST http://127.0.0.1:43123/termivar/oauth/callback HTTP/1.1",
799 ] {
800 assert_eq!(
801 parse_headers(
802 valid
803 .replacen("POST /termivar/oauth/callback HTTP/1.1", replacement, 1)
804 .as_bytes(),
805 "127.0.0.1:43123",
806 "https://issuer.example"
807 ),
808 None
809 );
810 }
811 for line in [
812 "Host: localhost:43123",
813 "Content-Length: 17",
814 "Transfer-Encoding: chunked",
815 "Expect: 100-continue",
816 "Content-Encoding: gzip",
817 "Authorization: synthetic-canary",
818 "Cookie: synthetic=canary",
819 "Origin: https://foreign.example",
820 ] {
821 assert_eq!(
822 parse_headers(
823 format!("{valid}\r\n{line}").as_bytes(),
824 "127.0.0.1:43123",
825 "https://issuer.example"
826 ),
827 None
828 );
829 }
830 for line in [
831 "X-Ignored: a\nContent-Length: 999",
832 "X-Ignored: a\rContent-Length: 999",
833 ] {
834 assert_eq!(
835 parse_headers(
836 format!("{valid}\r\n{line}").as_bytes(),
837 "127.0.0.1:43123",
838 "https://issuer.example"
839 ),
840 None
841 );
842 }
843 for n in ["0", "017", "8193", "12000", "true", "+17"] {
844 assert_eq!(
845 parse_headers(
846 valid
847 .replace("Length: 17", &format!("Length: {n}"))
848 .as_bytes(),
849 "127.0.0.1:43123",
850 "https://issuer.example"
851 ),
852 None
853 );
854 }
855 assert!(REPLY.len() < 512);
856 let body = REPLY.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
857 assert_eq!(REPLY.len() - body, 44);
858 }
859}
860
861#[cfg(test)]
862#[path = "oauth_authorization_tests.rs"]
863mod runtime_tests;