1use 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#[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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
319#[non_exhaustive]
320pub enum OAuthCodeInputError {
321 #[error("imported OAuth code input exceeds its byte limit")]
323 SecretTooLarge,
324 #[error("imported OAuth code input is malformed")]
326 MalformedGrant,
327 #[error("imported OAuth code input does not match the selected policy")]
329 PolicyMismatch,
330 #[error("imported OAuth code receipt time is outside its permitted window")]
332 StaleGrant,
333 #[error("OAuth token response is invalid for the selected bounded profile")]
335 InvalidTokenResponse,
336 #[error("OAuth token response requires an unsupported token profile")]
338 UnsupportedTokenProfile,
339}
340
341pub 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 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 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
556pub(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
573pub(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
602pub(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#[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
630pub(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 },
699 _ => {}, }
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 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 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}