Skip to main content

termivar_scanner/supplied_session_review/
oauth_code.rs

1//! Pure codecs for an explicitly imported public-client code/S256 grant.
2//!
3//! Importing a verifier/challenge pair does not prove client initiation, CSRF
4//! protection, issuer authenticity or authorization-server PKCE enforcement.
5//! Transport, single-use dispatch, response completeness and runtime authority
6//! belong to the parent runtime, never to these parsers.
7
8use std::{fmt, ops::Range};
9
10use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
11use serde::{
12    de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
13    Deserialize,
14};
15use sha2::{Digest, Sha256};
16use thiserror::Error;
17use url::{Host, Url};
18use zeroize::Zeroizing;
19
20use super::{
21    SuppliedSessionAuthorization, SuppliedSessionPolicy, SuppliedSessionPolicyError,
22    HARD_MAX_SUPPLIED_SESSION_OAUTH_CODE_SECRET_BYTES,
23    MAX_SUPPLIED_SESSION_OAUTH_TOKEN_RESPONSE_BYTES,
24};
25
26const PROFILE: &str = "external-public-code-s256-bearer/v1";
27const AUTHORIZATION_PROFILE: &str = "client-initiated-public-code-s256-bearer/v1";
28const HTTPS: &str = "verified-https/v1";
29const OWNED_HTTP: &str = "owned-numeric-loopback-http/v1";
30const MAX_URL_BYTES: usize = 2_048;
31const MAX_CLIENT_BYTES: usize = 128;
32const MAX_SCOPE_COUNT: usize = 8;
33const MAX_SCOPE_BYTES: usize = 64;
34const MAX_CODE_BYTES: usize = 2_048;
35const MAX_TOKEN_BYTES: usize = 8 * 1_024;
36const MAX_FORM_BYTES: usize = 16 * 1_024;
37const MAX_JSON_NODES: usize = 256;
38const MAX_JSON_KEYS: usize = 128;
39const MAX_JSON_KEY_BYTES: usize = 128;
40const MAX_JSON_DEPTH: usize = 8;
41const MAX_UNIX_SECONDS: i64 = 253_402_300_799;
42
43#[derive(Deserialize)]
44#[serde(deny_unknown_fields)]
45pub(super) struct WireOAuthCodePolicy {
46    pub(super) profile: String,
47    pub(super) transport_profile: String,
48    pub(super) issuer: String,
49    pub(super) token_endpoint: String,
50    pub(super) client_id: String,
51    pub(super) redirect_uri: String,
52    pub(super) scopes: Vec<String>,
53    pub(super) max_token_response_bytes: usize,
54}
55
56/// Validated non-secret configuration, not a network permit.
57#[derive(Clone)]
58pub(crate) struct OAuthCodePolicy {
59    profile: &'static str,
60    issuer: Url,
61    token_endpoint: Url,
62    client_id: String,
63    redirect_uri: Url,
64    scopes: Vec<String>,
65    max_token_response_bytes: usize,
66    transport_profile: String,
67    binding_reference: String,
68}
69
70impl OAuthCodePolicy {
71    pub(super) fn from_wire(wire: WireOAuthCodePolicy) -> Result<Self, SuppliedSessionPolicyError> {
72        Self::from_profile(wire, PROFILE)
73    }
74
75    /// Separate admission for a client-generated transaction. Historical
76    /// imported-code profiles retain their original HTTPS redirect rules.
77    pub(super) fn from_authorization_wire(
78        wire: WireOAuthCodePolicy,
79    ) -> Result<Self, SuppliedSessionPolicyError> {
80        Self::from_profile(wire, AUTHORIZATION_PROFILE)
81    }
82
83    fn from_profile(
84        mut wire: WireOAuthCodePolicy,
85        profile: &'static str,
86    ) -> Result<Self, SuppliedSessionPolicyError> {
87        let invalid = || {
88            if profile == AUTHORIZATION_PROFILE {
89                SuppliedSessionPolicyError::InvalidOAuthAuthorization
90            } else {
91                SuppliedSessionPolicyError::InvalidOAuthCode
92            }
93        };
94        if wire.profile != profile
95            || !matches!(wire.transport_profile.as_str(), HTTPS | OWNED_HTTP)
96            || wire.client_id.is_empty()
97            || wire.client_id.len() > MAX_CLIENT_BYTES
98            || !wire.client_id.bytes().all(visible_ascii)
99            || wire.scopes.is_empty()
100            || wire.scopes.len() > MAX_SCOPE_COUNT
101            || wire.scopes.iter().any(|scope| !valid_scope(scope))
102            || wire.max_token_response_bytes == 0
103            || wire.max_token_response_bytes > MAX_SUPPLIED_SESSION_OAUTH_TOKEN_RESPONSE_BYTES
104        {
105            return Err(invalid());
106        }
107        wire.scopes.sort();
108        if wire.scopes.windows(2).any(|pair| pair[0] == pair[1]) {
109            return Err(invalid());
110        }
111        let issuer = safe_url(&wire.issuer, &wire.transport_profile).ok_or_else(invalid)?;
112        let token_endpoint =
113            safe_url(&wire.token_endpoint, &wire.transport_profile).ok_or_else(invalid)?;
114        let redirect_uri = if profile == AUTHORIZATION_PROFILE {
115            authorization_callback_url(&wire.redirect_uri)
116        } else {
117            safe_url(&wire.redirect_uri, &wire.transport_profile)
118        }
119        .ok_or_else(invalid)?;
120        if issuer.origin() != token_endpoint.origin()
121            || !path_beneath(issuer.path(), token_endpoint.path())
122        {
123            return Err(invalid());
124        }
125        let mut hasher = Sha256::new();
126        hasher.update(b"security.supplied-session.oauth-code-policy.v1\0");
127        for field in [
128            profile,
129            wire.transport_profile.as_str(),
130            issuer.as_str(),
131            token_endpoint.as_str(),
132            wire.client_id.as_str(),
133            redirect_uri.as_str(),
134        ] {
135            super::hash_field(&mut hasher, field.as_bytes());
136        }
137        let scope_count = u64::try_from(wire.scopes.len()).map_err(|_| invalid())?;
138        super::hash_field(&mut hasher, &scope_count.to_be_bytes());
139        for scope in &wire.scopes {
140            super::hash_field(&mut hasher, scope.as_bytes());
141        }
142        let response_cap = u64::try_from(wire.max_token_response_bytes).map_err(|_| invalid())?;
143        super::hash_field(&mut hasher, &response_cap.to_be_bytes());
144        let binding_reference =
145            format!("oauth-code-policy-sha256:{}", super::hex(hasher.finalize()));
146        Ok(Self {
147            profile,
148            issuer,
149            token_endpoint,
150            client_id: wire.client_id,
151            redirect_uri,
152            scopes: wire.scopes,
153            max_token_response_bytes: wire.max_token_response_bytes,
154            transport_profile: wire.transport_profile,
155            binding_reference,
156        })
157    }
158
159    pub(crate) fn issuer(&self) -> &Url {
160        &self.issuer
161    }
162
163    pub(super) fn is_client_initiated(&self) -> bool {
164        self.profile == AUTHORIZATION_PROFILE
165    }
166
167    pub(crate) fn token_endpoint(&self) -> &Url {
168        &self.token_endpoint
169    }
170
171    pub(crate) fn client_id(&self) -> &str {
172        &self.client_id
173    }
174
175    pub(crate) fn redirect_uri(&self) -> &Url {
176        &self.redirect_uri
177    }
178
179    pub(crate) fn scopes(&self) -> &[String] {
180        &self.scopes
181    }
182
183    pub(crate) const fn max_token_response_bytes(&self) -> usize {
184        self.max_token_response_bytes
185    }
186
187    pub(crate) fn transport_profile(&self) -> &str {
188        &self.transport_profile
189    }
190
191    pub(crate) fn binding_reference(&self) -> &str {
192        &self.binding_reference
193    }
194
195    pub(crate) fn is_valid_for_application(&self, application: &Url) -> bool {
196        // The parent owns validation of the original application input. This
197        // method narrows an already parsed application; it cannot recover raw
198        // material that a caller normalized before constructing its Url.
199        application.path().ends_with('/')
200            && safe_url(application.as_str(), &self.transport_profile).as_ref() == Some(application)
201    }
202}
203
204impl fmt::Debug for OAuthCodePolicy {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        formatter
207            .debug_struct("OAuthCodePolicy")
208            .field("profile", &self.profile)
209            .field("transport_profile", &self.transport_profile)
210            .field("scope_count", &self.scopes.len())
211            .field("max_token_response_bytes", &self.max_token_response_bytes)
212            .field("binding_reference", &self.binding_reference)
213            .finish()
214    }
215}
216
217pub(super) fn safe_url(raw: &str, transport: &str) -> Option<Url> {
218    if raw.is_empty()
219        || raw.len() > MAX_URL_BYTES
220        || !raw.bytes().all(visible_ascii)
221        || raw
222            .bytes()
223            .any(|byte| matches!(byte, b'?' | b'#' | b'%' | b'\\'))
224    {
225        return None;
226    }
227    let (scheme, remainder) = raw.split_once("://")?;
228    if !matches!(scheme, "https" | "http") || remainder.starts_with('/') {
229        return None;
230    }
231    let authority_end = remainder.find('/').unwrap_or(remainder.len());
232    if remainder[..authority_end].contains('@') {
233        return None;
234    }
235    let raw_path = &remainder[authority_end..];
236    if raw_path.contains("//") || raw_path.split('/').any(|part| matches!(part, "." | "..")) {
237        return None;
238    }
239    let parsed = Url::parse(raw).ok()?;
240    if parsed.as_str() != raw
241        || !parsed.username().is_empty()
242        || parsed.password().is_some()
243        || parsed.query().is_some()
244        || parsed.fragment().is_some()
245        || parsed.port() == Some(0)
246    {
247        return None;
248    }
249    match parsed.host()? {
250        Host::Domain(host) if host.ends_with('.') => return None,
251        _ => {},
252    }
253    if parsed.scheme() == "http"
254        && !(transport == OWNED_HTTP
255            && match parsed.host()? {
256                Host::Ipv4(address) => address.is_loopback(),
257                Host::Ipv6(address) => address.is_loopback(),
258                Host::Domain(_) => false,
259            })
260    {
261        return None;
262    }
263    Some(parsed)
264}
265
266pub(super) fn path_beneath(parent: &str, child: &str) -> bool {
267    let directory = parent.trim_end_matches('/');
268    directory.is_empty()
269        || child == directory
270        || child
271            .strip_prefix(directory)
272            .is_some_and(|suffix| suffix.starts_with('/'))
273}
274
275fn authorization_callback_url(raw: &str) -> Option<Url> {
276    // This exception applies ONLY to the public client's explicitly registered
277    // callback, never its issuer/token endpoint or credentialed application.
278    let callback = safe_url(raw, OWNED_HTTP)?;
279    let port = callback.port().filter(|port| *port >= 1_024)?;
280    (callback.scheme() == "http"
281        && callback.host() == Some(Host::Ipv4(std::net::Ipv4Addr::LOCALHOST))
282        && callback.path() == "/termivar/oauth/callback"
283        && callback.as_str() == format!("http://127.0.0.1:{port}/termivar/oauth/callback"))
284    .then_some(callback)
285}
286
287fn visible_ascii(byte: u8) -> bool {
288    matches!(byte, 0x21..=0x7e)
289}
290
291fn valid_scope(scope: &str) -> bool {
292    !scope.is_empty()
293        && scope.len() <= MAX_SCOPE_BYTES
294        && !matches!(scope, "openid" | "offline_access")
295        && scope
296            .bytes()
297            .all(|byte| matches!(byte, 0x21 | 0x23..=0x5b | 0x5d..=0x7e))
298}
299
300fn scope_matches(raw: &str, policy: &OAuthCodePolicy) -> bool {
301    let mut scopes = Vec::new();
302    for scope in raw.split(' ') {
303        if scopes.len() == MAX_SCOPE_COUNT || !valid_scope(scope) {
304            return false;
305        }
306        scopes.push(scope);
307    }
308    scopes.sort_unstable();
309    !scopes.windows(2).any(|pair| pair[0] == pair[1])
310        && scopes.len() == policy.scopes().len()
311        && scopes
312            .iter()
313            .zip(policy.scopes())
314            .all(|(actual, expected)| *actual == expected.as_str())
315}
316
317/// Static failures which never include grant, token or provider text.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
319#[non_exhaustive]
320pub enum OAuthCodeInputError {
321    /// A secret source exceeded its fixed input ceiling.
322    #[error("imported OAuth code input exceeds its byte limit")]
323    SecretTooLarge,
324    /// Rows, text, exact keys or the local verifier relationship were invalid.
325    #[error("imported OAuth code input is malformed")]
326    MalformedGrant,
327    /// Repeated context did not agree with the independently selected policy.
328    #[error("imported OAuth code input does not match the selected policy")]
329    PolicyMismatch,
330    /// The asserted receipt time was outside the permitted freshness window.
331    #[error("imported OAuth code receipt time is outside its permitted window")]
332    StaleGrant,
333    /// The token response was structurally invalid or exceeded local limits.
334    #[error("OAuth token response is invalid for the selected bounded profile")]
335    InvalidTokenResponse,
336    /// The response required unsupported token or identity semantics.
337    #[error("OAuth token response requires an unsupported token profile")]
338    UnsupportedTokenProfile,
339}
340
341/// Move-only imported grant bound to one selected policy and principal alias.
342pub struct SuppliedSessionOAuthGrant {
343    backing: Zeroizing<Vec<u8>>,
344    code: Range<usize>,
345    verifier: Range<usize>,
346    policy_reference: String,
347    oauth_binding: String,
348    principal_alias: String,
349    received_unix_seconds: i64,
350}
351
352impl SuppliedSessionOAuthGrant {
353    /// Parses eight exact TSV keys without trimming or exporting secret values.
354    /// Receipt time and the local S256 relationship are assertions, not proof of
355    /// an authorization interaction or authorization-server enforcement.
356    pub fn parse_tsv(
357        policy: &SuppliedSessionPolicy,
358        source: Vec<u8>,
359        now_unix_seconds: i64,
360    ) -> Result<Self, OAuthCodeInputError> {
361        let backing = Zeroizing::new(source);
362        if backing.len() > HARD_MAX_SUPPLIED_SESSION_OAUTH_CODE_SECRET_BYTES {
363            return Err(OAuthCodeInputError::SecretTooLarge);
364        }
365        if !admits_imported_code(policy) {
366            return Err(OAuthCodeInputError::PolicyMismatch);
367        }
368        let oauth = policy
369            .oauth_code_policy()
370            .ok_or(OAuthCodeInputError::PolicyMismatch)?;
371        if backing.is_empty()
372            || backing.starts_with(&[0xef, 0xbb, 0xbf])
373            || std::str::from_utf8(&backing).is_err()
374        {
375            return Err(OAuthCodeInputError::MalformedGrant);
376        }
377        let mut fields: [Option<Range<usize>>; 8] = std::array::from_fn(|_| None);
378        let mut cursor = 0;
379        let mut rows = 0;
380        while cursor < backing.len() {
381            rows += 1;
382            if rows > fields.len() {
383                return Err(OAuthCodeInputError::MalformedGrant);
384            }
385            let physical_end = backing[cursor..]
386                .iter()
387                .position(|byte| *byte == b'\n')
388                .map_or(backing.len(), |offset| cursor + offset);
389            let mut end = physical_end;
390            if physical_end < backing.len() && end > cursor && backing[end - 1] == b'\r' {
391                end -= 1;
392            }
393            let line = &backing[cursor..end];
394            let tab = line
395                .iter()
396                .position(|byte| *byte == b'\t')
397                .ok_or(OAuthCodeInputError::MalformedGrant)?;
398            let index = match &line[..tab] {
399                b"authorization_code" => 0,
400                b"code_verifier" => 1,
401                b"code_challenge" => 2,
402                b"issuer" => 3,
403                b"client_id" => 4,
404                b"redirect_uri" => 5,
405                b"scope" => 6,
406                b"received_unix_seconds" => 7,
407                _ => return Err(OAuthCodeInputError::MalformedGrant),
408            };
409            let start = cursor + tab + 1;
410            if fields[index].is_some()
411                || start == end
412                || !backing[start..end]
413                    .iter()
414                    .all(|byte| matches!(*byte, 0x20..=0x7e))
415            {
416                return Err(OAuthCodeInputError::MalformedGrant);
417            }
418            fields[index] = Some(start..end);
419            cursor = if physical_end == backing.len() {
420                backing.len()
421            } else {
422                physical_end + 1
423            };
424        }
425        if rows != fields.len() || fields.iter().any(Option::is_none) {
426            return Err(OAuthCodeInputError::MalformedGrant);
427        }
428        let value = |index: usize| {
429            std::str::from_utf8(&backing[fields[index].clone().expect("all fields checked")])
430                .expect("the complete guarded input is UTF-8")
431        };
432        if value(0).len() > MAX_CODE_BYTES
433            || !value(0).bytes().all(visible_ascii)
434            || !(43..=128).contains(&value(1).len())
435            || !value(1).bytes().all(|byte| {
436                byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')
437            })
438            || value(2).len() != 43
439            || !value(2)
440                .bytes()
441                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
442        {
443            return Err(OAuthCodeInputError::MalformedGrant);
444        }
445        let digest = Zeroizing::new(<[u8; 32]>::from(Sha256::digest(value(1).as_bytes())));
446        let expected = Zeroizing::new(URL_SAFE_NO_PAD.encode(digest.as_slice()));
447        if expected.as_bytes() != value(2).as_bytes() {
448            return Err(OAuthCodeInputError::MalformedGrant);
449        }
450        if value(3) != oauth.issuer().as_str()
451            || value(4) != oauth.client_id()
452            || value(5) != oauth.redirect_uri().as_str()
453            || !scope_matches(value(6), oauth)
454        {
455            return Err(OAuthCodeInputError::PolicyMismatch);
456        }
457        let timestamp = value(7);
458        if timestamp.len() > 12
459            || !timestamp.bytes().all(|byte| byte.is_ascii_digit())
460            || (timestamp.len() > 1 && timestamp.starts_with('0'))
461        {
462            return Err(OAuthCodeInputError::MalformedGrant);
463        }
464        let received_unix_seconds = timestamp
465            .parse::<i64>()
466            .map_err(|_| OAuthCodeInputError::MalformedGrant)?;
467        if !fresh(received_unix_seconds, now_unix_seconds) {
468            return Err(OAuthCodeInputError::StaleGrant);
469        }
470        let code = fields[0].clone().expect("all fields checked");
471        let verifier = fields[1].clone().expect("all fields checked");
472        Ok(Self {
473            backing,
474            code,
475            verifier,
476            policy_reference: policy.policy_reference().to_owned(),
477            oauth_binding: oauth.binding_reference.clone(),
478            principal_alias: policy.principal_alias().to_owned(),
479            received_unix_seconds,
480        })
481    }
482
483    pub(crate) fn matches_policy(&self, policy: &SuppliedSessionPolicy) -> bool {
484        admits_imported_code(policy)
485            && self.policy_reference == policy.policy_reference()
486            && self.principal_alias == policy.principal_alias()
487            && policy
488                .oauth_code_policy()
489                .is_some_and(|oauth| self.oauth_binding == oauth.binding_reference)
490    }
491
492    /// Rechecks only the imported, operator-asserted receipt timestamp.
493    pub(crate) fn is_fresh_at(&self, now_unix_seconds: i64) -> bool {
494        fresh(self.received_unix_seconds, now_unix_seconds)
495    }
496
497    pub(crate) fn compose_form_body(
498        &self,
499        policy: &SuppliedSessionPolicy,
500    ) -> Result<OAuthCodeFormBody, OAuthCodeInputError> {
501        if !self.matches_policy(policy) {
502            return Err(OAuthCodeInputError::PolicyMismatch);
503        }
504        let oauth = policy
505            .oauth_code_policy()
506            .ok_or(OAuthCodeInputError::PolicyMismatch)?;
507        let text = |range: Range<usize>| {
508            std::str::from_utf8(&self.backing[range]).expect("grant UTF-8 was validated")
509        };
510        let mut body = Zeroizing::new(String::new());
511        {
512            let mut form = url::form_urlencoded::Serializer::new(&mut *body);
513            form.append_pair("grant_type", "authorization_code");
514            form.append_pair("client_id", oauth.client_id());
515            form.append_pair("redirect_uri", oauth.redirect_uri().as_str());
516            form.append_pair("code", text(self.code.clone()));
517            form.append_pair("code_verifier", text(self.verifier.clone()));
518            form.finish();
519        }
520        if body.len() > MAX_FORM_BYTES {
521            return Err(OAuthCodeInputError::SecretTooLarge);
522        }
523        Ok(OAuthCodeFormBody { value: body })
524    }
525}
526
527fn admits_imported_code(policy: &SuppliedSessionPolicy) -> bool {
528    use super::SuppliedSessionPolicyVersion;
529    match policy.version() {
530        SuppliedSessionPolicyVersion::V5 => policy.oauth_refresh_policy().is_none(),
531        SuppliedSessionPolicyVersion::V6 => policy.oauth_refresh_policy().is_some_and(|refresh| {
532            refresh.profile() == "scheduled-public-bearer-refresh/v1"
533                && refresh.after_resource_count() == 1
534                && refresh.max_attempts() == 1
535        }),
536        _ => false,
537    }
538}
539
540fn fresh(received: i64, now: i64) -> bool {
541    (0..=MAX_UNIX_SECONDS).contains(&received)
542        && (0..=MAX_UNIX_SECONDS).contains(&now)
543        && if received <= now {
544            now - received <= 60
545        } else {
546            received - now <= 5
547        }
548}
549
550impl fmt::Debug for SuppliedSessionOAuthGrant {
551    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
552        formatter.write_str("SuppliedSessionOAuthGrant(<redacted>)")
553    }
554}
555
556/// Guarded form bytes available only at the crate-owned composition boundary.
557pub(crate) struct OAuthCodeFormBody {
558    value: Zeroizing<String>,
559}
560
561impl OAuthCodeFormBody {
562    pub(crate) fn as_bytes(&self) -> &[u8] {
563        self.value.as_bytes()
564    }
565}
566
567impl fmt::Debug for OAuthCodeFormBody {
568    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
569        formatter.write_str("OAuthCodeFormBody(<redacted>)")
570    }
571}
572
573/// A guarded response reduction, not proof of token acceptance or identity.
574pub(crate) struct OAuthCodeTokenResponse {
575    access_token: Zeroizing<String>,
576    expires_in_seconds: u64,
577    refresh_token_returned: bool,
578}
579
580impl OAuthCodeTokenResponse {
581    pub(crate) fn into_authorization(
582        self,
583    ) -> Result<SuppliedSessionAuthorization, OAuthCodeInputError> {
584        authorization_from_token(self.access_token)
585    }
586
587    pub(crate) const fn expires_in_seconds(&self) -> u64 {
588        self.expires_in_seconds
589    }
590
591    pub(crate) const fn refresh_token_returned(&self) -> bool {
592        self.refresh_token_returned
593    }
594}
595
596impl fmt::Debug for OAuthCodeTokenResponse {
597    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
598        formatter.write_str("OAuthCodeTokenResponse(<redacted>)")
599    }
600}
601
602/// Parses inert bounded JSON. The caller separately proves HTTP status, MIME,
603/// identity coding, end-of-body, byte accounting and response commit.
604pub(crate) fn parse_token_response(
605    policy: &OAuthCodePolicy,
606    body: Zeroizing<Vec<u8>>,
607) -> Result<OAuthCodeTokenResponse, OAuthCodeInputError> {
608    let fields = parse_token_response_fields(policy, body, RefreshTokenHandling::Discard)?;
609    Ok(OAuthCodeTokenResponse {
610        access_token: fields.access_token,
611        expires_in_seconds: fields.expires_in_seconds,
612        refresh_token_returned: fields.refresh_token_returned,
613    })
614}
615
616/// Private reduction modes; retaining refresh material is not network authority.
617#[derive(Clone, Copy, PartialEq, Eq)]
618pub(super) enum RefreshTokenHandling {
619    Discard,
620    RetainAscii,
621}
622
623pub(super) struct ParsedOAuthTokenResponse {
624    pub(super) access_token: Zeroizing<String>,
625    pub(super) expires_in_seconds: u64,
626    pub(super) refresh_token_returned: bool,
627    pub(super) retained_refresh_token: Option<Zeroizing<String>>,
628}
629
630/// Both profiles use this one bounded JSON parser. Legacy V5 still drops a
631/// returned refresh value immediately and retains only its presence boolean.
632pub(super) fn parse_token_response_fields(
633    policy: &OAuthCodePolicy,
634    body: Zeroizing<Vec<u8>>,
635    refresh_handling: RefreshTokenHandling,
636) -> Result<ParsedOAuthTokenResponse, OAuthCodeInputError> {
637    if body.is_empty()
638        || body.len() > policy.max_token_response_bytes
639        || body.len() > MAX_SUPPLIED_SESSION_OAUTH_TOKEN_RESPONSE_BYTES
640    {
641        return Err(OAuthCodeInputError::InvalidTokenResponse);
642    }
643    let mut budget = JsonBudget::default();
644    let mut decoder = serde_json::Deserializer::from_slice(&body);
645    let value = JsonSeed {
646        budget: &mut budget,
647        depth: 0,
648    }
649    .deserialize(&mut decoder)
650    .map_err(|_| OAuthCodeInputError::InvalidTokenResponse)?;
651    decoder
652        .end()
653        .map_err(|_| OAuthCodeInputError::InvalidTokenResponse)?;
654    let JsonValue::Object(entries) = value else {
655        return Err(OAuthCodeInputError::InvalidTokenResponse);
656    };
657    let mut access_token = None;
658    let mut token_type = None;
659    let mut expires = None;
660    let mut scope = None;
661    let mut refresh_token_returned = false;
662    let mut retained_refresh_token = None;
663    for (key, value) in entries {
664        match key.as_str() {
665            "id_token" => return Err(OAuthCodeInputError::UnsupportedTokenProfile),
666            "cnf" if refresh_handling == RefreshTokenHandling::RetainAscii => {
667                return Err(OAuthCodeInputError::UnsupportedTokenProfile);
668            },
669            "error" | "error_description" | "error_uri" => {
670                return Err(OAuthCodeInputError::InvalidTokenResponse);
671            },
672            "access_token" => access_token = Some(require_string(value)?),
673            "token_type" => token_type = Some(require_string(value)?),
674            "expires_in" => {
675                let JsonValue::Unsigned(seconds @ 1..=3_600) = value else {
676                    return Err(OAuthCodeInputError::InvalidTokenResponse);
677                };
678                expires = Some(seconds);
679            },
680            "scope" => scope = Some(require_string(value)?),
681            "refresh_token" => {
682                let token = require_string(value)?;
683                if token.as_str().is_empty() {
684                    return Err(OAuthCodeInputError::InvalidTokenResponse);
685                }
686                refresh_token_returned = true;
687                if refresh_handling == RefreshTokenHandling::RetainAscii {
688                    if !token
689                        .as_str()
690                        .bytes()
691                        .all(|byte| matches!(byte, 0x20..=0x7e))
692                    {
693                        return Err(OAuthCodeInputError::InvalidTokenResponse);
694                    }
695                    retained_refresh_token = Some(token.value);
696                }
697                // Discard mode drops the guarded string here, as before V6.
698            },
699            _ => {}, // RFC 6749 section 5.1 extension values remain inert.
700        }
701    }
702    let access_token = access_token.ok_or(OAuthCodeInputError::InvalidTokenResponse)?;
703    let token_type = token_type.ok_or(OAuthCodeInputError::InvalidTokenResponse)?;
704    let scope = scope.ok_or(OAuthCodeInputError::InvalidTokenResponse)?;
705    if !token_type.as_str().eq_ignore_ascii_case("Bearer") {
706        return Err(OAuthCodeInputError::UnsupportedTokenProfile);
707    }
708    if !valid_bearer(access_token.as_str()) || !scope_matches(scope.as_str(), policy) {
709        return Err(OAuthCodeInputError::InvalidTokenResponse);
710    }
711    Ok(ParsedOAuthTokenResponse {
712        access_token: access_token.value,
713        expires_in_seconds: expires.ok_or(OAuthCodeInputError::InvalidTokenResponse)?,
714        refresh_token_returned,
715        retained_refresh_token,
716    })
717}
718
719pub(super) fn authorization_from_token(
720    access_token: Zeroizing<String>,
721) -> Result<SuppliedSessionAuthorization, OAuthCodeInputError> {
722    let mut header = Zeroizing::new(Vec::with_capacity(7 + access_token.len()));
723    header.extend_from_slice(b"Bearer ");
724    header.extend_from_slice(access_token.as_bytes());
725    SuppliedSessionAuthorization::new(std::mem::take(&mut *header))
726        .map_err(|_| OAuthCodeInputError::InvalidTokenResponse)
727}
728
729fn valid_bearer(value: &str) -> bool {
730    if value.is_empty()
731        || value.len() > MAX_TOKEN_BYTES
732        || value.len() + 7 > super::MAX_AUTHORIZATION_HEADER_BYTES
733    {
734        return false;
735    }
736    let (base, padding) = value
737        .find('=')
738        .map_or((value, ""), |index| value.split_at(index));
739    !base.is_empty()
740        && base.bytes().all(|byte| {
741            byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'+' | b'/')
742        })
743        && padding.bytes().all(|byte| byte == b'=')
744}
745
746fn require_string(value: JsonValue) -> Result<GuardedText, OAuthCodeInputError> {
747    match value {
748        JsonValue::Text(text) => Ok(text),
749        _ => Err(OAuthCodeInputError::InvalidTokenResponse),
750    }
751}
752
753struct GuardedText {
754    value: Zeroizing<String>,
755}
756
757impl GuardedText {
758    fn as_str(&self) -> &str {
759        self.value.as_str()
760    }
761}
762
763impl<'de> Deserialize<'de> for GuardedText {
764    fn deserialize<D: de::Deserializer<'de>>(decoder: D) -> Result<Self, D::Error> {
765        struct TextVisitor;
766        impl Visitor<'_> for TextVisitor {
767            type Value = GuardedText;
768
769            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
770                formatter.write_str("a bounded OAuth response string")
771            }
772
773            fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
774                self.visit_string(value.to_owned())
775            }
776
777            fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
778                let value = Zeroizing::new(value);
779                if value.len() > MAX_TOKEN_BYTES {
780                    return Err(E::custom("OAuth response string exceeds its bound"));
781                }
782                Ok(GuardedText { value })
783            }
784        }
785        decoder.deserialize_string(TextVisitor)
786    }
787}
788
789enum JsonValue {
790    Text(GuardedText),
791    Unsigned(u64),
792    Object(Vec<(GuardedText, Self)>),
793    Other,
794}
795
796#[derive(Default)]
797struct JsonBudget {
798    nodes: usize,
799    keys: usize,
800    string_bytes: usize,
801}
802
803impl JsonBudget {
804    fn string<E: de::Error>(&mut self, length: usize) -> Result<(), E> {
805        self.string_bytes = self
806            .string_bytes
807            .checked_add(length)
808            .ok_or_else(|| E::custom("OAuth response string accounting exceeded"))?;
809        if self.string_bytes > MAX_SUPPLIED_SESSION_OAUTH_TOKEN_RESPONSE_BYTES {
810            return Err(E::custom("OAuth response string accounting exceeded"));
811        }
812        Ok(())
813    }
814}
815
816struct JsonSeed<'a> {
817    budget: &'a mut JsonBudget,
818    depth: usize,
819}
820
821impl<'de> DeserializeSeed<'de> for JsonSeed<'_> {
822    type Value = JsonValue;
823
824    fn deserialize<D: de::Deserializer<'de>>(self, decoder: D) -> Result<Self::Value, D::Error> {
825        self.budget.nodes = self.budget.nodes.checked_add(1).ok_or_else(|| {
826            <D::Error as de::Error>::custom("OAuth response node accounting exceeded")
827        })?;
828        if self.budget.nodes > MAX_JSON_NODES || self.depth > MAX_JSON_DEPTH {
829            return Err(de::Error::custom(
830                "OAuth response nesting or work limit exceeded",
831            ));
832        }
833        decoder.deserialize_any(JsonVisitor {
834            budget: self.budget,
835            depth: self.depth,
836        })
837    }
838}
839
840struct JsonVisitor<'a> {
841    budget: &'a mut JsonBudget,
842    depth: usize,
843}
844
845impl<'de> Visitor<'de> for JsonVisitor<'_> {
846    type Value = JsonValue;
847
848    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
849        formatter.write_str("bounded OAuth response JSON")
850    }
851
852    fn visit_bool<E: de::Error>(self, _: bool) -> Result<Self::Value, E> {
853        Ok(JsonValue::Other)
854    }
855
856    fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
857        Ok(JsonValue::Other)
858    }
859
860    fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
861        Ok(u64::try_from(value).map_or(JsonValue::Other, JsonValue::Unsigned))
862    }
863
864    fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
865        Ok(JsonValue::Unsigned(value))
866    }
867
868    fn visit_f64<E: de::Error>(self, _: f64) -> Result<Self::Value, E> {
869        Ok(JsonValue::Other)
870    }
871
872    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
873        self.visit_string(value.to_owned())
874    }
875
876    fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
877        let value = Zeroizing::new(value);
878        if value.len() > MAX_TOKEN_BYTES {
879            return Err(E::custom("OAuth response string exceeds its bound"));
880        }
881        self.budget.string(value.len())?;
882        Ok(JsonValue::Text(GuardedText { value }))
883    }
884
885    fn visit_seq<A: SeqAccess<'de>>(self, mut sequence: A) -> Result<Self::Value, A::Error> {
886        while sequence
887            .next_element_seed(JsonSeed {
888                budget: &mut *self.budget,
889                depth: self.depth + 1,
890            })?
891            .is_some()
892        {}
893        Ok(JsonValue::Other)
894    }
895
896    fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
897        let mut entries: Vec<(GuardedText, JsonValue)> = Vec::new();
898        while let Some(key) = map.next_key::<GuardedText>()? {
899            self.budget.keys = self.budget.keys.checked_add(1).ok_or_else(|| {
900                <A::Error as de::Error>::custom("OAuth response key accounting exceeded")
901            })?;
902            if self.budget.keys > MAX_JSON_KEYS
903                || key.as_str().len() > MAX_JSON_KEY_BYTES
904                || entries
905                    .iter()
906                    .any(|(previous, _)| previous.as_str() == key.as_str())
907            {
908                return Err(de::Error::custom("OAuth response key contract exceeded"));
909            }
910            self.budget.string(key.as_str().len())?;
911            let value = map.next_value_seed(JsonSeed {
912                budget: &mut *self.budget,
913                depth: self.depth + 1,
914            })?;
915            entries.push((key, value));
916        }
917        Ok(JsonValue::Object(entries))
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    // Public RFC 7636 appendix-B known-answer values, not captured credentials:
926    // https://www.rfc-editor.org/rfc/rfc7636.html#appendix-B
927    const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
928    const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
929    const RESPONSE: &str = r#"{"access_token":"SYNTHETIC.ACCESS_TOKEN+/==","token_type":"Bearer","expires_in":60,"scope":"canary-read"}"#;
930
931    fn wire() -> WireOAuthCodePolicy {
932        WireOAuthCodePolicy {
933            profile: PROFILE.to_owned(),
934            transport_profile: HTTPS.to_owned(),
935            issuer: "https://issuer.test/realm".to_owned(),
936            token_endpoint: "https://issuer.test/realm/token".to_owned(),
937            client_id: "owned-client".to_owned(),
938            redirect_uri: "https://client.test/callback".to_owned(),
939            scopes: vec!["canary-read".to_owned()],
940            max_token_response_bytes: 32_768,
941        }
942    }
943
944    fn oauth() -> OAuthCodePolicy {
945        OAuthCodePolicy::from_wire(wire()).unwrap()
946    }
947
948    fn policy(alias: &str) -> SuppliedSessionPolicy {
949        let source = format!(
950            r#"schema = "security.supplied-session-policy/v5"
951principal_alias = "{alias}"
952credential_mechanism = "authorization_header"
953credential_acquisition = "imported_oauth_code_pkce"
954health_path = "/app/health"
955health_json_field = "authenticated"
956resources = ["/app/private"]
957max_session_requests = 4
958max_total_response_bytes = 65536
959max_response_body_bytes = 8192
960max_wall_time_ms = 10000
961[oauth_code]
962profile = "external-public-code-s256-bearer/v1"
963transport_profile = "verified-https/v1"
964issuer = "https://issuer.test/realm"
965token_endpoint = "https://issuer.test/realm/token"
966client_id = "owned-client"
967redirect_uri = "https://client.test/callback"
968scopes = ["canary-read"]
969max_token_response_bytes = 8192
970"#
971        );
972        SuppliedSessionPolicy::parse_toml(
973            &Url::parse("https://application.test/app/").unwrap(),
974            source.as_bytes(),
975        )
976        .unwrap()
977    }
978
979    fn grant_source(code: &str, received: i64) -> Vec<u8> {
980        format!(
981            "authorization_code\t{code}\ncode_verifier\t{VERIFIER}\ncode_challenge\t{CHALLENGE}\nissuer\thttps://issuer.test/realm\nclient_id\towned-client\nredirect_uri\thttps://client.test/callback\nscope\tcanary-read\nreceived_unix_seconds\t{received}\n"
982        )
983        .into_bytes()
984    }
985
986    fn token(body: &str) -> Result<OAuthCodeTokenResponse, OAuthCodeInputError> {
987        parse_token_response(&oauth(), Zeroizing::new(body.as_bytes().to_vec()))
988    }
989
990    #[test]
991    fn oauth_code_policy_has_stable_nonsecret_canonical_binding_and_bounds() {
992        let original = oauth();
993        assert_eq!(original.issuer().as_str(), "https://issuer.test/realm");
994        assert_eq!(
995            original.token_endpoint().as_str(),
996            "https://issuer.test/realm/token"
997        );
998        assert_eq!(original.client_id(), "owned-client");
999        assert_eq!(
1000            original.redirect_uri().as_str(),
1001            "https://client.test/callback"
1002        );
1003        assert_eq!(original.scopes(), &["canary-read"]);
1004        assert_eq!(original.max_token_response_bytes(), 32_768);
1005        assert_eq!(original.transport_profile(), HTTPS);
1006        // Independently frozen with Python hashlib/struct over nine literal
1007        // framed non-secret fields, not generated by this production helper.
1008        assert_eq!(
1009            original.binding_reference(),
1010            "oauth-code-policy-sha256:72e15098e7d39ca96ee6619165d087b919b49b2862cbe50393c5bc79f2a558f9"
1011        );
1012        assert!(original
1013            .binding_reference()
1014            .starts_with("oauth-code-policy-sha256:"));
1015        assert_eq!(
1016            original
1017                .binding_reference()
1018                .strip_prefix("oauth-code-policy-sha256:")
1019                .unwrap()
1020                .len(),
1021            64
1022        );
1023        let mut first = wire();
1024        first.scopes = vec!["write".to_owned(), "read".to_owned()];
1025        let mut second = wire();
1026        second.scopes = vec!["read".to_owned(), "write".to_owned()];
1027        let first = OAuthCodePolicy::from_wire(first).unwrap();
1028        let second = OAuthCodePolicy::from_wire(second).unwrap();
1029        assert_eq!(first.binding_reference(), second.binding_reference());
1030        let mut smaller = wire();
1031        smaller.max_token_response_bytes -= 1;
1032        assert_ne!(
1033            original.binding_reference(),
1034            OAuthCodePolicy::from_wire(smaller)
1035                .unwrap()
1036                .binding_reference()
1037        );
1038        assert!(original
1039            .is_valid_for_application(&Url::parse("https://application.test/app/").unwrap()));
1040        assert!(
1041            !original.is_valid_for_application(&Url::parse("http://127.0.0.1:8080/app/").unwrap())
1042        );
1043    }
1044
1045    #[test]
1046    fn client_authorization_factory_is_distinct_and_does_not_relax_imported_profiles() {
1047        let callback = "http://127.0.0.1:18443/termivar/oauth/callback";
1048        let mut imported = wire();
1049        imported.redirect_uri = callback.to_owned();
1050        assert!(OAuthCodePolicy::from_wire(imported).is_err());
1051        let mut initiated = wire();
1052        initiated.profile = AUTHORIZATION_PROFILE.to_owned();
1053        initiated.redirect_uri = callback.to_owned();
1054        let initiated = OAuthCodePolicy::from_authorization_wire(initiated).unwrap();
1055        assert!(initiated.is_client_initiated());
1056        assert_eq!(initiated.transport_profile(), "verified-https/v1");
1057        assert_eq!(initiated.issuer().as_str(), "https://issuer.test/realm");
1058        assert_eq!(initiated.redirect_uri().as_str(), callback);
1059        assert!(
1060            !initiated.is_valid_for_application(&Url::parse("http://127.0.0.1:8080/app/").unwrap())
1061        );
1062        assert!(!oauth().is_client_initiated());
1063        assert!(OAuthCodePolicy::from_authorization_wire(wire()).is_err());
1064        for callback in [
1065            "https://127.0.0.1:18443/termivar/oauth/callback",
1066            "http://localhost:18443/termivar/oauth/callback",
1067            "http://127.0.0.2:18443/termivar/oauth/callback",
1068            "http://[::1]:18443/termivar/oauth/callback",
1069            "http://127.0.0.1:1023/termivar/oauth/callback",
1070            "http://127.0.0.1:0/termivar/oauth/callback",
1071            "http://127.0.0.1:18443/termivar/oauth/%63allback",
1072            "http://127.0.0.1:18443/termivar/oauth/callback?state=secret",
1073            "http://127.0.0.1:18443/termivar/oauth/callback/",
1074            "http://127.0.0.1:18443/other",
1075        ] {
1076            let mut value = wire();
1077            value.profile = AUTHORIZATION_PROFILE.to_owned();
1078            value.redirect_uri = callback.to_owned();
1079            assert!(OAuthCodePolicy::from_authorization_wire(value).is_err());
1080        }
1081        for endpoint in [
1082            "http://127.0.0.1:18444/realm/token",
1083            "https://foreign.test/realm/token",
1084            "https://issuer.test/realm-evil/token",
1085        ] {
1086            let mut value = wire();
1087            value.profile = AUTHORIZATION_PROFILE.to_owned();
1088            value.redirect_uri = callback.to_owned();
1089            value.token_endpoint = endpoint.to_owned();
1090            assert!(OAuthCodePolicy::from_authorization_wire(value).is_err());
1091        }
1092    }
1093
1094    #[test]
1095    fn oauth_code_policy_rejects_unsafe_urls_before_normalization() {
1096        for value in [
1097            "https://issuer.test/realm/../other",
1098            "https://issuer.test/realm/./token",
1099            "https://issuer.test/realm/%2e%2e/token",
1100            "https://issuer.test/realm%2ftoken",
1101            "https://issuer.test/realm\\token",
1102            "https://issuer.test/realm?code=value",
1103            "https://issuer.test/realm#part",
1104            "https://reader@issuer.test/realm",
1105            "https://reader:password@issuer.test/realm",
1106            "https://ISSUER.test/realm",
1107            "https://issuer.test:443/realm",
1108            "https:issuer.test/realm",
1109            " https://issuer.test/realm",
1110            "https://issuer.test/realm\n",
1111            "https://issuer.test//realm",
1112            "https://issuer.test./realm",
1113            "https://issuer.test:0/realm",
1114        ] {
1115            let mut input = wire();
1116            input.issuer = value.to_owned();
1117            assert!(
1118                OAuthCodePolicy::from_wire(input).is_err(),
1119                "unsafe issuer admitted"
1120            );
1121            let mut input = wire();
1122            input.redirect_uri = value.to_owned();
1123            assert!(
1124                OAuthCodePolicy::from_wire(input).is_err(),
1125                "unsafe redirect admitted"
1126            );
1127        }
1128        for endpoint in [
1129            "https://issuer.test/realm-evil/token",
1130            "https://issuer.test/real/token",
1131            "https://issuer.test:8443/realm/token",
1132            "https://other.test/realm/token",
1133            "http://issuer.test/realm/token",
1134        ] {
1135            let mut input = wire();
1136            input.token_endpoint = endpoint.to_owned();
1137            assert!(
1138                OAuthCodePolicy::from_wire(input).is_err(),
1139                "endpoint scope widened"
1140            );
1141        }
1142    }
1143
1144    #[test]
1145    fn oauth_code_http_requires_the_explicit_numeric_loopback_profile() {
1146        for address in ["127.0.0.1", "[::1]"] {
1147            let mut input = wire();
1148            input.transport_profile = OWNED_HTTP.to_owned();
1149            input.issuer = format!("http://{address}:8080/realm");
1150            input.token_endpoint = format!("http://{address}:8080/realm/token");
1151            input.redirect_uri = format!("http://{address}:8081/callback");
1152            let parsed = OAuthCodePolicy::from_wire(input).unwrap();
1153            assert!(parsed.is_valid_for_application(
1154                &Url::parse(&format!("http://{address}:8082/app/")).unwrap()
1155            ));
1156        }
1157        for address in ["localhost", "127.1", "0.0.0.0", "192.0.2.1", "[::]"] {
1158            let mut input = wire();
1159            input.transport_profile = OWNED_HTTP.to_owned();
1160            input.issuer = format!("http://{address}:8080/realm");
1161            input.token_endpoint = format!("http://{address}:8080/realm/token");
1162            assert!(
1163                OAuthCodePolicy::from_wire(input).is_err(),
1164                "HTTP authority widened"
1165            );
1166        }
1167        let mut input = wire();
1168        input.issuer = "http://127.0.0.1:8080/realm".to_owned();
1169        input.token_endpoint = "http://127.0.0.1:8080/realm/token".to_owned();
1170        assert!(OAuthCodePolicy::from_wire(input).is_err());
1171    }
1172
1173    #[test]
1174    fn oauth_code_policy_rejects_unsupported_profiles_and_nonfinite_scope_contracts() {
1175        for scopes in [
1176            vec![],
1177            vec!["read", "read"],
1178            vec!["openid"],
1179            vec!["offline_access"],
1180            vec!["read write"],
1181            vec!["read\twrite"],
1182            vec!["a\\b"],
1183            vec!["a\"b"],
1184            vec!["é"],
1185            vec!["read"; 9],
1186        ] {
1187            let mut input = wire();
1188            input.scopes = scopes.into_iter().map(str::to_owned).collect();
1189            assert!(OAuthCodePolicy::from_wire(input).is_err());
1190        }
1191        for maximum in [0, 32_769, usize::MAX] {
1192            let mut input = wire();
1193            input.max_token_response_bytes = maximum;
1194            assert!(OAuthCodePolicy::from_wire(input).is_err());
1195        }
1196        for client in [
1197            "".to_owned(),
1198            "x".repeat(129),
1199            "client secret".to_owned(),
1200            "client\r\n".to_owned(),
1201        ] {
1202            let mut input = wire();
1203            input.client_id = client;
1204            assert!(OAuthCodePolicy::from_wire(input).is_err());
1205        }
1206        let mut input = wire();
1207        input.scopes = vec!["s".repeat(65)];
1208        assert!(OAuthCodePolicy::from_wire(input).is_err());
1209        let mut input = wire();
1210        input.profile = "implicit-or-plain".to_owned();
1211        assert!(OAuthCodePolicy::from_wire(input).is_err());
1212    }
1213
1214    #[test]
1215    fn oauth_code_wire_policy_rejects_unknown_duplicate_missing_and_wrong_types() {
1216        let source = r#"profile = "external-public-code-s256-bearer/v1"
1217transport_profile = "verified-https/v1"
1218issuer = "https://issuer.test/realm"
1219token_endpoint = "https://issuer.test/realm/token"
1220client_id = "owned-client"
1221redirect_uri = "https://client.test/callback"
1222scopes = ["canary-read"]
1223max_token_response_bytes = 8192
1224"#;
1225        assert!(toml::from_str::<WireOAuthCodePolicy>(source).is_ok());
1226        for malformed in [
1227            format!("{source}unknown = true\n"),
1228            format!("{source}client_id = \"other\"\n"),
1229            source.replace(
1230                "max_token_response_bytes = 8192",
1231                "max_token_response_bytes = true",
1232            ),
1233            source.replace("transport_profile = \"verified-https/v1\"\n", ""),
1234        ] {
1235            assert!(toml::from_str::<WireOAuthCodePolicy>(&malformed).is_err());
1236        }
1237    }
1238
1239    #[test]
1240    fn oauth_code_grant_uses_the_literal_s256_answer_and_exact_five_field_encoding() {
1241        let selected = policy("canary-a");
1242        let grant = SuppliedSessionOAuthGrant::parse_tsv(
1243            &selected,
1244            grant_source("CODE+/=&?%", 1_000),
1245            1_000,
1246        )
1247        .unwrap();
1248        assert!(grant.matches_policy(&selected));
1249        assert!(grant.is_fresh_at(1_060));
1250        assert!(!grant.is_fresh_at(1_061));
1251        let body = grant.compose_form_body(&selected).unwrap();
1252        let expected = concat!(
1253            "grant_type=authorization_code&client_id=owned-client&redirect_uri=",
1254            "https%3A%2F%2Fclient.test%2Fcallback&code=CODE%2B%2F%3D%26%3F%25",
1255            "&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
1256        );
1257        assert!(
1258            body.as_bytes() == expected.as_bytes(),
1259            "exact form oracle differed"
1260        );
1261        assert_eq!(url::form_urlencoded::parse(body.as_bytes()).count(), 5);
1262    }
1263
1264    #[test]
1265    fn oauth_code_grant_supports_crlf_unordered_rows_but_never_trims_values() {
1266        let selected = policy("canary-a");
1267        let source = String::from_utf8(grant_source("SYNTHETIC-CODE", 1_000)).unwrap();
1268        let reversed = source.lines().rev().collect::<Vec<_>>().join("\r\n") + "\r\n";
1269        assert!(
1270            SuppliedSessionOAuthGrant::parse_tsv(&selected, reversed.into_bytes(), 1_000).is_ok()
1271        );
1272        for replacement in [" CODE", "CODE ", "CODE\tEXTRA", "CODE\rX", "CODE\0"] {
1273            assert!(
1274                SuppliedSessionOAuthGrant::parse_tsv(
1275                    &selected,
1276                    grant_source(replacement, 1_000),
1277                    1_000,
1278                )
1279                .is_err(),
1280                "secret value was normalized"
1281            );
1282        }
1283    }
1284
1285    #[test]
1286    fn oauth_code_grant_rejects_missing_duplicate_unknown_and_malformed_rows() {
1287        let selected = policy("canary-a");
1288        let valid = String::from_utf8(grant_source("SYNTHETIC-CODE", 1_000)).unwrap();
1289        for index in 0..8 {
1290            let mut rows = valid.lines().collect::<Vec<_>>();
1291            rows.remove(index);
1292            assert!(SuppliedSessionOAuthGrant::parse_tsv(
1293                &selected,
1294                rows.join("\n").into_bytes(),
1295                1_000
1296            )
1297            .is_err());
1298            let mut rows = valid.lines().collect::<Vec<_>>();
1299            rows.push(rows[index]);
1300            assert!(SuppliedSessionOAuthGrant::parse_tsv(
1301                &selected,
1302                rows.join("\n").into_bytes(),
1303                1_000
1304            )
1305            .is_err());
1306        }
1307        for source in [
1308            Vec::new(),
1309            b"\xff".to_vec(),
1310            [b"\xef\xbb\xbf".as_slice(), valid.as_bytes()].concat(),
1311            (valid.clone() + "\n").into_bytes(),
1312            valid
1313                .replace("authorization_code\t", "unknown\t")
1314                .into_bytes(),
1315            format!("{}\r", valid.trim_end_matches('\n')).into_bytes(),
1316        ] {
1317            assert!(SuppliedSessionOAuthGrant::parse_tsv(&selected, source, 1_000).is_err());
1318        }
1319        assert_eq!(
1320            SuppliedSessionOAuthGrant::parse_tsv(&selected, vec![b'x'; 16_385], 1_000).unwrap_err(),
1321            OAuthCodeInputError::SecretTooLarge,
1322        );
1323    }
1324
1325    #[test]
1326    fn oauth_code_grant_checks_each_context_and_principal_binding() {
1327        let selected = policy("canary-a");
1328        let valid = String::from_utf8(grant_source("SYNTHETIC-CODE", 1_000)).unwrap();
1329        for (from, to) in [
1330            ("https://issuer.test/realm", "https://other.test/realm"),
1331            ("client_id\towned-client", "client_id\tother-client"),
1332            ("https://client.test/callback", "https://client.test/other"),
1333            ("scope\tcanary-read", "scope\tother-scope"),
1334            ("scope\tcanary-read", "scope\tcanary-read canary-read"),
1335        ] {
1336            assert_eq!(
1337                SuppliedSessionOAuthGrant::parse_tsv(
1338                    &selected,
1339                    valid.replace(from, to).into_bytes(),
1340                    1_000,
1341                )
1342                .unwrap_err(),
1343                OAuthCodeInputError::PolicyMismatch
1344            );
1345        }
1346        let grant =
1347            SuppliedSessionOAuthGrant::parse_tsv(&selected, valid.into_bytes(), 1_000).unwrap();
1348        let peer = policy("canary-b");
1349        assert!(!grant.matches_policy(&peer));
1350        assert_eq!(
1351            grant.compose_form_body(&peer).unwrap_err(),
1352            OAuthCodeInputError::PolicyMismatch
1353        );
1354    }
1355
1356    #[test]
1357    fn oauth_code_grant_rejects_plain_wrong_noncanonical_and_out_of_bounds_verifiers() {
1358        let selected = policy("canary-a");
1359        let source = String::from_utf8(grant_source("SYNTHETIC-CODE", 1_000)).unwrap();
1360        for (from, to) in [
1361            (VERIFIER, "short"),
1362            (VERIFIER, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX!"),
1363            (CHALLENGE, VERIFIER),
1364            (CHALLENGE, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cN"),
1365            (CHALLENGE, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM="),
1366        ] {
1367            assert_eq!(
1368                SuppliedSessionOAuthGrant::parse_tsv(
1369                    &selected,
1370                    source.replace(from, to).into_bytes(),
1371                    1_000,
1372                )
1373                .unwrap_err(),
1374                OAuthCodeInputError::MalformedGrant
1375            );
1376        }
1377        let oversized = source.replace(VERIFIER, &"x".repeat(129));
1378        assert!(
1379            SuppliedSessionOAuthGrant::parse_tsv(&selected, oversized.into_bytes(), 1_000).is_err()
1380        );
1381        assert!(SuppliedSessionOAuthGrant::parse_tsv(
1382            &selected,
1383            grant_source(&"x".repeat(2_049), 1_000),
1384            1_000
1385        )
1386        .is_err());
1387    }
1388
1389    #[test]
1390    fn oauth_code_grant_freshness_is_bounded_asserted_time_not_remote_proof() {
1391        let selected = policy("canary-a");
1392        for (received, now, accepted) in [
1393            (1_000, 1_000, true),
1394            (940, 1_000, true),
1395            (939, 1_000, false),
1396            (1_005, 1_000, true),
1397            (1_006, 1_000, false),
1398            (0, 0, true),
1399            (1_000, -1, false),
1400            (1_000, i64::MAX, false),
1401            (i64::MAX, 1_000, false),
1402        ] {
1403            assert_eq!(
1404                SuppliedSessionOAuthGrant::parse_tsv(
1405                    &selected,
1406                    grant_source("SYNTHETIC-CODE", received),
1407                    now,
1408                )
1409                .is_ok(),
1410                accepted
1411            );
1412        }
1413    }
1414
1415    #[test]
1416    fn oauth_code_token_is_an_opaque_guarded_bearer_and_refresh_is_only_a_fact() {
1417        let response = token(&RESPONSE.replace("Bearer", "bEaReR")).unwrap();
1418        assert_eq!(response.expires_in_seconds(), 60);
1419        assert!(!response.refresh_token_returned());
1420        let authorization = response.into_authorization().unwrap();
1421        assert!(
1422            authorization.as_str() == "Bearer SYNTHETIC.ACCESS_TOKEN+/==",
1423            "safe header composition differed"
1424        );
1425        let with_refresh = RESPONSE.replace(
1426            "\"scope\"",
1427            "\"refresh_token\":\"SYNTHETIC_REFRESH\",\"scope\"",
1428        );
1429        let response = token(&with_refresh).unwrap();
1430        assert!(response.refresh_token_returned());
1431    }
1432
1433    #[test]
1434    fn oauth_code_token_requires_typed_explicit_scope_expiry_and_supported_type() {
1435        for body in [
1436            "[]",
1437            "null",
1438            "true",
1439            "{}",
1440            r#"{"access_token":"x","token_type":"Bearer","expires_in":true,"scope":"canary-read"}"#,
1441            r#"{"access_token":"x","token_type":"Bearer","expires_in":60.0,"scope":"canary-read"}"#,
1442            r#"{"access_token":"x","token_type":"Bearer","expires_in":"60","scope":"canary-read"}"#,
1443            r#"{"access_token":"x","token_type":"Bearer","expires_in":0,"scope":"canary-read"}"#,
1444            r#"{"access_token":"x","token_type":"Bearer","expires_in":3601,"scope":"canary-read"}"#,
1445            r#"{"access_token":"x","token_type":"Bearer","expires_in":60}"#,
1446            r#"{"access_token":"x","token_type":"Bearer","scope":"canary-read"}"#,
1447            r#"{"token_type":"Bearer","expires_in":60,"scope":"canary-read"}"#,
1448            r#"{"access_token":"x","expires_in":60,"scope":"canary-read"}"#,
1449            r#"{"access_token":true,"token_type":"Bearer","expires_in":60,"scope":"canary-read"}"#,
1450        ] {
1451            assert!(token(body).is_err(), "invalid token contract admitted");
1452        }
1453        for scope in [
1454            "other",
1455            "canary-read canary-read",
1456            " canary-read",
1457            "canary-read ",
1458        ] {
1459            assert!(token(&RESPONSE.replace("canary-read", scope)).is_err());
1460        }
1461        assert_eq!(
1462            token(&RESPONSE.replace("Bearer", "DPoP")).unwrap_err(),
1463            OAuthCodeInputError::UnsupportedTokenProfile
1464        );
1465        for value in ["null", "false", "{}", "\"\""] {
1466            let body = RESPONSE.replacen('{', &format!("{{\"id_token\":{value},"), 1);
1467            assert_eq!(
1468                token(&body).unwrap_err(),
1469                OAuthCodeInputError::UnsupportedTokenProfile
1470            );
1471        }
1472    }
1473
1474    #[test]
1475    fn oauth_code_token_unknown_extensions_are_inert_but_duplicate_keys_always_fail() {
1476        let original = token(RESPONSE).unwrap();
1477        let extended = RESPONSE.replacen('{', "{\"provider_extension\":{\"values\":[1,true,null,{\"inert\":\"https://never-fetch.invalid/\"}]},", 1);
1478        let extended = token(&extended).unwrap();
1479        assert_eq!(extended.expires_in_seconds(), original.expires_in_seconds());
1480        assert!(!extended.refresh_token_returned());
1481        for extension in [
1482            "\"x\":1,\"x\":2,",
1483            "\"x\":{\"a\":1,\"a\":2},",
1484            "\"x\":[{\"a\":1,\"a\":2}],",
1485            "\"x\":1,\"\\u0078\":2,",
1486            "\"access_token\":\"other\",",
1487            "\"access_\\u0074oken\":\"other\",",
1488        ] {
1489            let body = RESPONSE.replacen('{', &format!("{{{extension}"), 1);
1490            assert!(token(&body).is_err(), "duplicate JSON key admitted");
1491        }
1492        assert!(token(&(RESPONSE.to_owned() + "{}")).is_err());
1493    }
1494
1495    #[test]
1496    fn oauth_code_token_known_errors_and_invalid_refresh_are_not_extensions() {
1497        for field in ["error", "error_description", "error_uri"] {
1498            for value in ["null", "false", "\"SYNTHETIC_PROVIDER_ERROR\""] {
1499                let extended = RESPONSE.replacen('{', &format!("{{\"{field}\":{value},"), 1);
1500                assert_eq!(
1501                    token(&extended).unwrap_err(),
1502                    OAuthCodeInputError::InvalidTokenResponse
1503                );
1504            }
1505        }
1506        for value in ["null", "false", "{}", "\"\""] {
1507            let extended = RESPONSE.replacen('{', &format!("{{\"refresh_token\":{value},"), 1);
1508            assert_eq!(
1509                token(&extended).unwrap_err(),
1510                OAuthCodeInputError::InvalidTokenResponse
1511            );
1512        }
1513    }
1514
1515    #[test]
1516    fn oauth_code_token_node_key_depth_and_string_limits_have_boundary_cases() {
1517        for (count, accepted) in [(250, true), (251, false)] {
1518            let extension = format!("\"x\":[{}],", vec!["0"; count].join(","));
1519            let body = RESPONSE.replacen('{', &format!("{{{extension}"), 1);
1520            assert_eq!(token(&body).is_ok(), accepted, "node boundary differed");
1521        }
1522        for (count, accepted) in [(124, true), (125, false)] {
1523            let extension = (0..count)
1524                .map(|n| format!("\"x{n}\":null,"))
1525                .collect::<String>();
1526            let body = RESPONSE.replacen('{', &format!("{{{extension}"), 1);
1527            assert_eq!(token(&body).is_ok(), accepted, "key boundary differed");
1528        }
1529        for (depth, accepted) in [(7, true), (8, false)] {
1530            let extension = format!("\"x\":{}0{},", "[".repeat(depth), "]".repeat(depth));
1531            let body = RESPONSE.replacen('{', &format!("{{{extension}"), 1);
1532            assert_eq!(token(&body).is_ok(), accepted, "depth boundary differed");
1533        }
1534        for (length, accepted) in [(8_192, true), (8_193, false)] {
1535            let extension = format!("\"x\":\"{}\",", "x".repeat(length));
1536            let body = RESPONSE.replacen('{', &format!("{{{extension}"), 1);
1537            assert_eq!(token(&body).is_ok(), accepted, "string boundary differed");
1538        }
1539        for (length, accepted) in [(128, true), (129, false)] {
1540            let extension = format!("\"{}\":null,", "x".repeat(length));
1541            let body = RESPONSE.replacen('{', &format!("{{{extension}"), 1);
1542            assert_eq!(
1543                token(&body).is_ok(),
1544                accepted,
1545                "key string boundary differed"
1546            );
1547        }
1548    }
1549
1550    #[test]
1551    fn oauth_code_token_bearer_grammar_and_existing_header_ceiling_are_joint_limits() {
1552        for access in ["", "=", "x=x", "has space", "x\r\nHost:x", "x\t", "é"] {
1553            let encoded = serde_json::to_string(access).unwrap();
1554            let body =
1555                RESPONSE.replace("SYNTHETIC.ACCESS_TOKEN+/==", &encoded[1..encoded.len() - 1]);
1556            assert!(token(&body).is_err(), "unsafe bearer value admitted");
1557        }
1558        for (length, accepted) in [(8_185, true), (8_186, false)] {
1559            let body = RESPONSE.replace("SYNTHETIC.ACCESS_TOKEN+/==", &"x".repeat(length));
1560            assert_eq!(token(&body).is_ok(), accepted);
1561        }
1562        let mut input = wire();
1563        input.max_token_response_bytes = 16;
1564        let limited = OAuthCodePolicy::from_wire(input).unwrap();
1565        assert!(
1566            parse_token_response(&limited, Zeroizing::new(RESPONSE.as_bytes().to_vec())).is_err()
1567        );
1568        assert!(parse_token_response(&oauth(), Zeroizing::new(vec![b'x'; 32_769])).is_err());
1569    }
1570
1571    #[test]
1572    fn oauth_code_secret_debug_and_error_paths_remain_redacted() {
1573        let selected = policy("canary-a");
1574        let grant = SuppliedSessionOAuthGrant::parse_tsv(
1575            &selected,
1576            grant_source("PRIVATE_CODE_SENTINEL", 1_000),
1577            1_000,
1578        )
1579        .unwrap();
1580        let form = grant.compose_form_body(&selected).unwrap();
1581        let response = token(RESPONSE).unwrap();
1582        let error = token("{\"PRIVATE_TOKEN_SENTINEL\":").unwrap_err();
1583        for rendered in [
1584            format!("{grant:?}"),
1585            format!("{form:?}"),
1586            format!("{response:?}"),
1587            format!("{error:?} {error}"),
1588        ] {
1589            for sentinel in [
1590                "PRIVATE_CODE_SENTINEL",
1591                VERIFIER,
1592                CHALLENGE,
1593                "SYNTHETIC.ACCESS_TOKEN+/==",
1594                "PRIVATE_TOKEN_SENTINEL",
1595            ] {
1596                assert!(!rendered.contains(sentinel), "redaction guard failed");
1597            }
1598        }
1599    }
1600}