Skip to main content

termivar_scanner/
supplied_session_review.rs

1//! Transport-neutral contracts for one explicitly supplied authorization session.
2//!
3//! The policy is non-secret and grants only a small ordered set of bodyless,
4//! operator-selected GET requests beneath one already selected application.
5//! Application-defined GET handling can still have server-side effects. The
6//! authorization value is move-only, zeroized on drop, never serialized, and
7//! exposed only to the crate-owned request-composition boundary.
8
9use std::{fmt, ops::Range};
10
11use reqwest::header::HeaderValue;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15use url::{Host, Url};
16use zeroize::Zeroizing;
17
18pub(crate) mod oauth_authorization;
19pub(crate) mod oauth_code;
20pub(crate) mod oauth_refresh;
21pub use oauth_code::{OAuthCodeInputError, SuppliedSessionOAuthGrant};
22
23/// Strict V1 supplied-session policy schema.
24pub const SUPPLIED_SESSION_POLICY_SCHEMA: &str = "security.supplied-session-policy/v1";
25/// Strict V2 supplied-cookie policy schema.
26pub const SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA: &str = "security.supplied-session-policy/v2";
27/// Strict V3 bounded form-login policy schema.
28pub const SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA: &str = "security.supplied-session-policy/v3";
29/// Strict V4 supplied-cookie session with one explicitly scheduled renewal.
30pub const SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA: &str = "security.supplied-session-policy/v4";
31/// Strict V5 acquisition of a Bearer credential from an imported code transaction.
32pub const SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA: &str = "security.supplied-session-policy/v5";
33/// Strict V6 imported-code acquisition and one explicitly scheduled IdP refresh.
34pub const SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA: &str =
35    "security.supplied-session-policy/v6";
36/// Strict V7 client-initiated S256 transaction with one loopback form response.
37pub const SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA: &str =
38    "security.supplied-session-policy/v7";
39/// Maximum accepted policy bytes.
40pub const HARD_MAX_SUPPLIED_SESSION_POLICY_BYTES: usize = 64 * 1024;
41/// Maximum accepted supplied-cookie secret bytes.
42pub const HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES: usize = 16 * 1024;
43/// Maximum accepted username/password secret bytes for one form-login attempt.
44pub const HARD_MAX_SUPPLIED_SESSION_FORM_SECRET_BYTES: usize = 8 * 1024;
45/// Maximum explicitly supplied code/PKCE transaction bytes. No browser is read.
46pub const HARD_MAX_SUPPLIED_SESSION_OAUTH_CODE_SECRET_BYTES: usize = 16 * 1024;
47pub(crate) const MAX_SUPPLIED_SESSION_OAUTH_TOKEN_RESPONSE_BYTES: usize = 32 * 1024;
48/// Maximum supplied cookies declared by V2.
49pub const MAX_SUPPLIED_SESSION_COOKIES: usize = 8;
50/// Maximum composed `Cookie` request-header bytes.
51pub const MAX_SUPPLIED_SESSION_COOKIE_HEADER_BYTES: usize = 8 * 1024;
52/// Maximum explicitly selected protected resources.
53pub const MAX_SUPPLIED_SESSION_RESOURCES: usize = 4;
54/// Maximum health checkpoints: startup plus one after every selected resource.
55pub const MAX_SUPPLIED_SESSION_CHECKPOINTS: usize = MAX_SUPPLIED_SESSION_RESOURCES + 1;
56/// Maximum session-owned requests: startup health plus resource/checkpoint pairs.
57pub const MAX_SUPPLIED_SESSION_REQUESTS: u8 = 9;
58/// Maximum session-owned response bytes declared by one policy.
59pub const MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES: u64 = 256 * 1024;
60/// Maximum retained body bytes for one session-owned response.
61pub const MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES: usize = 64 * 1024;
62/// Maximum local wall-clock allowance declared by one policy.
63pub const MAX_SUPPLIED_SESSION_WALL_TIME_MS: u64 = 10_000;
64
65pub(crate) const SUPPLIED_SESSION_HEALTH_ACTION_ID: &str = "web.review.supplied-session.health";
66pub(crate) const SUPPLIED_SESSION_RESOURCE_ACTION_ID: &str = "web.review.supplied-session.resource";
67pub(crate) const SUPPLIED_SESSION_LOGIN_PAGE_ACTION_ID: &str =
68    "web.review.supplied-session.login-page";
69pub(crate) const SUPPLIED_SESSION_LOGIN_SUBMIT_ACTION_ID: &str =
70    "web.review.supplied-session.login-submit";
71pub(crate) const SUPPLIED_SESSION_RENEWAL_ACTION_ID: &str = "web.review.supplied-session.renewal";
72pub(crate) const SUPPLIED_SESSION_OAUTH_CODE_ACTION_ID: &str =
73    "web.review.supplied-session.oauth-code-exchange";
74pub(crate) const SUPPLIED_SESSION_OAUTH_REFRESH_ACTION_ID: &str =
75    "web.review.supplied-session.oauth-refresh";
76
77const MAX_PRINCIPAL_ALIAS_BYTES: usize = 128;
78const MAX_HEALTH_FIELD_BYTES: usize = 128;
79const MAX_POLICY_PATH_BYTES: usize = 2 * 1024;
80const MAX_AUTHORIZATION_HEADER_BYTES: usize = 8 * 1024;
81const MAX_COOKIE_ID_BYTES: usize = 128;
82const MAX_COOKIE_NAME_BYTES: usize = 128;
83const MAX_COOKIE_VALUE_BYTES: usize = 4 * 1024;
84const MAX_LOGIN_USERNAME_BYTES: usize = 256;
85const MAX_LOGIN_PASSWORD_BYTES: usize = 4 * 1024;
86const MAX_LOGIN_FIELD_BYTES: usize = 128;
87const MAX_LOGIN_CSRF_VALUE_BYTES: usize = 2 * 1024;
88// Every UTF-8 byte may expand to one `%XX` triplet. Three field names, three
89// values, three `=` separators, and two `&` separators form the closed V3 body.
90const MAX_LOGIN_FORM_BODY_BYTES: usize = (MAX_LOGIN_FIELD_BYTES * 3
91    + MAX_LOGIN_USERNAME_BYTES
92    + MAX_LOGIN_PASSWORD_BYTES
93    + MAX_LOGIN_CSRF_VALUE_BYTES)
94    * 3
95    + 5;
96const POLICY_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-policy.reference.v1\0";
97const COOKIE_POLICY_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-policy.reference.v2\0";
98const FORM_LOGIN_POLICY_REFERENCE_DOMAIN: &[u8] =
99    b"security.supplied-session-policy.reference.v3\0";
100const APPLICATION_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-application.reference.v1\0";
101const HEALTH_FIELD_REFERENCE_DOMAIN: &[u8] =
102    b"security.supplied-session-health-field.reference.v1\0";
103const RESOURCE_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-resource.reference.v1\0";
104const LOGIN_REFERENCE_DOMAIN: &[u8] = b"security.supplied-session-login.reference.v1\0";
105const REQUEST_DESCRIPTOR_REFERENCE_DOMAIN: &[u8] =
106    b"security.supplied-session-request-descriptor.reference.v1\0";
107const SUPPLIED_SESSION_PRINCIPAL_REFERENCE: &str = "supplied-session-principal-0001";
108
109/// Supplied-session policy generation.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
111#[serde(rename_all = "snake_case")]
112#[non_exhaustive]
113pub enum SuppliedSessionPolicyVersion {
114    /// Authorization-header policy V1.
115    V1,
116    /// Supplied-cookie policy V2.
117    V2,
118    /// One bounded form login that establishes a cookie session.
119    V3,
120    /// Supplied host-only cookies and one bounded scheduled renewal.
121    V4,
122    /// One imported external public-client code/PKCE credential acquisition.
123    V5,
124    /// Imported code acquisition with one declared same-IdP refresh boundary.
125    V6,
126    /// Client-generated public-code/S256 transaction and one exact loopback response.
127    V7,
128}
129
130impl SuppliedSessionPolicyVersion {
131    /// Exact schema identifier for this generation.
132    pub const fn schema(self) -> &'static str {
133        match self {
134            Self::V1 => SUPPLIED_SESSION_POLICY_SCHEMA,
135            Self::V2 => SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA,
136            Self::V3 => SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA,
137            Self::V4 => SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA,
138            Self::V5 => SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA,
139            Self::V6 => SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA,
140            Self::V7 => SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA,
141        }
142    }
143}
144
145/// How the credential used for protected requests is established.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
147#[serde(rename_all = "snake_case")]
148#[non_exhaustive]
149pub enum SuppliedSessionCredentialAcquisition {
150    /// The caller supplied a complete Authorization value.
151    SuppliedAuthorization,
152    /// The caller supplied a bounded cookie jar.
153    SuppliedCookieJar,
154    /// One explicit form login established the bounded cookie jar.
155    BoundedFormLogin,
156    /// One imported external code/PKCE transaction was selected for redemption.
157    #[serde(rename = "imported_oauth_code_pkce")]
158    ImportedOAuthCodePkce,
159    /// The client generated the state/verifier and receives one bound response.
160    #[serde(rename = "client_initiated_oauth_pkce")]
161    ClientInitiatedOAuthPkce,
162}
163
164impl SuppliedSessionCredentialAcquisition {
165    /// Exact stable wire token.
166    pub const fn as_str(self) -> &'static str {
167        match self {
168            Self::SuppliedAuthorization => "supplied_authorization",
169            Self::SuppliedCookieJar => "supplied_cookie_jar",
170            Self::BoundedFormLogin => "bounded_form_login",
171            Self::ImportedOAuthCodePkce => "imported_oauth_code_pkce",
172            Self::ClientInitiatedOAuthPkce => "client_initiated_oauth_pkce",
173        }
174    }
175}
176
177/// Credential mechanism selected by a supplied-session policy.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
179#[serde(rename_all = "snake_case")]
180#[non_exhaustive]
181pub enum SuppliedSessionCredentialMechanism {
182    /// One complete caller-supplied `Authorization` header value.
183    AuthorizationHeader,
184    /// A bounded caller-supplied cookie jar with non-secret policy metadata.
185    CookieJar,
186}
187
188impl SuppliedSessionCredentialMechanism {
189    /// Exact stable wire token.
190    pub const fn as_str(self) -> &'static str {
191        match self {
192            Self::AuthorizationHeader => "authorization_header",
193            Self::CookieJar => "cookie_jar",
194        }
195    }
196}
197
198/// V2 behavior when a selected cookie is reissued by a response.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
200#[serde(rename_all = "snake_case")]
201#[non_exhaustive]
202pub enum SuppliedSessionCookieUpdatePolicy {
203    /// Never apply a response update; stop before further session work.
204    StopOnSelectedCookie,
205}
206
207impl SuppliedSessionCookieUpdatePolicy {
208    /// Exact stable wire token.
209    pub const fn as_str(self) -> &'static str {
210        match self {
211            Self::StopOnSelectedCookie => "stop_on_selected_cookie",
212        }
213    }
214}
215
216/// Preserved SameSite declaration for one supplied cookie.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
218#[serde(rename_all = "snake_case")]
219#[non_exhaustive]
220pub enum SuppliedSessionCookieSameSite {
221    /// No SameSite attribute was declared.
222    Missing,
223    /// `SameSite=Strict` was declared.
224    Strict,
225    /// `SameSite=Lax` was declared.
226    Lax,
227    /// `SameSite=None` was declared.
228    None,
229}
230
231impl SuppliedSessionCookieSameSite {
232    /// Exact stable wire token.
233    pub const fn as_str(self) -> &'static str {
234        match self {
235            Self::Missing => "missing",
236            Self::Strict => "strict",
237            Self::Lax => "lax",
238            Self::None => "none",
239        }
240    }
241}
242
243/// Static, redaction-safe policy validation failure.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
245#[non_exhaustive]
246pub enum SuppliedSessionPolicyError {
247    /// Policy input exceeded its compiled source ceiling.
248    #[error("supplied-session policy exceeds its compiled byte limit")]
249    PolicyTooLarge,
250    /// TOML, UTF-8, required fields, duplicate fields, or bounded values were invalid.
251    #[error("supplied-session policy is malformed")]
252    MalformedPolicy,
253    /// The schema identifier is not supported.
254    #[error("supplied-session policy schema is unsupported")]
255    UnsupportedSchema,
256    /// The credential mechanism is not supported by V1.
257    #[error("supplied-session credential mechanism is unsupported")]
258    UnsupportedCredentialMechanism,
259    /// The response-cookie update policy is not supported by V2.
260    #[error("supplied-session cookie update policy is unsupported")]
261    UnsupportedCookieUpdatePolicy,
262    /// The selected application is not a safe trailing-slash HTTP(S) application URL.
263    #[error("supplied-session application is invalid")]
264    InvalidApplication,
265    /// A principal alias violated the bounded opaque-token contract.
266    #[error("supplied-session principal alias is invalid")]
267    InvalidPrincipalAlias,
268    /// A health field violated the bounded top-level-field contract.
269    #[error("supplied-session health JSON field is invalid")]
270    InvalidHealthField,
271    /// A path was unsafe, action-oriented, duplicated, or outside the application.
272    #[error("supplied-session path is invalid or outside the selected application")]
273    InvalidPath,
274    /// The selected resource count violated V1's closed bound.
275    #[error("supplied-session resource count is invalid")]
276    InvalidResourceCount,
277    /// The selected cookie count violated V2's closed bound.
278    #[error("supplied-session cookie count is invalid")]
279    InvalidCookieCount,
280    /// Cookie metadata was unsafe, out of scope, duplicated, or internally inconsistent.
281    #[error("supplied-session cookie metadata is invalid")]
282    InvalidCookieMetadata,
283    /// Form-login paths, field bindings, acquisition, or attempt limit were invalid.
284    #[error("supplied-session form-login policy is invalid")]
285    InvalidFormLogin,
286    /// The imported-code profile or its independently declared IdP authority is invalid.
287    #[error("supplied-session OAuth code policy is invalid or unsupported")]
288    InvalidOAuthCode,
289    /// A selected OAuth refresh profile, boundary or replacement policy was invalid.
290    #[error("supplied-session OAuth refresh policy is invalid or unsupported")]
291    InvalidOAuthRefresh,
292    /// The client-initiated profile, fixed callback or authorization binding is invalid.
293    #[error("supplied-session OAuth authorization policy is invalid or unsupported")]
294    InvalidOAuthAuthorization,
295    /// A policy limit was zero, inconsistent, or above a compiled maximum.
296    #[error("supplied-session limits are invalid")]
297    InvalidLimits,
298}
299
300/// Static, redaction-safe authorization value validation failure.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
302#[non_exhaustive]
303pub enum SuppliedSessionAuthorizationError {
304    /// The value was empty, malformed, non-visible ASCII, or too large.
305    #[error("supplied-session authorization is not a bounded safe HTTP header value")]
306    InvalidValue,
307}
308
309/// Static, redaction-safe supplied-cookie validation failure.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
311#[non_exhaustive]
312pub enum SuppliedSessionCookieError {
313    /// Secret input exceeded its compiled byte limit.
314    #[error("supplied-session cookie secret exceeds its compiled byte limit")]
315    SecretTooLarge,
316    /// Secret rows, identifiers, values, or their exact declared set were invalid.
317    #[error("supplied-session cookie secret is malformed")]
318    MalformedSecret,
319    /// The selected policy does not admit a supplied-cookie credential.
320    #[error("supplied-session cookie credential does not match the selected policy")]
321    PolicyMismatch,
322    /// At least one selected request had no initially applicable cookie.
323    #[error("supplied-session cookie credential does not cover every selected request")]
324    IncompleteInitialCoverage,
325    /// The exact requested target was not admitted by the bound policy.
326    #[error("supplied-session cookie target is not admitted by the selected policy")]
327    TargetNotAdmitted,
328    /// No unexpired cookie applies to this admitted request.
329    #[error("supplied-session cookie credential is unavailable for this request")]
330    NoApplicableCookie,
331    /// Applicable cookie pairs exceeded the request-header ceiling.
332    #[error("supplied-session cookie header exceeds its compiled byte limit")]
333    HeaderTooLarge,
334}
335
336/// Static, redaction-safe form credential validation failure.
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
338#[non_exhaustive]
339pub enum SuppliedSessionFormCredentialError {
340    /// Secret input exceeded its compiled byte limit.
341    #[error("supplied-session form credential exceeds its compiled byte limit")]
342    SecretTooLarge,
343    /// Secret rows, identifiers, values, or their exact declared set were invalid.
344    #[error("supplied-session form credential is malformed")]
345    MalformedSecret,
346    /// The selected policy does not admit bounded form login.
347    #[error("supplied-session form credential does not match the selected policy")]
348    PolicyMismatch,
349    /// The extracted form token was absent, malformed, or too large.
350    #[error("supplied-session login form token is invalid")]
351    InvalidCsrfToken,
352}
353
354/// One move-only complete `Authorization` header value.
355pub struct SuppliedSessionAuthorization {
356    value: Zeroizing<String>,
357}
358
359impl SuppliedSessionAuthorization {
360    /// Validates bounded visible-ASCII header bytes without retaining a second copy.
361    pub fn new(value: impl Into<Vec<u8>>) -> Result<Self, SuppliedSessionAuthorizationError> {
362        let mut value = Zeroizing::new(value.into());
363        if value.is_empty()
364            || value.len() > MAX_AUTHORIZATION_HEADER_BYTES
365            || value.iter().any(|byte| !matches!(*byte, 0x20..=0x7e))
366        {
367            return Err(SuppliedSessionAuthorizationError::InvalidValue);
368        }
369        let parsed = HeaderValue::from_bytes(value.as_slice())
370            .map_err(|_| SuppliedSessionAuthorizationError::InvalidValue)?;
371        let text = parsed
372            .to_str()
373            .map_err(|_| SuppliedSessionAuthorizationError::InvalidValue)?;
374        let Some((scheme, credentials)) = text.split_once(' ') else {
375            return Err(SuppliedSessionAuthorizationError::InvalidValue);
376        };
377        if scheme.is_empty()
378            || credentials.is_empty()
379            || credentials.starts_with(' ')
380            || credentials.ends_with(' ')
381            || !scheme.bytes().all(http_token_byte)
382        {
383            return Err(SuppliedSessionAuthorizationError::InvalidValue);
384        }
385        let owned = String::from_utf8(std::mem::take(&mut *value))
386            .map_err(|_| SuppliedSessionAuthorizationError::InvalidValue)?;
387        Ok(Self {
388            value: Zeroizing::new(owned),
389        })
390    }
391
392    pub(crate) fn as_str(&self) -> &str {
393        self.value.as_str()
394    }
395}
396
397impl fmt::Debug for SuppliedSessionAuthorization {
398    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
399        formatter.write_str("SuppliedSessionAuthorization(<redacted>)")
400    }
401}
402
403/// Non-secret aggregate of the supplied cookie metadata selected by a policy.
404#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
405pub struct SuppliedSessionCookieSummary {
406    cookie_count: usize,
407    host_only_count: usize,
408    domain_count: usize,
409    secure_count: usize,
410    http_only_count: usize,
411    session_count: usize,
412    persistent_count: usize,
413    same_site_missing_count: usize,
414    same_site_strict_count: usize,
415    same_site_lax_count: usize,
416    same_site_none_count: usize,
417}
418
419impl SuppliedSessionCookieSummary {
420    /// Number of declared cookie records.
421    pub const fn cookie_count(self) -> usize {
422        self.cookie_count
423    }
424
425    /// Number of host-only cookie records.
426    pub const fn host_only_count(self) -> usize {
427        self.host_only_count
428    }
429
430    /// Number of Domain-attribute cookie records.
431    pub const fn domain_count(self) -> usize {
432        self.domain_count
433    }
434
435    /// Number of Secure cookie records.
436    pub const fn secure_count(self) -> usize {
437        self.secure_count
438    }
439
440    /// Number of HttpOnly cookie records.
441    pub const fn http_only_count(self) -> usize {
442        self.http_only_count
443    }
444
445    /// Number of session cookies without a declared expiry.
446    pub const fn session_count(self) -> usize {
447        self.session_count
448    }
449
450    /// Number of cookies with a declared expiry.
451    pub const fn persistent_count(self) -> usize {
452        self.persistent_count
453    }
454
455    /// Number of records without a SameSite declaration.
456    pub const fn same_site_missing_count(self) -> usize {
457        self.same_site_missing_count
458    }
459
460    /// Number of `SameSite=Strict` records.
461    pub const fn same_site_strict_count(self) -> usize {
462        self.same_site_strict_count
463    }
464
465    /// Number of `SameSite=Lax` records.
466    pub const fn same_site_lax_count(self) -> usize {
467        self.same_site_lax_count
468    }
469
470    /// Number of `SameSite=None` records.
471    pub const fn same_site_none_count(self) -> usize {
472        self.same_site_none_count
473    }
474}
475
476struct SuppliedSessionCookieMetadata {
477    id: String,
478    name: String,
479    domain: String,
480    host_only: bool,
481    path: String,
482    secure: bool,
483    http_only: bool,
484    same_site: SuppliedSessionCookieSameSite,
485    expires_unix_seconds: Option<i64>,
486}
487
488struct SuppliedSessionCookieValue {
489    metadata_index: usize,
490    range: Range<usize>,
491}
492
493/// Move-only supplied cookie values bound to one validated V2 policy.
494///
495/// Secret bytes remain in one zeroizing backing allocation. Request headers
496/// are composed transiently only for exact policy-admitted targets.
497pub struct SuppliedSessionCookies {
498    backing: Zeroizing<Vec<u8>>,
499    values: Vec<SuppliedSessionCookieValue>,
500    policy_reference: String,
501    principal_alias: String,
502}
503
504impl SuppliedSessionCookies {
505    /// Parses strict `cookie-id<TAB>value` rows and validates initial coverage.
506    ///
507    /// Every policy cookie must occur exactly once. Values use the RFC cookie
508    /// octet subset and are never trimmed or decoded. `now_unix_seconds` is an
509    /// explicit caller-supplied clock reading used only for expiry checks.
510    pub fn parse_tsv(
511        policy: &SuppliedSessionPolicy,
512        source: impl Into<Vec<u8>>,
513        now_unix_seconds: i64,
514    ) -> Result<Self, SuppliedSessionCookieError> {
515        if policy.credential_mechanism != SuppliedSessionCredentialMechanism::CookieJar
516            || !matches!(
517                policy.version,
518                SuppliedSessionPolicyVersion::V2 | SuppliedSessionPolicyVersion::V4
519            )
520            || policy.cookies.is_empty()
521        {
522            return Err(SuppliedSessionCookieError::PolicyMismatch);
523        }
524        let backing = Zeroizing::new(source.into());
525        if backing.is_empty() {
526            return Err(SuppliedSessionCookieError::MalformedSecret);
527        }
528        if backing.len() > HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES {
529            return Err(SuppliedSessionCookieError::SecretTooLarge);
530        }
531
532        let mut ranges: Vec<Option<Range<usize>>> =
533            (0..policy.cookies.len()).map(|_| None).collect();
534        let mut cursor = 0_usize;
535        while cursor < backing.len() {
536            let physical_end = backing[cursor..]
537                .iter()
538                .position(|byte| *byte == b'\n')
539                .map_or(backing.len(), |offset| cursor + offset);
540            let mut line_end = physical_end;
541            if line_end > cursor && backing[line_end - 1] == b'\r' {
542                line_end -= 1;
543            }
544            if line_end == cursor {
545                return Err(SuppliedSessionCookieError::MalformedSecret);
546            }
547            let line = &backing[cursor..line_end];
548            let Some(tab_offset) = line.iter().position(|byte| *byte == b'\t') else {
549                return Err(SuppliedSessionCookieError::MalformedSecret);
550            };
551            if line[tab_offset + 1..].contains(&b'\t') {
552                return Err(SuppliedSessionCookieError::MalformedSecret);
553            }
554            let id = std::str::from_utf8(&line[..tab_offset])
555                .map_err(|_| SuppliedSessionCookieError::MalformedSecret)?;
556            let Some(metadata_index) = policy.cookies.iter().position(|cookie| cookie.id == id)
557            else {
558                return Err(SuppliedSessionCookieError::MalformedSecret);
559            };
560            if ranges[metadata_index].is_some() {
561                return Err(SuppliedSessionCookieError::MalformedSecret);
562            }
563            let value_start = cursor
564                .checked_add(tab_offset)
565                .and_then(|value| value.checked_add(1))
566                .ok_or(SuppliedSessionCookieError::MalformedSecret)?;
567            let value = &backing[value_start..line_end];
568            if value.is_empty()
569                || value.len() > MAX_COOKIE_VALUE_BYTES
570                || !value.iter().copied().all(cookie_octet)
571            {
572                return Err(SuppliedSessionCookieError::MalformedSecret);
573            }
574            ranges[metadata_index] = Some(value_start..line_end);
575            cursor = if physical_end == backing.len() {
576                backing.len()
577            } else {
578                physical_end + 1
579            };
580        }
581        if ranges.iter().any(Option::is_none) {
582            return Err(SuppliedSessionCookieError::MalformedSecret);
583        }
584        if policy.cookies.iter().any(|cookie| {
585            cookie
586                .expires_unix_seconds
587                .is_some_and(|expires| expires <= now_unix_seconds)
588        }) {
589            return Err(SuppliedSessionCookieError::IncompleteInitialCoverage);
590        }
591        let values = ranges
592            .into_iter()
593            .enumerate()
594            .map(|(metadata_index, range)| SuppliedSessionCookieValue {
595                metadata_index,
596                range: range.expect("the exact cookie id set was checked above"),
597            })
598            .collect::<Vec<_>>();
599        let cookies = Self {
600            backing,
601            values,
602            policy_reference: policy.policy_reference.clone(),
603            principal_alias: policy.principal_alias.clone(),
604        };
605        if !policy
606            .execution_targets()
607            .all(|target| cookies.has_applicable_cookie(policy, target, now_unix_seconds))
608        {
609            return Err(SuppliedSessionCookieError::IncompleteInitialCoverage);
610        }
611        Ok(cookies)
612    }
613
614    /// Acquires the exact host-only session-cookie set declared by a V3 policy.
615    ///
616    /// This sealed transition accepts no Domain, persistence, extension, or
617    /// undeclared cookie fields. Only copied cookie values enter the guarded
618    /// backing allocation; response header bytes remain transport-owned.
619    pub(crate) fn from_form_login_set_cookie_fields<'a, I>(
620        policy: &SuppliedSessionPolicy,
621        fields: I,
622        now_unix_seconds: i64,
623    ) -> Result<Self, SuppliedSessionCookieError>
624    where
625        I: IntoIterator<Item = &'a [u8]>,
626    {
627        if !((policy.version == SuppliedSessionPolicyVersion::V3
628            && policy.credential_acquisition
629                == SuppliedSessionCredentialAcquisition::BoundedFormLogin)
630            || (policy.version == SuppliedSessionPolicyVersion::V4 && policy.renewal.is_some()))
631            || policy.cookies.is_empty()
632        {
633            return Err(SuppliedSessionCookieError::PolicyMismatch);
634        }
635        let mut backing = Zeroizing::new(Vec::new());
636        let mut ranges: Vec<Option<Range<usize>>> =
637            (0..policy.cookies.len()).map(|_| None).collect();
638        let mut field_count = 0_usize;
639        let mut total_bytes = 0_usize;
640        for raw in fields {
641            field_count = field_count.saturating_add(1);
642            total_bytes = total_bytes.saturating_add(raw.len());
643            if field_count > MAX_SUPPLIED_SESSION_COOKIES
644                || raw.is_empty()
645                || raw.len() > MAX_COOKIE_VALUE_BYTES.saturating_add(1024)
646                || total_bytes > HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES
647            {
648                return Err(SuppliedSessionCookieError::MalformedSecret);
649            }
650            let parsed = parse_form_login_set_cookie(raw, policy)?;
651            if ranges[parsed.metadata_index].is_some() {
652                return Err(SuppliedSessionCookieError::MalformedSecret);
653            }
654            let start = backing.len();
655            backing.extend_from_slice(parsed.value);
656            let end = backing.len();
657            ranges[parsed.metadata_index] = Some(start..end);
658        }
659        if field_count != policy.cookies.len() || ranges.iter().any(Option::is_none) {
660            return Err(SuppliedSessionCookieError::MalformedSecret);
661        }
662        let values = ranges
663            .into_iter()
664            .enumerate()
665            .map(|(metadata_index, range)| SuppliedSessionCookieValue {
666                metadata_index,
667                range: range.expect("the exact V3 cookie set was checked above"),
668            })
669            .collect::<Vec<_>>();
670        let cookies = Self {
671            backing,
672            values,
673            policy_reference: policy.policy_reference.clone(),
674            principal_alias: policy.principal_alias.clone(),
675        };
676        if !policy
677            .execution_targets()
678            .all(|target| cookies.has_applicable_cookie(policy, target, now_unix_seconds))
679        {
680            return Err(SuppliedSessionCookieError::IncompleteInitialCoverage);
681        }
682        Ok(cookies)
683    }
684
685    /// Number of secret cookie values retained by this jar.
686    pub fn cookie_count(&self) -> usize {
687        self.values.len()
688    }
689
690    pub(crate) fn sensitive_header_for_target(
691        &self,
692        policy: &SuppliedSessionPolicy,
693        target: &Url,
694        now_unix_seconds: i64,
695    ) -> Result<HeaderValue, SuppliedSessionCookieError> {
696        if !self.matches_policy(policy) {
697            return Err(SuppliedSessionCookieError::PolicyMismatch);
698        }
699        if !policy.admits_execution_target(target) {
700            return Err(SuppliedSessionCookieError::TargetNotAdmitted);
701        }
702        // The supplied jar is one fixed session epoch. Silently omitting an
703        // expired selected cookie while sending the remaining set would change
704        // that credential context without a validated renewal transition.
705        if policy.cookies.iter().any(|cookie| {
706            cookie
707                .expires_unix_seconds
708                .is_some_and(|expires| expires <= now_unix_seconds)
709        }) {
710            return Err(SuppliedSessionCookieError::NoApplicableCookie);
711        }
712        let mut selected = self
713            .values
714            .iter()
715            .filter(|value| {
716                cookie_applies(
717                    &policy.cookies[value.metadata_index],
718                    target,
719                    now_unix_seconds,
720                )
721            })
722            .collect::<Vec<_>>();
723        selected.sort_by(|left, right| {
724            policy.cookies[right.metadata_index]
725                .path
726                .len()
727                .cmp(&policy.cookies[left.metadata_index].path.len())
728                .then_with(|| left.metadata_index.cmp(&right.metadata_index))
729        });
730        if selected.is_empty() {
731            return Err(SuppliedSessionCookieError::NoApplicableCookie);
732        }
733
734        let mut composed = Zeroizing::new(Vec::new());
735        for (position, value) in selected.into_iter().enumerate() {
736            let metadata = &policy.cookies[value.metadata_index];
737            let separator_bytes = usize::from(position > 0) * 2;
738            let next_len = composed
739                .len()
740                .checked_add(separator_bytes)
741                .and_then(|length| length.checked_add(metadata.name.len()))
742                .and_then(|length| length.checked_add(1))
743                .and_then(|length| length.checked_add(value.range.len()))
744                .ok_or(SuppliedSessionCookieError::HeaderTooLarge)?;
745            if next_len > MAX_SUPPLIED_SESSION_COOKIE_HEADER_BYTES {
746                return Err(SuppliedSessionCookieError::HeaderTooLarge);
747            }
748            if position > 0 {
749                composed.extend_from_slice(b"; ");
750            }
751            composed.extend_from_slice(metadata.name.as_bytes());
752            composed.push(b'=');
753            composed.extend_from_slice(&self.backing[value.range.clone()]);
754        }
755        let mut header = HeaderValue::from_bytes(composed.as_slice())
756            .map_err(|_| SuppliedSessionCookieError::MalformedSecret)?;
757        header.set_sensitive(true);
758        Ok(header)
759    }
760
761    fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
762        matches!(
763            policy.version,
764            SuppliedSessionPolicyVersion::V2
765                | SuppliedSessionPolicyVersion::V3
766                | SuppliedSessionPolicyVersion::V4
767        ) && policy.credential_mechanism == SuppliedSessionCredentialMechanism::CookieJar
768            && self.policy_reference == policy.policy_reference
769            && self.principal_alias == policy.principal_alias
770            && self.values.len() == policy.cookies.len()
771    }
772
773    fn has_applicable_cookie(
774        &self,
775        policy: &SuppliedSessionPolicy,
776        target: &Url,
777        now_unix_seconds: i64,
778    ) -> bool {
779        self.values.iter().any(|value| {
780            cookie_applies(
781                &policy.cookies[value.metadata_index],
782                target,
783                now_unix_seconds,
784            )
785        })
786    }
787}
788
789struct ParsedFormLoginCookie<'a> {
790    metadata_index: usize,
791    value: &'a [u8],
792}
793
794fn parse_form_login_set_cookie<'a>(
795    raw: &'a [u8],
796    policy: &SuppliedSessionPolicy,
797) -> Result<ParsedFormLoginCookie<'a>, SuppliedSessionCookieError> {
798    let mut segments = raw.split(|byte| *byte == b';');
799    let pair = trim_cookie_segment(
800        segments
801            .next()
802            .ok_or(SuppliedSessionCookieError::MalformedSecret)?,
803    );
804    let equals = pair
805        .iter()
806        .position(|byte| *byte == b'=')
807        .ok_or(SuppliedSessionCookieError::MalformedSecret)?;
808    let name = std::str::from_utf8(&pair[..equals])
809        .map_err(|_| SuppliedSessionCookieError::MalformedSecret)?;
810    let value = &pair[equals + 1..];
811    if name.is_empty()
812        || value.is_empty()
813        || value.len() > MAX_COOKIE_VALUE_BYTES
814        || !value.iter().copied().all(cookie_octet)
815    {
816        return Err(SuppliedSessionCookieError::MalformedSecret);
817    }
818    let mut path = None;
819    let mut secure = false;
820    let mut http_only = false;
821    let mut same_site = None;
822    for raw_attribute in segments {
823        let attribute = trim_cookie_segment(raw_attribute);
824        if attribute.is_empty() {
825            return Err(SuppliedSessionCookieError::MalformedSecret);
826        }
827        let (attribute_name, attribute_value) = attribute
828            .iter()
829            .position(|byte| *byte == b'=')
830            .map_or((attribute, None), |offset| {
831                (&attribute[..offset], Some(&attribute[offset + 1..]))
832            });
833        if attribute_name.eq_ignore_ascii_case(b"path") {
834            let candidate = attribute_value.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
835            if path.replace(candidate).is_some() {
836                return Err(SuppliedSessionCookieError::MalformedSecret);
837            }
838        } else if attribute_name.eq_ignore_ascii_case(b"secure") {
839            if attribute_value.is_some() || secure {
840                return Err(SuppliedSessionCookieError::MalformedSecret);
841            }
842            secure = true;
843        } else if attribute_name.eq_ignore_ascii_case(b"httponly") {
844            if attribute_value.is_some() || http_only {
845                return Err(SuppliedSessionCookieError::MalformedSecret);
846            }
847            http_only = true;
848        } else if attribute_name.eq_ignore_ascii_case(b"samesite") {
849            let candidate = attribute_value.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
850            if same_site.replace(candidate).is_some() {
851                return Err(SuppliedSessionCookieError::MalformedSecret);
852            }
853        } else {
854            return Err(SuppliedSessionCookieError::MalformedSecret);
855        }
856    }
857    let path = path.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
858    let same_site = same_site.ok_or(SuppliedSessionCookieError::MalformedSecret)?;
859    let mut matching_metadata = policy.cookies.iter().enumerate().filter(|(_, cookie)| {
860        cookie.name == name
861            && path == cookie.path.as_bytes()
862            && secure == cookie.secure
863            && http_only == cookie.http_only
864            && same_site.eq_ignore_ascii_case(cookie.same_site.as_str().as_bytes())
865    });
866    let metadata_index = matching_metadata
867        .next()
868        .map(|(index, _)| index)
869        .ok_or(SuppliedSessionCookieError::MalformedSecret)?;
870    if matching_metadata.next().is_some() {
871        return Err(SuppliedSessionCookieError::MalformedSecret);
872    }
873    Ok(ParsedFormLoginCookie {
874        metadata_index,
875        value,
876    })
877}
878
879fn trim_cookie_segment(mut value: &[u8]) -> &[u8] {
880    while value
881        .first()
882        .is_some_and(|byte| matches!(*byte, b' ' | b'\t'))
883    {
884        value = &value[1..];
885    }
886    while value
887        .last()
888        .is_some_and(|byte| matches!(*byte, b' ' | b'\t'))
889    {
890        value = &value[..value.len() - 1];
891    }
892    value
893}
894
895impl fmt::Debug for SuppliedSessionCookies {
896    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
897        formatter
898            .debug_struct("SuppliedSessionCookies")
899            .field("values", &"<redacted>")
900            .field("cookie_count", &self.values.len())
901            .finish()
902    }
903}
904
905/// Move-only supplied credential selected for one session runtime.
906pub enum SuppliedSessionCredential {
907    /// One caller-supplied Authorization header.
908    Authorization(SuppliedSessionAuthorization),
909    /// One bounded caller-supplied cookie jar.
910    SuppliedCookies(SuppliedSessionCookies),
911}
912
913impl SuppliedSessionCredential {
914    /// Credential mechanism carried by this value.
915    pub const fn mechanism(&self) -> SuppliedSessionCredentialMechanism {
916        match self {
917            Self::Authorization(_) => SuppliedSessionCredentialMechanism::AuthorizationHeader,
918            Self::SuppliedCookies(_) => SuppliedSessionCredentialMechanism::CookieJar,
919        }
920    }
921
922    pub(crate) const fn authorization(&self) -> Option<&SuppliedSessionAuthorization> {
923        match self {
924            Self::Authorization(value) => Some(value),
925            Self::SuppliedCookies(_) => None,
926        }
927    }
928
929    pub(crate) const fn cookies(&self) -> Option<&SuppliedSessionCookies> {
930        match self {
931            Self::Authorization(_) => None,
932            Self::SuppliedCookies(value) => Some(value),
933        }
934    }
935}
936
937impl From<SuppliedSessionAuthorization> for SuppliedSessionCredential {
938    fn from(value: SuppliedSessionAuthorization) -> Self {
939        Self::Authorization(value)
940    }
941}
942
943impl From<SuppliedSessionCookies> for SuppliedSessionCredential {
944    fn from(value: SuppliedSessionCookies) -> Self {
945        Self::SuppliedCookies(value)
946    }
947}
948
949impl fmt::Debug for SuppliedSessionCredential {
950    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
951        match self {
952            Self::Authorization(_) => {
953                formatter.write_str("SuppliedSessionCredential::Authorization(<redacted>)")
954            },
955            Self::SuppliedCookies(_) => {
956                formatter.write_str("SuppliedSessionCredential::SuppliedCookies(<redacted>)")
957            },
958        }
959    }
960}
961
962/// Strict non-secret policy for one supplied session.
963pub struct SuppliedSessionPolicy {
964    version: SuppliedSessionPolicyVersion,
965    application_url: Url,
966    principal_alias: String,
967    health_url: Url,
968    health_json_field: String,
969    resources: Vec<SuppliedSessionResource>,
970    credential_mechanism: SuppliedSessionCredentialMechanism,
971    credential_acquisition: SuppliedSessionCredentialAcquisition,
972    cookie_update_policy: Option<SuppliedSessionCookieUpdatePolicy>,
973    cookies: Vec<SuppliedSessionCookieMetadata>,
974    login: Option<SuppliedSessionLoginPolicy>,
975    renewal: Option<SuppliedSessionRenewalPolicy>,
976    // Keep the new optional payload out of every legacy policy's inline layout.
977    oauth_code: Option<Box<oauth_code::OAuthCodePolicy>>,
978    oauth_refresh: Option<Box<oauth_refresh::OAuthRefreshPolicy>>,
979    oauth_authorization: Option<Box<oauth_authorization::OAuthAuthorizationPolicy>>,
980    policy_reference: String,
981    application_reference: String,
982    principal_reference: String,
983    health_field_reference: String,
984    max_session_requests: u8,
985    max_total_response_bytes: u64,
986    max_response_body_bytes: usize,
987    max_wall_time_ms: u64,
988}
989
990struct SuppliedSessionResource {
991    url: Url,
992    reference: String,
993}
994
995/// One prevalidated runtime input: either a ready outbound credential or the
996/// guarded values needed for the single bounded login transition.
997pub enum SuppliedSessionRuntimeInput {
998    /// An already supplied Authorization value or cookie jar.
999    Ready(SuppliedSessionCredential),
1000    /// One username/password pair for a policy-bound form login.
1001    FormLogin(SuppliedSessionFormCredential),
1002    /// One guarded, externally obtained authorization code/PKCE transaction.
1003    OAuthCode(SuppliedSessionOAuthGrant),
1004    /// One explicitly selected, bounded private handoff writer; no secret import.
1005    OAuthAuthorization(Box<SuppliedSessionOAuthAuthorization>),
1006}
1007
1008impl SuppliedSessionRuntimeInput {
1009    /// Whether this runtime input is exactly compatible with the policy.
1010    pub fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
1011        match self {
1012            Self::Ready(credential) => {
1013                policy.version() != SuppliedSessionPolicyVersion::V3
1014                    && policy.version() != SuppliedSessionPolicyVersion::V5
1015                    && policy.version() != SuppliedSessionPolicyVersion::V6
1016                    && policy.version() != SuppliedSessionPolicyVersion::V7
1017                    && policy.credential_mechanism() == credential.mechanism()
1018            },
1019            Self::FormLogin(credential) => credential.matches_policy(policy),
1020            Self::OAuthCode(grant) => grant.matches_policy(policy),
1021            Self::OAuthAuthorization(authorization) => authorization.matches_policy(policy),
1022        }
1023    }
1024}
1025
1026impl From<SuppliedSessionCredential> for SuppliedSessionRuntimeInput {
1027    fn from(value: SuppliedSessionCredential) -> Self {
1028        Self::Ready(value)
1029    }
1030}
1031
1032impl From<SuppliedSessionAuthorization> for SuppliedSessionRuntimeInput {
1033    fn from(value: SuppliedSessionAuthorization) -> Self {
1034        Self::Ready(SuppliedSessionCredential::Authorization(value))
1035    }
1036}
1037
1038impl From<SuppliedSessionCookies> for SuppliedSessionRuntimeInput {
1039    fn from(value: SuppliedSessionCookies) -> Self {
1040        Self::Ready(SuppliedSessionCredential::SuppliedCookies(value))
1041    }
1042}
1043
1044impl From<SuppliedSessionFormCredential> for SuppliedSessionRuntimeInput {
1045    fn from(value: SuppliedSessionFormCredential) -> Self {
1046        Self::FormLogin(value)
1047    }
1048}
1049
1050impl From<SuppliedSessionOAuthGrant> for SuppliedSessionRuntimeInput {
1051    fn from(value: SuppliedSessionOAuthGrant) -> Self {
1052        Self::OAuthCode(value)
1053    }
1054}
1055
1056impl From<SuppliedSessionOAuthAuthorization> for SuppliedSessionRuntimeInput {
1057    fn from(value: SuppliedSessionOAuthAuthorization) -> Self {
1058        Self::OAuthAuthorization(Box::new(value))
1059    }
1060}
1061
1062impl fmt::Debug for SuppliedSessionRuntimeInput {
1063    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1064        match self {
1065            Self::Ready(_) => formatter.write_str("SuppliedSessionRuntimeInput::Ready(<redacted>)"),
1066            Self::FormLogin(_) => {
1067                formatter.write_str("SuppliedSessionRuntimeInput::FormLogin(<redacted>)")
1068            },
1069            Self::OAuthCode(_) => {
1070                formatter.write_str("SuppliedSessionRuntimeInput::OAuthCode(<redacted>)")
1071            },
1072            Self::OAuthAuthorization(_) => {
1073                formatter.write_str("SuppliedSessionRuntimeInput::OAuthAuthorization(<redacted>)")
1074            },
1075        }
1076    }
1077}
1078
1079/// Static errors from the selected private OAuth handoff sink.
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
1081#[non_exhaustive]
1082pub enum OAuthAuthorizationInputError {
1083    /// The selected policy does not admit client-initiated authorization.
1084    #[error("OAuth authorization handoff does not match the selected policy")]
1085    PolicyMismatch,
1086    /// A handoff attempt already consumed this writer, including a failed write.
1087    #[error("OAuth authorization handoff was already attempted")]
1088    AlreadyAttempted,
1089    /// The generated one-line URI did not satisfy the fixed sink bound.
1090    #[error("OAuth authorization handoff URI is invalid")]
1091    InvalidUrl,
1092    /// The private writer could not complete and flush the bounded handoff.
1093    #[error("OAuth authorization handoff could not be written")]
1094    WriteFailed,
1095}
1096
1097/// Move-only, explicitly selected private authorization-URI output.
1098///
1099/// The caller owns destination creation, permissions and cleanup. The runtime
1100/// receives no pathname or filesystem authority. This is a sensitive operator
1101/// handoff, never an assessment report or public diagnostic. A successful write
1102/// does not establish that a user agent opened it or authenticated a principal.
1103pub struct SuppliedSessionOAuthAuthorization {
1104    writer: Box<dyn std::io::Write + Send + Sync>,
1105    policy_reference: String,
1106    authorization_prefix: String,
1107    attempted: bool,
1108}
1109
1110impl SuppliedSessionOAuthAuthorization {
1111    /// Binds one private writer to a validated V7 policy without reading secrets.
1112    pub fn new(
1113        policy: &SuppliedSessionPolicy,
1114        writer: impl std::io::Write + Send + Sync + 'static,
1115    ) -> Result<Self, OAuthAuthorizationInputError> {
1116        let authorization = policy
1117            .oauth_authorization_policy()
1118            .filter(|_| {
1119                policy.version() == SuppliedSessionPolicyVersion::V7
1120                    && policy.credential_acquisition()
1121                        == SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
1122                    && policy.oauth_refresh_policy().is_none()
1123            })
1124            .ok_or(OAuthAuthorizationInputError::PolicyMismatch)?;
1125        Ok(Self {
1126            writer: Box::new(writer),
1127            policy_reference: policy.policy_reference().to_owned(),
1128            authorization_prefix: format!("{}?", authorization.authorization_endpoint()),
1129            attempted: false,
1130        })
1131    }
1132
1133    pub(crate) fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
1134        !self.attempted
1135            && policy.version() == SuppliedSessionPolicyVersion::V7
1136            && policy.credential_acquisition()
1137                == SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
1138            && self.policy_reference == policy.policy_reference()
1139            && policy
1140                .oauth_authorization_policy()
1141                .is_some_and(|authorization| {
1142                    self.authorization_prefix
1143                        == format!("{}?", authorization.authorization_endpoint())
1144                })
1145    }
1146
1147    /// Emits one bounded generated URI plus a newline. Mark the attempt before
1148    /// validation or I/O so partial/error paths cannot silently retry the handoff.
1149    pub(crate) fn write_authorization_url(
1150        &mut self,
1151        url: &str,
1152    ) -> Result<(), OAuthAuthorizationInputError> {
1153        if std::mem::replace(&mut self.attempted, true) {
1154            return Err(OAuthAuthorizationInputError::AlreadyAttempted);
1155        }
1156        if url.len() > 4_096
1157            || !url.starts_with(&self.authorization_prefix)
1158            || url.len() == self.authorization_prefix.len()
1159            || !url.bytes().all(|byte| matches!(byte, 0x21..=0x7e))
1160        {
1161            return Err(OAuthAuthorizationInputError::InvalidUrl);
1162        }
1163        let mut output = Zeroizing::new(Vec::with_capacity(url.len() + 1));
1164        output.extend_from_slice(url.as_bytes());
1165        output.push(b'\n');
1166        self.writer
1167            .write_all(&output)
1168            .and_then(|()| self.writer.flush())
1169            .map_err(|_| OAuthAuthorizationInputError::WriteFailed)
1170    }
1171}
1172
1173impl fmt::Debug for SuppliedSessionOAuthAuthorization {
1174    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1175        formatter.write_str("SuppliedSessionOAuthAuthorization(<redacted>)")
1176    }
1177}
1178
1179/// Move-only username/password pair for one bounded form-login attempt.
1180pub struct SuppliedSessionFormCredential {
1181    backing: Zeroizing<Vec<u8>>,
1182    username: Range<usize>,
1183    password: Range<usize>,
1184    policy_reference: String,
1185    principal_alias: String,
1186}
1187
1188impl SuppliedSessionFormCredential {
1189    /// Parses exactly `username<TAB>value` and `password<TAB>value` rows.
1190    pub fn parse_tsv(
1191        policy: &SuppliedSessionPolicy,
1192        source: impl Into<Vec<u8>>,
1193    ) -> Result<Self, SuppliedSessionFormCredentialError> {
1194        if policy.version != SuppliedSessionPolicyVersion::V3
1195            || policy.credential_acquisition
1196                != SuppliedSessionCredentialAcquisition::BoundedFormLogin
1197        {
1198            return Err(SuppliedSessionFormCredentialError::PolicyMismatch);
1199        }
1200        let backing = Zeroizing::new(source.into());
1201        if backing.is_empty() || backing.starts_with(&[0xef, 0xbb, 0xbf]) {
1202            return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1203        }
1204        if backing.len() > HARD_MAX_SUPPLIED_SESSION_FORM_SECRET_BYTES {
1205            return Err(SuppliedSessionFormCredentialError::SecretTooLarge);
1206        }
1207        let mut username = None;
1208        let mut password = None;
1209        let mut cursor = 0_usize;
1210        while cursor < backing.len() {
1211            let physical_end = backing[cursor..]
1212                .iter()
1213                .position(|byte| *byte == b'\n')
1214                .map_or(backing.len(), |offset| cursor + offset);
1215            let mut line_end = physical_end;
1216            if line_end > cursor && backing[line_end - 1] == b'\r' {
1217                line_end -= 1;
1218            }
1219            if line_end == cursor {
1220                return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1221            }
1222            let line = &backing[cursor..line_end];
1223            let Some(tab_offset) = line.iter().position(|byte| *byte == b'\t') else {
1224                return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1225            };
1226            if line[tab_offset + 1..].contains(&b'\t') {
1227                return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1228            }
1229            let label = &line[..tab_offset];
1230            let value_start = cursor
1231                .checked_add(tab_offset)
1232                .and_then(|value| value.checked_add(1))
1233                .ok_or(SuppliedSessionFormCredentialError::MalformedSecret)?;
1234            let value = &backing[value_start..line_end];
1235            let maximum = match label {
1236                b"username" if username.is_none() => MAX_LOGIN_USERNAME_BYTES,
1237                b"password" if password.is_none() => MAX_LOGIN_PASSWORD_BYTES,
1238                _ => return Err(SuppliedSessionFormCredentialError::MalformedSecret),
1239            };
1240            if value.is_empty()
1241                || value.len() > maximum
1242                || value
1243                    .iter()
1244                    .any(|byte| matches!(*byte, b'\0' | b'\r' | b'\n' | b'\t'))
1245                || std::str::from_utf8(value).is_err()
1246            {
1247                return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1248            }
1249            let range = value_start..line_end;
1250            match label {
1251                b"username" => username = Some(range),
1252                b"password" => password = Some(range),
1253                _ => unreachable!("the exact labels were matched above"),
1254            }
1255            cursor = if physical_end == backing.len() {
1256                backing.len()
1257            } else {
1258                physical_end + 1
1259            };
1260        }
1261        let (Some(username), Some(password)) = (username, password) else {
1262            return Err(SuppliedSessionFormCredentialError::MalformedSecret);
1263        };
1264        Ok(Self {
1265            backing,
1266            username,
1267            password,
1268            policy_reference: policy.policy_reference.clone(),
1269            principal_alias: policy.principal_alias.clone(),
1270        })
1271    }
1272
1273    fn value(&self, range: Range<usize>) -> &str {
1274        std::str::from_utf8(&self.backing[range])
1275            .expect("form credential UTF-8 was validated at construction")
1276    }
1277
1278    pub(crate) fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
1279        policy.version == SuppliedSessionPolicyVersion::V3
1280            && policy.credential_acquisition
1281                == SuppliedSessionCredentialAcquisition::BoundedFormLogin
1282            && self.policy_reference == policy.policy_reference
1283            && self.principal_alias == policy.principal_alias
1284    }
1285
1286    pub(crate) fn compose_form_body(
1287        &self,
1288        policy: &SuppliedSessionPolicy,
1289        csrf: &str,
1290    ) -> Result<SuppliedSessionFormBody, SuppliedSessionFormCredentialError> {
1291        if !self.matches_policy(policy)
1292            || csrf.is_empty()
1293            || csrf.len() > MAX_LOGIN_CSRF_VALUE_BYTES
1294            || csrf
1295                .bytes()
1296                .any(|byte| matches!(byte, b'\0' | b'\r' | b'\n'))
1297        {
1298            return Err(SuppliedSessionFormCredentialError::InvalidCsrfToken);
1299        }
1300        let (Some(csrf_field), Some(username_field), Some(password_field)) = (
1301            policy.login_csrf_field(),
1302            policy.login_username_field(),
1303            policy.login_password_field(),
1304        ) else {
1305            return Err(SuppliedSessionFormCredentialError::PolicyMismatch);
1306        };
1307        let mut serializer = url::form_urlencoded::Serializer::new(String::new());
1308        serializer.append_pair(csrf_field, csrf);
1309        serializer.append_pair(username_field, self.value(self.username.clone()));
1310        serializer.append_pair(password_field, self.value(self.password.clone()));
1311        let body = serializer.finish();
1312        if body.len() > MAX_LOGIN_FORM_BODY_BYTES {
1313            return Err(SuppliedSessionFormCredentialError::SecretTooLarge);
1314        }
1315        Ok(SuppliedSessionFormBody {
1316            value: Zeroizing::new(body),
1317        })
1318    }
1319}
1320
1321impl fmt::Debug for SuppliedSessionFormCredential {
1322    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1323        formatter.write_str("SuppliedSessionFormCredential(<redacted>)")
1324    }
1325}
1326
1327/// Move-only encoded form body exposed only to the broker boundary.
1328pub(crate) struct SuppliedSessionFormBody {
1329    value: Zeroizing<String>,
1330}
1331
1332impl SuppliedSessionFormBody {
1333    pub(crate) fn as_bytes(&self) -> &[u8] {
1334        self.value.as_bytes()
1335    }
1336}
1337
1338impl fmt::Debug for SuppliedSessionFormBody {
1339    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1340        formatter.write_str("SuppliedSessionFormBody(<redacted>)")
1341    }
1342}
1343
1344struct SuppliedSessionLoginPolicy {
1345    url: Url,
1346    username_field: String,
1347    password_field: String,
1348    csrf_field: String,
1349    max_attempts: u8,
1350}
1351
1352struct SuppliedSessionRenewalPolicy {
1353    url: Url,
1354    after_resource_count: u8,
1355}
1356
1357impl SuppliedSessionPolicy {
1358    /// Parses and resolves a strict policy beneath `application_url`.
1359    pub fn parse_toml(
1360        application_url: &Url,
1361        source: &[u8],
1362    ) -> Result<Self, SuppliedSessionPolicyError> {
1363        if source.len() > HARD_MAX_SUPPLIED_SESSION_POLICY_BYTES {
1364            return Err(SuppliedSessionPolicyError::PolicyTooLarge);
1365        }
1366        validate_application_url(application_url)?;
1367        let source =
1368            std::str::from_utf8(source).map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)?;
1369        let value: toml::Value =
1370            toml::from_str(source).map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)?;
1371        let schema = value
1372            .get("schema")
1373            .and_then(toml::Value::as_str)
1374            .ok_or(SuppliedSessionPolicyError::MalformedPolicy)?;
1375        match schema {
1376            SUPPLIED_SESSION_POLICY_SCHEMA => toml::from_str::<WirePolicy>(source)
1377                .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1378                .and_then(|wire| Self::from_v1_wire(application_url, wire)),
1379            SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA => toml::from_str::<WireCookiePolicy>(source)
1380                .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1381                .and_then(|wire| Self::from_v2_wire(application_url, wire)),
1382            SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA => {
1383                toml::from_str::<WireFormLoginPolicy>(source)
1384                    .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1385                    .and_then(|wire| Self::from_v3_wire(application_url, wire))
1386            },
1387            SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA => toml::from_str::<WireRenewalPolicy>(source)
1388                .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1389                .and_then(|wire| Self::from_v4_wire(application_url, wire)),
1390            SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA => {
1391                toml::from_str::<WireOAuthCodePolicy>(source)
1392                    .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1393                    .and_then(|wire| Self::from_v5_wire(application_url, wire))
1394            },
1395            SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA => {
1396                toml::from_str::<WireOAuthRefreshPolicy>(source)
1397                    .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1398                    .and_then(|wire| Self::from_v6_wire(application_url, wire))
1399            },
1400            SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA => {
1401                toml::from_str::<WireOAuthAuthorizationPolicy>(source)
1402                    .map_err(|_| SuppliedSessionPolicyError::MalformedPolicy)
1403                    .and_then(|wire| Self::from_v7_wire(application_url, wire))
1404            },
1405            _ => Err(SuppliedSessionPolicyError::UnsupportedSchema),
1406        }
1407    }
1408
1409    fn from_v1_wire(
1410        application_url: &Url,
1411        wire: WirePolicy,
1412    ) -> Result<Self, SuppliedSessionPolicyError> {
1413        if wire.schema != SUPPLIED_SESSION_POLICY_SCHEMA {
1414            return Err(SuppliedSessionPolicyError::UnsupportedSchema);
1415        }
1416        let credential_mechanism = match wire.credential_mechanism.as_str() {
1417            "authorization_header" => SuppliedSessionCredentialMechanism::AuthorizationHeader,
1418            _ => return Err(SuppliedSessionPolicyError::UnsupportedCredentialMechanism),
1419        };
1420        validate_token(&wire.principal_alias, MAX_PRINCIPAL_ALIAS_BYTES)
1421            .map_err(|_| SuppliedSessionPolicyError::InvalidPrincipalAlias)?;
1422        validate_token(&wire.health_json_field, MAX_HEALTH_FIELD_BYTES)
1423            .map_err(|_| SuppliedSessionPolicyError::InvalidHealthField)?;
1424        if wire.resources.is_empty() || wire.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES {
1425            return Err(SuppliedSessionPolicyError::InvalidResourceCount);
1426        }
1427        let minimum_requests = 1_usize
1428            .checked_add(
1429                wire.resources
1430                    .len()
1431                    .checked_mul(2)
1432                    .ok_or(SuppliedSessionPolicyError::InvalidLimits)?,
1433            )
1434            .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
1435        if wire.max_session_requests == 0
1436            || usize::from(wire.max_session_requests) < minimum_requests
1437            || wire.max_session_requests > MAX_SUPPLIED_SESSION_REQUESTS
1438            || wire.max_total_response_bytes == 0
1439            || wire.max_total_response_bytes > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
1440            || wire.max_response_body_bytes == 0
1441            || wire.max_response_body_bytes > MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES
1442            || u64::try_from(wire.max_response_body_bytes).unwrap_or(u64::MAX)
1443                > wire.max_total_response_bytes
1444            || wire.max_wall_time_ms == 0
1445            || wire.max_wall_time_ms > MAX_SUPPLIED_SESSION_WALL_TIME_MS
1446        {
1447            return Err(SuppliedSessionPolicyError::InvalidLimits);
1448        }
1449
1450        let health_url = resolve_safe_path(application_url, &wire.health_path)?;
1451        let mut seen = std::collections::BTreeSet::new();
1452        let mut resources = Vec::with_capacity(wire.resources.len());
1453        for raw in &wire.resources {
1454            let url = resolve_safe_path(application_url, raw)?;
1455            if url == health_url || !seen.insert(url.as_str().to_owned()) {
1456                return Err(SuppliedSessionPolicyError::InvalidPath);
1457            }
1458            resources.push(SuppliedSessionResource {
1459                reference: reference(
1460                    RESOURCE_REFERENCE_DOMAIN,
1461                    url.as_str(),
1462                    "supplied-session-resource-sha256",
1463                ),
1464                url,
1465            });
1466        }
1467        let policy_reference = policy_reference(application_url, &wire, credential_mechanism);
1468        Ok(Self {
1469            version: SuppliedSessionPolicyVersion::V1,
1470            application_url: application_url.clone(),
1471            principal_alias: wire.principal_alias,
1472            health_url,
1473            health_json_field: wire.health_json_field.clone(),
1474            resources,
1475            credential_mechanism,
1476            credential_acquisition: SuppliedSessionCredentialAcquisition::SuppliedAuthorization,
1477            cookie_update_policy: None,
1478            cookies: Vec::new(),
1479            login: None,
1480            renewal: None,
1481            oauth_code: None,
1482            oauth_refresh: None,
1483            oauth_authorization: None,
1484            policy_reference,
1485            application_reference: reference(
1486                APPLICATION_REFERENCE_DOMAIN,
1487                application_url.as_str(),
1488                "supplied-session-application-sha256",
1489            ),
1490            // V1 has exactly one run-local principal slot. This deliberately
1491            // does not publish an unkeyed digest of the operator's potentially
1492            // low-entropy alias (and never derives identity from credentials).
1493            principal_reference: SUPPLIED_SESSION_PRINCIPAL_REFERENCE.to_owned(),
1494            health_field_reference: reference(
1495                HEALTH_FIELD_REFERENCE_DOMAIN,
1496                &wire.health_json_field,
1497                "supplied-session-health-field-sha256",
1498            ),
1499            max_session_requests: wire.max_session_requests,
1500            max_total_response_bytes: wire.max_total_response_bytes,
1501            max_response_body_bytes: wire.max_response_body_bytes,
1502            max_wall_time_ms: wire.max_wall_time_ms,
1503        })
1504    }
1505
1506    fn from_v2_wire(
1507        application_url: &Url,
1508        wire: WireCookiePolicy,
1509    ) -> Result<Self, SuppliedSessionPolicyError> {
1510        if wire.schema != SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA {
1511            return Err(SuppliedSessionPolicyError::UnsupportedSchema);
1512        }
1513        let credential_mechanism = match wire.credential_mechanism.as_str() {
1514            "cookie_jar" => SuppliedSessionCredentialMechanism::CookieJar,
1515            _ => return Err(SuppliedSessionPolicyError::UnsupportedCredentialMechanism),
1516        };
1517        let cookie_update_policy = match wire.cookie_update_policy.as_str() {
1518            "stop_on_selected_cookie" => SuppliedSessionCookieUpdatePolicy::StopOnSelectedCookie,
1519            _ => return Err(SuppliedSessionPolicyError::UnsupportedCookieUpdatePolicy),
1520        };
1521        validate_token(&wire.principal_alias, MAX_PRINCIPAL_ALIAS_BYTES)
1522            .map_err(|_| SuppliedSessionPolicyError::InvalidPrincipalAlias)?;
1523        validate_token(&wire.health_json_field, MAX_HEALTH_FIELD_BYTES)
1524            .map_err(|_| SuppliedSessionPolicyError::InvalidHealthField)?;
1525        if wire.resources.is_empty() || wire.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES {
1526            return Err(SuppliedSessionPolicyError::InvalidResourceCount);
1527        }
1528        validate_policy_limits(
1529            wire.resources.len(),
1530            wire.max_session_requests,
1531            wire.max_total_response_bytes,
1532            wire.max_response_body_bytes,
1533            wire.max_wall_time_ms,
1534        )?;
1535
1536        let health_url = resolve_safe_path(application_url, &wire.health_path)?;
1537        let mut seen_resources = std::collections::BTreeSet::new();
1538        let mut resources = Vec::with_capacity(wire.resources.len());
1539        for raw in &wire.resources {
1540            let url = resolve_safe_path(application_url, raw)?;
1541            if url == health_url || !seen_resources.insert(url.as_str().to_owned()) {
1542                return Err(SuppliedSessionPolicyError::InvalidPath);
1543            }
1544            resources.push(SuppliedSessionResource {
1545                reference: reference(
1546                    RESOURCE_REFERENCE_DOMAIN,
1547                    url.as_str(),
1548                    "supplied-session-resource-sha256",
1549                ),
1550                url,
1551            });
1552        }
1553        if wire.cookies.is_empty() || wire.cookies.len() > MAX_SUPPLIED_SESSION_COOKIES {
1554            return Err(SuppliedSessionPolicyError::InvalidCookieCount);
1555        }
1556        let canonical_host = application_url
1557            .host_str()
1558            .ok_or(SuppliedSessionPolicyError::InvalidApplication)?;
1559        let application_host_is_domain = matches!(application_url.host(), Some(Host::Domain(_)));
1560        let mut seen_ids = std::collections::BTreeSet::new();
1561        let mut seen_tuples = std::collections::BTreeSet::new();
1562        let mut cookies = Vec::with_capacity(wire.cookies.len());
1563        for cookie in &wire.cookies {
1564            validate_token(&cookie.id, MAX_COOKIE_ID_BYTES)
1565                .map_err(|_| SuppliedSessionPolicyError::InvalidCookieMetadata)?;
1566            if cookie.name.is_empty()
1567                || cookie.name.len() > MAX_COOKIE_NAME_BYTES
1568                || !cookie.name.bytes().all(http_token_byte)
1569                || cookie.name.starts_with('$')
1570                || cookie.domain != canonical_host
1571                || (!cookie.host_only && !application_host_is_domain)
1572                || (cookie.secure && application_url.scheme() != "https")
1573            {
1574                return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1575            }
1576            validate_cookie_path(&cookie.path)?;
1577            let same_site = parse_cookie_same_site(&cookie.same_site)?;
1578            if same_site == SuppliedSessionCookieSameSite::None && !cookie.secure {
1579                return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1580            }
1581            if !cookie_path_matches(&cookie.path, health_url.path())
1582                && !resources
1583                    .iter()
1584                    .any(|resource| cookie_path_matches(&cookie.path, resource.url.path()))
1585            {
1586                return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1587            }
1588            if !seen_ids.insert(cookie.id.clone())
1589                || !seen_tuples.insert((
1590                    cookie.name.clone(),
1591                    cookie.domain.clone(),
1592                    cookie.path.clone(),
1593                ))
1594            {
1595                return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1596            }
1597            cookies.push(SuppliedSessionCookieMetadata {
1598                id: cookie.id.clone(),
1599                name: cookie.name.clone(),
1600                domain: cookie.domain.clone(),
1601                host_only: cookie.host_only,
1602                path: cookie.path.clone(),
1603                secure: cookie.secure,
1604                http_only: cookie.http_only,
1605                same_site,
1606                expires_unix_seconds: cookie.expires_unix_seconds,
1607            });
1608        }
1609
1610        let policy_reference = cookie_policy_reference(
1611            application_url,
1612            &wire,
1613            credential_mechanism,
1614            cookie_update_policy,
1615        );
1616        Ok(Self {
1617            version: SuppliedSessionPolicyVersion::V2,
1618            application_url: application_url.clone(),
1619            principal_alias: wire.principal_alias,
1620            health_url,
1621            health_json_field: wire.health_json_field.clone(),
1622            resources,
1623            credential_mechanism,
1624            credential_acquisition: SuppliedSessionCredentialAcquisition::SuppliedCookieJar,
1625            cookie_update_policy: Some(cookie_update_policy),
1626            cookies,
1627            login: None,
1628            renewal: None,
1629            oauth_code: None,
1630            oauth_refresh: None,
1631            oauth_authorization: None,
1632            policy_reference,
1633            application_reference: reference(
1634                APPLICATION_REFERENCE_DOMAIN,
1635                application_url.as_str(),
1636                "supplied-session-application-sha256",
1637            ),
1638            principal_reference: SUPPLIED_SESSION_PRINCIPAL_REFERENCE.to_owned(),
1639            health_field_reference: reference(
1640                HEALTH_FIELD_REFERENCE_DOMAIN,
1641                &wire.health_json_field,
1642                "supplied-session-health-field-sha256",
1643            ),
1644            max_session_requests: wire.max_session_requests,
1645            max_total_response_bytes: wire.max_total_response_bytes,
1646            max_response_body_bytes: wire.max_response_body_bytes,
1647            max_wall_time_ms: wire.max_wall_time_ms,
1648        })
1649    }
1650
1651    fn from_v3_wire(
1652        application_url: &Url,
1653        wire: WireFormLoginPolicy,
1654    ) -> Result<Self, SuppliedSessionPolicyError> {
1655        if wire.schema != SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA
1656            || wire.credential_mechanism != "cookie_jar"
1657            || wire.credential_acquisition != "bounded_form_login"
1658            || wire.max_login_attempts != 1
1659        {
1660            return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1661        }
1662        let cookie_update_policy = match wire.cookie_update_policy.as_str() {
1663            "stop_on_selected_cookie" => SuppliedSessionCookieUpdatePolicy::StopOnSelectedCookie,
1664            _ => return Err(SuppliedSessionPolicyError::UnsupportedCookieUpdatePolicy),
1665        };
1666        validate_token(&wire.principal_alias, MAX_PRINCIPAL_ALIAS_BYTES)
1667            .map_err(|_| SuppliedSessionPolicyError::InvalidPrincipalAlias)?;
1668        validate_token(&wire.health_json_field, MAX_HEALTH_FIELD_BYTES)
1669            .map_err(|_| SuppliedSessionPolicyError::InvalidHealthField)?;
1670        for field in [&wire.username_field, &wire.password_field, &wire.csrf_field] {
1671            validate_html_field_name(field)?;
1672        }
1673        if wire.username_field == wire.password_field
1674            || wire.username_field == wire.csrf_field
1675            || wire.password_field == wire.csrf_field
1676            || wire.resources.is_empty()
1677            || wire.resources.len() > MAX_SUPPLIED_SESSION_RESOURCES.saturating_sub(1)
1678        {
1679            return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1680        }
1681        validate_form_login_policy_limits(
1682            wire.resources.len(),
1683            wire.max_session_requests,
1684            wire.max_total_response_bytes,
1685            wire.max_response_body_bytes,
1686            wire.max_wall_time_ms,
1687        )?;
1688        if wire.login_method != "post" || wire.login_encoding != "application/x-www-form-urlencoded"
1689        {
1690            return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1691        }
1692        let login_url = resolve_login_path(application_url, &wire.login_path)?;
1693        let health_url = resolve_safe_path(application_url, &wire.health_path)?;
1694        let mut seen_resources = std::collections::BTreeSet::new();
1695        let mut resources = Vec::with_capacity(wire.resources.len());
1696        for raw in &wire.resources {
1697            let url = resolve_safe_path(application_url, raw)?;
1698            if url == health_url
1699                || url == login_url
1700                || !seen_resources.insert(url.as_str().to_owned())
1701            {
1702                return Err(SuppliedSessionPolicyError::InvalidPath);
1703            }
1704            resources.push(SuppliedSessionResource {
1705                reference: reference(
1706                    RESOURCE_REFERENCE_DOMAIN,
1707                    url.as_str(),
1708                    "supplied-session-resource-sha256",
1709                ),
1710                url,
1711            });
1712        }
1713        let cookies = validate_v3_cookie_metadata(application_url, &health_url, &resources, &wire)?;
1714        let policy_reference =
1715            form_login_policy_reference(application_url, &wire, cookie_update_policy);
1716        Ok(Self {
1717            version: SuppliedSessionPolicyVersion::V3,
1718            application_url: application_url.clone(),
1719            principal_alias: wire.principal_alias,
1720            health_url,
1721            health_json_field: wire.health_json_field.clone(),
1722            resources,
1723            credential_mechanism: SuppliedSessionCredentialMechanism::CookieJar,
1724            credential_acquisition: SuppliedSessionCredentialAcquisition::BoundedFormLogin,
1725            cookie_update_policy: Some(cookie_update_policy),
1726            cookies,
1727            login: Some(SuppliedSessionLoginPolicy {
1728                url: login_url,
1729                username_field: wire.username_field,
1730                password_field: wire.password_field,
1731                csrf_field: wire.csrf_field,
1732                max_attempts: wire.max_login_attempts,
1733            }),
1734            renewal: None,
1735            oauth_code: None,
1736            oauth_refresh: None,
1737            oauth_authorization: None,
1738            policy_reference,
1739            application_reference: reference(
1740                APPLICATION_REFERENCE_DOMAIN,
1741                application_url.as_str(),
1742                "supplied-session-application-sha256",
1743            ),
1744            principal_reference: SUPPLIED_SESSION_PRINCIPAL_REFERENCE.to_owned(),
1745            health_field_reference: reference(
1746                HEALTH_FIELD_REFERENCE_DOMAIN,
1747                &wire.health_json_field,
1748                "supplied-session-health-field-sha256",
1749            ),
1750            max_session_requests: wire.max_session_requests,
1751            max_total_response_bytes: wire.max_total_response_bytes,
1752            max_response_body_bytes: wire.max_response_body_bytes,
1753            max_wall_time_ms: wire.max_wall_time_ms,
1754        })
1755    }
1756
1757    fn from_v4_wire(
1758        application_url: &Url,
1759        wire: WireRenewalPolicy,
1760    ) -> Result<Self, SuppliedSessionPolicyError> {
1761        if wire.schema != SUPPLIED_SESSION_RENEWAL_POLICY_SCHEMA
1762            || wire.renewal_method != "post"
1763            || wire.max_renewal_attempts != 1
1764            || wire.renewal_after_resource_count == 0
1765            || usize::from(wire.renewal_after_resource_count) >= wire.resources.len()
1766            || wire.resources.len() > 3
1767            || usize::from(wire.max_session_requests) != 3 + 2 * wire.resources.len()
1768        {
1769            return Err(SuppliedSessionPolicyError::InvalidFormLogin);
1770        }
1771        let renewal_url = resolve_safe_path(application_url, &wire.renewal_path)?;
1772        let after_resource_count = wire.renewal_after_resource_count;
1773        let mut policy = Self::from_v2_wire(
1774            application_url,
1775            WireCookiePolicy {
1776                schema: SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA.to_owned(),
1777                principal_alias: wire.principal_alias,
1778                credential_mechanism: wire.credential_mechanism,
1779                cookie_update_policy: wire.cookie_update_policy,
1780                health_path: wire.health_path,
1781                health_json_field: wire.health_json_field,
1782                resources: wire.resources,
1783                cookies: wire.cookies,
1784                max_session_requests: wire.max_session_requests,
1785                max_total_response_bytes: wire.max_total_response_bytes,
1786                max_response_body_bytes: wire.max_response_body_bytes,
1787                max_wall_time_ms: wire.max_wall_time_ms,
1788            },
1789        )?;
1790        if policy.admits_execution_target(&renewal_url)
1791            || policy.cookies.iter().any(|cookie| {
1792                !cookie.host_only
1793                    || cookie.expires_unix_seconds.is_some()
1794                    || cookie.secure != (application_url.scheme() == "https")
1795                    || !cookie.http_only
1796                    || !matches!(
1797                        cookie.same_site,
1798                        SuppliedSessionCookieSameSite::Strict | SuppliedSessionCookieSameSite::Lax
1799                    )
1800                    || !cookie_path_matches(&cookie.path, renewal_url.path())
1801                    || !policy
1802                        .execution_targets()
1803                        .all(|target| cookie_path_matches(&cookie.path, target.path()))
1804            })
1805        {
1806            return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
1807        }
1808        let mut digest = Sha256::new();
1809        digest.update(b"security.supplied-session-policy.reference.v4\0");
1810        hash_field(&mut digest, policy.policy_reference.as_bytes());
1811        hash_field(&mut digest, renewal_url.as_str().as_bytes());
1812        hash_field(&mut digest, &[after_resource_count]);
1813        hash_field(&mut digest, b"post;max_attempts=1");
1814        policy.policy_reference =
1815            format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
1816        policy.version = SuppliedSessionPolicyVersion::V4;
1817        policy.renewal = Some(SuppliedSessionRenewalPolicy {
1818            url: renewal_url,
1819            after_resource_count,
1820        });
1821        Ok(policy)
1822    }
1823
1824    fn from_v5_wire(
1825        application_url: &Url,
1826        wire: WireOAuthCodePolicy,
1827    ) -> Result<Self, SuppliedSessionPolicyError> {
1828        let minimum_requests = wire
1829            .resources
1830            .len()
1831            .checked_mul(2)
1832            .and_then(|count| count.checked_add(2))
1833            .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
1834        if wire.schema != SUPPLIED_SESSION_OAUTH_CODE_POLICY_SCHEMA
1835            || wire.credential_acquisition != "imported_oauth_code_pkce"
1836            || wire.resources.is_empty()
1837            || wire.resources.len() > 3
1838            || usize::from(wire.max_session_requests) != minimum_requests
1839        {
1840            return Err(SuppliedSessionPolicyError::InvalidOAuthCode);
1841        }
1842        let oauth_code = oauth_code::OAuthCodePolicy::from_wire(wire.oauth_code)?;
1843        if !oauth_code.is_valid_for_application(application_url)
1844            || oauth_code.max_token_response_bytes() > wire.max_response_body_bytes
1845            || u64::try_from(oauth_code.max_token_response_bytes()).unwrap_or(u64::MAX)
1846                > wire.max_total_response_bytes
1847        {
1848            return Err(SuppliedSessionPolicyError::InvalidOAuthCode);
1849        }
1850        // Reuse the historical target-scope, health, resource and parent-limit
1851        // validators, not an IdP URL passed through target-origin authority.
1852        let mut policy = Self::from_v1_wire(
1853            application_url,
1854            WirePolicy {
1855                schema: SUPPLIED_SESSION_POLICY_SCHEMA.to_owned(),
1856                principal_alias: wire.principal_alias,
1857                credential_mechanism: wire.credential_mechanism,
1858                health_path: wire.health_path,
1859                health_json_field: wire.health_json_field,
1860                resources: wire.resources,
1861                max_session_requests: wire.max_session_requests,
1862                max_total_response_bytes: wire.max_total_response_bytes,
1863                max_response_body_bytes: wire.max_response_body_bytes,
1864                max_wall_time_ms: wire.max_wall_time_ms,
1865            },
1866        )?;
1867        let mut digest = Sha256::new();
1868        digest.update(b"security.supplied-session-policy.reference.v5\0");
1869        hash_field(&mut digest, policy.policy_reference.as_bytes());
1870        hash_field(&mut digest, oauth_code.binding_reference().as_bytes());
1871        hash_field(
1872            &mut digest,
1873            b"imported_oauth_code_pkce;one_exchange;external_context_asserted",
1874        );
1875        policy.policy_reference =
1876            format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
1877        policy.version = SuppliedSessionPolicyVersion::V5;
1878        policy.credential_acquisition = SuppliedSessionCredentialAcquisition::ImportedOAuthCodePkce;
1879        policy.oauth_code = Some(Box::new(oauth_code));
1880        Ok(policy)
1881    }
1882
1883    fn from_v6_wire(
1884        application_url: &Url,
1885        wire: WireOAuthRefreshPolicy,
1886    ) -> Result<Self, SuppliedSessionPolicyError> {
1887        // Code + startup + two resource/checkpoint pairs + refresh + renewed
1888        // health = eight. Three resources would exceed the unchanged cap of nine.
1889        if wire.schema != SUPPLIED_SESSION_OAUTH_REFRESH_POLICY_SCHEMA
1890            || wire.credential_acquisition != "imported_oauth_code_pkce"
1891            || wire.resources.len() != 2
1892            || wire.max_session_requests != 8
1893        {
1894            return Err(SuppliedSessionPolicyError::InvalidOAuthRefresh);
1895        }
1896        let oauth_code = oauth_code::OAuthCodePolicy::from_wire(wire.oauth_code)?;
1897        let oauth_refresh = oauth_refresh::OAuthRefreshPolicy::from_wire(wire.oauth_refresh)?;
1898        if !oauth_code.is_valid_for_application(application_url)
1899            || oauth_code.max_token_response_bytes() > wire.max_response_body_bytes
1900            || u64::try_from(oauth_code.max_token_response_bytes()).unwrap_or(u64::MAX)
1901                > wire.max_total_response_bytes
1902            || oauth_refresh.after_resource_count() != 1
1903            || oauth_refresh.max_attempts() != 1
1904        {
1905            return Err(SuppliedSessionPolicyError::InvalidOAuthRefresh);
1906        }
1907        // Reuse the existing application, read-oriented path, health and bound
1908        // validators. This does not grant the target pool any IdP authority.
1909        let mut policy = Self::from_v1_wire(
1910            application_url,
1911            WirePolicy {
1912                schema: SUPPLIED_SESSION_POLICY_SCHEMA.to_owned(),
1913                principal_alias: wire.principal_alias,
1914                credential_mechanism: wire.credential_mechanism,
1915                health_path: wire.health_path,
1916                health_json_field: wire.health_json_field,
1917                resources: wire.resources,
1918                max_session_requests: wire.max_session_requests,
1919                max_total_response_bytes: wire.max_total_response_bytes,
1920                max_response_body_bytes: wire.max_response_body_bytes,
1921                max_wall_time_ms: wire.max_wall_time_ms,
1922            },
1923        )?;
1924        let mut digest = Sha256::new();
1925        digest.update(b"security.supplied-session-policy.reference.v6\0");
1926        hash_field(&mut digest, policy.policy_reference.as_bytes());
1927        hash_field(&mut digest, oauth_code.binding_reference().as_bytes());
1928        hash_field(&mut digest, oauth_refresh.binding_reference().as_bytes());
1929        hash_field(
1930            &mut digest,
1931            b"external_code;one_scheduled_refresh;new_epoch_health",
1932        );
1933        policy.policy_reference =
1934            format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
1935        policy.version = SuppliedSessionPolicyVersion::V6;
1936        policy.credential_acquisition = SuppliedSessionCredentialAcquisition::ImportedOAuthCodePkce;
1937        policy.oauth_code = Some(Box::new(oauth_code));
1938        policy.oauth_refresh = Some(Box::new(oauth_refresh));
1939        Ok(policy)
1940    }
1941
1942    fn from_v7_wire(
1943        application_url: &Url,
1944        wire: WireOAuthAuthorizationPolicy,
1945    ) -> Result<Self, SuppliedSessionPolicyError> {
1946        let outbound_requests = wire
1947            .resources
1948            .len()
1949            .checked_mul(2)
1950            .and_then(|count| count.checked_add(2))
1951            .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
1952        // One separately reported callback-reception lease plus the code,
1953        // startup and resource/checkpoint schedule fit the existing nine-attempt
1954        // ceiling. The ordinary session window remains at most ten seconds;
1955        // the explicit authorization wait is a separate child of the SAME parent.
1956        if wire.schema != SUPPLIED_SESSION_OAUTH_AUTHORIZATION_POLICY_SCHEMA
1957            || wire.credential_acquisition != "client_initiated_oauth_pkce"
1958            || wire.resources.is_empty()
1959            || wire.resources.len() > 3
1960            || usize::from(wire.max_session_requests) != outbound_requests
1961            || outbound_requests
1962                .checked_add(1)
1963                .is_none_or(|count| count > usize::from(MAX_SUPPLIED_SESSION_REQUESTS))
1964        {
1965            return Err(SuppliedSessionPolicyError::InvalidOAuthAuthorization);
1966        }
1967        let oauth_code = oauth_code::OAuthCodePolicy::from_authorization_wire(wire.oauth_code)?;
1968        let oauth_authorization = oauth_authorization::OAuthAuthorizationPolicy::from_wire(
1969            wire.oauth_authorization,
1970            &oauth_code,
1971        )?;
1972        if !oauth_code.is_valid_for_application(application_url)
1973            || oauth_code.max_token_response_bytes() > wire.max_response_body_bytes
1974            || u64::try_from(oauth_code.max_token_response_bytes()).unwrap_or(u64::MAX)
1975                > wire.max_total_response_bytes
1976        {
1977            return Err(SuppliedSessionPolicyError::InvalidOAuthAuthorization);
1978        }
1979        let mut policy = Self::from_v1_wire(
1980            application_url,
1981            WirePolicy {
1982                schema: SUPPLIED_SESSION_POLICY_SCHEMA.to_owned(),
1983                principal_alias: wire.principal_alias,
1984                credential_mechanism: wire.credential_mechanism,
1985                health_path: wire.health_path,
1986                health_json_field: wire.health_json_field,
1987                resources: wire.resources,
1988                max_session_requests: wire.max_session_requests,
1989                max_total_response_bytes: wire.max_total_response_bytes,
1990                max_response_body_bytes: wire.max_response_body_bytes,
1991                max_wall_time_ms: wire.max_wall_time_ms,
1992            },
1993        )?;
1994        let mut digest = Sha256::new();
1995        digest.update(b"security.supplied-session-policy.reference.v7\0");
1996        hash_field(&mut digest, policy.policy_reference.as_bytes());
1997        hash_field(&mut digest, oauth_code.binding_reference().as_bytes());
1998        hash_field(
1999            &mut digest,
2000            oauth_authorization.binding_reference().as_bytes(),
2001        );
2002        hash_field(
2003            &mut digest,
2004            b"client_initiated_oauth_pkce;one_loopback_form_post;one_exchange;no_oidc",
2005        );
2006        policy.policy_reference =
2007            format!("supplied-session-policy-sha256:{}", hex(digest.finalize()));
2008        policy.version = SuppliedSessionPolicyVersion::V7;
2009        policy.credential_acquisition =
2010            SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce;
2011        policy.oauth_code = Some(Box::new(oauth_code));
2012        policy.oauth_authorization = Some(Box::new(oauth_authorization));
2013        Ok(policy)
2014    }
2015
2016    /// Policy schema generation.
2017    pub const fn version(&self) -> SuppliedSessionPolicyVersion {
2018        self.version
2019    }
2020
2021    /// Exact policy schema identifier.
2022    pub const fn schema(&self) -> &'static str {
2023        self.version.schema()
2024    }
2025
2026    /// Stable semantic policy reference; it contains no credential or raw URL.
2027    pub fn policy_reference(&self) -> &str {
2028        &self.policy_reference
2029    }
2030
2031    /// Stable exact-application reference; it contains no raw URL.
2032    pub fn application_reference(&self) -> &str {
2033        &self.application_reference
2034    }
2035
2036    /// Opaque run-local principal slot; it is not an authenticated or cross-run identity.
2037    pub fn principal_reference(&self) -> &str {
2038        &self.principal_reference
2039    }
2040
2041    /// Explicitly non-secret operator assertion saved for cross-run context distinction.
2042    ///
2043    /// This label is not authenticated identity truth and is never derived from
2044    /// the authorization value. Operators should use a non-sensitive stable alias.
2045    pub fn principal_alias(&self) -> &str {
2046        &self.principal_alias
2047    }
2048
2049    /// Stable configured-health-field reference.
2050    pub fn health_field_reference(&self) -> &str {
2051        &self.health_field_reference
2052    }
2053
2054    /// Selected credential mechanism.
2055    pub const fn credential_mechanism(&self) -> SuppliedSessionCredentialMechanism {
2056        self.credential_mechanism
2057    }
2058
2059    /// How the protected-request credential is established.
2060    pub const fn credential_acquisition(&self) -> SuppliedSessionCredentialAcquisition {
2061        self.credential_acquisition
2062    }
2063
2064    /// V2 response-cookie update behavior, absent for V1.
2065    pub const fn cookie_update_policy(&self) -> Option<SuppliedSessionCookieUpdatePolicy> {
2066        self.cookie_update_policy
2067    }
2068
2069    /// Non-secret aggregate cookie metadata, absent for V1.
2070    pub fn cookie_summary(&self) -> Option<SuppliedSessionCookieSummary> {
2071        (!self.cookies.is_empty()).then(|| summarize_cookies(&self.cookies))
2072    }
2073
2074    /// Number of selected protected resources.
2075    pub fn resource_count(&self) -> usize {
2076        self.resources.len()
2077    }
2078
2079    pub const fn max_session_requests(&self) -> u8 {
2080        self.max_session_requests
2081    }
2082
2083    pub const fn max_total_response_bytes(&self) -> u64 {
2084        self.max_total_response_bytes
2085    }
2086
2087    pub const fn max_response_body_bytes(&self) -> usize {
2088        self.max_response_body_bytes
2089    }
2090
2091    pub const fn max_wall_time_ms(&self) -> u64 {
2092        self.max_wall_time_ms
2093    }
2094
2095    pub(crate) const fn application_url(&self) -> &Url {
2096        &self.application_url
2097    }
2098
2099    pub(crate) fn oauth_code_policy(&self) -> Option<&oauth_code::OAuthCodePolicy> {
2100        self.oauth_code.as_deref()
2101    }
2102
2103    pub(crate) fn oauth_refresh_policy(&self) -> Option<&oauth_refresh::OAuthRefreshPolicy> {
2104        self.oauth_refresh.as_deref()
2105    }
2106
2107    pub(crate) fn oauth_authorization_policy(
2108        &self,
2109    ) -> Option<&oauth_authorization::OAuthAuthorizationPolicy> {
2110        self.oauth_authorization.as_deref()
2111    }
2112
2113    pub(crate) fn health_json_field(&self) -> &str {
2114        &self.health_json_field
2115    }
2116
2117    pub(crate) fn login_url(&self) -> Option<&Url> {
2118        self.login.as_ref().map(|login| &login.url)
2119    }
2120
2121    pub(crate) fn login_username_field(&self) -> Option<&str> {
2122        self.login
2123            .as_ref()
2124            .map(|login| login.username_field.as_str())
2125    }
2126
2127    pub(crate) fn login_password_field(&self) -> Option<&str> {
2128        self.login
2129            .as_ref()
2130            .map(|login| login.password_field.as_str())
2131    }
2132
2133    pub(crate) fn login_csrf_field(&self) -> Option<&str> {
2134        self.login.as_ref().map(|login| login.csrf_field.as_str())
2135    }
2136
2137    pub(crate) fn max_login_attempts(&self) -> Option<u8> {
2138        self.login.as_ref().map(|login| login.max_attempts)
2139    }
2140
2141    pub(crate) fn execution_resources(&self) -> impl ExactSizeIterator<Item = (&Url, &str)> {
2142        self.resources
2143            .iter()
2144            .map(|resource| (&resource.url, resource.reference.as_str()))
2145    }
2146
2147    fn execution_targets(&self) -> impl Iterator<Item = &Url> {
2148        std::iter::once(&self.health_url)
2149            .chain(self.resources.iter().map(|resource| &resource.url))
2150            .chain(self.renewal.iter().map(|renewal| &renewal.url))
2151    }
2152
2153    fn admits_execution_target(&self, target: &Url) -> bool {
2154        self.health_url == *target
2155            || self
2156                .renewal
2157                .as_ref()
2158                .is_some_and(|renewal| renewal.url == *target)
2159            || self
2160                .resources
2161                .iter()
2162                .any(|resource| resource.url == *target)
2163    }
2164
2165    pub(crate) fn is_selected_cookie_name(&self, name: &str) -> bool {
2166        self.cookies.iter().any(|cookie| cookie.name == name)
2167    }
2168
2169    pub(crate) fn renewal_after_resource_count(&self) -> Option<u8> {
2170        self.renewal
2171            .as_ref()
2172            .map(|renewal| renewal.after_resource_count)
2173    }
2174}
2175
2176impl fmt::Debug for SuppliedSessionPolicy {
2177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2178        formatter
2179            .debug_struct("SuppliedSessionPolicy")
2180            .field("application", &"<redacted>")
2181            .field("health", &"<redacted>")
2182            .field("health_json_field", &"<redacted>")
2183            .field("principal_alias", &"<operator-declared>")
2184            .field("version", &self.version)
2185            .field("resource_count", &self.resources.len())
2186            .field("credential_mechanism", &self.credential_mechanism)
2187            .field("credential_acquisition", &self.credential_acquisition)
2188            .field("cookie_update_policy", &self.cookie_update_policy)
2189            .field("cookie_count", &self.cookies.len())
2190            .field("login", &self.login.as_ref().map(|_| "<validated>"))
2191            .field(
2192                "oauth_code",
2193                &self.oauth_code.as_ref().map(|_| "<validated>"),
2194            )
2195            .field(
2196                "oauth_refresh",
2197                &self.oauth_refresh.as_ref().map(|_| "<validated>"),
2198            )
2199            .field(
2200                "oauth_authorization",
2201                &self.oauth_authorization.as_ref().map(|_| "<validated>"),
2202            )
2203            .field("policy_reference", &self.policy_reference)
2204            .field("application_reference", &self.application_reference)
2205            .field("principal_reference", &self.principal_reference)
2206            .field("max_session_requests", &self.max_session_requests)
2207            .field("max_total_response_bytes", &self.max_total_response_bytes)
2208            .field("max_response_body_bytes", &self.max_response_body_bytes)
2209            .field("max_wall_time_ms", &self.max_wall_time_ms)
2210            .finish()
2211    }
2212}
2213
2214/// Closed request purpose checked at both construction and broker dispatch.
2215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2216pub(crate) enum SuppliedSessionRequestPurpose {
2217    LoginPage,
2218    LoginSubmit,
2219    Renewal,
2220    Health,
2221    Resource,
2222}
2223
2224/// Private proof that one exact URL was admitted by a validated session policy.
2225///
2226/// Fields are private so an arbitrary same-origin URL cannot be substituted by
2227/// another runtime. The broker calls `validate` again immediately before
2228/// request construction and accounting.
2229#[derive(Clone, PartialEq, Eq)]
2230pub(crate) struct SuppliedSessionRequestDescriptor {
2231    application_url: Url,
2232    target: Url,
2233    target_reference: String,
2234    purpose: SuppliedSessionRequestPurpose,
2235    credential_mechanism: SuppliedSessionCredentialMechanism,
2236    policy_reference: String,
2237    purpose_binding: String,
2238}
2239
2240impl SuppliedSessionRequestDescriptor {
2241    pub(crate) fn renewal(policy: &SuppliedSessionPolicy) -> Option<Self> {
2242        let target = policy.renewal.as_ref()?.url.clone();
2243        let purpose = SuppliedSessionRequestPurpose::Renewal;
2244        let descriptor = Self {
2245            application_url: policy.application_url.clone(),
2246            target_reference: reference(
2247                RESOURCE_REFERENCE_DOMAIN,
2248                target.as_str(),
2249                "supplied-session-resource-sha256",
2250            ),
2251            purpose_binding: request_descriptor_reference(
2252                &policy.application_url,
2253                &target,
2254                purpose,
2255                policy.credential_mechanism,
2256                &policy.policy_reference,
2257            ),
2258            target,
2259            purpose,
2260            credential_mechanism: policy.credential_mechanism,
2261            policy_reference: policy.policy_reference.clone(),
2262        };
2263        descriptor.validate_against(policy).then_some(descriptor)
2264    }
2265    pub(crate) fn login_page(policy: &SuppliedSessionPolicy) -> Option<Self> {
2266        Self::login(policy, SuppliedSessionRequestPurpose::LoginPage)
2267    }
2268
2269    pub(crate) fn login_submit(policy: &SuppliedSessionPolicy) -> Option<Self> {
2270        Self::login(policy, SuppliedSessionRequestPurpose::LoginSubmit)
2271    }
2272
2273    fn login(
2274        policy: &SuppliedSessionPolicy,
2275        purpose: SuppliedSessionRequestPurpose,
2276    ) -> Option<Self> {
2277        let target = policy.login_url()?.clone();
2278        let target_reference = reference(
2279            LOGIN_REFERENCE_DOMAIN,
2280            target.as_str(),
2281            "supplied-session-login-sha256",
2282        );
2283        let descriptor = Self {
2284            application_url: policy.application_url.clone(),
2285            target: target.clone(),
2286            target_reference,
2287            purpose,
2288            credential_mechanism: policy.credential_mechanism,
2289            policy_reference: policy.policy_reference.clone(),
2290            purpose_binding: request_descriptor_reference(
2291                &policy.application_url,
2292                &target,
2293                purpose,
2294                policy.credential_mechanism,
2295                &policy.policy_reference,
2296            ),
2297        };
2298        descriptor.validate_against(policy).then_some(descriptor)
2299    }
2300
2301    pub(crate) fn health(policy: &SuppliedSessionPolicy) -> Self {
2302        let purpose = SuppliedSessionRequestPurpose::Health;
2303        let descriptor = Self {
2304            application_url: policy.application_url.clone(),
2305            target: policy.health_url.clone(),
2306            target_reference: reference(
2307                RESOURCE_REFERENCE_DOMAIN,
2308                policy.health_url.as_str(),
2309                "supplied-session-resource-sha256",
2310            ),
2311            purpose,
2312            credential_mechanism: policy.credential_mechanism,
2313            policy_reference: policy.policy_reference.clone(),
2314            purpose_binding: request_descriptor_reference(
2315                &policy.application_url,
2316                &policy.health_url,
2317                purpose,
2318                policy.credential_mechanism,
2319                &policy.policy_reference,
2320            ),
2321        };
2322        debug_assert!(descriptor.validate_against(policy));
2323        descriptor
2324    }
2325
2326    pub(crate) fn resource(
2327        policy: &SuppliedSessionPolicy,
2328        target: &Url,
2329        target_reference: &str,
2330    ) -> Option<Self> {
2331        if !policy
2332            .resources
2333            .iter()
2334            .any(|resource| resource.url == *target && resource.reference == target_reference)
2335        {
2336            return None;
2337        }
2338        let purpose = SuppliedSessionRequestPurpose::Resource;
2339        let descriptor = Self {
2340            application_url: policy.application_url.clone(),
2341            target: target.clone(),
2342            target_reference: target_reference.to_owned(),
2343            purpose,
2344            credential_mechanism: policy.credential_mechanism,
2345            policy_reference: policy.policy_reference.clone(),
2346            purpose_binding: request_descriptor_reference(
2347                &policy.application_url,
2348                target,
2349                purpose,
2350                policy.credential_mechanism,
2351                &policy.policy_reference,
2352            ),
2353        };
2354        descriptor.validate_against(policy).then_some(descriptor)
2355    }
2356
2357    pub(crate) fn validate(&self) -> bool {
2358        validate_application_url(&self.application_url).is_ok()
2359            && self.target.origin() == self.application_url.origin()
2360            && self.target.query().is_none()
2361            && self.target.fragment().is_none()
2362            && self.target.username().is_empty()
2363            && self.target.password().is_none()
2364            && path_within_application(&self.application_url, &self.target)
2365            && !self
2366                .target
2367                .path()
2368                .bytes()
2369                .any(|byte| matches!(byte, b'%' | b'\\'))
2370            && (matches!(
2371                self.purpose,
2372                SuppliedSessionRequestPurpose::LoginPage
2373                    | SuppliedSessionRequestPurpose::LoginSubmit
2374            ) || !self.target.path().split('/').any(action_or_admin_segment))
2375            && self.target_reference
2376                == if matches!(
2377                    self.purpose,
2378                    SuppliedSessionRequestPurpose::LoginPage
2379                        | SuppliedSessionRequestPurpose::LoginSubmit
2380                ) {
2381                    reference(
2382                        LOGIN_REFERENCE_DOMAIN,
2383                        self.target.as_str(),
2384                        "supplied-session-login-sha256",
2385                    )
2386                } else {
2387                    reference(
2388                        RESOURCE_REFERENCE_DOMAIN,
2389                        self.target.as_str(),
2390                        "supplied-session-resource-sha256",
2391                    )
2392                }
2393            && self.purpose_binding
2394                == request_descriptor_reference(
2395                    &self.application_url,
2396                    &self.target,
2397                    self.purpose,
2398                    self.credential_mechanism,
2399                    &self.policy_reference,
2400                )
2401    }
2402
2403    pub(crate) fn validate_against(&self, policy: &SuppliedSessionPolicy) -> bool {
2404        self.validate()
2405            && self.policy_reference == policy.policy_reference
2406            && self.application_url == policy.application_url
2407            && self.credential_mechanism == policy.credential_mechanism
2408            && match self.purpose {
2409                SuppliedSessionRequestPurpose::Renewal => {
2410                    policy.version == SuppliedSessionPolicyVersion::V4
2411                        && policy
2412                            .renewal
2413                            .as_ref()
2414                            .is_some_and(|renewal| renewal.url == self.target)
2415                },
2416                SuppliedSessionRequestPurpose::LoginPage
2417                | SuppliedSessionRequestPurpose::LoginSubmit => {
2418                    policy.version == SuppliedSessionPolicyVersion::V3
2419                        && policy.login_url() == Some(&self.target)
2420                },
2421                SuppliedSessionRequestPurpose::Health => {
2422                    self.target == policy.health_url
2423                        && self.target_reference
2424                            == reference(
2425                                RESOURCE_REFERENCE_DOMAIN,
2426                                policy.health_url.as_str(),
2427                                "supplied-session-resource-sha256",
2428                            )
2429                },
2430                SuppliedSessionRequestPurpose::Resource => {
2431                    policy.resources.iter().any(|resource| {
2432                        resource.url == self.target && resource.reference == self.target_reference
2433                    })
2434                },
2435            }
2436    }
2437
2438    pub(crate) const fn target(&self) -> &Url {
2439        &self.target
2440    }
2441
2442    pub(crate) fn target_reference(&self) -> &str {
2443        &self.target_reference
2444    }
2445
2446    pub(crate) const fn purpose(&self) -> SuppliedSessionRequestPurpose {
2447        self.purpose
2448    }
2449
2450    pub(crate) const fn credential_mechanism(&self) -> SuppliedSessionCredentialMechanism {
2451        self.credential_mechanism
2452    }
2453}
2454
2455impl fmt::Debug for SuppliedSessionRequestDescriptor {
2456    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2457        formatter
2458            .debug_struct("SuppliedSessionRequestDescriptor")
2459            .field("application", &"<redacted>")
2460            .field("target", &"<redacted>")
2461            .field("purpose", &self.purpose)
2462            .field("credential_mechanism", &self.credential_mechanism)
2463            .finish()
2464    }
2465}
2466
2467#[derive(Deserialize)]
2468#[serde(deny_unknown_fields)]
2469struct WirePolicy {
2470    schema: String,
2471    principal_alias: String,
2472    credential_mechanism: String,
2473    health_path: String,
2474    health_json_field: String,
2475    resources: Vec<String>,
2476    max_session_requests: u8,
2477    max_total_response_bytes: u64,
2478    max_response_body_bytes: usize,
2479    max_wall_time_ms: u64,
2480}
2481
2482#[derive(Deserialize)]
2483#[serde(deny_unknown_fields)]
2484struct WireOAuthCodePolicy {
2485    schema: String,
2486    principal_alias: String,
2487    credential_mechanism: String,
2488    credential_acquisition: String,
2489    health_path: String,
2490    health_json_field: String,
2491    resources: Vec<String>,
2492    max_session_requests: u8,
2493    max_total_response_bytes: u64,
2494    max_response_body_bytes: usize,
2495    max_wall_time_ms: u64,
2496    oauth_code: oauth_code::WireOAuthCodePolicy,
2497}
2498
2499#[derive(Deserialize)]
2500#[serde(deny_unknown_fields)]
2501struct WireOAuthRefreshPolicy {
2502    schema: String,
2503    principal_alias: String,
2504    credential_mechanism: String,
2505    credential_acquisition: String,
2506    health_path: String,
2507    health_json_field: String,
2508    resources: Vec<String>,
2509    max_session_requests: u8,
2510    max_total_response_bytes: u64,
2511    max_response_body_bytes: usize,
2512    max_wall_time_ms: u64,
2513    oauth_code: oauth_code::WireOAuthCodePolicy,
2514    oauth_refresh: oauth_refresh::WireOAuthRefreshPolicy,
2515}
2516
2517#[derive(Deserialize)]
2518#[serde(deny_unknown_fields)]
2519struct WireOAuthAuthorizationPolicy {
2520    schema: String,
2521    principal_alias: String,
2522    credential_mechanism: String,
2523    credential_acquisition: String,
2524    health_path: String,
2525    health_json_field: String,
2526    resources: Vec<String>,
2527    max_session_requests: u8,
2528    max_total_response_bytes: u64,
2529    max_response_body_bytes: usize,
2530    max_wall_time_ms: u64,
2531    oauth_code: oauth_code::WireOAuthCodePolicy,
2532    oauth_authorization: oauth_authorization::WireOAuthAuthorizationPolicy,
2533}
2534
2535#[derive(Deserialize)]
2536#[serde(deny_unknown_fields)]
2537struct WireCookiePolicy {
2538    schema: String,
2539    principal_alias: String,
2540    credential_mechanism: String,
2541    cookie_update_policy: String,
2542    health_path: String,
2543    health_json_field: String,
2544    resources: Vec<String>,
2545    cookies: Vec<WireCookieMetadata>,
2546    max_session_requests: u8,
2547    max_total_response_bytes: u64,
2548    max_response_body_bytes: usize,
2549    max_wall_time_ms: u64,
2550}
2551
2552#[derive(Deserialize)]
2553#[serde(deny_unknown_fields)]
2554struct WireRenewalPolicy {
2555    schema: String,
2556    principal_alias: String,
2557    credential_mechanism: String,
2558    cookie_update_policy: String,
2559    renewal_path: String,
2560    renewal_method: String,
2561    renewal_after_resource_count: u8,
2562    max_renewal_attempts: u8,
2563    health_path: String,
2564    health_json_field: String,
2565    resources: Vec<String>,
2566    cookies: Vec<WireCookieMetadata>,
2567    max_session_requests: u8,
2568    max_total_response_bytes: u64,
2569    max_response_body_bytes: usize,
2570    max_wall_time_ms: u64,
2571}
2572
2573#[derive(Deserialize)]
2574#[serde(deny_unknown_fields)]
2575struct WireFormLoginPolicy {
2576    schema: String,
2577    principal_alias: String,
2578    credential_mechanism: String,
2579    credential_acquisition: String,
2580    cookie_update_policy: String,
2581    login_path: String,
2582    login_method: String,
2583    login_encoding: String,
2584    username_field: String,
2585    password_field: String,
2586    csrf_field: String,
2587    max_login_attempts: u8,
2588    health_path: String,
2589    health_json_field: String,
2590    resources: Vec<String>,
2591    cookies: Vec<WireCookieMetadata>,
2592    max_session_requests: u8,
2593    max_total_response_bytes: u64,
2594    max_response_body_bytes: usize,
2595    max_wall_time_ms: u64,
2596}
2597
2598#[derive(Deserialize)]
2599#[serde(deny_unknown_fields)]
2600struct WireCookieMetadata {
2601    id: String,
2602    name: String,
2603    domain: String,
2604    host_only: bool,
2605    path: String,
2606    secure: bool,
2607    http_only: bool,
2608    same_site: String,
2609    expires_unix_seconds: Option<i64>,
2610}
2611
2612fn validate_policy_limits(
2613    resource_count: usize,
2614    max_session_requests: u8,
2615    max_total_response_bytes: u64,
2616    max_response_body_bytes: usize,
2617    max_wall_time_ms: u64,
2618) -> Result<(), SuppliedSessionPolicyError> {
2619    let minimum_requests = 1_usize
2620        .checked_add(
2621            resource_count
2622                .checked_mul(2)
2623                .ok_or(SuppliedSessionPolicyError::InvalidLimits)?,
2624        )
2625        .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
2626    if max_session_requests == 0
2627        || usize::from(max_session_requests) < minimum_requests
2628        || max_session_requests > MAX_SUPPLIED_SESSION_REQUESTS
2629        || max_total_response_bytes == 0
2630        || max_total_response_bytes > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
2631        || max_response_body_bytes == 0
2632        || max_response_body_bytes > MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES
2633        || u64::try_from(max_response_body_bytes).unwrap_or(u64::MAX) > max_total_response_bytes
2634        || max_wall_time_ms == 0
2635        || max_wall_time_ms > MAX_SUPPLIED_SESSION_WALL_TIME_MS
2636    {
2637        Err(SuppliedSessionPolicyError::InvalidLimits)
2638    } else {
2639        Ok(())
2640    }
2641}
2642
2643fn validate_form_login_policy_limits(
2644    resource_count: usize,
2645    max_session_requests: u8,
2646    max_total_response_bytes: u64,
2647    max_response_body_bytes: usize,
2648    max_wall_time_ms: u64,
2649) -> Result<(), SuppliedSessionPolicyError> {
2650    let minimum_requests = 3_usize
2651        .checked_add(
2652            resource_count
2653                .checked_mul(2)
2654                .ok_or(SuppliedSessionPolicyError::InvalidLimits)?,
2655        )
2656        .ok_or(SuppliedSessionPolicyError::InvalidLimits)?;
2657    if usize::from(max_session_requests) != minimum_requests
2658        || max_session_requests > MAX_SUPPLIED_SESSION_REQUESTS
2659        || max_total_response_bytes == 0
2660        || max_total_response_bytes > MAX_SUPPLIED_SESSION_TOTAL_RESPONSE_BYTES
2661        || max_response_body_bytes == 0
2662        || max_response_body_bytes > MAX_SUPPLIED_SESSION_RESPONSE_BODY_BYTES
2663        || u64::try_from(max_response_body_bytes).unwrap_or(u64::MAX) > max_total_response_bytes
2664        || max_wall_time_ms == 0
2665        || max_wall_time_ms > MAX_SUPPLIED_SESSION_WALL_TIME_MS
2666    {
2667        Err(SuppliedSessionPolicyError::InvalidLimits)
2668    } else {
2669        Ok(())
2670    }
2671}
2672
2673fn validate_html_field_name(value: &str) -> Result<(), SuppliedSessionPolicyError> {
2674    if value.is_empty()
2675        || value.len() > MAX_LOGIN_FIELD_BYTES
2676        || !value.bytes().all(|byte| {
2677            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':' | b'[' | b']')
2678        })
2679    {
2680        Err(SuppliedSessionPolicyError::InvalidFormLogin)
2681    } else {
2682        Ok(())
2683    }
2684}
2685
2686fn validate_v3_cookie_metadata(
2687    application_url: &Url,
2688    health_url: &Url,
2689    resources: &[SuppliedSessionResource],
2690    wire: &WireFormLoginPolicy,
2691) -> Result<Vec<SuppliedSessionCookieMetadata>, SuppliedSessionPolicyError> {
2692    if wire.cookies.is_empty() || wire.cookies.len() > MAX_SUPPLIED_SESSION_COOKIES {
2693        return Err(SuppliedSessionPolicyError::InvalidCookieCount);
2694    }
2695    let canonical_host = application_url
2696        .host_str()
2697        .ok_or(SuppliedSessionPolicyError::InvalidApplication)?;
2698    let expected_secure = application_url.scheme() == "https";
2699    let mut seen_ids = std::collections::BTreeSet::new();
2700    let mut seen_tuples = std::collections::BTreeSet::new();
2701    let mut cookies = Vec::with_capacity(wire.cookies.len());
2702    for cookie in &wire.cookies {
2703        validate_token(&cookie.id, MAX_COOKIE_ID_BYTES)
2704            .map_err(|_| SuppliedSessionPolicyError::InvalidCookieMetadata)?;
2705        if cookie.name.is_empty()
2706            || cookie.name.len() > MAX_COOKIE_NAME_BYTES
2707            || !cookie.name.bytes().all(http_token_byte)
2708            || cookie.name.starts_with('$')
2709            || cookie.domain != canonical_host
2710            || !cookie.host_only
2711            || cookie.secure != expected_secure
2712            || !cookie.http_only
2713            || cookie.expires_unix_seconds.is_some()
2714        {
2715            return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
2716        }
2717        validate_cookie_path(&cookie.path)?;
2718        let same_site = parse_cookie_same_site(&cookie.same_site)?;
2719        if !matches!(
2720            same_site,
2721            SuppliedSessionCookieSameSite::Strict | SuppliedSessionCookieSameSite::Lax
2722        ) || !cookie_path_matches(&cookie.path, health_url.path())
2723            || !resources
2724                .iter()
2725                .all(|resource| cookie_path_matches(&cookie.path, resource.url.path()))
2726            || !seen_ids.insert(cookie.id.clone())
2727            || !seen_tuples.insert((
2728                cookie.name.clone(),
2729                cookie.domain.clone(),
2730                cookie.path.clone(),
2731            ))
2732        {
2733            return Err(SuppliedSessionPolicyError::InvalidCookieMetadata);
2734        }
2735        cookies.push(SuppliedSessionCookieMetadata {
2736            id: cookie.id.clone(),
2737            name: cookie.name.clone(),
2738            domain: cookie.domain.clone(),
2739            host_only: true,
2740            path: cookie.path.clone(),
2741            secure: cookie.secure,
2742            http_only: true,
2743            same_site,
2744            expires_unix_seconds: None,
2745        });
2746    }
2747    Ok(cookies)
2748}
2749
2750fn validate_cookie_path(path: &str) -> Result<(), SuppliedSessionPolicyError> {
2751    if path.is_empty()
2752        || path.len() > MAX_POLICY_PATH_BYTES
2753        || !path.starts_with('/')
2754        || path.contains("//")
2755        || path
2756            .bytes()
2757            .any(|byte| matches!(byte, b'?' | b'#' | b'%' | b'\\'))
2758        || path.bytes().any(|byte| {
2759            !(byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~'))
2760        })
2761        || path
2762            .split('/')
2763            .skip(1)
2764            .filter(|segment| !segment.is_empty())
2765            .any(|segment| matches!(segment, "." | ".."))
2766    {
2767        Err(SuppliedSessionPolicyError::InvalidCookieMetadata)
2768    } else {
2769        Ok(())
2770    }
2771}
2772
2773fn parse_cookie_same_site(
2774    value: &str,
2775) -> Result<SuppliedSessionCookieSameSite, SuppliedSessionPolicyError> {
2776    match value {
2777        "missing" => Ok(SuppliedSessionCookieSameSite::Missing),
2778        "strict" => Ok(SuppliedSessionCookieSameSite::Strict),
2779        "lax" => Ok(SuppliedSessionCookieSameSite::Lax),
2780        "none" => Ok(SuppliedSessionCookieSameSite::None),
2781        _ => Err(SuppliedSessionPolicyError::InvalidCookieMetadata),
2782    }
2783}
2784
2785fn cookie_path_matches(cookie_path: &str, request_path: &str) -> bool {
2786    request_path == cookie_path
2787        || request_path
2788            .strip_prefix(cookie_path)
2789            .is_some_and(|suffix| {
2790                cookie_path.ends_with('/') || suffix.as_bytes().first() == Some(&b'/')
2791            })
2792}
2793
2794fn cookie_applies(
2795    cookie: &SuppliedSessionCookieMetadata,
2796    target: &Url,
2797    now_unix_seconds: i64,
2798) -> bool {
2799    let Some(target_host) = target.host_str() else {
2800        return false;
2801    };
2802    let host_matches = if cookie.host_only {
2803        target_host == cookie.domain
2804    } else {
2805        target_host == cookie.domain
2806            || (target_host.ends_with(&cookie.domain)
2807                && target_host
2808                    .as_bytes()
2809                    .get(target_host.len().saturating_sub(cookie.domain.len() + 1))
2810                    == Some(&b'.'))
2811    };
2812    host_matches
2813        && (!cookie.secure || target.scheme() == "https")
2814        && cookie_path_matches(&cookie.path, target.path())
2815        && cookie
2816            .expires_unix_seconds
2817            .is_none_or(|expires| expires > now_unix_seconds)
2818}
2819
2820fn cookie_octet(byte: u8) -> bool {
2821    matches!(byte, 0x21 | 0x23..=0x2b | 0x2d..=0x3a | 0x3c..=0x5b | 0x5d..=0x7e)
2822}
2823
2824fn summarize_cookies(cookies: &[SuppliedSessionCookieMetadata]) -> SuppliedSessionCookieSummary {
2825    let mut summary = SuppliedSessionCookieSummary {
2826        cookie_count: cookies.len(),
2827        host_only_count: 0,
2828        domain_count: 0,
2829        secure_count: 0,
2830        http_only_count: 0,
2831        session_count: 0,
2832        persistent_count: 0,
2833        same_site_missing_count: 0,
2834        same_site_strict_count: 0,
2835        same_site_lax_count: 0,
2836        same_site_none_count: 0,
2837    };
2838    for cookie in cookies {
2839        if cookie.host_only {
2840            summary.host_only_count += 1;
2841        } else {
2842            summary.domain_count += 1;
2843        }
2844        summary.secure_count += usize::from(cookie.secure);
2845        summary.http_only_count += usize::from(cookie.http_only);
2846        if cookie.expires_unix_seconds.is_some() {
2847            summary.persistent_count += 1;
2848        } else {
2849            summary.session_count += 1;
2850        }
2851        match cookie.same_site {
2852            SuppliedSessionCookieSameSite::Missing => summary.same_site_missing_count += 1,
2853            SuppliedSessionCookieSameSite::Strict => summary.same_site_strict_count += 1,
2854            SuppliedSessionCookieSameSite::Lax => summary.same_site_lax_count += 1,
2855            SuppliedSessionCookieSameSite::None => summary.same_site_none_count += 1,
2856        }
2857    }
2858    summary
2859}
2860
2861fn validate_application_url(application: &Url) -> Result<(), SuppliedSessionPolicyError> {
2862    if !matches!(application.scheme(), "http" | "https")
2863        || application.host().is_none()
2864        || !application.username().is_empty()
2865        || application.password().is_some()
2866        || application.query().is_some()
2867        || application.fragment().is_some()
2868        || !application.path().starts_with('/')
2869        || !application.path().ends_with('/')
2870        || application.path().contains('%')
2871        || application.path().contains('\\')
2872    {
2873        return Err(SuppliedSessionPolicyError::InvalidApplication);
2874    }
2875    Ok(())
2876}
2877
2878fn resolve_safe_path(application: &Url, raw: &str) -> Result<Url, SuppliedSessionPolicyError> {
2879    resolve_bounded_path(application, raw, false)
2880}
2881
2882fn resolve_login_path(application: &Url, raw: &str) -> Result<Url, SuppliedSessionPolicyError> {
2883    let target = resolve_bounded_path(application, raw, true)?;
2884    if raw
2885        .split('/')
2886        .skip(1)
2887        .filter(|segment| !segment.is_empty())
2888        .any(login_disallowed_action_segment)
2889    {
2890        return Err(SuppliedSessionPolicyError::InvalidPath);
2891    }
2892    Ok(target)
2893}
2894
2895fn resolve_bounded_path(
2896    application: &Url,
2897    raw: &str,
2898    allow_action_segment: bool,
2899) -> Result<Url, SuppliedSessionPolicyError> {
2900    if raw.is_empty()
2901        || raw.len() > MAX_POLICY_PATH_BYTES
2902        || !raw.starts_with('/')
2903        || raw.starts_with("//")
2904        || raw == "/"
2905        || raw.contains("//")
2906        || raw
2907            .bytes()
2908            .any(|byte| matches!(byte, b'?' | b'#' | b'%' | b'\\'))
2909        || raw.bytes().any(|byte| {
2910            !(byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~'))
2911        })
2912        || raw
2913            .split('/')
2914            .skip(1)
2915            .filter(|segment| !segment.is_empty())
2916            .any(|segment| matches!(segment, "." | ".."))
2917    {
2918        return Err(SuppliedSessionPolicyError::InvalidPath);
2919    }
2920    if !allow_action_segment
2921        && raw
2922            .split('/')
2923            .skip(1)
2924            .filter(|segment| !segment.is_empty())
2925            .any(action_or_admin_segment)
2926    {
2927        return Err(SuppliedSessionPolicyError::InvalidPath);
2928    }
2929    let mut target = application.clone();
2930    target.set_path(raw);
2931    target.set_query(None);
2932    target.set_fragment(None);
2933    if target.origin() != application.origin() || !path_within_application(application, &target) {
2934        return Err(SuppliedSessionPolicyError::InvalidPath);
2935    }
2936    Ok(target)
2937}
2938
2939fn path_within_application(application: &Url, target: &Url) -> bool {
2940    target.path().starts_with(application.path())
2941}
2942
2943fn action_or_admin_segment(segment: &str) -> bool {
2944    segment
2945        .split(['-', '_', '.'])
2946        .filter(|token| !token.is_empty())
2947        .any(|token| {
2948            matches!(
2949                token.to_ascii_lowercase().as_str(),
2950                "admin"
2951                    | "login"
2952                    | "logout"
2953                    | "action"
2954                    | "actions"
2955                    | "delete"
2956                    | "remove"
2957                    | "update"
2958                    | "create"
2959                    | "edit"
2960                    | "upload"
2961                    | "download"
2962            )
2963        })
2964}
2965
2966fn login_disallowed_action_segment(segment: &str) -> bool {
2967    segment
2968        .split(['-', '_', '.'])
2969        .filter(|token| !token.is_empty())
2970        .any(|token| {
2971            matches!(
2972                token.to_ascii_lowercase().as_str(),
2973                "logout"
2974                    | "action"
2975                    | "actions"
2976                    | "delete"
2977                    | "remove"
2978                    | "update"
2979                    | "create"
2980                    | "edit"
2981                    | "upload"
2982                    | "download"
2983            )
2984        })
2985}
2986
2987fn request_descriptor_reference(
2988    application: &Url,
2989    target: &Url,
2990    purpose: SuppliedSessionRequestPurpose,
2991    credential_mechanism: SuppliedSessionCredentialMechanism,
2992    policy_reference: &str,
2993) -> String {
2994    let mut hasher = Sha256::new();
2995    hasher.update(REQUEST_DESCRIPTOR_REFERENCE_DOMAIN);
2996    hash_field(&mut hasher, application.as_str().as_bytes());
2997    hash_field(&mut hasher, target.as_str().as_bytes());
2998    hash_field(
2999        &mut hasher,
3000        match purpose {
3001            SuppliedSessionRequestPurpose::LoginPage => b"login_page",
3002            SuppliedSessionRequestPurpose::LoginSubmit => b"login_submit",
3003            SuppliedSessionRequestPurpose::Renewal => b"renewal",
3004            SuppliedSessionRequestPurpose::Health => b"health",
3005            SuppliedSessionRequestPurpose::Resource => b"resource",
3006        },
3007    );
3008    hash_field(&mut hasher, credential_mechanism.as_str().as_bytes());
3009    hash_field(&mut hasher, policy_reference.as_bytes());
3010    hex(hasher.finalize().as_slice())
3011}
3012
3013fn validate_token(value: &str, maximum: usize) -> Result<(), ()> {
3014    if value.is_empty()
3015        || value.len() > maximum
3016        || !value.bytes().all(|byte| {
3017            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'@' | b'-')
3018        })
3019    {
3020        Err(())
3021    } else {
3022        Ok(())
3023    }
3024}
3025
3026fn policy_reference(
3027    application: &Url,
3028    wire: &WirePolicy,
3029    mechanism: SuppliedSessionCredentialMechanism,
3030) -> String {
3031    let mut hasher = Sha256::new();
3032    hasher.update(POLICY_REFERENCE_DOMAIN);
3033    hash_field(&mut hasher, SUPPLIED_SESSION_POLICY_SCHEMA.as_bytes());
3034    hash_field(&mut hasher, application.as_str().as_bytes());
3035    // The operator's potentially low-entropy display alias is validated but
3036    // deliberately excluded from every public reference. V1 has one opaque
3037    // run-local principal slot, so the alias grants no transport distinction.
3038    hash_field(
3039        &mut hasher,
3040        match mechanism {
3041            SuppliedSessionCredentialMechanism::AuthorizationHeader => b"authorization_header",
3042            SuppliedSessionCredentialMechanism::CookieJar => b"cookie_jar",
3043        },
3044    );
3045    hash_field(&mut hasher, wire.health_path.as_bytes());
3046    hash_field(&mut hasher, wire.health_json_field.as_bytes());
3047    for resource in &wire.resources {
3048        hash_field(&mut hasher, resource.as_bytes());
3049    }
3050    hash_field(&mut hasher, &[wire.max_session_requests]);
3051    hash_field(&mut hasher, &wire.max_total_response_bytes.to_be_bytes());
3052    hash_field(
3053        &mut hasher,
3054        &u64::try_from(wire.max_response_body_bytes)
3055            .unwrap_or(u64::MAX)
3056            .to_be_bytes(),
3057    );
3058    hash_field(&mut hasher, &wire.max_wall_time_ms.to_be_bytes());
3059    format!("supplied-session-policy-sha256:{}", hex(hasher.finalize()))
3060}
3061
3062fn cookie_policy_reference(
3063    application: &Url,
3064    wire: &WireCookiePolicy,
3065    mechanism: SuppliedSessionCredentialMechanism,
3066    update_policy: SuppliedSessionCookieUpdatePolicy,
3067) -> String {
3068    let mut hasher = Sha256::new();
3069    hasher.update(COOKIE_POLICY_REFERENCE_DOMAIN);
3070    hash_field(
3071        &mut hasher,
3072        SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA.as_bytes(),
3073    );
3074    hash_field(&mut hasher, application.as_str().as_bytes());
3075    hash_field(&mut hasher, mechanism.as_str().as_bytes());
3076    hash_field(&mut hasher, update_policy.as_str().as_bytes());
3077    hash_field(&mut hasher, wire.health_path.as_bytes());
3078    hash_field(&mut hasher, wire.health_json_field.as_bytes());
3079    for resource in &wire.resources {
3080        hash_field(&mut hasher, resource.as_bytes());
3081    }
3082    for cookie in &wire.cookies {
3083        hash_field(&mut hasher, cookie.id.as_bytes());
3084        hash_field(&mut hasher, cookie.name.as_bytes());
3085        hash_field(&mut hasher, cookie.domain.as_bytes());
3086        hash_field(&mut hasher, &[u8::from(cookie.host_only)]);
3087        hash_field(&mut hasher, cookie.path.as_bytes());
3088        hash_field(&mut hasher, &[u8::from(cookie.secure)]);
3089        hash_field(&mut hasher, &[u8::from(cookie.http_only)]);
3090        hash_field(&mut hasher, cookie.same_site.as_bytes());
3091        match cookie.expires_unix_seconds {
3092            Some(expires) => {
3093                hash_field(&mut hasher, b"persistent");
3094                hash_field(&mut hasher, &expires.to_be_bytes());
3095            },
3096            None => hash_field(&mut hasher, b"session"),
3097        }
3098    }
3099    hash_field(&mut hasher, &[wire.max_session_requests]);
3100    hash_field(&mut hasher, &wire.max_total_response_bytes.to_be_bytes());
3101    hash_field(
3102        &mut hasher,
3103        &u64::try_from(wire.max_response_body_bytes)
3104            .unwrap_or(u64::MAX)
3105            .to_be_bytes(),
3106    );
3107    hash_field(&mut hasher, &wire.max_wall_time_ms.to_be_bytes());
3108    format!("supplied-session-policy-sha256:{}", hex(hasher.finalize()))
3109}
3110
3111fn form_login_policy_reference(
3112    application: &Url,
3113    wire: &WireFormLoginPolicy,
3114    update_policy: SuppliedSessionCookieUpdatePolicy,
3115) -> String {
3116    let mut hasher = Sha256::new();
3117    hasher.update(FORM_LOGIN_POLICY_REFERENCE_DOMAIN);
3118    for value in [
3119        SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA,
3120        application.as_str(),
3121        "cookie_jar",
3122        "bounded_form_login",
3123        update_policy.as_str(),
3124        wire.login_path.as_str(),
3125        wire.login_method.as_str(),
3126        wire.login_encoding.as_str(),
3127        wire.username_field.as_str(),
3128        wire.password_field.as_str(),
3129        wire.csrf_field.as_str(),
3130        wire.health_path.as_str(),
3131        wire.health_json_field.as_str(),
3132    ] {
3133        hash_field(&mut hasher, value.as_bytes());
3134    }
3135    hash_field(&mut hasher, &[wire.max_login_attempts]);
3136    for resource in &wire.resources {
3137        hash_field(&mut hasher, resource.as_bytes());
3138    }
3139    for cookie in &wire.cookies {
3140        hash_field(&mut hasher, cookie.id.as_bytes());
3141        hash_field(&mut hasher, cookie.name.as_bytes());
3142        hash_field(&mut hasher, cookie.domain.as_bytes());
3143        hash_field(&mut hasher, &[u8::from(cookie.host_only)]);
3144        hash_field(&mut hasher, cookie.path.as_bytes());
3145        hash_field(&mut hasher, &[u8::from(cookie.secure)]);
3146        hash_field(&mut hasher, &[u8::from(cookie.http_only)]);
3147        hash_field(&mut hasher, cookie.same_site.as_bytes());
3148        match cookie.expires_unix_seconds {
3149            Some(expires) => hash_field(&mut hasher, &expires.to_be_bytes()),
3150            None => hash_field(&mut hasher, b"session"),
3151        }
3152    }
3153    hash_field(&mut hasher, &[wire.max_session_requests]);
3154    hash_field(&mut hasher, &wire.max_total_response_bytes.to_be_bytes());
3155    hash_field(
3156        &mut hasher,
3157        &u64::try_from(wire.max_response_body_bytes)
3158            .unwrap_or(u64::MAX)
3159            .to_be_bytes(),
3160    );
3161    hash_field(&mut hasher, &wire.max_wall_time_ms.to_be_bytes());
3162    format!("supplied-session-policy-sha256:{}", hex(hasher.finalize()))
3163}
3164
3165fn reference(domain: &[u8], value: &str, prefix: &str) -> String {
3166    let mut hasher = Sha256::new();
3167    hasher.update(domain);
3168    hash_field(&mut hasher, value.as_bytes());
3169    format!("{prefix}:{}", hex(hasher.finalize()))
3170}
3171
3172fn hash_field(hasher: &mut Sha256, value: &[u8]) {
3173    hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
3174    hasher.update(value);
3175}
3176
3177fn hex(bytes: impl AsRef<[u8]>) -> String {
3178    use std::fmt::Write as _;
3179    let bytes = bytes.as_ref();
3180    let mut output = String::with_capacity(bytes.len() * 2);
3181    for byte in bytes {
3182        write!(output, "{byte:02x}").expect("writing into String cannot fail");
3183    }
3184    output
3185}
3186
3187fn http_token_byte(byte: u8) -> bool {
3188    byte.is_ascii_alphanumeric()
3189        || matches!(
3190            byte,
3191            b'!' | b'#'
3192                | b'$'
3193                | b'%'
3194                | b'&'
3195                | b'\''
3196                | b'*'
3197                | b'+'
3198                | b'-'
3199                | b'.'
3200                | b'^'
3201                | b'_'
3202                | b'`'
3203                | b'|'
3204                | b'~'
3205        )
3206}
3207
3208#[cfg(test)]
3209mod tests {
3210    use super::*;
3211
3212    // Original non-secret V7 fixture. Expected request counts are specified
3213    // independently: one code POST, one startup health and two per resource.
3214    fn authorization_source() -> String {
3215        r#"schema = "security.supplied-session-policy/v7"
3216principal_alias = "authorization-canary-a"
3217credential_mechanism = "authorization_header"
3218credential_acquisition = "client_initiated_oauth_pkce"
3219health_path = "/app/health"
3220health_json_field = "authenticated"
3221resources = ["/app/private/one", "/app/private/two"]
3222max_session_requests = 6
3223max_total_response_bytes = 65536
3224max_response_body_bytes = 8192
3225max_wall_time_ms = 10000
3226[oauth_code]
3227profile = "client-initiated-public-code-s256-bearer/v1"
3228transport_profile = "verified-https/v1"
3229issuer = "https://issuer.test/realm"
3230token_endpoint = "https://issuer.test/realm/token"
3231client_id = "termivar-owned-public"
3232redirect_uri = "http://127.0.0.1:18443/termivar/oauth/callback"
3233scopes = ["canary-read"]
3234max_token_response_bytes = 8192
3235[oauth_authorization]
3236profile = "loopback-form-post-state/v1"
3237authorization_endpoint = "https://issuer.test/realm/authorize"
3238max_wait_ms = 20000
3239"#
3240        .to_owned()
3241    }
3242
3243    fn authorization_policy() -> SuppliedSessionPolicy {
3244        SuppliedSessionPolicy::parse_toml(
3245            &Url::parse("https://application.test/app/").unwrap(),
3246            authorization_source().as_bytes(),
3247        )
3248        .unwrap()
3249    }
3250
3251    #[test]
3252    fn oauth_authorization_v7_has_separate_explicit_acquisition_and_finite_phases() {
3253        let policy = authorization_policy();
3254        assert_eq!(policy.version(), SuppliedSessionPolicyVersion::V7);
3255        assert_eq!(policy.schema(), "security.supplied-session-policy/v7");
3256        assert_eq!(
3257            policy.credential_acquisition(),
3258            SuppliedSessionCredentialAcquisition::ClientInitiatedOAuthPkce
3259        );
3260        assert_eq!(
3261            policy.credential_acquisition().as_str(),
3262            "client_initiated_oauth_pkce"
3263        );
3264        assert_eq!(policy.resource_count(), 2);
3265        assert_eq!(policy.max_session_requests(), 6);
3266        assert_eq!(policy.max_wall_time_ms(), 10_000);
3267        assert_eq!(
3268            policy.oauth_authorization_policy().unwrap().max_wait_ms(),
3269            20_000
3270        );
3271        assert!(policy.oauth_refresh_policy().is_none());
3272        assert!(policy.oauth_code_policy().unwrap().is_client_initiated());
3273        assert!(SuppliedSessionOAuthGrant::parse_tsv(&policy, Vec::new(), 0).is_err());
3274        let ready = SuppliedSessionRuntimeInput::from(
3275            SuppliedSessionAuthorization::new(b"Bearer SYNTHETIC".to_vec()).unwrap(),
3276        );
3277        assert!(!ready.matches_policy(&policy));
3278        for (resource_list, outgoing) in [
3279            (r#"["/app/private/one"]"#, 4),
3280            (r#"["/app/private/one", "/app/private/two"]"#, 6),
3281            (
3282                r#"["/app/private/one", "/app/private/two", "/app/private/three"]"#,
3283                8,
3284            ),
3285        ] {
3286            let source = authorization_source()
3287                .replace(r#"["/app/private/one", "/app/private/two"]"#, resource_list)
3288                .replace(
3289                    "max_session_requests = 6",
3290                    &format!("max_session_requests = {outgoing}"),
3291                );
3292            let selected = SuppliedSessionPolicy::parse_toml(
3293                &Url::parse("https://application.test/app/").unwrap(),
3294                source.as_bytes(),
3295            )
3296            .unwrap();
3297            assert_eq!(selected.max_session_requests(), outgoing);
3298            assert!(selected.max_session_requests() < 9);
3299        }
3300    }
3301
3302    #[test]
3303    fn oauth_authorization_v7_rejects_cross_profile_scope_and_limit_mutations() {
3304        let source = authorization_source();
3305        for (from, to) in [
3306            ("client_initiated_oauth_pkce", "imported_oauth_code_pkce"),
3307            ("authorization_header", "cookie_jar"),
3308            (
3309                "client-initiated-public-code-s256-bearer/v1",
3310                "external-public-code-s256-bearer/v1",
3311            ),
3312            ("loopback-form-post-state/v1", "automatic-browser-login/v1"),
3313            ("max_wait_ms = 20000", "max_wait_ms = 0"),
3314            ("max_wait_ms = 20000", "max_wait_ms = 30001"),
3315            ("max_wait_ms = 20000", "max_wait_ms = true"),
3316            ("max_session_requests = 6", "max_session_requests = 5"),
3317            ("max_session_requests = 6", "max_session_requests = 7"),
3318            ("max_wall_time_ms = 10000", "max_wall_time_ms = 10001"),
3319            (
3320                "max_response_body_bytes = 8192",
3321                "max_response_body_bytes = 8191",
3322            ),
3323            (
3324                "https://issuer.test/realm/authorize",
3325                "https://foreign.test/realm/authorize",
3326            ),
3327            (
3328                "http://127.0.0.1:18443/termivar/oauth/callback",
3329                "http://localhost:18443/termivar/oauth/callback",
3330            ),
3331            (
3332                "http://127.0.0.1:18443/termivar/oauth/callback",
3333                "http://127.0.0.2:18443/termivar/oauth/callback",
3334            ),
3335            (
3336                "http://127.0.0.1:18443/termivar/oauth/callback",
3337                "http://127.0.0.1:18443/other",
3338            ),
3339            ("scopes = [\"canary-read\"]", "scopes = [\"openid\"]"),
3340        ] {
3341            let changed = source.replace(from, to);
3342            assert_ne!(changed, source, "mutation must change its literal input");
3343            assert!(
3344                SuppliedSessionPolicy::parse_toml(
3345                    &Url::parse("https://application.test/app/").unwrap(),
3346                    changed.as_bytes(),
3347                )
3348                .is_err(),
3349                "invalid V7 policy admitted"
3350            );
3351        }
3352        for extra in ["unknown = true", "max_wait_ms = 1"] {
3353            assert!(SuppliedSessionPolicy::parse_toml(
3354                &Url::parse("https://application.test/app/").unwrap(),
3355                format!("{source}{extra}\n").as_bytes(),
3356            )
3357            .is_err());
3358        }
3359        let missing = source.split("[oauth_authorization]").next().unwrap();
3360        assert!(SuppliedSessionPolicy::parse_toml(
3361            &Url::parse("https://application.test/app/").unwrap(),
3362            missing.as_bytes(),
3363        )
3364        .is_err());
3365        assert!(
3366            SuppliedSessionPolicy::parse_toml(
3367                &Url::parse("http://127.0.0.1:8080/app/").unwrap(),
3368                source.as_bytes(),
3369            )
3370            .is_err(),
3371            "callback HTTP exception must not authorize an HTTP application"
3372        );
3373    }
3374
3375    #[test]
3376    fn oauth_authorization_binding_preserves_application_identity_and_changes_with_policy() {
3377        let first = authorization_policy();
3378        let second = SuppliedSessionPolicy::parse_toml(
3379            &Url::parse("https://application.test/app/").unwrap(),
3380            authorization_source()
3381                .replace("max_wait_ms = 20000", "max_wait_ms = 19999")
3382                .as_bytes(),
3383        )
3384        .unwrap();
3385        assert_ne!(first.policy_reference(), second.policy_reference());
3386        assert_eq!(
3387            first.application_reference(),
3388            second.application_reference()
3389        );
3390        assert_ne!(
3391            first
3392                .oauth_authorization_policy()
3393                .unwrap()
3394                .binding_reference(),
3395            second
3396                .oauth_authorization_policy()
3397                .unwrap()
3398                .binding_reference(),
3399        );
3400        let legacy = renewal_policy();
3401        assert!(legacy.oauth_authorization_policy().is_none());
3402        assert!(SuppliedSessionOAuthAuthorization::new(&legacy, std::io::sink()).is_err());
3403    }
3404
3405    #[derive(Clone)]
3406    struct AuthorizationTestWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
3407
3408    impl std::io::Write for AuthorizationTestWriter {
3409        fn write(&mut self, source: &[u8]) -> std::io::Result<usize> {
3410            self.0.lock().unwrap().extend_from_slice(source);
3411            Ok(source.len())
3412        }
3413
3414        fn flush(&mut self) -> std::io::Result<()> {
3415            Ok(())
3416        }
3417    }
3418
3419    #[test]
3420    fn oauth_authorization_writer_is_policy_bound_move_only_and_one_attempt() {
3421        let selected = authorization_policy();
3422        let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
3423        let mut sink = SuppliedSessionOAuthAuthorization::new(
3424            &selected,
3425            AuthorizationTestWriter(captured.clone()),
3426        )
3427        .unwrap();
3428        assert!(sink.matches_policy(&selected));
3429        let changed = SuppliedSessionPolicy::parse_toml(
3430            &Url::parse("https://application.test/app/").unwrap(),
3431            authorization_source()
3432                .replace("max_wait_ms = 20000", "max_wait_ms = 19999")
3433                .as_bytes(),
3434        )
3435        .unwrap();
3436        assert!(!sink.matches_policy(&changed));
3437        let now = std::time::Instant::now();
3438        let transaction = oauth_authorization::PendingOAuthAuthorization::generate(
3439            selected.oauth_code_policy().unwrap(),
3440            selected.oauth_authorization_policy().unwrap(),
3441            selected.policy_reference(),
3442            selected.principal_alias(),
3443            now + std::time::Duration::from_secs(1),
3444        )
3445        .unwrap();
3446        let url = transaction.authorization_url().unwrap();
3447        assert_eq!(
3448            format!("{sink:?}"),
3449            "SuppliedSessionOAuthAuthorization(<redacted>)"
3450        );
3451        sink.write_authorization_url(&url).unwrap();
3452        assert!(!sink.matches_policy(&selected));
3453        assert_eq!(
3454            sink.write_authorization_url(&url).unwrap_err(),
3455            OAuthAuthorizationInputError::AlreadyAttempted
3456        );
3457        let bytes = captured.lock().unwrap();
3458        assert_eq!(&bytes[..bytes.len() - 1], url.as_bytes());
3459        assert_eq!(bytes.last(), Some(&b'\n'));
3460        assert!(bytes.len() <= 4_097);
3461        let input = SuppliedSessionRuntimeInput::from(
3462            SuppliedSessionOAuthAuthorization::new(&selected, std::io::sink()).unwrap(),
3463        );
3464        assert!(input.matches_policy(&selected));
3465        assert!(!input.matches_policy(&renewal_policy()));
3466        assert_eq!(
3467            format!("{input:?}"),
3468            "SuppliedSessionRuntimeInput::OAuthAuthorization(<redacted>)"
3469        );
3470    }
3471
3472    #[test]
3473    fn oauth_authorization_writer_burns_invalid_or_failed_attempts_without_error_text() {
3474        struct FailedWriter;
3475        impl std::io::Write for FailedWriter {
3476            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
3477                Err(std::io::Error::other("PRIVATE_WRITER_ERROR_SENTINEL"))
3478            }
3479            fn flush(&mut self) -> std::io::Result<()> {
3480                Ok(())
3481            }
3482        }
3483        let selected = authorization_policy();
3484        let url = "https://issuer.test/realm/authorize?state=SYNTHETIC-HANDOFF";
3485        for invalid in [
3486            "https://foreign.test/realm/authorize?state=SYNTHETIC",
3487            "https://issuer.test/realm/authorize?",
3488            "https://issuer.test/realm/authorize?state=SYNTHETIC\nINJECTION",
3489        ] {
3490            let mut sink =
3491                SuppliedSessionOAuthAuthorization::new(&selected, std::io::sink()).unwrap();
3492            assert_eq!(
3493                sink.write_authorization_url(invalid).unwrap_err(),
3494                OAuthAuthorizationInputError::InvalidUrl
3495            );
3496            assert_eq!(
3497                sink.write_authorization_url(url).unwrap_err(),
3498                OAuthAuthorizationInputError::AlreadyAttempted
3499            );
3500        }
3501        let mut sink = SuppliedSessionOAuthAuthorization::new(&selected, FailedWriter).unwrap();
3502        let error = sink.write_authorization_url(url).unwrap_err();
3503        assert_eq!(error, OAuthAuthorizationInputError::WriteFailed);
3504        assert!(!format!("{error:?}: {error}").contains("PRIVATE_WRITER_ERROR_SENTINEL"));
3505        assert_eq!(
3506            sink.write_authorization_url(url).unwrap_err(),
3507            OAuthAuthorizationInputError::AlreadyAttempted
3508        );
3509    }
3510
3511    #[test]
3512    fn oauth_optional_policy_storage_is_indirect_for_legacy_contexts() {
3513        fn optional_payload(
3514            policy: &SuppliedSessionPolicy,
3515        ) -> &Option<Box<oauth_code::OAuthCodePolicy>> {
3516            &policy.oauth_code
3517        }
3518        let legacy = renewal_policy();
3519        assert!(optional_payload(&legacy).is_none());
3520        assert!(legacy.oauth_code_policy().is_none());
3521        assert_eq!(
3522            std::mem::size_of_val(optional_payload(&legacy)),
3523            std::mem::size_of::<usize>()
3524        );
3525    }
3526
3527    // Original, independently specified imported-code profile. It does not
3528    // assert that Termivar initiated an authorization or verified OIDC identity.
3529    #[test]
3530    fn imported_oauth_code_policy_is_a_distinct_bounded_acquisition() {
3531        let source = br#"
3532schema = "security.supplied-session-policy/v5"
3533principal_alias = "canary-a"
3534credential_mechanism = "authorization_header"
3535credential_acquisition = "imported_oauth_code_pkce"
3536health_path = "/app/health"
3537health_json_field = "authenticated"
3538resources = ["/app/private/one", "/app/private/two"]
3539max_session_requests = 6
3540max_total_response_bytes = 65536
3541max_response_body_bytes = 8192
3542max_wall_time_ms = 10000
3543
3544[oauth_code]
3545profile = "external-public-code-s256-bearer/v1"
3546transport_profile = "verified-https/v1"
3547issuer = "https://issuer.test/realm"
3548token_endpoint = "https://issuer.test/realm/token"
3549client_id = "termivar-owned-public"
3550redirect_uri = "https://client.test/callback"
3551scopes = ["canary-read"]
3552max_token_response_bytes = 8192
3553"#;
3554        let application = Url::parse("https://application.test/app/").unwrap();
3555        let parsed = SuppliedSessionPolicy::parse_toml(&application, source)
3556            .expect("the imported-code slice must have its own strict policy");
3557        assert_eq!(
3558            parsed.version().schema(),
3559            "security.supplied-session-policy/v5"
3560        );
3561        assert_eq!(parsed.resource_count(), 2);
3562        assert_eq!(parsed.max_session_requests(), 6);
3563    }
3564
3565    pub(crate) fn renewal_policy() -> SuppliedSessionPolicy {
3566        SuppliedSessionPolicy::parse_toml(
3567            &Url::parse("https://example.test/app/").unwrap(),
3568            renewal_source().as_bytes(),
3569        )
3570        .unwrap()
3571    }
3572
3573    fn renewal_source() -> String {
3574        r#"schema = "security.supplied-session-policy/v4"
3575principal_alias = "renewal-reader"
3576credential_mechanism = "cookie_jar"
3577cookie_update_policy = "stop_on_selected_cookie"
3578renewal_path = "/app/renew"
3579renewal_method = "post"
3580renewal_after_resource_count = 1
3581max_renewal_attempts = 1
3582health_path = "/app/health"
3583health_json_field = "authenticated"
3584resources = ["/app/before", "/app/after"]
3585max_session_requests = 7
3586max_total_response_bytes = 131072
3587max_response_body_bytes = 32768
3588max_wall_time_ms = 5000
3589[[cookies]]
3590id = "session"
3591name = "session"
3592domain = "example.test"
3593host_only = true
3594path = "/app/"
3595secure = true
3596http_only = true
3597same_site = "lax"
3598"#
3599        .to_owned()
3600    }
3601
3602    #[test]
3603    fn renewal_policy_is_closed_bounded_and_retains_private_cookie_scope() {
3604        let policy = renewal_policy();
3605        assert_eq!(policy.version(), SuppliedSessionPolicyVersion::V4);
3606        assert_eq!(policy.max_session_requests(), 7);
3607        let descriptor = SuppliedSessionRequestDescriptor::renewal(&policy).unwrap();
3608        assert!(descriptor.validate_against(&policy));
3609        assert_eq!(
3610            descriptor.target().as_str(),
3611            "https://example.test/app/renew"
3612        );
3613        let initial =
3614            SuppliedSessionCookies::parse_tsv(&policy, b"session\tinitial-canary".to_vec(), 0)
3615                .unwrap();
3616        assert!(initial
3617            .sensitive_header_for_target(&policy, descriptor.target(), 0)
3618            .is_ok());
3619        let replacement = SuppliedSessionCookies::from_form_login_set_cookie_fields(
3620            &policy,
3621            [
3622                b"session=replacement-canary; Path=/app/; Secure; HttpOnly; SameSite=Lax"
3623                    .as_slice(),
3624            ],
3625            0,
3626        )
3627        .unwrap();
3628        assert_eq!(replacement.cookie_count(), 1);
3629        for (from, to) in [
3630            ("max_session_requests = 7", "max_session_requests = 9"),
3631            ("renewal_method = \"post\"", "renewal_method = \"get\""),
3632            ("max_renewal_attempts = 1", "max_renewal_attempts = 2"),
3633            (
3634                "renewal_after_resource_count = 1",
3635                "renewal_after_resource_count = 0",
3636            ),
3637            (
3638                "renewal_after_resource_count = 1",
3639                "renewal_after_resource_count = 2",
3640            ),
3641            (
3642                "renewal_path = \"/app/renew\"",
3643                "renewal_path = \"/app-evil/renew\"",
3644            ),
3645            (
3646                "renewal_path = \"/app/renew\"",
3647                "renewal_path = \"/app/%2e%2e/renew\"",
3648            ),
3649            (
3650                "renewal_path = \"/app/renew\"",
3651                "renewal_path = \"/app/health\"",
3652            ),
3653            ("host_only = true", "host_only = false"),
3654            ("secure = true", "secure = false"),
3655            ("http_only = true", "http_only = false"),
3656            ("same_site = \"lax\"", "same_site = \"none\""),
3657            ("path = \"/app/\"", "path = \"/app/before\""),
3658        ] {
3659            assert!(
3660                SuppliedSessionPolicy::parse_toml(
3661                    &Url::parse("https://example.test/app/").unwrap(),
3662                    renewal_source().replace(from, to).as_bytes()
3663                )
3664                .is_err(),
3665                "accepted mutation {from}"
3666            );
3667        }
3668        for field in [
3669            b"session=rotated; Path=/app/; HttpOnly; SameSite=Lax".as_slice(),
3670            b"session=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax; Domain=example.test",
3671            b"session=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax; Max-Age=10",
3672            b"session=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax; Secure",
3673            b"other=rotated; Path=/app/; Secure; HttpOnly; SameSite=Lax",
3674        ] {
3675            assert!(
3676                SuppliedSessionCookies::from_form_login_set_cookie_fields(&policy, [field], 0)
3677                    .is_err()
3678            );
3679        }
3680    }
3681
3682    fn policy() -> &'static [u8] {
3683        br#"schema = "security.supplied-session-policy/v1"
3684principal_alias = "fixture-user"
3685credential_mechanism = "authorization_header"
3686health_path = "/app/session-health"
3687health_json_field = "authenticated"
3688resources = ["/app/private-one", "/app/private-two"]
3689max_session_requests = 5
3690max_total_response_bytes = 262144
3691max_response_body_bytes = 65536
3692max_wall_time_ms = 10000
3693"#
3694    }
3695
3696    fn cookie_policy() -> &'static [u8] {
3697        br#"schema = "security.supplied-session-policy/v2"
3698principal_alias = "fixture-user"
3699credential_mechanism = "cookie_jar"
3700cookie_update_policy = "stop_on_selected_cookie"
3701health_path = "/app/session-health"
3702health_json_field = "authenticated"
3703resources = ["/app/private-one", "/app/private-two"]
3704max_session_requests = 5
3705max_total_response_bytes = 262144
3706max_response_body_bytes = 65536
3707max_wall_time_ms = 10000
3708
3709[[cookies]]
3710id = "broad-session"
3711name = "session"
3712domain = "example.test"
3713host_only = true
3714path = "/app/"
3715secure = true
3716http_only = true
3717same_site = "lax"
3718
3719[[cookies]]
3720id = "narrow-session"
3721name = "session"
3722domain = "example.test"
3723host_only = false
3724path = "/app/private-one"
3725secure = true
3726http_only = true
3727same_site = "strict"
3728expires_unix_seconds = 4102444800
3729"#
3730    }
3731
3732    fn form_login_policy() -> &'static [u8] {
3733        br#"schema = "security.supplied-session-policy/v3"
3734principal_alias = "fixture-user"
3735credential_mechanism = "cookie_jar"
3736credential_acquisition = "bounded_form_login"
3737cookie_update_policy = "stop_on_selected_cookie"
3738login_path = "/app/login"
3739login_method = "post"
3740login_encoding = "application/x-www-form-urlencoded"
3741username_field = "user"
3742password_field = "pass"
3743csrf_field = "csrf"
3744max_login_attempts = 1
3745health_path = "/app/session-health"
3746health_json_field = "authenticated"
3747resources = ["/app/private-one", "/app/private-two"]
3748max_session_requests = 7
3749max_total_response_bytes = 262144
3750max_response_body_bytes = 65536
3751max_wall_time_ms = 10000
3752
3753[[cookies]]
3754id = "generated-session"
3755name = "session"
3756domain = "example.test"
3757host_only = true
3758path = "/app/"
3759secure = true
3760http_only = true
3761same_site = "lax"
3762"#
3763    }
3764
3765    #[test]
3766    fn policy_is_strict_bounded_and_path_scoped() {
3767        let application = Url::parse("https://example.test/app/").unwrap();
3768        let parsed = SuppliedSessionPolicy::parse_toml(&application, policy()).unwrap();
3769        assert_eq!(parsed.resource_count(), 2);
3770        assert_eq!(parsed.max_session_requests(), 5);
3771        assert!(parsed
3772            .policy_reference()
3773            .starts_with("supplied-session-policy-sha256:"));
3774        assert_eq!(
3775            parsed.policy_reference(),
3776            "supplied-session-policy-sha256:502b4044585b1cb18b9d4692f3c864d4611bb51e4aef0663188951de15a018c0"
3777        );
3778        assert_eq!(parsed.version(), SuppliedSessionPolicyVersion::V1);
3779        assert_eq!(parsed.schema(), SUPPLIED_SESSION_POLICY_SCHEMA);
3780        assert_eq!(parsed.cookie_update_policy(), None);
3781        assert_eq!(parsed.cookie_summary(), None);
3782        assert!(parsed
3783            .application_reference()
3784            .starts_with("supplied-session-application-sha256:"));
3785        assert_eq!(
3786            parsed.principal_reference(),
3787            "supplied-session-principal-0001"
3788        );
3789        assert_eq!(parsed.principal_alias(), "fixture-user");
3790        assert!(!format!("{parsed:?}").contains("/app/private"));
3791        assert!(!format!("{parsed:?}").contains("fixture-user"));
3792        let renamed = std::str::from_utf8(policy())
3793            .unwrap()
3794            .replace("fixture-user", "another-low-entropy-label");
3795        let renamed = SuppliedSessionPolicy::parse_toml(&application, renamed.as_bytes()).unwrap();
3796        assert_eq!(parsed.policy_reference(), renamed.policy_reference());
3797        assert_eq!(parsed.principal_reference(), renamed.principal_reference());
3798        assert_ne!(parsed.principal_alias(), renamed.principal_alias());
3799
3800        for invalid in [
3801            "/app/../admin",
3802            "/app/%2e%2e/admin",
3803            "/app",
3804            "/application/private",
3805            "/app/logout",
3806            "/app/account/logout.php",
3807            "/app/delete.php",
3808            "/app/admin-panel/",
3809            "https://example.test/app/private",
3810            "/app/private?x=1",
3811            "/app/privaté",
3812            "/app/private[0]",
3813        ] {
3814            let text = std::str::from_utf8(policy()).unwrap().replace(
3815                "resources = [\"/app/private-one\", \"/app/private-two\"]",
3816                &format!("resources = [\"{invalid}\"]"),
3817            );
3818            assert!(SuppliedSessionPolicy::parse_toml(&application, text.as_bytes()).is_err());
3819        }
3820
3821        let ordinary = std::str::from_utf8(policy()).unwrap().replace(
3822            "resources = [\"/app/private-one\", \"/app/private-two\"]",
3823            "resources = [\"/app/administrator-guide\"]",
3824        );
3825        let ordinary = ordinary.replace("max_session_requests = 5", "max_session_requests = 3");
3826        assert!(SuppliedSessionPolicy::parse_toml(&application, ordinary.as_bytes()).is_ok());
3827    }
3828
3829    #[test]
3830    fn policy_rejects_duplicates_unknowns_and_inconsistent_limits() {
3831        let application = Url::parse("https://example.test/app/").unwrap();
3832        let duplicate = std::str::from_utf8(policy()).unwrap().replace(
3833            "resources = [\"/app/private-one\", \"/app/private-two\"]",
3834            "resources = [\"/app/private-one\", \"/app/private-one\"]",
3835        );
3836        assert_eq!(
3837            SuppliedSessionPolicy::parse_toml(&application, duplicate.as_bytes()).unwrap_err(),
3838            SuppliedSessionPolicyError::InvalidPath
3839        );
3840        let unknown = [policy(), b"unknown = true\n"].concat();
3841        assert_eq!(
3842            SuppliedSessionPolicy::parse_toml(&application, &unknown).unwrap_err(),
3843            SuppliedSessionPolicyError::MalformedPolicy
3844        );
3845        let too_few = std::str::from_utf8(policy())
3846            .unwrap()
3847            .replace("max_session_requests = 5", "max_session_requests = 4");
3848        assert_eq!(
3849            SuppliedSessionPolicy::parse_toml(&application, too_few.as_bytes()).unwrap_err(),
3850            SuppliedSessionPolicyError::InvalidLimits
3851        );
3852    }
3853
3854    #[test]
3855    fn form_login_policy_preserves_a_closed_one_attempt_transition() {
3856        let application = Url::parse("https://example.test/app/").unwrap();
3857        let parsed = SuppliedSessionPolicy::parse_toml(&application, form_login_policy()).unwrap();
3858        assert_eq!(parsed.version(), SuppliedSessionPolicyVersion::V3);
3859        assert_eq!(parsed.schema(), SUPPLIED_SESSION_FORM_LOGIN_POLICY_SCHEMA);
3860        assert_eq!(
3861            parsed.credential_mechanism(),
3862            SuppliedSessionCredentialMechanism::CookieJar
3863        );
3864        assert_eq!(
3865            parsed.credential_acquisition(),
3866            SuppliedSessionCredentialAcquisition::BoundedFormLogin
3867        );
3868        assert_eq!(parsed.max_login_attempts(), Some(1));
3869        assert_eq!(parsed.resource_count(), 2);
3870        assert_eq!(parsed.max_session_requests(), 7);
3871        assert_eq!(
3872            parsed.login_url().unwrap().as_str(),
3873            "https://example.test/app/login"
3874        );
3875        assert_eq!(parsed.login_username_field(), Some("user"));
3876        assert_eq!(parsed.login_password_field(), Some("pass"));
3877        assert_eq!(parsed.login_csrf_field(), Some("csrf"));
3878        assert_eq!(parsed.cookie_summary().unwrap().cookie_count(), 1);
3879        assert!(parsed
3880            .policy_reference()
3881            .starts_with("supplied-session-policy-sha256:"));
3882
3883        let page = SuppliedSessionRequestDescriptor::login_page(&parsed).unwrap();
3884        let submit = SuppliedSessionRequestDescriptor::login_submit(&parsed).unwrap();
3885        assert_eq!(page.purpose(), SuppliedSessionRequestPurpose::LoginPage);
3886        assert_eq!(submit.purpose(), SuppliedSessionRequestPurpose::LoginSubmit);
3887        assert!(page.validate_against(&parsed));
3888        assert!(submit.validate_against(&parsed));
3889        assert_eq!(page.target(), submit.target());
3890        assert_eq!(page.target_reference(), submit.target_reference());
3891
3892        for (needle, replacement) in [
3893            ("max_login_attempts = 1", "max_login_attempts = 2"),
3894            ("login_method = \"post\"", "login_method = \"get\""),
3895            (
3896                "login_encoding = \"application/x-www-form-urlencoded\"",
3897                "login_encoding = \"multipart/form-data\"",
3898            ),
3899            (
3900                "login_path = \"/app/login\"",
3901                "login_path = \"/app/logout\"",
3902            ),
3903            ("host_only = true", "host_only = false"),
3904            ("secure = true", "secure = false"),
3905            ("http_only = true", "http_only = false"),
3906        ] {
3907            let changed =
3908                std::str::from_utf8(form_login_policy())
3909                    .unwrap()
3910                    .replacen(needle, replacement, 1);
3911            assert!(
3912                SuppliedSessionPolicy::parse_toml(&application, changed.as_bytes()).is_err(),
3913                "mutation unexpectedly accepted: {needle} -> {replacement}"
3914            );
3915        }
3916    }
3917
3918    #[test]
3919    fn form_login_secret_and_generated_cookie_are_bounded_and_redacted() {
3920        let application = Url::parse("https://example.test/app/").unwrap();
3921        let parsed = SuppliedSessionPolicy::parse_toml(&application, form_login_policy()).unwrap();
3922        let credential = SuppliedSessionFormCredential::parse_tsv(
3923            &parsed,
3924            b"username\tUSER-CANARY\npassword\tPASSWORD-CANARY\n".to_vec(),
3925        )
3926        .unwrap();
3927        assert_eq!(
3928            format!("{credential:?}"),
3929            "SuppliedSessionFormCredential(<redacted>)"
3930        );
3931        assert!(!format!("{credential:?}").contains("CANARY"));
3932        assert!(SuppliedSessionRuntimeInput::from(credential).matches_policy(&parsed));
3933
3934        let credential = SuppliedSessionFormCredential::parse_tsv(
3935            &parsed,
3936            b"username\tUSER CANARY\npassword\tPASSWORD&CANARY\n".to_vec(),
3937        )
3938        .unwrap();
3939        let body = credential
3940            .compose_form_body(&parsed, "CSRF+/=CANARY")
3941            .unwrap();
3942        assert_eq!(
3943            std::str::from_utf8(body.as_bytes()).unwrap(),
3944            "csrf=CSRF%2B%2F%3DCANARY&user=USER+CANARY&pass=PASSWORD%26CANARY"
3945        );
3946        assert!(!format!("{body:?}").contains("CANARY"));
3947
3948        let worst_case_credential = SuppliedSessionFormCredential::parse_tsv(
3949            &parsed,
3950            format!(
3951                "username\t{}\npassword\t{}\n",
3952                "%".repeat(MAX_LOGIN_USERNAME_BYTES),
3953                "%".repeat(MAX_LOGIN_PASSWORD_BYTES)
3954            )
3955            .into_bytes(),
3956        )
3957        .unwrap();
3958        let worst_case_body = worst_case_credential
3959            .compose_form_body(&parsed, &"%".repeat(MAX_LOGIN_CSRF_VALUE_BYTES))
3960            .unwrap();
3961        assert!(worst_case_body.as_bytes().len() > 10_624);
3962        assert!(worst_case_body.as_bytes().len() <= MAX_LOGIN_FORM_BODY_BYTES);
3963
3964        for invalid in [
3965            b"username\tONLY\n".as_slice(),
3966            b"password\tPASS\nusername\tUSER\nextra\tVALUE\n".as_slice(),
3967            b"username\tONE\nusername\tTWO\npassword\tPASS\n".as_slice(),
3968            b"username\tUSER\npassword\tBAD\tVALUE\n".as_slice(),
3969            b"\xef\xbb\xbfusername\tUSER\npassword\tPASS\n".as_slice(),
3970        ] {
3971            assert!(SuppliedSessionFormCredential::parse_tsv(&parsed, invalid.to_vec()).is_err());
3972        }
3973
3974        let cookies = SuppliedSessionCookies::from_form_login_set_cookie_fields(
3975            &parsed,
3976            [b"session=SESSION-CANARY; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice()],
3977            2_000_000_000,
3978        )
3979        .unwrap();
3980        assert_eq!(cookies.cookie_count(), 1);
3981        assert!(!format!("{cookies:?}").contains("CANARY"));
3982        let health = SuppliedSessionRequestDescriptor::health(&parsed);
3983        assert_eq!(
3984            cookies
3985                .sensitive_header_for_target(&parsed, health.target(), 2_000_000_000)
3986                .unwrap()
3987                .to_str()
3988                .unwrap(),
3989            "session=SESSION-CANARY"
3990        );
3991
3992        for invalid in [
3993            b"session=VALUE; Path=/app/; Secure; SameSite=Lax".as_slice(),
3994            b"session=VALUE; Path=/app/; Secure; HttpOnly; SameSite=None".as_slice(),
3995            b"session=VALUE; Domain=example.test; Path=/app/; Secure; HttpOnly; SameSite=Lax"
3996                .as_slice(),
3997            b"session=VALUE; Path=/app/; Secure; HttpOnly; SameSite=Lax; Max-Age=10".as_slice(),
3998            b"other=VALUE; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
3999        ] {
4000            assert!(SuppliedSessionCookies::from_form_login_set_cookie_fields(
4001                &parsed,
4002                [invalid],
4003                2_000_000_000,
4004            )
4005            .is_err());
4006        }
4007    }
4008
4009    #[test]
4010    fn form_login_generated_cookies_with_one_name_are_bound_by_exact_path() {
4011        let application = Url::parse("https://example.test/app/").unwrap();
4012        let two_cookie_policy = [
4013            form_login_policy(),
4014            br#"
4015[[cookies]]
4016id = "generated-root-session"
4017name = "session"
4018domain = "example.test"
4019host_only = true
4020path = "/"
4021secure = true
4022http_only = true
4023same_site = "lax"
4024"#,
4025        ]
4026        .concat();
4027        let parsed = SuppliedSessionPolicy::parse_toml(&application, &two_cookie_policy).unwrap();
4028        assert_eq!(parsed.cookie_summary().unwrap().cookie_count(), 2);
4029
4030        // Response field order does not select policy metadata. The complete
4031        // attribute tuple binds each value, and outbound cookie order remains
4032        // the RFC path-length order used by supplied-cookie sessions.
4033        let cookies = SuppliedSessionCookies::from_form_login_set_cookie_fields(
4034            &parsed,
4035            [
4036                b"session=ROOT-CANARY; Path=/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4037                b"session=APP-CANARY; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4038            ],
4039            2_000_000_000,
4040        )
4041        .unwrap();
4042        assert_eq!(cookies.cookie_count(), 2);
4043        let health = SuppliedSessionRequestDescriptor::health(&parsed);
4044        assert_eq!(
4045            cookies
4046                .sensitive_header_for_target(&parsed, health.target(), 2_000_000_000)
4047                .unwrap()
4048                .to_str()
4049                .unwrap(),
4050            "session=APP-CANARY; session=ROOT-CANARY"
4051        );
4052        assert!(SuppliedSessionCookies::from_form_login_set_cookie_fields(
4053            &parsed,
4054            [
4055                b"session=ONE; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4056                b"session=TWO; Path=/app/; Secure; HttpOnly; SameSite=Lax".as_slice(),
4057            ],
4058            2_000_000_000,
4059        )
4060        .is_err());
4061    }
4062
4063    #[test]
4064    fn authorization_is_move_only_bounded_and_redacted() {
4065        let authorization = SuppliedSessionAuthorization::new("Bearer SECRET-CANARY").unwrap();
4066        assert_eq!(
4067            format!("{authorization:?}"),
4068            "SuppliedSessionAuthorization(<redacted>)"
4069        );
4070        assert!(!format!("{authorization:?}").contains("SECRET-CANARY"));
4071        for invalid in [
4072            "",
4073            "Bearer",
4074            "Bearer  spaced",
4075            "Bearer trailing ",
4076            "Bad\r\n injected",
4077        ] {
4078            assert!(SuppliedSessionAuthorization::new(invalid).is_err());
4079        }
4080    }
4081
4082    #[test]
4083    fn cookie_policy_and_secret_preserve_metadata_and_rfc_path_order() {
4084        let application = Url::parse("https://example.test/app/").unwrap();
4085        let parsed = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4086        assert_eq!(parsed.version(), SuppliedSessionPolicyVersion::V2);
4087        assert_eq!(parsed.schema(), SUPPLIED_SESSION_COOKIE_POLICY_SCHEMA);
4088        assert_eq!(
4089            parsed.credential_mechanism(),
4090            SuppliedSessionCredentialMechanism::CookieJar
4091        );
4092        assert_eq!(
4093            parsed.cookie_update_policy(),
4094            Some(SuppliedSessionCookieUpdatePolicy::StopOnSelectedCookie)
4095        );
4096        let summary = parsed.cookie_summary().unwrap();
4097        assert_eq!(summary.cookie_count(), 2);
4098        assert_eq!(summary.host_only_count(), 1);
4099        assert_eq!(summary.domain_count(), 1);
4100        assert_eq!(summary.secure_count(), 2);
4101        assert_eq!(summary.http_only_count(), 2);
4102        assert_eq!(summary.session_count(), 1);
4103        assert_eq!(summary.persistent_count(), 1);
4104        assert_eq!(summary.same_site_lax_count(), 1);
4105        assert_eq!(summary.same_site_strict_count(), 1);
4106        assert_eq!(summary.same_site_missing_count(), 0);
4107        assert_eq!(summary.same_site_none_count(), 0);
4108        assert!(parsed.is_selected_cookie_name("session"));
4109        assert!(!parsed.is_selected_cookie_name("Session"));
4110
4111        let cookies = SuppliedSessionCookies::parse_tsv(
4112            &parsed,
4113            b"narrow-session\tNARROW-CANARY\r\nbroad-session\tBROAD-CANARY\n".to_vec(),
4114            2_000_000_000,
4115        )
4116        .unwrap();
4117        assert_eq!(cookies.cookie_count(), 2);
4118        assert_eq!(
4119            format!("{cookies:?}"),
4120            "SuppliedSessionCookies { values: \"<redacted>\", cookie_count: 2 }"
4121        );
4122        assert!(!format!("{cookies:?}").contains("CANARY"));
4123
4124        let target = Url::parse("https://example.test/app/private-one").unwrap();
4125        let header = cookies
4126            .sensitive_header_for_target(&parsed, &target, 2_000_000_000)
4127            .unwrap();
4128        assert!(header.is_sensitive());
4129        assert_eq!(
4130            header.to_str().unwrap(),
4131            "session=NARROW-CANARY; session=BROAD-CANARY"
4132        );
4133
4134        let second = Url::parse("https://example.test/app/private-two").unwrap();
4135        assert_eq!(
4136            cookies
4137                .sensitive_header_for_target(&parsed, &second, 2_000_000_000)
4138                .unwrap()
4139                .to_str()
4140                .unwrap(),
4141            "session=BROAD-CANARY"
4142        );
4143        assert_eq!(
4144            cookies
4145                .sensitive_header_for_target(
4146                    &parsed,
4147                    &Url::parse("https://example.test/app/unselected").unwrap(),
4148                    2_000_000_000,
4149                )
4150                .unwrap_err(),
4151            SuppliedSessionCookieError::TargetNotAdmitted
4152        );
4153    }
4154
4155    #[test]
4156    fn cookie_policy_rejects_unsafe_scope_duplicates_and_wrong_contracts() {
4157        let application = Url::parse("https://example.test/app/").unwrap();
4158        for (needle, replacement) in [
4159            ("domain = \"example.test\"", "domain = \"parent.test\""),
4160            ("path = \"/app/\"", "path = \"/application/\""),
4161            ("same_site = \"lax\"", "same_site = \"future\""),
4162            (
4163                "cookie_update_policy = \"stop_on_selected_cookie\"",
4164                "cookie_update_policy = \"apply_automatically\"",
4165            ),
4166            (
4167                "credential_mechanism = \"cookie_jar\"",
4168                "credential_mechanism = \"authorization_header\"",
4169            ),
4170        ] {
4171            let changed =
4172                std::str::from_utf8(cookie_policy())
4173                    .unwrap()
4174                    .replacen(needle, replacement, 1);
4175            assert!(SuppliedSessionPolicy::parse_toml(&application, changed.as_bytes()).is_err());
4176        }
4177
4178        let duplicate_id = std::str::from_utf8(cookie_policy())
4179            .unwrap()
4180            .replace("id = \"narrow-session\"", "id = \"broad-session\"");
4181        assert_eq!(
4182            SuppliedSessionPolicy::parse_toml(&application, duplicate_id.as_bytes()).unwrap_err(),
4183            SuppliedSessionPolicyError::InvalidCookieMetadata
4184        );
4185        let duplicate_tuple = std::str::from_utf8(cookie_policy())
4186            .unwrap()
4187            .replace("path = \"/app/private-one\"", "path = \"/app/\"");
4188        assert_eq!(
4189            SuppliedSessionPolicy::parse_toml(&application, duplicate_tuple.as_bytes())
4190                .unwrap_err(),
4191            SuppliedSessionPolicyError::InvalidCookieMetadata
4192        );
4193        let insecure_none = std::str::from_utf8(cookie_policy())
4194            .unwrap()
4195            .replacen("secure = true", "secure = false", 1)
4196            .replacen("same_site = \"lax\"", "same_site = \"none\"", 1);
4197        assert_eq!(
4198            SuppliedSessionPolicy::parse_toml(&application, insecure_none.as_bytes()).unwrap_err(),
4199            SuppliedSessionPolicyError::InvalidCookieMetadata
4200        );
4201
4202        let ip_application = Url::parse("https://127.0.0.1/app/").unwrap();
4203        let ip_domain = std::str::from_utf8(cookie_policy())
4204            .unwrap()
4205            .replace("example.test", "127.0.0.1")
4206            .replace("host_only = true", "host_only = false");
4207        assert_eq!(
4208            SuppliedSessionPolicy::parse_toml(&ip_application, ip_domain.as_bytes()).unwrap_err(),
4209            SuppliedSessionPolicyError::InvalidCookieMetadata
4210        );
4211    }
4212
4213    #[test]
4214    fn cookie_secret_is_exact_bounded_redacted_and_expiry_aware() {
4215        let application = Url::parse("https://example.test/app/").unwrap();
4216        let parsed = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4217        for invalid in [
4218            b"broad-session\tVALUE\n".as_slice(),
4219            b"broad-session\tVALUE\nnarrow-session\tVALUE\nextra\tVALUE\n".as_slice(),
4220            b"broad-session\tVALUE\nbroad-session\tOTHER\nnarrow-session\tVALUE\n".as_slice(),
4221            b"broad-session\tBAD VALUE\nnarrow-session\tVALUE\n".as_slice(),
4222            b"broad-session\tBAD;VALUE\nnarrow-session\tVALUE\n".as_slice(),
4223            b"\n".as_slice(),
4224        ] {
4225            assert_eq!(
4226                SuppliedSessionCookies::parse_tsv(&parsed, invalid.to_vec(), 2_000_000_000)
4227                    .unwrap_err(),
4228                SuppliedSessionCookieError::MalformedSecret
4229            );
4230        }
4231        assert_eq!(
4232            SuppliedSessionCookies::parse_tsv(
4233                &parsed,
4234                vec![b'a'; HARD_MAX_SUPPLIED_SESSION_COOKIE_SECRET_BYTES + 1],
4235                2_000_000_000,
4236            )
4237            .unwrap_err(),
4238            SuppliedSessionCookieError::SecretTooLarge
4239        );
4240
4241        let expired = std::str::from_utf8(cookie_policy()).unwrap().replace(
4242            "expires_unix_seconds = 4102444800",
4243            "expires_unix_seconds = 1",
4244        );
4245        let expired = SuppliedSessionPolicy::parse_toml(&application, expired.as_bytes()).unwrap();
4246        let values = b"broad-session\tBROAD\nnarrow-session\tNARROW\n".to_vec();
4247        assert_eq!(
4248            SuppliedSessionCookies::parse_tsv(&expired, values, 2_000_000_000).unwrap_err(),
4249            SuppliedSessionCookieError::IncompleteInitialCoverage
4250        );
4251
4252        let authorization_policy =
4253            SuppliedSessionPolicy::parse_toml(&application, policy()).unwrap();
4254        assert_eq!(
4255            SuppliedSessionCookies::parse_tsv(
4256                &authorization_policy,
4257                b"broad-session\tSECRET\n".to_vec(),
4258                2_000_000_000,
4259            )
4260            .unwrap_err(),
4261            SuppliedSessionCookieError::PolicyMismatch
4262        );
4263    }
4264
4265    #[test]
4266    fn cookie_header_limit_and_mid_run_expiry_fail_closed() {
4267        let application = Url::parse("https://example.test/app/").unwrap();
4268        let parsed = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4269        let secret = format!(
4270            "broad-session\t{}\nnarrow-session\t{}\n",
4271            "a".repeat(MAX_COOKIE_VALUE_BYTES),
4272            "b".repeat(MAX_COOKIE_VALUE_BYTES)
4273        );
4274        let cookies =
4275            SuppliedSessionCookies::parse_tsv(&parsed, secret.into_bytes(), 2_000_000_000).unwrap();
4276        let target = Url::parse("https://example.test/app/private-one").unwrap();
4277        assert_eq!(
4278            cookies
4279                .sensitive_header_for_target(&parsed, &target, 2_000_000_000)
4280                .unwrap_err(),
4281            SuppliedSessionCookieError::HeaderTooLarge
4282        );
4283
4284        let one_cookie = std::str::from_utf8(cookie_policy()).unwrap();
4285        let one_cookie = one_cookie
4286            .split("\n[[cookies]]\nid = \"narrow-session\"")
4287            .next()
4288            .unwrap();
4289        let one_cookie = format!("{one_cookie}\nexpires_unix_seconds = 2000000001\n");
4290        let one_cookie =
4291            SuppliedSessionPolicy::parse_toml(&application, one_cookie.as_bytes()).unwrap();
4292        let cookies = SuppliedSessionCookies::parse_tsv(
4293            &one_cookie,
4294            b"broad-session\tSECRET\n".to_vec(),
4295            2_000_000_000,
4296        )
4297        .unwrap();
4298        let health = SuppliedSessionRequestDescriptor::health(&one_cookie);
4299        assert_eq!(
4300            cookies
4301                .sensitive_header_for_target(&one_cookie, health.target(), 2_000_000_001)
4302                .unwrap_err(),
4303            SuppliedSessionCookieError::NoApplicableCookie
4304        );
4305
4306        let partially_expiring = std::str::from_utf8(cookie_policy()).unwrap().replace(
4307            "expires_unix_seconds = 4102444800",
4308            "expires_unix_seconds = 2000000001",
4309        );
4310        let partially_expiring =
4311            SuppliedSessionPolicy::parse_toml(&application, partially_expiring.as_bytes()).unwrap();
4312        let cookies = SuppliedSessionCookies::parse_tsv(
4313            &partially_expiring,
4314            b"broad-session\tBROAD\nnarrow-session\tNARROW\n".to_vec(),
4315            2_000_000_000,
4316        )
4317        .unwrap();
4318        let target = Url::parse("https://example.test/app/private-one").unwrap();
4319        assert_eq!(
4320            cookies
4321                .sensitive_header_for_target(&partially_expiring, &target, 2_000_000_001)
4322                .unwrap_err(),
4323            SuppliedSessionCookieError::NoApplicableCookie
4324        );
4325    }
4326
4327    #[test]
4328    fn typed_credentials_are_redacted_and_mechanism_specific() {
4329        let authorization = SuppliedSessionCredential::Authorization(
4330            SuppliedSessionAuthorization::new("Bearer SECRET-CANARY").unwrap(),
4331        );
4332        assert_eq!(
4333            authorization.mechanism(),
4334            SuppliedSessionCredentialMechanism::AuthorizationHeader
4335        );
4336        assert!(authorization.authorization().is_some());
4337        assert!(authorization.cookies().is_none());
4338        assert!(!format!("{authorization:?}").contains("SECRET-CANARY"));
4339
4340        let application = Url::parse("https://example.test/app/").unwrap();
4341        let policy = SuppliedSessionPolicy::parse_toml(&application, cookie_policy()).unwrap();
4342        let cookies = SuppliedSessionCookies::parse_tsv(
4343            &policy,
4344            b"broad-session\tBROAD\nnarrow-session\tNARROW\n".to_vec(),
4345            2_000_000_000,
4346        )
4347        .unwrap();
4348        let cookies = SuppliedSessionCredential::SuppliedCookies(cookies);
4349        assert_eq!(
4350            cookies.mechanism(),
4351            SuppliedSessionCredentialMechanism::CookieJar
4352        );
4353        assert!(cookies.authorization().is_none());
4354        assert!(cookies.cookies().is_some());
4355        assert!(!format!("{cookies:?}").contains("BROAD"));
4356    }
4357
4358    #[test]
4359    fn request_descriptor_binds_its_closed_purpose() {
4360        let application = Url::parse("https://example.test/app/").unwrap();
4361        let parsed = SuppliedSessionPolicy::parse_toml(&application, policy()).unwrap();
4362        let mut health = SuppliedSessionRequestDescriptor::health(&parsed);
4363        assert!(health.validate());
4364        assert!(health.validate_against(&parsed));
4365        health.purpose = SuppliedSessionRequestPurpose::Resource;
4366        assert!(!health.validate());
4367        assert!(!health.validate_against(&parsed));
4368
4369        let (target, reference) = parsed.execution_resources().next().unwrap();
4370        let mut resource = SuppliedSessionRequestDescriptor::resource(&parsed, target, reference)
4371            .expect("literal resource must be admitted");
4372        assert!(resource.validate());
4373        assert!(resource.validate_against(&parsed));
4374        resource.purpose = SuppliedSessionRequestPurpose::Health;
4375        assert!(!resource.validate());
4376
4377        let mut mechanism = SuppliedSessionRequestDescriptor::health(&parsed);
4378        assert_eq!(
4379            mechanism.credential_mechanism(),
4380            SuppliedSessionCredentialMechanism::AuthorizationHeader
4381        );
4382        mechanism.credential_mechanism = SuppliedSessionCredentialMechanism::CookieJar;
4383        assert!(!mechanism.validate());
4384
4385        let changed_policy_source = std::str::from_utf8(policy())
4386            .unwrap()
4387            .replace("max_wall_time_ms = 10000", "max_wall_time_ms = 9999");
4388        let changed_policy =
4389            SuppliedSessionPolicy::parse_toml(&application, changed_policy_source.as_bytes())
4390                .unwrap();
4391        let descriptor = SuppliedSessionRequestDescriptor::health(&parsed);
4392        assert!(descriptor.validate());
4393        assert!(!descriptor.validate_against(&changed_policy));
4394
4395        let mut wrong_reference = SuppliedSessionRequestDescriptor::health(&parsed);
4396        wrong_reference.policy_reference = changed_policy.policy_reference().to_owned();
4397        assert!(!wrong_reference.validate());
4398        assert!(!wrong_reference.validate_against(&parsed));
4399    }
4400}