Skip to main content

termivar_scanner/web_runtime/supplied_session_runtime/
oauth_authorization.rs

1//! One private, metered loopback response reception. Parsed form data alone is
2//! not authority: dispatch requires the exact committed callback proof below.
3
4use 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/// Closed reception result, not an identity/authentication verdict.
38#[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/// Value-safe collection facts. Inbound callback reservation is distinct from
54/// outgoing token/health/resource requests; both use the same parent ledger.
55#[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
248/// Not constructible from saved data, a form fixture, or an imported TSV.
249pub(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
292/// Guarded values plus committed proof; the broker revalidates the proof and
293/// monotonic freshness immediately before its single token dispatch.
294pub(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    // receive_inner owns listener/stream; both are dropped before publication.
366    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    // A failed or malicious first connection cannot cause another accept.
425    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    // Identical inert reply regardless of semantic result; no code/state or
486    // provider prose is reflected. Count writes, not unprovable peer receipt.
487    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        // One byte beyond the cap detects overflow without unbounded reads.
537        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        // Test-only private proof construction; this fixture cannot be imported
724        // or used to mint a production listener/dispatch permit.
725        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;